PHP伪随机数漏洞实战:从mt_rand种子爆破到预测抽奖码
2026/9/16 21:39:23 网站建设 项目流程

点开题目,页面简陋得让人想直接关掉——白底、一行大字"枯燥的抽奖"、一个输入框和一个按钮。点击"抽奖",服务端回了一串字符:v0MbgNx2Jq;再点一次,又变成别的。题目要求把"下一期"的抽奖码填进去,填对了就给 flag。按 62 个字符随机组合来算,正确概率是 62 的 10 次方分之一,约等于 1 除以 8.3 亿亿,纯靠猜肯定没戏。但"看起来随机"和"真随机"是两回事,这类 PHP 抽奖页面的随机数生成器往往藏着一个致命问题:mt_rand()的种子可以被反推。这篇文章我会完整复盘这个题从信息收集、原理分析、种子爆破到最终提交 flag 的全过程,核心工具是 Openwall 的 php_mt_seed,也会讲到 str_shuffle 变体、PHP 版本差异等实战里很容易翻车的细节。适合想入门 PHP 伪随机数漏洞的 CTF 选手,也适合所有在业务里用过mt_rand做抽奖、验证码、短 token 的开发者——看完你会想把线上代码里的mt_rand全部换掉。

1. 拿到题目后的第一眼:这个抽奖页面到底暴露了多少信息

1.1 页面上唯一有价值的东西是那一串字符

题目没有任何框架依赖,打开就是一个居中的表单。点"抽奖"后页面刷新,显示一串新的字符,同时保留一个输入框。我一开始以为输入框是用来提交"自己选的号码",随便输入一次,返回"没中奖,再试试吧"。

关键观察点有三个:

  1. 抽奖码固定是 10 位,我连续刷了几十次,长度没变过。
  2. 字符集固定是0-9a-zA-Z,共 62 个字符,没有+/=这些 base64 里常见的符号。
  3. 输入框的提示文案明确写着"输入下一期抽奖码即可获得 flag"。

也就是说,这道题不是让你跟服务端比谁运气好,而是明摆着告诉你:号码是可以预测的,你只需要找到预测的方法。这个提示其实已经剧透了一半——服务端生成抽奖码的过程一定存在某种确定性。

1.2 源码、备份文件与响应体里的线索

Web 题第一步永远是看源码。右键查看源代码,HTML 结构非常简单:没有隐藏字段、没有 JS 混淆、没有外部资源,只看到一个 form,method 是 POST,文本框的 name 是num

随后我例行探测了一波备份文件和隐藏文件:/www.zip/index.php.bak/index.php.swp/.git/HEAD/robots.txt/.DS_Store。这题没有给源码泄露的机会,这些路径全部 404。不过响应头里能看到X-Powered-By: PHP/7.2.10,这个信息量已经不小了——目标是一个 PHP 7 环境,后面本地复现的时候要选对版本。

另外要注意响应头里的Date字段和Set-Cookie。如果服务端发放了 session 相关的 Cookie,说明随机数状态很可能存在 session 里;如果完全没有 Cookie,则可能是每次请求重新生成。这个区别直接决定了后面怎么预测,我先记下了。

1.3 从字符集推断出生成逻辑

把几十次抽奖码放在一起对比,会发现它们非常像一段典型 PHP 代码的输出:

$str_long1 = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; $str = ''; for ($i = 0; $i < 10; $i++) { $str .= $str_long1[mt_rand(0, strlen($str_long1) - 1)]; }

为什么我敢这么猜?因为这段代码就是 PHP 生态里生成"随机字符串"最常见的写法:定义好字母表,然后用mt_rand(0, 61)逐个往里面取字符。字符集恰好 62 个,没有符号,长度固定,全部吻合。

如果真是这样,那么抽奖码的每一位都等价于一次mt_rand(0, 61)的返回值。这就是后面所有攻击的切入点。当然,题目也可能改用str_shuffle对整个字母表洗牌后截取,这两种情况的分析方法略有不同,我在第 3.5 节专门讲 str_shuffle 的处理方式。

2. 伪随机数的本质:为什么知道了种子就等于看穿了整个抽奖结果

2.1 梅森旋转不是"随机",而是"可复算"

