CTF PWN入门:栈溢出覆盖随机数种子破解dice_game
第一次在CTF平台的PWN引导模式里点到dice_game时我其实没太当回事。界面上一行“连续猜对五次骰子点数就能拿flag”看着就是拼人品的小游戏和印象里那些需要跟内存布局斗智斗勇的PWN题完全不像。直到我把它扔进反汇编工具才发现引导模式这四个字的意思——它用一个骰子游戏把“越界覆盖关键变量”和“随机数可预测”这两个入门PWN必须搞懂的概念串在了一起。如果你刚开始接触PWN想找一道不用碰堆、不用绕canary又能完整走一遍逆向分析、漏洞定位到exp编写全流程的题目这一题很值得认真过一遍。我一开始也想过直接爆破五分之一的概率是 (1/6)^5约等于 1/7776理论上重连几千次总能撞对一次。但真去试就会发现这种靠运气的做法既慢又蠢尤其是每次交互还要重新输入名字、重新等提示撞上十几次基本就失去耐心了。更关键的在于这类引导题的设计初衷从来不是让你赌概率而是让你意识到程序既然把随机数种子暴露在输入可控的范围内那么“随机”本身就不再随机。1. 题目初印象一个“五连胜”游戏背后的考点1.1 五轮猜骰子的规则到底在考什么这道题启动后会打印几句欢迎信息然后要你输入名字。名字输入完之后进入五轮游戏。每一轮程序内部会生成一个 1 到 6 的整数类比成骰子点数然后问你“请猜一个数”。如果你猜的和程序生成的完全一致就进入下一轮五轮全对则触发胜利分支通常就是调用system(cat flag)或者system(/bin/sh)反正目标很明确让程序认为你是一个“幸运玩家”。如果只从表面逻辑看这就是个简单得不能再简单的猜数字游戏。但你反过来想一个CTF平台为什么要把这种纯看运气的游戏收录到PWN引导模式里唯一合理的解释是这个游戏的“公平性”可以被破坏。既然程序允许你在猜骰子之前先输入一长串名字那它就给了你一个改动内存状态的机会。很多玩家第一次卡在这道题就是因为光顾着在循环里猜数字根本没把前面的“输入名字”和后面的“随机数生成”联系起来。1.2 拿到文件后的三分钟静态检查拿到dice_game文件后先做基础检查。我的习惯是两条命令起步file dice_game checksec --file./dice_game这一步信息量很大。64位ELF、动态链接、x86-64小端序这些基本不用记重点是下面的保护情况。当时我看到的输出大概是这样的检查项结果我的解读Archamd64-64-little64位程序地址和参数传递规则按SysV AMD64来RELROPartial RELRO没有完全只读重定位但本题用不上StackNo canary found栈上无金丝雀栈溢出成本低NXNX enabled栈不可执行别想着往栈上放shellcodePIENo PIE (0x400000)地址固定代码段地址可以直接引用在PWN题里No canary 和 No PIE 是最友好的两个信号。前者意味着我们可以更粗暴地覆盖栈上数据后者意味着如果需要函数指针或返回地址可以直接写死地址。但这里有个容易误判的点这道题虽然栈不可执行、没有canary但很多人一上来就试图往返回地址上打ROP绕了半天才发现根本用不上。真正的关键并不在返回地址而是在栈上一个更“靠前”的位置。2. 逆向分析从 main 函数追踪随机数的“开关”2.1 反编译之后的程序骨架我用反汇编工具打开程序恢复出的核心逻辑大概是下面这样。这里我写成了便于阅读的C伪代码真实反编译结果可能有些变量名不一样但结构一致int main() { char name[48]; unsigned long long seed; // 栈上局部变量紧挨着 name 的高地址方向 setvbuf(stdin, 0, 2, 0); setvbuf(stdout, 0, 2, 0); puts(Welcome to dice game!); puts(Whats your name?); read(0, name, 0x50); // 读入 80 字节name 只有 48 字节 printf(Hello %s\n, name); srand(seed); for (int round 1; round 5; round) { int dice rand() % 6 1; printf(Round %d. Input your guess: , round); int guess; scanf(%d, guess); if (guess ! dice) { puts(Wrong!); exit(0); } puts(Correct!); } puts(You win!); system(cat flag); return 0; }注意read(0, name, 0x50)。0x50 是十进制 80而name是 48 字节。这已经是一个很明显的越界信号程序允许你写入的数据比你准备的缓冲区多了 32 字节。平台把考点放在这里就是要你利用这 32 字节的溢出空间。再看seed变量。它是一个栈上的本地变量跟name在同一个栈帧里位置在name的高地址方向。程序在读入名字之后没有对你做任何二次校验直接把seed丢给srand()。这就等于告诉你你写的越界数据可以决定后续所有随机数的生成结果。2.2 从字符串和导入函数确认胜利后门静态检查时除了看反编译还建议用strings快速瞄一眼关键字符串。这个程序里我能看到Welcome to dice game、Whats your name、You win!以及cat flag这类字符串。cat flag出现的位置基本就是胜利分支。导入函数里一般也会有这几个关键符号read、printf、srand、rand、scanf、system。看到srand和rand配对出现就要立刻想到“随机数序列可预测”这个话题。对一个CTF题目来说随机数往往不是安全问题的核心真正的问题是随机数种子可控。2.3 栈布局与覆盖偏移接下来必须搞清楚两件事name到seed的距离是多少seed的长度是多少。这题变量在栈上的布局大致如下相对位置内容长度说明rbp-0x38name[48]48输入缓冲区rbp-0x10seed8随机数种子rbp-0x08saved rbp8旧基址rbp0x08return address8返回地址按照这个布局name起始地址和seed起始地址之间正好相差 0x30也就是 48 字节。也就是说我只要先输入 48 个字节把name填满紧接着的 8 个字节就会落到seed上。payload 头尾就变得非常规整payload bA * 48 p64(0)这里的p64(0)把种子覆盖成 0。为什么是 0因为只要种子的值是已知的后面rand()生成的序列就是完全确定的。用 0 只是为了方便你也可以用 1、2、3只要你自己能同步生成同一套答案。3. 漏洞本质越界的那 80 个字节做了什么3.1 缓冲区越界写入的直观理解把栈想象成一个长条形快递柜每个格位大小固定。name占 48 格seed在它上面一格。正常情况下你只能在 48 格以内放东西。但read(0, name, 0x50)告诉系统“请从 socket 接收 80 字节放到 name 起始的地址上。”于是系统不会关心你只有 48 格它会老老实实地一个个字节塞进去直到塞满 80 字节。多出来的部分自然就流进了邻居的格位。这些邻居格位里最先被碰到的是紧接着name的变量。如果程序员的代码恰好是先把这个变量传给srand()再在游戏循环里不断调用rand()那么攻击者实际上就控制了整个游戏的核心状态。听起来很绕但本质上就是一道“数据流劫持”题。通常PWN题喜欢让你劫持控制流比如覆盖返回地址跳到后门函数而这道题不需要劫持控制流只需要劫持数据流让程序自己走进胜利分支。3.2 为什么不需要覆盖返回地址很多刚接触PWN的人看到栈溢出第一反应就是“覆盖返回地址跳到system(cat flag)”。这是很多入门题的标准答案但在这题里属于绕远路。原因有两个层面。第一seed在栈上的位置比返回地址更靠近name所以输入的前 80 字节会先覆盖到seed继续往下写才会碰到 saved rbp 和返回地址。如果只为了跳到胜利分支你完全可以不去管后面的返回地址。第二即使返回地址在溢出范围内直接覆盖也会面临程序后续正常退出时崩溃的问题而五轮猜对之后程序会立刻调用system(cat flag)你根本不关心 main 函数最后怎么返回。所以“覆盖关键变量”比“覆盖返回地址”要稳得多。这其实也解释了为什么题目没有开 canary 却依然很适合入门。它并没有让你冒险去爆破 canary而是把seed放在了一个不需要越过 canary 就能控制的位置。一个真正好的引导题就是这样它让新手能专注于“理解漏洞如何影响程序逻辑”而不是一开始就被各种缓解机制劝退。3.3read和gets在本场景的差异有人可能会问都是读入字符串用gets和用read有什么区别区别很重要。gets遇到\0或者换行会停止读取而且会自动在末尾追加\0。如果我用p64(0)这种包含空字节的 payload 发给gets程序大概率只读到第一个空字节就停了后面全部丢弃覆盖自然失败。但read是按长度读不关心内容里有没有\0。只要指定长度是 80它就会尽量读满 80 字节哪怕里面全是空字节也会照单全收。这题恰好用的就是read所以我们可以放心地发送包含空字节的p64(0)把种子精确覆写成 0。如果你在本地实验时发现覆盖不生效先检查一下题目到底用的是read还是gets不同的输入函数对 payload 的影响完全不同。4. 随机数预测只要知道种子五轮答案就已经定死了4.1 glibc 的随机数到底可不可预测C 标准库里的rand()不是真正的随机数生成器。它内部维护一个状态srand(seed)用来初始化这个状态之后每次调用rand()都会根据当前状态计算出下一个状态并返回一个值。同一个初始种子必然会得到同一个输出序列。这个性质和“真随机”完全相反但它足够满足大多数普通程序的随机需求比如游戏、抽样、洗牌。对PWN玩家来说这意味着只要控制了srand()的参数整个随机数序列就变成了公开信息。更妙的是CTF题目通常运行在同一套或相似版本的 glibc 环境下你完全可以用自己的程序生成同样的序列。这里不建议直接用 Python 的random模块因为 Python 的随机数算法和 C 的rand()不一样生成出来的数字对不上。正确做法是使用ctypes调本机的 libc或者直接写个小C程序。4.2 本地生成五轮答案我用一个C程序来生成固定种子对应的前五个随机骰子点数#include stdio.h #include stdlib.h int main() { unsigned int seed 0; srand(seed); for (int i 0; i 5; i) { printf(%d\n, rand() % 6 1); } return 0; }编译运行gcc gen_seq.c -o gen_seq ./gen_seq输出就是题目五轮游戏里每一轮的“正确答案”。如果把种子改成其他值输出序列会完全不同。这个C程序简单直接非常适合先跑一遍验证“种子和序列”的对应关系。在 exp 里我更推荐用 Python 脚本直接调 libc省掉外部程序import ctypes libc ctypes.CDLL(libc.so.6) libc.srand(0) answers [str(libc.rand() % 6 1).encode() for _ in range(5)] print(answers)需要留意的是远程服务器的 glibc 版本可能和本机不一致。对这道引导题来说通常平台已经保证了环境一致性但如果远程连了几次都反馈答案错误就要考虑本地 libc 和远程 libc 版本不同的可能性。题目如果提供了专属 libc可以用pwninit之类的工具统一链接环境或者自行提取远端 libc 版本再调ctypes。4.3 为什么选 0 作为种子选 0 纯粹是图省事。p64(0)构造起来简单而且srand(0)的输出序列也是完全确定的。如果你不喜欢空字节也可以选一个不含空字节的小整数比如p64(1)、p64(2)但是别忘了脚本里生成答案时也必须同步改成同一个种子。种子本身并不需要多复杂因为它只是一个“已知量”。真正的难点从来不是破解种子而是找到覆盖种子数据的位置。这道题里位置就是name 48的偏移。5. 完整 exp从本地拿 shell 到远程取 flag5.1 一个能直接跑的 pwntools 脚本我习惯用 pwntools 处理交互因为它能稳定处理收发数据尤其是空字节 payload 和循环问答。下面是完整脚本from pwn import * import ctypes context.arch amd64 context.log_level info LOCAL True if LOCAL: p process(./dice_game) else: p remote(127.0.0.1, 12345) # 换成题目实际地址和端口 # 生成答案序列 libc ctypes.CDLL(libc.so.6) seed 0 libc.srand(seed) answers [str(libc.rand() % 6 1).encode() for _ in range(5)] log.info(answers: %s, answers) # 第一阶段覆盖 seed p.recvuntil(bname?) payload bA * 48 p64(seed) p.sendline(payload) # 第二阶段依次提交五轮答案 for round_no, ans in enumerate(answers, 1): p.recvuntil(bguess:) p.sendline(ans) log.success(round %d - %s, round_no, ans.decode()) # 进入交互拿 flag p.interactive()这段脚本最核心的只有两行。第一行是payload bA * 48 p64(seed)作用是填满name后用 8 字节覆盖seed。第二行是libc.srand(seed)作用是让脚本里的随机数序列和题目程序保持一致。其他代码都是常见的连接、交互和日志。5.2 为什么用sendline而不是sendsendline会在 payload 末尾追加换行read会把这个换行也读进去多出的一个字节会继续向后覆盖。这通常是允许的因为seed旁边的位置即使多被写一个字节也不会影响srand(seed)的执行而且题目只读取前 80 字节。只要偏移不超过 80多一个换行并不会改变seed的值因为seed在 56 字节处payload 换行到第 57 字节。但严格来说想精确控制覆盖内容用send不追加换行更干净。不过在这种交互场景里read要读到足够数据才会往下执行如果没有换行程序可能一直阻塞。所以实战中我一般用sendline多一个换行无伤大雅。如果调试时发现覆盖后种子不对可以先去掉换行试一下再用 GDB 查seed的实际值。5.3 本地验证效果脚本跑起来后能看到类似这样的输出[] Starting local process ./dice_game [*] answers: [4, 6, 1, 3, 2] [] round 1 - 4 [] round 2 - 6 [] round 3 - 1 [] round 4 - 3 [] round 5 - 2 [*] Switching to interactive mode $ cat flag flag{dice_game_pwn}到了interactive()阶段实际上程序已经执行了胜利分支把控制权交给了 shell。这时你看到$提示符就可以直接输入命令查看flag。如果题目用的是system(cat flag)可能不会进入交互式shell而是直接把flag打印到屏幕上这取决于具体版本。6. 踩坑记录那些“看起来对了但就是不通”的瞬间6.1 偏移量不是“看着像就行”我第一次做题时凭经验觉得name和局部变量的偏移可能是 0x20结果 payload 写进去程序确实多读了内容但随机数序列完全对不上。后来我用反编译工具确认name起始于rbp-0x38seed起始于rbp-0x10中间是 0x30也就是 48 字节。如果你的反编译环境和我不一样千万别直接抄偏移。最简单的方法是在 GDB 里断到 main 函数打印两个变量的地址gdb ./dice_game b main run p name p seed用地址相减就能得到准确偏移。做PWN题最忌讳的就是“我看别人的payload是 48我也写 48”。不同编译版本、不同GCC参数会让栈布局发生变化多花半分钟确认一下能省掉后面大量无意义的调试时间。6.2 本地能通远程不通先怀疑 libc 版本这道题依赖 glibc 的rand()序列。如果本地环境是 glibc 2.31远程是 glibc 2.27同样的种子生成的骰子序列不一样远程自然会判定答错。遇到这种情况不要急着怀疑payload。先看一下题目描述里有没有写明环境或者远程是否直接给出了 libc 文件。如果给了 libc可以在脚本里用ctypes.CDLL(./libc.so.6)代替本机 libc然后配合p启动时设置LD_PRELOAD确保程序也加载同一个 libc。没有给 libc 时只能先猜测远程环境或者用libc.rip之类的服务根据泄露函数地址反查版本。6.3 五轮都答对了却卡住不输出这多半不是题目逻辑的问题而是交互顺序没对齐。scanf(%d, guess)在读取整数后会把换行留在输入缓冲区里。如果下一次p.recvuntil(bguess:)没等到预期的字符串脚本就会一直挂起。解决办法是严格按照程序输出顺序来接收数据或者干脆在循环里加一点超时重试。pwntools 的recvuntil默认会超时如果超时说明你上一次发送的内容可能被程序在别的分支消耗掉了。另外如果题目胜利后打印You win!但不进入交互式 shell而是直接system(cat flag)那么interactive()可能会看不到东西。这种情况就改成p.recvline()或者p.recvall()直接收输出没必要死等 shell。6.4 payload 里的空字节会不会被截断很多人第一次用p64(0)时心里会犯嘀咕空字节发出去不会被当作字符串结束吗这里要分对象。如果是gets会被截断如果是scanf(%s)也会被截断但如果是read就不会截断因为read根本没有“字符串”的概念它只搬运指定长度的字节流。所以做这道题之前最好先确认输入函数。通常反编译里能看到read(0, buf, 0x50)那就放心用包含空字节的 payload。如果看到的是gets(buf)你需要想别的办法绕开空字节比如选一个不含空字节的种子或者用单字节多次输入这时候流程会更复杂。7. 从 dice_game 延伸开去值得带走的三个思路7.1 不是所有栈溢出都得覆盖返回地址很多入门教材讲到栈溢出第一个例子就是覆盖返回地址做控制流劫持。但真实漏洞利用里还有一大类是“数据流劫持”也就是修改程序中的某个关键数据变量从而改变程序的业务逻辑。dice_game就是这类题目最好的入门示范它覆盖的不是返回地址而是一个随机数种子。当我后来遇到类似需要猜随机数的题目时第一反应就变成了“这个种子存在哪我能不能碰到它”。这个思维转换非常重要。栈溢出覆盖的并不一定必须是代码指针也可能是一个权限标志位、一个循环计数、一个身份ID。只要它决定程序的决策结果就值得关注。7.2 随机数作为安全机制是不可靠的这道题也提醒我在CTF里看到任何“随机生成”的关键数据都不要默认无法预测。尤其是srand(time(0))和srand(固定值)这种形式前者可以爆破附近几百个时间窗口后者直接就是写死的后门。如果程序用了更复杂的随机数生成器比如random设备文件那就没法这么简单地预测但只要调用的是srand加rand那所有随机性就全部寄托在种子是否保密上。在写代码时这个教训同样适用。不要把随机数种子放在用户可以影响的范围内更不要把种子作为安全决策的唯一依据。真正需要不可预测性的场景应该用系统提供的安全随机源。7.3 做题顺序比做题速度更重要我复盘这题时发现自己最大的收获不是写出了exp而是养成了一套稳定的做题顺序先file和checksec看保护再反编译恢复主流程接着找“输入点”和“可控目标变量”最后构造 payload 并验证。每走一步脑子里都在反复确认“这个变量在哪里、谁写入了它、谁读取了它”。这道题完美符合这三个问题name是谁写入的是read。seed是谁读取的是srand。rand的输出决定了什么决定了五轮猜骰子的胜负。三问一答exp 自然就出来了。我现在拿到任何PWN题都习惯先问这三问而不是急着猜考点。Dice_game 看似是个靠运气的游戏实际上每一步都在教你用逻辑拆掉运气。这一课比单纯的 flag 值值钱多了。