这个题目我印象挺深的,玩CTF的朋友应该都知道,抽奖类题目十有八九不是在考验运气,而是在考验你对后端随机函数的理解。它有个特别容易让人误判的点:页面看起来很天真,一副“你猜中了就给你flag”的架势,但真正解题的时候你会发现,能不能中奖完全取决于你对伪随机数生成器的掌握程度。
先交代一下背景。这道题出自2019年的某场CTF比赛,名字叫“[GWCTF 2019]枯燥的抽奖”,单看名字就知道出题人自己也觉得这机制很枯燥——事实也确实如此,它把PHP的mt_rand函数、种子预测、以及一点编程能力串在了一起。对我来说,这类题最大的价值不在于flag本身,而在于它用最直白的方式演示了一件事:在安全场景里,用种子可预测的伪随机数做抽奖,等于把门锁挂在门框上。
这篇文章我会尽量还原我当时完整做题的思路,包括怎么从页面特征判断后端逻辑、怎么收集随机数样本、怎么用工具反推种子、最后一个脚本搞定预测。整个流程不依赖什么高深数学,更偏向“工具 + 思路 + 细节”的组合拳。适合刚接触伪随机数类题目的新手,也适合想系统整理一下解题套路的选手。
1. 初见题目:抽奖页面到底在考什么
1.1 点开页面先看什么
题目环境打开之后就是一个很朴素的抽奖页面。页面上有个输入框,提示“输入你预测的中奖号码”,旁边显示最近几期的开奖结果。说实话,这种界面第一眼根本看不出考点,因为它既可以是一道简单的JS前端题,也可以是后端逻辑漏洞,甚至可能是会话伪造。所以我当时做的第一件事就是看源码。
源码里通常藏着三类信息:注释、接口路径、以及残留的调试信息。这道题的HTML源码里就有一行非常关键的注释,大意是提醒你“中奖号码由服务器生成,规则藏在代码里”。顺着这个提示,我用浏览器开发者工具看了一下网络请求,发现页面在加载时会请求一个开奖接口,返回的是一串数字。这串数字的格式非常重要,它直接决定了下手的方向。
这里有个经验:遇到抽奖类题目,别急着去“抽奖”,先把返回数据拿到手。抽奖行为本身是客户端参与的,但中奖判断一定发生在服务端,而服务端要生成随机数,就必然会把某些随机数输出到前端。你要做的,就是把这些输出当成“密码学样本”看待,而不是单纯的游戏数据。
1.2 从一次开奖结果猜测后端实现
我拿到的接口返回大概是这样的:
{"result":[5,9,2,7,1,8,3,6,4,0]}一次返回10个数字,范围是0到9,看起来是某种“开奖号码”。这时候先做一个最朴素的分析:如果后端用rand(),结果分布会有些特征;如果用的是mt_rand(),虽然结果看起来也是均匀的,但注意一个细节——mt_rand的分布质量更高,而且它的输出序列存在可预测性。由于题目是PHP环境,我更倾向于怀疑是mt_rand()。
当然,光凭一次返回是没法确认的。我连续刷新了几次页面,把接口返回的数字记录下来。这里有个很重要的观察点:每次刷新后,这组号码和上一组有没有连续变化?如果后端每次请求都用mt_rand()从同一个种子流里不断往后取数,那么你拿到的多组结果就等价于同一个随机数序列的多个连续片段。如果后端每次刷新都重置种子,那多组结果之间可能看不出直接关系,但没关系,后续仍然可以通过种子预测来解决。
这道题的特点就是:后端在每次页面加载时生成一个随机种子,然后基于这个种子产生一组开奖号码;而你输入预测号码后,后端会按同样的种子继续往后生成下一个数,对比是否一致。所以核心目标非常明确:从已知的开奖号码反推出种子的值,然后算出下一个开奖号码。
2. 锁定突破口:为什么“抽奖”能被预测
2.1 伪随机数生成器的本质
很多人一听“随机数可以被预测”就觉得矛盾:既然是随机,怎么还能预测?其实这里的“随机”指的是伪随机。PHP里的mt_rand()底层是梅森旋转算法的一个变种。这个算法会维护一个巨大的内部状态数组,每次调用输出一个数,然后内部状态按照固定规则更新。
关键在于:梅森旋转算法只要你能拿到足够多的连续输出,就有办法恢复出内部状态;而内部状态一旦恢复,整条序列在你眼里就跟透明一样。如果输出的序列还和某个“种子”直接绑定,那么反推出种子之后,不但能预测后续所有数,甚至能复现之前的所有数。
这里可以打个比方:伪随机数生成器就像一本号码簿,种子决定了你从第几页开始翻。每调用一次mt_rand()就是按顺序读一个号码。如果你看到连续几十个号码,就一定能倒推出页码,也就是种子。
2.2 从输出反推种子的两条路线
面对这类题目,反推种子主要有两条路线:
- 路线一:已知种子范围有限,直接用暴力匹配。如果种子是
time()、date('his')这类可枚举的值,你可以把候选种子挨个生成一遍,看哪条序列的前缀和你观察到的数据一致。现实中很多业务代码喜欢用时间戳做种子,这一条路线特别有效。 - 路线二:种子范围大但输出足够多,用专用工具恢复状态。比如
php_mt_seed这个工具,它就是根据mt_rand()的输出值,利用梅森旋转内部状态之间的数学关系,快速缩小种子候选范围。
这道题属于哪种?我测试了一下,连续刷新页面拿到的多期结果之间没有明显的“时间相关”规律,说明种子可能是一个较大的随机值,直接用枚举时间的方式不现实。于是自然就走上了路线二:使用专用工具。
注意,在尝试反推之前,一定要搞清楚两件事:一是PHP版本,二是
mt_rand()调用时的参数范围。PHP 7.1之前和之后的mt_rand实现细节有一些差异;mt_rand(min, max)和mt_rand()不带参数时的输出格式也不同,这两个信息直接决定了工具参数怎么填。
2.3 为什么“输出格式”这么重要
这是很多新手最容易卡住的地方。php_mt_seed工具的输入不是“返回值本身”,而是“返回值在mt_rand()输出中所处的位置和范围”。具体来说:
- 如果
mt_rand()直接返回一个0到9的数字,那么你观察到的每个数字,就对应一次mt_rand(0, 9)调用。 - 如果后端代码写的是先
mt_rand(0, PHP_INT_MAX),然后对10取模得到0到9的数字,那么你观察到的数字就不能直接当作mt_rand(0,9)的输出来用,因为这个数字丢失了高位信息。
所以拿到接口返回的10个数字之后,我第一反应不是急着跑工具,而是先判断数字到底是“直接随机出来的”还是“变换出来的”。判断方式也很简单:多拿几组数据,看看相邻两次的波动规律。如果是mt_rand(0,9)直接输出,0到9出现的频率会比较均匀;如果是对大范围随机数取模或取余,分布上会有细微偏差,但肉眼很难看出来。
这道题的反馈是:接口返回的数字正好就是mt_rand(0, 9)的连续10次输出。这让后面的工具参数填写变得非常简单:每个数字对应一个6位范围(0到9),顺序就是调用顺序。
3. 实操第一环:收集足够的随机数输出
3.1 抓包确认接口规则
我先在浏览器里刷新了几次页面,把网络面板里返回的数据复制下来。这里有一个经验:别贪快,要确保你拿到的多组数据属于同一个“随机数流”。如果题目设计成每次刷新都会重新生成一个全新的种子并开启一段新的随机数流,那么跨页面拿到的那几组结果之间其实没有序列关系,只能把其中一组单独拿来用。
更好的做法是:用脚本直接调接口,在同一个会话内连续请求多次,然后把结果保存下来。我当时的思路是先用浏览器拿到一道“开奖号码”的完整格式,然后把请求参数固化,用一个简单的Python脚本重复请求几十次,观察是否有规律。
这个阶段主要做三件事:
- 确认接口地址和参数;
- 确认返回数据格式是JSON还是其他文本;
- 确认每次返回的数字长度是不是固定的10位。
这三件事看起来简单,但非常重要。因为如果返回的数字是变长的、或者中间混入了其他字符,你在构造php_mt_seed参数时就会出错。后来我在写采集脚本时,直接按“数字数组”解析,过滤掉所有非数字字符,确保只保留纯数字序列。
3.2 计算每个数字对应的“输出位置”
这里有个细节值得单独拿出来说。假设接口返回的是[5, 9, 2, 7, 1, 8, 3, 6, 4, 0],那么这10个数字就对应10次连续的mt_rand(0, 9)调用。但是题目页面在加载的时候,后端可能不只调用了这10次随机数。比如:页面自身的功能可能先用掉几个随机数,然后才调用接口;或者接口在生成结果之前,还调用过其他随机函数。
这就引出一个问题:你看到的10个数字,真的是种子流里的第1到第10个数吗?不一定。有可能实际是第11到第20个。如果位置对不上,你用php_mt_seed跑出来的“种子”,预测出来的下一个数也会对不上。这就是为什么有些选手拿到了正确的随机数序列,却始终猜不中下一个数。
应对方法很简单:多试几个偏移量。比如先假设看到的数字是第1到第10个,反推出种子后,算出第11个数去提交;如果不对,再假设看到的是第2到第11个,反推出另一个种子,算第12个数去提交。通常偏移量就在个位数范围内,因为后端逻辑不会太复杂,不会调用太多次随机函数。
3.3 用脚本批量采集,减少手误
手抄随机数容易出错,尤其是数字一多就容易眼花。我当时直接用Python写了个小脚本,请求接口然后把返回结果原样存到本地文件,顺便格式化成一行的数字序列。这样有两大好处:一是保证数据准确,二是方便后面反复尝试不同偏移量。
import requests url = "http://目标地址/api/xxx.php" data = [] for i in range(20): r = requests.get(url) # 假设返回 {"result":"5927183640"} 这样的文本格式 result = r.json()["result"] data.append(result) print("".join(data))脚本输出的结果就是一段长长的数字流。我建议先别急着合并所有期数,每期单独保存一行,这样后面在调试偏移量时可以灵活截取任意一段连续输出。当时我采集了10期,每期10位数字,加起来一共100个随机样本。这个样本量足够跑了。
4. 核心武器:php_mt_seed 反推种子
4.1 工具安装与编译
php_mt_seed是一个专门用于根据mt_rand输出反推PHP种子的小工具。它的用法不复杂,但细节容易出错。我在自己的Linux环境上直接下载源码编译,命令大概是:
wget http://www.openwall.com/php_mt_seed/php_mt_seed-4.14.tar.gz tar -xzf php_mt_seed-4.14.tar.gz cd php_mt_seed-4.14 make编译之后会生成一个可执行文件,用法核心就是把你观察到的随机数按格式传进去。这个工具的文档写得很直白:每个数字后跟它的最小值、最大值,多个数字连续排列即可。由于我们观察到的数字范围是0到9,如果看到了三个数字5 9 2,那么参数就是:
./php_mt_seed 5 0 9 9 0 9 2 0 94.2 参数构造的三种常见格式
在构造参数时,我得先明确题目环境中mt_rand的调用范围。有的PHP代码调用mt_rand(0,9),那参数是数字 0 9。有的代码调用mt_rand() % 10,那情况就变成大范围随机数取模,工具需要特殊处理,甚至不能直接用。
我遇到的问题就是:题目给出的10个数字,直接作为0 9范围传给工具。第一次跑的时候,工具几秒钟就返回了一大堆种子候选,少说有几十个。这时候别急着高兴,因为候选种子多意味着你需要进一步筛选。
筛选的方法是:用这些候选种子分别生成下一组随机数,然后提交到页面判断哪个种子是对的。其实更高效的做法是,把你采集到的随机数序列切成两段:一段用来反推种子,一段用来验证。我当时把20期开奖数据排成一行,前面10个数字喂给php_mt_seed,后面10个数字用来验证预测结果,这样能在不碰页面的情况下快速筛选种子。
4.3 跑种子时的版本与平台注意点
这里必须多啰嗦几句,因为这是最容易被忽略的坑:
- PHP版本差异。PHP 7.1之前和之后,mt_rand的底层实现有变化,虽然梅森旋转的框架一致,但输出映射可能存在差异。php_mt_seed工具本身也在更新,新版通常会兼容更多PHP版本。跑种子时,最好确认题目环境用的PHP版本,再看工具的输出是否支持该版本。这场比赛的环境是PHP 5.x,所以我选择老版本兼容模式,跑起来很顺。
- 大端小端与位数。在Windows平台和Linux平台上,int的位数可能不同。虽然题目环境基本都是Linux,但如果你在本地Windows上跑工具,可能会有一些莫名其妙的偏差。建议直接在Linux虚拟机或容器里操作。
- 种子候选过多怎么办。如果只喂了4到5个数字,php_mt_seed可能会输出上万个候选种子。这时候就要多喂一些数字,喂满10个以上,候选会断崖式下降。这也是为什么我强调先采集足够多的随机样本。
我第一次跑的时候只用了4个数字,结果候选种子茫茫多,根本没法验证。后来改成一次喂20个数字,候选只剩个位数。所以一个很朴素的建议是:样本越多越好,至少准备20个连续输出。
5. 预测与提交:从种子到“中奖”
5.1 编写预测脚本
反推出唯一候选种子之后,接下来的事情就简单了。先用PHP模拟出和题目环境一致的随机数流,生成下一组开奖号码。
这里有个关键技巧:不仅要复现“下一个数”,还要保证你的PHP调用次数和题目的调用次数完全一致。比如,如果题目在生成开奖号码之前先消耗了3次mt_rand()调用,那么你在本地预测时也要在计算“下一个数”之前先调用3次占位。否则,你算出来的“第11个数”实际对应着题目里的“第14个数”,自然对不上。
我当时写了一个PHP脚本:
<?php mt_srand(12345); // 模拟题目消耗的随机数调用 for ($i = 0; $i < 10; $i++) { mt_rand(0, 9); } // 预测下一个数 $predict = mt_rand(0, 9); echo $predict . "\n"; ?>如果你不确定题目到底消耗了几次,可以把“预测下一数”改成“预测下几个数”,然后把结果依次提交试试。页面一般会提示“中奖号码是多少”,你可以从预测序列里逐个尝试。
5.2 完整解题脚本参考
完整的解题流程通常是:先用Python把接口返回的随机数整理成php_mt_seed需要的格式,然后跑工具得到种子,再写PHP或Python脚本预测下一位。我当时的做法是分段完成,因为中途需要人工判断候选种子。
下面是一个简化的解题脚本思路:
import subprocess # observed 是我们观察到的连续随机数,比如20个 observed = [5, 9, 2, 7, 1, 8, 3, 6, 4, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3] # 构造 php_mt_seed 参数 params = [] for v in observed: params.extend([str(v), "0", "9"]) # 运行 php_mt_seed result = subprocess.run( ["/path/to/php_mt_seed"] + params, capture_output=True, text=True ) print(result.stdout)工具输出种子之后,再用PHP预测。如果你不想把PHP嵌进Python,也可以直接用Python写MT19937的预测代码,但那样要做很多底层映射,容易出错。个人建议:反推种子用现成工具,预测用PHP原生命令,实际操作最稳。
5.3 提交与拿到flag
最终,我用反推出的种子生成了后续10个数字,把第一个提交到页面,页面显示“中奖”,随后弹出了flag。整个流程跑通之后,我复盘了一下,发现最耗时间的反而不是跑种子,而是中间几个细节判断——比如偏移量、PHP版本、以及种子候选的筛选。把这三个坑避开之后,这类题目基本15分钟内可以拿下。
一个额外提醒:当你拿到flag后,最好回头把种子值记录下来。因为有些题目会要求你继续预测“下一期”甚至“多期”,记录下种子后可以直接批量生成后续所有号码。
6. 实战视角:这类漏洞离业务有多近
6.1 抽奖、验证码、防重放中的随机数陷阱
CTF里的抽奖题,放到真实业务里其实也不罕见。很多公司做营销抽奖时,后台抽奖逻辑可能就写了几行PHP或Java代码,用mt_rand()生成中奖号码。如果中奖规则是“预设一批号码池,从中随机抽一个”,并且种子可以从历史开奖记录里反推出来,那薅羊毛的人完全可以预测下一期中奖号码,把奖品和优惠券全包了。
同理,验证码、邮件链接里的token、防重放的nonce、会话ID的生成,都应该使用加密安全的随机数源。不同随机数源的区别在于:普通的伪随机数生成器追求的是“看起来均匀、速度快”,而安全随机数生成器追求的是“即使知道前面几百个输出,也无法预测后面任何一个新输出”。这两类需求不能混用。
有一次我帮一个做活动的团队做代码审计,就看到他们用mt_rand(100000, 999999)生成短信验证码。短信验证码一共就6位数字,攻击者只要注册一个账号、购买一次短信,就有一个验证码样本;如果验证码的生成时间区间已知,结合工具反推种子,短时间内就能把种子范围缩得很小,然后批量预测所有用户的验证码。这比逐个暴力猜6位数字不知道快了多少倍。
6.2 业务代码避坑清单
结合这道题,我整理了一份简单实用的避坑清单,适合直接贴进代码评审规范里:
- 安全场景一律不用mt_rand。验证码、token、密码重置链接、抽奖开奖规则,这四类需求全部用
random_int()或操作系统的真随机源。 - 抽奖系统不要把开奖号码直接完整返回前端。哪怕历史开奖结果可以做数据分析,也不要让当前开奖未完成的号码提前出现在任何接口响应里。很多漏洞就是这么泄露出去的。
- 如果必须用随机数做奖品分配,建议用服务端预生成+加密签名。先把中奖结果生成好,抽奖行为只做“揭晓”,不做“实时随机”。
- 记录随机数调用次数要覆盖所有分支。这道题的偏移量问题,本质上就是“前端看到的随机数”和“后端完整随机数流的位置”错位了。真实业务里如果随机函数散落在各种日志、上下文中,这个问题更隐蔽,也更危险。
6.3 关于开源工具和黑产工具的边界
这里顺便多说一句。php_mt_seed这类工具本身是安全研究人员为了方便测试而写的开源工具,用于证明某个随机数方案是否可预测。它的定位和端口扫描器、密码恢复工具一样,属于中性技术工具。我在文章中详细描述它的用法,目的是让读者理解这类漏洞的成因并学会自查和防御,而不是鼓励对真实业务实施恶意攻击。如果你在真实业务场景里发现自己系统存在这类问题,正确做法是通过内部漏洞报告流程反馈给开发团队修复。
7. 解题复盘与几个容易踩的坑
这道题整体思路不难,但做起来有几个环节非常容易让人卡住。我把它们单独拎出来总结一下:
坑一:只拿到4到5个随机数就去跑种子。这样跑出来的候选数量会非常多,你根本不知道选哪个。正确做法是尽可能多收集随机数,至少连续20个。这个样本量足以把候选种子压到个位数。
坑二:忽略了随机数调用的偏移量。页面加载过程中,后端可能先用掉了几个随机数,导致你看到的“第一期的第一个数字”并不是种子流里的第一个输出。解决方式是多试几个偏移量,最好写个循环自动调试。
坑三:不确认PHP版本。如果你用最新版工具的默认行为去跑PHP 5.x环境的题目,结果可能完全对不上。遇到这种情况,先看题目环境提示或响应头里的版本信息,再决定工具参数。
坑四:把mt_rand(0,9)的输出和mt_rand()%10的输出当成一回事。这两种调用方式的工具参数完全不同。前者直接把数字丢进工具,后者可能根本跑不出来。判断依据还是那句老话:多看几组数据,分析数字分布,确认是原生范围随机还是大范围随机后取模。
最后一个经验,也是我从这类题目里收获最多的:解题时一定要建立“从结果倒推过程”的意识。很多漏洞表面上看起来是“随机数不够随机”“权限校验不严”“数据过滤不到位”,但往深了想,都是“信息输出超出了必要范围”。你在题目里看到的每一串数字、每一个接口响应,都在帮助攻击者重建内部状态。所以在写业务代码的时候,也要反过来想:我到底把哪些内部状态通过功能接口暴露给了外部?这些暴露的输出,会不会组合起来形成一条完整的攻击链?
回到最开始说的,这题“枯燥”吗?确实枯燥,本质上就是机械地收集数据、跑工具、提交结果。但真正把它拆开看,背后藏着的伪随机数安全问题和信息泄露问题,一点也不枯燥。至少从那以后,我再看到哪个产品用普通随机函数做抽奖或验证码,都会下意识地多留个心眼。