要理解这个漏洞,得先搞清楚mt_rand()的底层机制。PHP 的mt_rand()基于 MT19937,也就是梅森旋转算法,一种非常经典的伪随机数生成器(PRNG)。

PRNG 的特点是:它内部维护一个有限状态,每次调用输出一个数,然后状态向前推进。梅森旋转的状态是 624 个 32 位整数,周期长达 2^19937 - 1,统计分布也非常均匀,所以很长一段时间里大家都觉得它够用了。但它有一个致命特性:一切从初始种子决定。同一个种子,无论调用多少次,生成的序列永远一模一样。

打个比方:伪随机数生成器就像一台发牌机。普通发牌机每次洗牌是随机的,但这台发牌机只要你告诉它一个"初始设定",它每次发的牌序就是固定的。攻击者一旦知道初始设定,就能提前算出发牌机接下来吐出来的每一张牌。

在 PHP 里,这个"初始设定"就是mt_srand($seed)传入的种子。而且 PHP 允许你在任意时刻重新设定种子,这给了开发者一个"看起来很安全"的错觉——只要每次抽奖前重新播种,不就不一样了吗?事实是,重新播种的安全性完全取决于种子的来源。

2.2 用 time() 做种子,等于把保险箱密码贴在门上

如果代码是mt_srand(time()),那意味着种子就是当前 Unix 时间戳。时间戳是一个 32 位整数,理论上枚举全部 0 到 2^32-1 也并非不可能,但实际攻击窗口更小:假设你能把服务端生成号码的时间精确到某一个小时,那一小时只有 3600 个可能的种子;哪怕完全不知道时间,现代工具在几组输出的约束下也能在合理时间内扫完整个 32 位空间。

种子空间小还不是唯一的问题。更关键的是,你手里有一组"锚点"——抽奖码的每一位就是一次mt_rand(0, 61)的输出。10 个输出对种子构成的约束远远超过了种子本身的信息量,所以爆破出来的种子几乎是唯一的。用信息论的粗略估算:每个观测值有 62 种可能,约 6 比特信息;32 比特的种子至少需要 6 个观测值才能收窄到唯一解,10 个绰绰有余。

有一个常见的误解是"那我把种子改成mt_rand()的输出不就行了"。这种想法在当年的题目里确实出现,但mt_rand()本身也是同一个 PRNG 的输出,它同样可预测,只是把问题藏远了一层。真正安全的做法是用 CSPRNG,这个话题留到第 5 节讲。

2.3 抽奖码的每一位都是一次 mt_rand(0, 61) 调用

给定字母表:

abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ

其中小写 a-z 对应下标 0-25,数字 0-9 对应下标 26-35,大写 A-Z 对应下标 36-61。于是抽奖码v0MbgNx2Jq可以拆成:

v=21, 0=26, M=48, b=1, g=6, N=49, x=23, 2=28, J=45, q=16

这一串数字就是 10 次mt_rand(0, 61)的返回值。攻击目标变成:找到种子 S,使得mt_srand(S)之后前 10 次mt_rand(0, 61)恰好依次等于上面这串数字。

有一点必须提醒:PHP 的mt_rand(0, 61)并不是简单地调用mt_rand() % 62。它对区间参数有一套专门的映射逻辑,而且 PHP 5 和 PHP 7 的映射逻辑还不一样。所以我从来不自己写模运算去模拟,而是直接用专门工具爆破,再拿对应版本的 PHP 做验证。

3. 核心爆破:用 php_mt_seed 把种子从 32 位空间里捞出来

3.1 为什么不用自己写循环

看到时间戳种子,第一反应是写个 PHP 脚本从当前时间倒着扫。这种做法不是不行,但有两个问题:一是只能应对时间戳这种低熵种子,一旦种子是随机 32 位整数,脚本就跑不动了;二是 PHP 脚本本身跑得慢,一个循环里要初始化整个 MT 状态再逐次生成,效率很低。

Openwall 出品的 php_mt_seed 就是专门干这个的。它针对 PHPmt_rand()的具体实现做了深度优化:不是简单枚举种子后重放,而是把观测输出转成 MT19937 状态位之间的约束关系,利用位运算和向量化指令批量排除不可能的种子。实际效果非常夸张,几组输出就能在几秒到几分钟内从 2^32 个种子里筛出正确答案。

