拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PHP伪随机数漏洞实战:从mt_rand种子爆破到预测抽奖码

点开题目页面简陋得让人想直接关掉——白底、一行大字枯燥的抽奖、一个输入框和一个按钮。点击抽奖服务端回了一串字符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 页面上唯一有价值的东西是那一串字符题目没有任何框架依赖打开就是一个居中的表单。点抽奖后页面刷新显示一串新的字符同时保留一个输入框。我一开始以为输入框是用来提交自己选的号码随便输入一次返回没中奖再试试吧。关键观察点有三个抽奖码固定是 10 位我连续刷了几十次长度没变过。字符集固定是0-9a-zA-Z共 62 个字符没有、/、这些 base64 里常见的符号。输入框的提示文案明确写着输入下一期抽奖码即可获得 flag。也就是说这道题不是让你跟服务端比谁运气好而是明摆着告诉你号码是可以预测的你只需要找到预测的方法。这个提示其实已经剧透了一半——服务端生成抽奖码的过程一定存在某种确定性。1.2 源码、备份文件与响应体里的线索Web 题第一步永远是看源码。右键查看源代码HTML 结构非常简单没有隐藏字段、没有 JS 混淆、没有外部资源只看到一个 formmethod 是 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可以拆成v21, 026, M48, b1, g6, N49, x23, 228, J45, q16这一串数字就是 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 15551285921555128592 转成十六进制是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. 复现攻击链从种子到预测下一期号码并拿下 flag4.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或者直接用 Dockerdocker run --rm -v $PWD:/work -w /work php:7.2-cli php check.php我习惯同时准备php:5.6-cli和php:7.2-cli两个容器爆破出种子后分别跑一遍总有一个能对上。php_mt_seed 4.0 在搜索时也会同时考虑两套算法所以它输出的候选种子可能不止一行——哪个版本对应的验证通过了就用哪个。4.5 如果服务端每次请求都重置种子还有一种变体是服务端在每次请求处理时都执行mt_srand(time())这样你看到的抽奖码只代表当前这一秒的种子。预测下一期就变成预测下一秒的种子。这种变体的解法思路一样但多了一步先用响应头里的Date字段估算服务器当前时间拿到一个基准时间戳 T。假设题目在你提交时会生成下一期号码那个号码的种子大概率是 T1。你可以在本地对 T、T1、T2 分别生成号码然后手动多试几次如果网络有延迟导致错过了那一秒就把窗口放宽到前后 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 相关的题目里确认随机数来源优先找源码泄露没有源码就靠输出格式猜。字符集 62、长度固定、无符号大概率是mt_rand从字母表逐位取字符如果看到的是完整排列多半是str_shuffle。收集观测值把每个字符映射成字母表下标记录请求时间。观测值至少 6 个才比较稳建议直接收集 10 个以上。爆破种子mt_rand(0, N)的观测用 php_mt_seed 的三元组格式str_shuffle 的先按时间窗暴力模拟不行再写逆洗牌。本地复现用同版本 PHP 复现已知串确认一切对上。预测提交只在第一次mt_srand后连续生成需要的次数把预测值提交。我自己的习惯是把第 2 步的字符转下标做成一个通用工具函数配合 alphabet 参数遇到不同的题直接复用省去大量重复劳动。5.3 给业务开发者的提醒抽奖系统千万别用 mt_rand这道题看起来是 CTF 里的一个玩具但它映射的是真实世界里非常常见的安全问题。我做过不少代码审计见过用mt_rand生成优惠券码、开奖号码、短信验证码、会话 token 的真实系统攻击手法几乎一模一样拿到几个连续输出恢复种子然后预测后续所有随机结果。优惠券被薅秃、抽奖被预言家包场都是这么来的。正确的替代方案是 PHP 自带的 CSPRNG 函数random_int()、random_bytes()或者bin2hex(random_bytes($n))。它们不从种子推导安全性完全不一样。如果你的抽奖系统已经上线了至少先检查一下随机数的来源别让这段枯燥的抽奖代码变成一个真实的漏洞。最后说点个人体会。这道题名字叫枯燥的抽奖但做完之后回头看它其实把 PHP 伪随机数漏洞的完整攻击链讲得清清楚楚弱种子、可观测输出、状态可复现三个条件凑齐任何随机都等于透明。我后来在实战里看到一个抽奖功能第一反应就是翻后端代码里有没有mt_srand和mt_rand这个习惯就是那时候养成的。做同类题的时候记住一句话种子一泄全盘皆输。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门