PHP伪随机数mt_rand种子可预测性分析与爆破实战

发布时间:2026/9/16 22:09:14
PHP伪随机数mt_rand种子可预测性分析与爆破实战 前阵子复盘 GWCTF 2019 的 Web 题目时我又把“枯燥的抽奖”翻出来做了一遍。这道题表面就是个抽奖小游戏——输入数字、点击抽奖、猜对了给你 flag猜错了就继续名字还带着一股“这题很无聊”的味道。但真正做进去才知道它把 PHP 伪随机数 mt_rand 的种子可预测问题玩得很透既适合当 CTF 入门题练手也适合把它当成一个真实业务漏洞的缩影来看。整道题不需要复杂的 RCE 链不用绕 WAF核心就一件事你能不能拿到足够多的随机数样本反推出服务器生成随机数时用的种子。这篇文章我会从题目分析和考点定位开始把 mt_rand 为什么可以被预测的原理讲清楚然后完整走一遍用 php_mt_seed 爆破种子、预测下一次抽奖结果、最终拿 flag 的实操过程最后再聊聊我实际踩过的坑以及这类弱随机数问题在现代业务里的真实影响。适合正在刷 CTF 的选手、对 PHP 安全感兴趣的人以及做业务风控或后端开发的工程师参考。1. 题目信息与考点定位1.1 从“抽奖”页面能看到的线索打开题目环境第一眼是个很普通的抽奖页面。页面上有一个输入框、一个抽奖按钮旁边可能还显示着一串历史中奖号码。随便输入一个数字提交返回结果大概率就是“差一点再试试”或者“抱歉未中奖”。如果直接瞎猜纯靠运气那确实很枯燥——所以题目名字其实是在暗示你别真的靠运气猜去看它背后的随机数生成逻辑。我把页面源码看了一圈前端逻辑很简单真正干活的是一个接口比如api.php?actdraw。每次调用这个接口服务端会返回一个数字写着“本次开奖号码6”之类的。一开始我以为这只是给用户看的中奖结果但再想一步如果服务端把每次 mt_rand() 生成的数字原样返回给了前端那这个数字就不只是抽奖结果它还是一次随机数泄漏。这个判断是整个题目的突破口。很多抽奖类 CTF 题都长一个样后端用mt_rand()生成中奖号码前端展示号码然后要求你预测下一次。看似不可攻破但 PHP 的mt_rand()并不是密码学安全的随机数生成器一旦你拿到了足够多的输出整套随机数系统就可以被逆向复原。1.2 核心考点PHP 伪随机数生成机制这道题的核心考点非常明确PHP 的 mt_rand() 随机数可预测性。PHP 里生成随机数最常用的两个函数是rand()和mt_rand()后者基于梅森旋转算法Mersenne Twister性能比rand()好随机性在统计上也更“像样”。但梅森旋转算法本身并不是为了安全设计的而是一种伪随机数生成算法PRNG。伪随机数的意思是它看起来随机但只要初始状态也就是种子 seed确定后面产生的序列就完全确定了。PHP 里用mt_srand($seed)设置种子之后每次调用mt_rand()都从这个种子开始按算法逐步生成新的输出。如果种子不重新设置同一进程内每次请求生成的序列会接着上一次继续但很多 PHP 题目的写法是在每次请求开始时就mt_srand()固定种子或者用当前时间、某个固定值做种子这就让攻击者有了可乘之机。知道种子值就等于拿到了整条随机数序列。问题变成怎么拿到种子有些题目会在备份文件里把种子写死有些题目会通过报错把 seed 泄漏出来而这道题的思路是——用多次已知输出反推种子。2. 整体解题思路与方向选择2.1 先交互再审计最后才爆破我做这种题一般有个固定顺序先带着页面交互几轮搞清楚功能再看前端源码和接口搞明白参数最后才进入暴力破解环节。直接上来就爆破种子连服务端生成随机数的方式都没确认很容易白费时间。实际交互几轮我注意到每次调用抽奖接口服务端都会立刻返回一个新数字而且这个数字看起来没有规律。记录下来的话大概长这样第 1 次3 第 2 次7 第 3 次1 第 4 次9 第 5 次4 第 6 次2把这些数字放到一起能做的分析就很清晰了这是mt_rand()在某个固定种子下产生的一组输出输出范围被限制在了 0 到 9 之间。如果只是看数字本身没有任何规律可言但这不重要——我们要做的是根据这些输出反向把种子找出来。2.2 为什么选择 php_mt_seed 而不是自己写脚本一旦确认要用已知输出反推种子摆在前面的路有两条一是自己写脚本去恢复梅森旋转算法的内部状态二是直接用现成工具。自己写理论上可行但梅森旋转算法的状态恢复涉及对 624 个 32 位整数的状态矩阵做线性变换还要处理 PHP 特有的 temper 和 untemper 过程工作量不小而且容易在细节上出错。更关键的是这道题我们拿到的并不是 mt_rand() 的完整原始输出而是经过mt_rand(0, 9)取模后的结果。这就好比我让你猜一个我写在纸上的 32 位数但我只告诉你它除以 10 的余数信息的损失非常大。自己从 0 到 9 的余数反推完整状态复杂度很高。我直接选了 php_mt_seed 这个工具。它的原理是专门针对 PHP 的 mt_rand() 做种子爆破支持传入多个已知输出包括取模后的结果通过枚举种子的方式快速收敛。因为在 PHP 里种子本身是一个 32 位整数范围有限暴力枚举在可接受的时间范围内是可行的。php_mt_seed 会把从 0 到 9 的输出作为约束条件快速筛掉不可能成为种子的值最终留下一个或少数几个候选种子。2.3 从源码备份等附加信息里找捷径有时候 GWCTF 这类比赛会在题目目录里遗留一些敏感文件比如/www.zip、/.git、/index.php.bak。如果题目给了源码备份那恭喜直接看源码里的种子设置方式比爆破省事十倍。我见过不少“抽奖”题其实是把种子直接写在源码里比如mt_srand(20240101); $num mt_rand(0, 9);这种就完全不需要爆破拿到源码就等于拿到了答案。所以做题时不要一上来就埋头跑工具先花两分钟探测一下有没有源码泄露。不过“枯燥的抽奖”这道题里源码备份未必存在最稳妥的路线还是收集样本 爆破种子。3. 核心原理mt_rand 为什么能被预测3.1 梅森旋转算法与 PHP 的实现差异要理解为什么几个 0 到 9 的数字就能算出种子得先看懂 mt_rand() 的内部结构。PHP 的 mt_rand() 使用的是 MT19937也就是梅森旋转算法的 32 位版本。算法内部维护一个长度为 624 的 32 位整数状态数组加上一个索引指针。生成随机数时主要分两步生成新状态当索引到达 624 时对整个状态数组执行梅森旋转生成下一批 624 个状态值。提取输出从当前状态值经过一个叫 tempering 的位运算变换输出一个 32 位整数。如果我直接拿到mt_rand()的完整输出并且连续收集 624 个就能通过逆 tempering 恢复完整状态数组进而预测之后所有输出。但这里有个麻烦题目只给了mt_rand(0, 9)的输出也就是完整的 32 位输出经过取模后只保留了个位数字大部分信息都丢了。3.2 种子空间小暴力枚举反而是最快的路既然无法直接恢复内部状态那就换个思路直接枚举种子。种子是用来初始化 MT19937 状态数组的一个 32 位整数在 PHP 里通常取值范围是0 ~ 0xFFFFFFFF也就是约 43 亿种可能。看起来很多但结合取模后的输出约束可以把搜索空间迅速缩小。举个例子某个种子生成的第一个mt_rand(0,9)是 3那我枚举种子时就可以直接跳过那些第一个输出不是 3 的候选。每多一个已知输出筛掉的候选就多一批。给定 6 到 8 个输出时候选种子会缩小到非常少的数量甚至直接唯一确定。php_mt_seed 做的就是这件事它把多个输出作为过滤条件利用 CPU 的并行能力快速遍历种子空间。在实际操作中给 6 个左右的输出几秒钟到几分钟就能跑完输出给得越多爆破速度越快。3.3 拿到种子之后整个系统就透明了一旦爆破出种子剩下的就是复现。用同一个种子调用mt_srand()再依次调用mt_rand(0, 9)就能得到一个与服务器完全一致的随机数序列。服务器下一次的秘密号码就是序列里的下一个数。这就是弱随机数的核心危害它不是不可预测只是“看起来不可预测”。攻击者只需要观察到少量输出就可以通过种子重建整个序列后续的每一笔抽奖结果都在掌握之中。放在真实业务里这可以演变成抽奖作弊、优惠券盗刷、验证码绕过、token 伪造等一系列问题。4. 实操解题全过程记录4.1 收集随机数样本我实际操作时先用 Burp Suite 抓了一下抽奖接口的请求和响应确认服务端返回的格式。直接用浏览器多次点击抽奖按钮也可以但用 Burp 更高效还能顺便看看有没有参数可以利用。我拿到了这样一组输出3 7 1 9 4 2这 6 个数字看着不多但已经足够作为 php_mt_seed 的输入了。如果你在实战中拿到更多比如 10 个、20 个爆破速度会快很多但 6 个一般也能出结果只是可能要多等一会儿。4.2 编译并运行 php_mt_seedphp_mt_seed 是个开源工具在 GitHub 上很容易找到源码。我在 Kali Linux 上操作先克隆下来再编译git clone https://github.com/0xSad0x/php_mt_seed.git cd php_mt_seed make编译完成后目录下会生成一个可执行文件。运行方式很简单把观察到的随机数逐个作为参数传进去./php_mt_seed 3 7 1 9 4 2工具会开始遍历种子空间输出所有符合条件的种子。运行过程中屏幕上会滚动大量信息最终给出类似这样的结果seed 123456789如果给出的样本足够精确一般能筛出一到几个候选种子。万一出现多个候选种子就再取几个新样本或者直接按候选种子分别预测下一次结果逐一尝试总能对上。4.3 用种子复现服务端随机数序列拿到种子后我用一个本地 PHP 脚本验证?php mt_srand(123456789); for ($i 0; $i 10; $i) { echo mt_rand(0, 9) . ; } ?运行后前 6 个输出应该和服务器返回的3 7 1 9 4 2完全一致。如果对不上说明爆破出的种子不对或者服务端生成随机数的代码不是我猜测的mt_rand(0, 9)这时候要回去看代码细节。验证通过后我找到第 7 个输出值比如是5那这就是服务器下一次抽奖的号码。把这个数字提交到抽奖接口页面直接返回了 flag。4.4 不同题面下的变体加盐、加偏移、多次取模如果你在做的时候发现直接按mt_rand(0, 9)来复现对不上也不要慌。有些题目会做一些变形比如mt_srand($seed 1000); $num mt_rand(0, 9);或者$num mt_rand(0, 9) * 2;这些变体都会让直接用原始输出爆破种子失败。解决办法是多观察源码确认随机数的具体生成方式或者调整 php_mt_seed 的输入参数。比如输出是原始随机数乘以 2那传给工具时就要把观察到的数字除以 2 再传入。总之理解题目代码里的随机数生成逻辑比盲目跑工具更重要。5. 常见问题与排查技巧实录5.1 为什么爆破不到种子我一开始做题时也卡过很久种子怎么都爆破不出来。后来总结了几个高频原因样本数量太少只给两三个输出筛选条件不足候选种子成千上万工具很难收敛。建议至少给 6 个以上输出。服务端不是直接mt_rand()可能加了偏移、取模、多次调用等变换。仔细观察代码或接口返回确定真实的随机数生成过程。PHP 版本差异不同版本的 PHP 对mt_srand()和mt_rand()的底层实现略有差异php_mt_seed 工具本身也内置了多种版本模式必要时切换模式重试。我把常见问题整理成了一个表方便对照排查问题现象可能原因排查方式爆破无结果输出样本太少多收集几轮随机数再重试种子对不上随机数生成方式有偏移审计代码去掉偏移或调整输入值复现序列不一致PHP 版本不同检查工具版本参数换版本模式多个候选种子输出数量不够补充新输出继续筛选接口返回格式特殊有 base64、编码等处理解码后再当作随机数样本5.2 实操中的独家技巧我自己的经验里有几个小事挺有用的第一收集样本时不要只记数字要记“第几次”。因为有些题目会重置计数器或每轮抽奖重新设置种子你如果把不同种子下的输出混在一起爆破当然失败。我看到有的新手喜欢把浏览器里密密麻麻的抽奖记录直接复制出来结果混入了好几轮的不同序列白白浪费时间。第二用脚本自动化和服务器交互。手工点按钮太慢了尤其需要收集大量样本时。我一般直接用 Python 写个小脚本循环请求抽奖接口把返回的数字存到文件里再用命令行传给 php_mt_seed形成一条自动化流程。这样不仅快还能避免手滑复制错数字。第三拿到 flag 之后再多想一步。这道题不是刷完就完了我会顺手想一下如果把mt_rand()换成random_int()这道题是不是就彻底死了答案是肯定的。也正因为这个对比它成了我讲弱随机数漏洞时最喜欢的例子之一。5.3 从 CTF 到真实世界的思考很多人在 CTF 里做完了这道题觉得只是“出一道脑筋急转弯”但这类问题在真实系统里非常常见。尤其是很多年前写的业务系统抽奖、秒杀、短信验证码、优惠券生成都可能用mt_rand()或者rand()来实现。攻击者只要能拿到几次输出就能预测后续结果。真实的修复方式也很简单PHP 里优先使用random_int()或random_bytes()它们基于操作系统的安全随机源生成不可预测。如果是其他语言也要选密码学安全的伪随机数生成器CSPRNG比如 Python 的secrets模块、Java 的SecureRandom。遇到类似业务需求时我的经验是只要这个随机数和钱、权益、身份验证沾边就一定不能用普通的随机函数。中奖号码、优惠券码、重置链接的 token任何能被猜到的值最终都会被薅穿。安全不是做个黑名单过滤就完事了而是要在架构层面用正确的东西。这道题我每次讲给朋友听都会说它是最典型的“看起来防住了其实形同虚设”的案例。页面上是枯燥的抽奖背后是随机数种子被爆破的隐患。只要你理解了 mt_rand 的原理再回头去看那些还在用普通随机数做抽奖的系统会由衷觉得冷汗直冒——有时候安全的边界就藏在一个毫不起眼的数字里。