CTFshow Web25 这道题,前前后后卡了我一个下午。不是读不懂代码,而是明明看到一串随机数就在面前,却不知道下一次会生成什么。后来我把 PHP 的伪随机数机制和 php_mt_seed 这个工具彻底过了一遍,才算真正打通。今天这篇,就把这个从“看到 mt_rand 输出”到“用 php_mt_seed 爆破种子、预测下一段随机数、拿到 flag”的完整链路拆开讲清楚。
这篇文章适合三类人:一是刚接触 CTF Web 题、遇到伪随机数题目毫无头绪的新手;二是已经在用 php_mt_seed 但对参数和原理一知半解的选手;三是写 PHP 业务时随手用 mt_rand 生成验证码、token,想搞清楚这玩意儿到底有多不安全的开发者。我会把工具原理、编译方式、参数构造、实战脚本和翻车记录都写出来,尽量做到你照着操作就能复现。
1. 先搞懂敌情:mt_rand 为什么能被一秒看穿
1.1 伪随机数不是“随机数”,是有种子的序列
很多人一看到 mt_rand 就默认它是“随机的”,这恰恰是掉坑的起点。PHP 的 mt_rand 底层是梅森旋转算法(Mersenne Twister,MT19937),它本质是一个状态机:先用一个种子初始化内部状态数组,之后每调用一次,就按固定规则从状态里算出一个数,同时更新内部状态。
用生活里的例子说,这就像一台设定好参数的洗牌机:你把一副牌放进机器,设定同一个初始顺序(种子),机器的每一次洗牌结果都是完全固定的。只要你一开始的种子相同,后面所有输出都能一模一样地复现。CTF 题目里常见的漏洞,就是服务端自己 mt_srand 了一个种子,然后把 mt_rand 的输出用作验证码、token、订单号等安全凭证。
这里有个最核心的结论:同一个种子,一定产生同一个随机序列。反过来,如果我能根据已观测到的输出反推出种子,那我就能计算出整条后续序列。php_mt_seed 干的就是这件事。
1.2 mt_srand 与 mt_rand 输出之间的“中间层”
在实战里,只记住“同种子同序列”还不够,你还要知道 PHP 对 mt_rand 的输出做了哪些手脚。
早期的 PHP(5.2.1 之前)和后来的版本行为不一样;PHP 5.2.1 到 7.0 之间,mt_rand 返回值会强制去掉符号位或做额外位移,导致输出范围集中在 0 到 2147483647;PHP 7.1 之后又改了实现,输出映射和内部状态的关系又变了一截。更麻烦的是,当你调用 mt_rand($min, $max) 时,PHP 不是直接把内部生成的 32 位整数做一次取模,而是走了一个范围映射函数,这个映射本身也是可以逆推的。
这意味着,你用 php_mt_seed 恢复种子时,必须把你观测到的输出还原成“内部值”,或者至少给工具足够的信息让它自己算。工具支持指定每次 mt_rand 调用的最小值和最大值,实际上就是让工具在搜索种子时,用范围映射条件去剪枝。
我最早犯的错就是拿到一个 mt_rand(0, 99999) 的输出,直接当成原始输出丢给 php_mt_seed,结果跑几百万个种子都匹配不上。后来才知道必须告诉工具“这个数是在 0 到 99999 范围内生成的”,工具的搜索逻辑才能对齐 PHP 内部的范围映射。
1.3 为什么“暴力搜种子”这条路可行
理论上,MT19937 的内部状态有 624 个 32 位字,如果连续拿到 624 个原始输出,你可以直接重建整个状态矩阵,然后预测后续任意输出。但这是理想情况,实战里你往往只拿得到一两个随机数,而且这些数还被范围映射加工过,用经典的状态恢复算法非常麻烦。
php_mt_seed 换了一个思路:不去恢复 624 个字的内部状态,而是暴力搜索种子。PHP 的 mt_srand 接受的是整数种子,32 位平台就是 2^32 量级,64 位平台虽然理论上能接受更大的值,但绝大多数题目场景服务端用的也就是一个常规整数或时间戳。2^32 看起来很吓人,但配合“输出值 + 范围”的剪枝条件,实际搜索空间会急剧缩小——工具不是傻傻地从 0 枚举到 4294967295,而是按输出数值的二进制位逐级判断,每一个已观测输出都能砍掉一大批候选种子。
所以,给工具的输出样本越多、范围信息越精确,爆破速度就越快。一个带完整 min/max 的样本,通常几秒到一两分钟就能出种子;如果只有一个裸的 mt_rand() 输出,没有范围信息,搜索空间会大很多,可能需要跑比较久,这也是后面实战里要尽量避免的情况。
2. 工具落位:php_mt_seed 编译、参数与版本匹配
2.1 从源码到可执行文件,两分钟搞定
php_mt_seed 是个命令行工具,主流用法是在 Linux 环境下编译运行。你从开源仓库把源码拉下来,目录里通常就是几个 C 源文件和一个 Makefile,直接 make 就能出可执行文件。
cd php_mt_seed make ls -l php_mt_seed编译过程基本不会遇到依赖问题,因为工具只用到标准 C 库。需要注意两点:一是尽量在 64 位 Linux 上编译运行,32 位环境下部分版本会编译失败或搜索范围受限;二是如果你平时用 Windows 做题,建议装个 WSL 或者用虚拟机,不要指望在原生 Windows 命令行里跑起来,网上那些“Windows 编译版”多半是别人交叉编译的,版本旧不说,参数行为也可能不一致。
我个人的建议是:把这个工具放到一个固定目录里,配好别名或者写进脚本。因为它后面你刷题会经常用到,每次重新编译浪费时间,而且旧版本和新版本的参数格式有差异,混着用容易出错。
2.2 参数格式:给工具“吃”什么它才知道种子
php_mt_seed 最常用的输入格式有三种。
第一种,只有一个裸输出值:
./php_mt_seed 803799806这种情况对应的是服务端直接调用 mt_rand() 得到的值,工具按默认的完整输出范围去搜索。
第二种,带最小值和最大值:
./php_mt_seed 42964 0 99999这个三元组表达的意思是:某次调用了 mt_rand(0, 99999),观测到的结果是 42964。工具在搜索种子时,会以“这个输出在 0 到 99999 范围内映射后等于 42964”为条件剪枝。这个信息非常关键,比单纯一个裸值有价值得多——范围越小,符合条件的种子候选越少,爆破越快。
第三种,一次给多个输出样本:
./php_mt_seed 42964 0 99999 88331 0 99999 12004 0 99999这三个三元组对应的是连续三次调用 mt_rand(0, 99999) 的结果。多个样本的剪枝效果是相乘的,能极大缩小候选种子区间。实战里如果你只能拿到一个样本,最后的候选种子往往会有一大堆,需要额外手段才能确定唯一真正种子,这我在后面第 3 章会详细讲。
2.3 一个最容易被忽略的坑:PHP 版本模式
php_mt_seed 在搜索种子时要模拟 PHP 内部 mt_rand 的行为,而 PHP 不同版本的 mt_rand 输出映射并不一样。工具本身提供了对多个 PHP 版本行为的支持,但你需要知道当前靶机用的是哪个版本,或者干脆多试几种模式。
如果你看到的题目环境是 PHP 5.2.1 到 7.0,mt_rand 的输出是“旧版模式”;PHP 7.1 之后是“新版模式”。php_mt_seed 的不同发行版本对模式的处理方式也不同,有的版本在编译时需要你指定 PHP 版本,有的版本运行时通过参数切换。
这里我踩过一个很深的坑:题目返回头里显示 PHP 7.4,我用默认模式跑种子,怎么跑都出不来;后来发现那个版本的工具默认按旧版 mt_rand 行为搜索,而 7.4 应该用新版模式。切到对应模式之后,几秒钟种子就出来了。所以,爆破之前先确认题目环境的 PHP 版本,或者用两种模式各跑一次,别在一个模式上死磕。
3. 实战拆解:从随机数样本逆推种子的完整链路
3.1 勘察题目:要预测的到底是哪个随机数
CTFshow Web25 这类题,出题套路高度相似。表面上是一个普通的业务功能,比如评论区留言、抽奖、修改密码、意见反馈,点击提交后服务端会返回一个“随机编号”。但你再往下走,会发现某个关键操作(比如查看 flag 的页面、重置密码的链接)需要你提交一个“凭证”,这个凭证恰好就是用 mt_rand 系列函数生成的。
拿到题目后我的做法是三步走:
第一,把所有请求和响应里的数字参数列出来,尤其关注看起来像“随机数”的字段,比如 rnd、token、code、verify、sid 这类名字。第二,反复触发几次,观察这个数字是否每次都变,变的话大致区间是多少。第三,看代码或环境信息,判断这个数字是由 mt_rand() 直接生成,还是 mt_rand($min, $max) 生成——后者一定会在页面、接口或注释里透露出范围。
以最常见的变体为例:你点“生成反馈编号”,服务端返回feedback_id=42964,同时页面提示“编号范围 0-99999”。这个 42964 就是 mt_rand(0, 99999) 的一次输出,而你要预测的下一个编号,就是服务端连续生成的下一个 mt_rand(0, 99999) 结果。
3.2 收集样本并构造工具输入
拿到范围和输出后,先别急着爆破,先老老实实多取几个样本。我这里说的“多个样本”,最好是同一会话里连续触发生成的结果,因为它们对应同一个种子序列中连续的状态。
比如我连续触发三次“生成编号”,得到了:
第一次:42964 第二次:88331 第三次:12004观测到的都是 0 到 99999 范围内的整数。那么构造给 php_mt_seed 的输入就是:
./php_mt_seed 42964 0 99999 88331 0 99999 12004 0 99999这里有一个细节:三元组的顺序必须和实际生成顺序一致。php_mt_seed 按你给定的顺序逐个核对输出,如果你把第二次和第一次的结果写反了,工具会认为这三个输出对应的随机序列和你提供的不匹配,自然搜不出种子。
如果题目只给你一个输出,那你也要硬着头皮跑:
./php_mt_seed 42964 0 99999跑完你会得到一批候选种子。我遇到过候选种子几百个的情况,这时候就需要靠“多取一个输出”来过滤。这也是为什么我一直强调,做题时能多拿样本就多拿,被观察到的输出越多,唯一性越高。
3.3 爆破种子并本地复现
工具跑完,如果一切正常,会输出类似这样一行:
Found seed 123456789这里要特别提醒一句:工具给出的种子有可能是多个候选。尤其是在样本少、范围大的情况下,你会在输出里看到一堆形形色色的数字,不能随便拿一个就去预测目标值,那样大概率失败。
正确的做法是:写一个本地 PHP 脚本,用候选种子初始化 mt_srand,然后完整复现服务端的随机数生成逻辑,看能否得到和你观测到的一模一样的输出序列。
<?php // 候选种子 $seed = 123456789; mt_srand($seed); $rangeMin = 0; $rangeMax = 99999; $observed = [42964, 88331, 12004]; $ok = true; foreach ($observed as $v) { $r = mt_rand($rangeMin, $rangeMax); if ($r !== $v) { $ok = false; break; } } if ($ok) { echo "[+] seed confirmed: $seed\n"; // 继续生成下一个目标值 echo "[*] next = " . mt_rand($rangeMin, $rangeMax) . "\n"; } else { echo "[-] seed mismatch: $seed\n"; } ?>不要小看这一步。我在实际做题时遇到过工具报出 4 个候选种子,前 3 个看起来都挺像回事,脚本一验证全是错的,只有最后一个能完整复现输出序列。原因很简单:你的样本本身就来自确定序列,工具在剪枝搜索时可能会把一些“当前样本匹配但后续随机序列不匹配”的种子也留下,验证脚本就是最后一道坎。
确认种子之后,脚本里继续调用 mt_rand($min, $max) 生成的下一个值,就是服务端下一次会生成的目标凭证。注意脚本里的调用次数不能多也不能少,因为 mt_rand 每次调用都会推进内部状态——多调用一次,下一个值就完全不对了。
3.4 提交目标值,拿到 flag
拿到预测值之后,把它放到触发 flag 的业务请求里去提交。这一步通常没什么悬念,把字段名改对,把值填进去,就能看到 flag。
如果提交后没反应,我建议优先检查两点:一是你预测的是“直接可见的下一次输出”,但服务端可能在你观测和预测之间还调用了别的随机数函数,比如先调了一次 mt_rand() 做别的用途,这种情况下本地脚本也要把那次调用“补位”上;二是检查目标凭证类型,如果题目里凭证是个 8 位数字,但你按 0-99999 的范围跑了,说明你根本没对齐题目的实际生成逻辑,要回头重新看代码。
4. 高频翻车现场:爆破失败的五大原因排查
4.1 常见问题速查表
这里把我刷各种 mt_rand 题时遇到的典型失败场景整理成一个速查表,方便你排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 工具跑很久没结果 | 样本太少、范围太大 | 多取几次连续输出,补全 min/max |
| 跑出大量候选种子 | 单个输出信息量不足 | 追加后续输出过滤,或用脚本验证 |
| 按默认模式跑不出 | PHP 版本模式不匹配 | 切新版/旧版模式重试 |
| 预测值和实际提交值不一致 | 本地调用次数和服务端不一致 | 检查服务端生成凭证前是否调用了其他 mt_rand |
| 输出带符号或截断异常 | 平台位数 / PHP int 溢出 | 确认 64 位 PHP,必要时把输出转成无符号数再处理 |
4.2 样本太少,候选种子太多怎么收敛
样本太少这个问题几乎人人都遇到。只有一个 mt_rand(0, 99999) 输出时,候选种子可能成千上万,光靠肉眼根本没法确定唯一解。
我试过几种办法,最有效的是再来一次请求,拿到相邻的下一个输出。因为同一个会话、同一个种子里,相邻两个 mt_rand 输出本身就是严格序列关系,你把它一起放进命令,立刻能把候选种子收敛到一个或几个。如果题目限制你只能取一个样本,那就观察“下一次业务功能”里是否又泄露了一个随机数——很多题目没有把随机数藏死,只是藏得比较隐蔽。
另一种办法是看种子本身是否具有时间特征。比如你通过 php_mt_seed 得到的候选种子大多集中在某个时间戳附近,那说明服务端很可能是 mt_srand(time()) 初始化种子的。这种情况你可以直接锁定几个靠近当前时间戳的候选种子,配合本地的 PHP 脚本验证,通常很快能确定。
4.3 爆破耗时太长怎么优化
工具跑很久,多半是剪枝条件不够强。我通常按以下顺序优化:
第一,确认每个三元组都给出了准确的 min/max。能用 0 到 99999 就别偷懒填 0 到 999999999,范围越小,搜索树剪得越狠。第二,增加样本数量到至少 2 到 3 个,多个样本的剪枝效果是叠加的。第三,如果工具支持线程或分片参数,可以并行跑多个区间。第四,不要在低配置虚拟机里跑大范围爆破,宿主机上直接跑通常快一个量级。
我最初跑一个 0 到 4294967295 的裸值,在虚拟机里跑了近二十分钟才出种子,挪到宿主机加上第二个样本之后,不到一分钟就出结果了。工具性能只是一方面,更关键的是你喂给工具的信息质量。
4.4 本地验证脚本和服务端状态不一致
这个坑非常隐蔽。题目页面里展示的“编号”可能是 mt_rand(0, 99999) 的结果,但服务端在生成编号之前,可能已经调用过一次 mt_rand() 用来生成别的随机参数,只是没有回显给你。这种情况下,本地脚本如果从 mt_srand 之后直接开始生成编号,其实会比服务端少了一次状态推进,预测自然不准。
排查办法是尽量在同一个请求路径里判断:如果页面源码里能看到另一个随机数的痕迹,比如一个隐藏字段或固定格式的随机串,那本地脚本就要把对应的 mt_rand 调用次数补进去。换句话说,你的本地脚本必须完整模拟从种子到目标输出的全部 mt_rand 调用序列,而不是只模拟你能看到的那一部分。
5. 走出 Web25:伪随机数攻防的一体两面
5.1 同样的坑还会出现在哪些地方
学会了 php_mt_seed,你会发现 Web25 只是冰山一角。凡是业务里用 mt_rand 生成安全敏感数据的地方,都存在同类问题。
最典型的是密码重置 token。很多老系统生成重置链接时,会用 mt_rand() 拼接一个随机串,攻击者如果拿到其中一段输出,就能反推种子并预测其他用户的 token。其次是验证码、抽奖码、优惠券兑换码,这些看起来“随机”的码一旦落到 mt_rand 手里,就完全不具备不可预测性。还有一些框架还会用 mt_rand 生成 session id 的种子,那就更严重了,种子一旦被恢复,整个会话体系都可能被接管。
不光是 PHP,其他语言的伪随机数也有类似问题。Python 的 random 模块、Java 的 Random 类、C 的 rand 都属于可预测伪随机数生成器,只要你观测到足够多输出,都能用对应的工具或脚本反推内部状态。所以这个思路一通百通:先判断随机数生成器是否以固定种子初始化,再判断是否有输出泄露,最后用工具做状态恢复或种子搜索。
5.2 防御端怎么堵:别用可预测随机数当安全凭证
从开发角度说,这类题反复出现,本质上是很多开发者把 mt_rand 当成了安全随机数。实际上,PHP 官方早就提供了真正的加密安全伪随机数接口:random_bytes()和random_int()。这两个函数基于操作系统提供的 CSPRNG(加密安全伪随机数生成器),不可预测性远强于 MT19937,用于 token、验证码、重置链接都是更稳妥的选择。
如果因为历史原因必须保留 mt_rand 生成的凭证,至少要做到两点:一是不要把 mt_rand 的原始输出直接回显给用户,因为每一次泄露都在为攻击者提供样本;二是给最终凭证加一层不可逆的变换,简单说就是别让“凭证值”和“mt_rand 输出”之间是一一对应的直接关系。除此之外,还要给关键操作加一次性校验和频率限制,让攻击者即使算出了目标值,也没有机会反复尝试。
5.3 我做这类题的一点个人体会
刷了这么多伪随机数题目,我最深的感受是:这类题最难的地方不是工具怎么用,而是你敢不敢确定“这个参数就是 mt_rand 的输出”。很多人一上来就期望 php_mt_seed 直接给答案,结果喂进去一个根本没有范围信息的字符串,工具想帮你都帮不上。
我现在的标准流程是:先花五分钟把题目里所有“看起来像随机数”的字段都列出来,确认范围和生成方式;再根据已知信息构造尽可能精确的三元组;然后跑工具,拿到候选种子后立刻写 PHP 脚本验证;最后才去预测目标值提交。这个流程也许不是最快的,但成功率最高,也最适合在赛场上心态紧绷的时候照着做。
最后再分享一个小技巧:如果你手头只有一个输出,别急着爆破,先翻一下页面源码、请求头、Cookie 里有没有别的数字参数,很多时候第二个样本就藏在你不经意扫过的地方。多拿一个样本,后面能省下几十分钟的爆破时间。伪随机数爆破是个“信息量比拼”的活儿,你给工具的信息越多,它给你的答案就越快越准。