news 2026/9/16 20:52:24

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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=预测出的抽奖结果&data=O: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.bak
2源码审计找出所有 mt_rand() 调用位置和反序列化接口
3样本采集获取抽奖接口返回的随机字符串
4索引还原把字符串按字符集映射回随机数输出值
5种子爆破用 php_mt_seed 恢复种子
6序列预测在本地模拟目标调用顺序,预测下一次输出
7payload 构造生成反序列化 POP 链,提交触发

以后再遇到类似“抽奖”“验证码”“token 生成”的题,先不要慌,优先确认服务端到底用的是不是 mt_rand(),如果是,这套流程就能直接平移过去。

最后再分享一个小技巧:拿到源码后,先把所有涉及随机数的地方打上标记,然后用“找入口、找链尾、连成串”的思路去读,比眉毛胡子一把抓高效得多。我个人的体会是,这类题目看起来很绕,实际上考察的就是两个基础能力:对伪随机数原理的理解,以及对 PHP 反序列化魔术方法触发条件的敏感度。这两个能力练扎实了,做题速度和成功率都会有质的提升。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 20:51:42

CPRI与eCPRI前传协议帧结构解析:从Wireshark抓包到丢包排查实战

上周在实验室帮同事看一台分布式小站的eCPRI前传抓包&#xff0c;抓到一大堆以太网帧&#xff0c;同事问我&#xff1a;这里边怎么看不到IQ数据&#xff1f;我说你往协议树下面翻&#xff0c;找到EtherType 0x22EE那一条&#xff0c;eCPRI的IQ数据全藏在以太网帧的载荷里。他恍…

作者头像 李华
网站建设 2026/9/16 20:51:05

CMake入门:从编译痛点到底层构建逻辑,一文讲透C++项目构建

很多写C的朋友第一次接触CMake&#xff0c;都是因为项目从单个cpp文件变成了十几个文件&#xff0c;又或者是从GitHub上拉了一个项目下来&#xff0c;发现根本没有Visual Studio的.sln&#xff0c;也没有Makefile&#xff0c;只有一堆CMakeLists.txt。网上教程一搜一大把&#…

作者头像 李华
网站建设 2026/9/16 20:49:32

宝塔邮局25端口被封?465端口中继配置全指南

1. 为什么25端口突然“失联”&#xff1f;这不是故障&#xff0c;是行业常态你刚在宝塔面板里点开邮局管理器&#xff0c;填好域名、设置好用户&#xff0c;信心满满地点击“发送测试邮件”&#xff0c;结果日志里赫然跳出一行红字&#xff1a;Connection refused或timeout on …

作者头像 李华
网站建设 2026/9/16 20:49:26

深入拆解目标文件(.o):ELF结构、符号表与重定位实战

写C/C的人每天都会跟编译器打交道&#xff0c;但说起gcc -c main.c之后生成的那个main.o&#xff0c;大部分人其实没真正打开看过。有人觉得没必要&#xff0c;有人觉得反正链接器能搞定&#xff0c;看它纯属浪费时间。但等我遇到几次头疼的链接报错之后&#xff0c;才意识到这…

作者头像 李华