工具地址是 openwall.com/php_mt_seed,我一直用的是 4.0 版本,它同时支持 PHP 5 和 PHP 7 两套mt_rand区间映射算法,很省心。

3.2 安装与基本用法

在 Linux 环境下编译只需要三条命令:

wget https://www.openwall.com/php_mt_seed/php_mt_seed-4.0.tar.gz tar -xzf php_mt_seed-4.0.tar.gz cd php_mt_seed-4.0 && make

依赖非常少,只需要 gcc 和 make。Windows 用户建议直接用 WSL 或者 Docker,避免在环境上浪费时间。

用法分两种情况:

  • 如果你观测到的是无参mt_rand()的输出,直接把多个值用空格分隔传进去:./php_mt_seed 1234567890 987654321
  • 如果你观测到的是mt_rand($min, $max)的输出,每个观测值必须写成三个参数:输出值、最小值、最大值。

这道题观测到的是mt_rand(0, 61),所以每个观测值都要写成21 0 61这样的三元组。

3.3 把字符下标变成爆破参数

手工转换太容易错,写个小脚本最稳:

alphabet = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" token = "v0MbgNx2Jq" args = [] for ch in token: idx = alphabet.index(ch) args.extend([str(idx), "0", "61"]) print(" ".join(args))

输出:

21 0 61 26 0 61 48 0 61 1 0 61 6 0 61 49 0 61 23 0 61 28 0 61 45 0 61 16 0 61

然后执行:

./php_mt_seed 21 0 61 26 0 61 48 0 61 1 0 61 6 0 61 49 0 61 23 0 61 28 0 61 45 0 61 16 0 61

这里有一个小经验:参数必须是"输出值 最小值 最大值"三件套,一个都不能少。如果漏了 min/max,工具会把你的参数当成无参mt_rand()的输出去匹配,结果自然是空。

3.4 结果解读与验证

我的实际运行结果很快就出来了,工具报告找到了匹配的种子:

seed = 1555128592

1555128592 转成十六进制是0x5cb16110,而且它本身是一个很标准的 Unix 时间戳,落在 2019 年 4 月前后。看到这个数字的时候,我心里已经确认题目就是用 time() 播种的了。

拿到种子别急着提交号码,先做一步验证:在本地用相同版本的 PHP 跑一下生成逻辑,确认能原样复现题目给的那串v0MbgNx2Jq。如果复现不一致,第一嫌疑是 PHP 版本不对,这个在第 4.4 节会细讲。

3.5 遇到 str_shuffle 变体时的两条出路

不少相似题目不会傻傻地把mt_rand(0, 61)的下标直接暴露给你,而是用str_shuffle($alphabet)对整个 62 字符的字母表洗牌,再截取一段当成抽奖码。这时候观测结果不是字符下标,而是一个排列。

str_shuffle 在 PHP 底层实现是 Fisher-Yates 洗牌:从数组末尾开始,每一步用mt_rand(0, $i)决定和哪个位置交换。也就是说,洗牌过程消耗的是一串mt_rand(0, 61),mt_rand(0, 60), ...,mt_rand(0, 1)的输出,但你不能直接把排列结果喂给 php_mt_seed。

我的处理方式有两种。

第一种,写逆洗牌恢复每次交换位置。从排列末尾开始,反推每一步mt_rand(0, $i)的取值,再把恢复出来的三元组喂给 php_mt_seed。这个思路理论上很干净,但实现起来容易在边界条件上翻车,我通常不首选。

第二种,躺平暴力扫描。因为种子还是时间戳,范围就那么大,直接在本地对每个候选种子执行mt_srand($seed); str_shuffle($alphabet);,把结果和题目返回的抽奖码比对。一个 PHP 脚本扫几十万次也只要几秒钟:

<?php $target = "题目展示的抽奖码"; $alphabet = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; for ($seed = time() - 86400; $seed <= time() + 3600; $seed++) { mt_srand($seed); $first = substr(str_shuffle($alphabet), 0, 10); if ($first === $target) { echo "seed: $seed\n"; // 注意这次 str_shuffle 已经消耗了一次洗牌 echo "next: " . substr(str_shuffle($alphabet), 0, 10) . "\n"; break; } } ?>

这段脚本里,$first用来匹配题目展示的那一串,匹配成功后再调一次str_shuffle得到下一期号码。整个过程不需要 php_mt_seed,但对"种子必须是小范围可枚举"这一点依赖很强。遇到mt_srand(rand())之类的复合种子,还是老老实实回到工具。

4. 复现攻击链:从种子到预测下一期号码并拿下 flag

4.1 先复现已知的第一串号码

用刚才爆破出的种子 1555128592 在本地跑下面这段 PHP:

<?php mt_srand(1555128592); $alphabet = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; $str = ''; for ($i = 0; $i < 10; $i++) { $str .= $alphabet[mt_rand(0, strlen($alphabet) - 1)]; } echo $str . "\n"; ?>

如果一切正常,输出应该是v0MbgNx2Jq。这一步的意义是确认:爆破工具解出的种子、本地的 PHP 实现、题目服务端的生成逻辑,三者对上了。任何一环出错,后续预测都是白搭。

这里要特别强调"生成逻辑要对上"。题目源码虽然没泄露,但通过字符集推断出来的循环结构就是最合理的假设。如果换成 str_shuffle 变体,这里的复现脚本也要换成对应的str_shuffle

4.2 关键一步:不要在两次生成之间重复播种

恢复出第一串之后,下一步是预测第二串。很多新手在这一步反复失败,原因是他们在生成两串之间又调了一次mt_srand($seed)

要知道,mt_srand设置的是整个随机数序列的起点。服务端生成第一串抽奖码时,内部状态已经从种子位置前进了 10 次mt_rand调用;如果它在同一个会话里接着生成第二串,那么第二串是基于"前进了 10 次的状态"继续生成的,而不是重新从种子开始。

提示:服务端具体如何比对,可以理解为"会话开始时播种一次,展示当期号码时,同时用同一个随机数流生成下一期号码作为比对基准"。只要我们从展示号码恢复出种子,就能把随机数流继续往下推进,算出这个比对基准。

所以正确的复现方式是:只调用一次mt_srand($seed),然后连续跑两次生成循环:

<?php mt_srand(1555128592); function draw($alphabet, $len) { $str = ''; for ($i = 0; $i < $len; $i++) { $str .= $alphabet[mt_rand(0, strlen($alphabet) - 1)]; } return $str; } $alphabet = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; echo "第一串: " . draw($alphabet, 10) . "\n"; echo "第二串: " . draw($alphabet, 10) . "\n"; ?>

理论上第一串输出是v0MbgNx2Jq,第二串就是我需要提交的"下一期号码"。这里还有个小细节:如果你中途用mt_rand()做任何别的测试,状态就会被污染,输出对不上。所以验证和预测应该放在同一个脚本里,一次跑完。

4.3 提交号码,拿到 flag

把第二串号码填进输入框,POST 提交,服务端返回了 flag,格式就是常见的flag{...}

整个攻击链路到此闭合:观察输出 -> 反推字符下标 -> 爆破种子 -> 复现随机数状态 -> 预测下一期。这个链路里最核心的资产其实是那个种子,一旦种子到手,后面全部是本地算术问题。

4.4 同一个种子在不同 PHP 版本下输出不一样的坑

这道题我做得比较顺,但这类题里最经典的坑就是 PHP 版本差异。原因在于,PHP 的mt_rand()底层虽然一直是 MT19937,但从 PHP 7.1 开始,mt_rand($min, $max)的区间映射算法变了,导致同一个种子在 PHP 5.x 和 PHP 7.x 下生成"0 到 61 之间整数"的结果完全不同。

具体来说,旧的实现存在分布偏差,新的实现做了修正,把更多随机位保留下来用于区间映射。如果你用 php_mt_seed 爆破出来的种子,在本地一个 PHP 5.6 环境里复现,发现字符串对不上,不要怀疑种子错了,先看看本机 PHP 版本是不是和目标环境差了太多。

解决办法很简单:本地装一个和目标版本一致的 PHP,或者直接用 Docker:

docker run --rm -v "$PWD":/work -w /work php:7.2-cli php check.php

我习惯同时准备php:5.6-cliphp:7.2-cli两个容器,爆破出种子后分别跑一遍,总有一个能对上。php_mt_seed 4.0 在搜索时也会同时考虑两套算法,所以它输出的候选种子可能不止一行——哪个版本对应的验证通过了,就用哪个。

4.5 如果服务端每次请求都重置种子

还有一种变体是:服务端在每次请求处理时都执行mt_srand(time()),这样你看到的抽奖码只代表当前这一秒的种子。预测下一期,就变成预测下一秒的种子。

这种变体的解法思路一样,但多了一步:先用响应头里的Date字段估算服务器当前时间,拿到一个基准时间戳 T。假设题目在你提交时会生成"下一期"号码,那个号码的种子大概率是 T+1。你可以在本地对 T、T+1、T+2 分别生成号码,然后手动多试几次;如果网络有延迟导致错过了那一秒,就把窗口放宽到前后 5 秒。

这种设计对网络要求很高,体验很差,所以正规比赛的题目一般不会这么做。但我确实在实战中遇到过,提出来是希望大家碰见的时候知道怎么应对。

5. 踩坑清单与可复用的解题模板

5.1 我实际踩过的坑

把这类题里最常见的坑整理成一张表,都是我或者身边人真实踩过的:

现象原因解决办法
php_mt_seed 跑不出任何候选种子字符下标映射错误,比如把字母表大小写顺序搞反用同一字母表演算多个已知抽奖码,交叉核对映射
爆破出种子但本地复现结果对不上本地 PHP 版本与服务端不同,区间映射算法不同用 Docker 分别跑 php:5.6-cli 和 php:7.x-cli 验证
预测的下一期号码差一位生成第二串之前重复执行了 mt_srand只播种一次,连续跑两次生成循环,中间不要调用其他 mt_rand
每次刷新号码都变且无法预测服务端每次请求都重新 mt_srand(time())用响应头 Date 时间戳做前后几秒的种子扫描
抽奖码长度太短,爆破结果不唯一观测输出信息量不足多点几次,收集多个号码合并约束;或改用时间窗精确限定
题目是 str_shuffle 输出还硬套下标法混淆了洗牌后的排列与 mt_rand 原始输出改用时间窗暴力模拟 str_shuffle 比对

5.2 遇到同类题目的五步套路

把这次的解题过程抽象出来,其实可以复用到所有 mt_rand 相关的题目里:

  1. 确认随机数来源:优先找源码泄露,没有源码就靠输出格式猜。字符集 62、长度固定、无符号,大概率是mt_rand从字母表逐位取字符;如果看到的是完整排列,多半是str_shuffle
  2. 收集观测值:把每个字符映射成字母表下标,记录请求时间。观测值至少 6 个才比较稳,建议直接收集 10 个以上。
  3. 爆破种子:mt_rand(0, N)的观测用 php_mt_seed 的三元组格式;str_shuffle 的先按时间窗暴力模拟,不行再写逆洗牌。
  4. 本地复现:用同版本 PHP 复现已知串,确认一切对上。
  5. 预测提交:只在第一次mt_srand后连续生成需要的次数,把预测值提交。

我自己的习惯是,把第 2 步的字符转下标做成一个通用工具函数,配合 alphabet 参数,遇到不同的题直接复用,省去大量重复劳动。

5.3 给业务开发者的提醒:抽奖系统千万别用 mt_rand

这道题看起来是 CTF 里的一个玩具,但它映射的是真实世界里非常常见的安全问题。我做过不少代码审计,见过用mt_rand生成优惠券码、开奖号码、短信验证码、会话 token 的真实系统,攻击手法几乎一模一样:拿到几个连续输出,恢复种子,然后预测后续所有"随机"结果。优惠券被薅秃、抽奖被预言家包场,都是这么来的。

正确的替代方案是 PHP 自带的 CSPRNG 函数:random_int()random_bytes(),或者bin2hex(random_bytes($n))。它们不从种子推导,安全性完全不一样。如果你的抽奖系统已经上线了,至少先检查一下随机数的来源,别让这段"枯燥的抽奖"代码变成一个真实的漏洞。

最后说点个人体会。这道题名字叫"枯燥的抽奖",但做完之后回头看,它其实把 PHP 伪随机数漏洞的完整攻击链讲得清清楚楚:弱种子、可观测输出、状态可复现,三个条件凑齐,任何"随机"都等于透明。我后来在实战里看到一个抽奖功能,第一反应就是翻后端代码里有没有mt_srandmt_rand,这个习惯就是那时候养成的。做同类题的时候记住一句话:种子一泄,全盘皆输。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询