ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

picoCTF rop chain 实战:x64栈溢出与ROP链构造详解

picoCTF rop chain 实战:x64栈溢出与ROP链构造详解

去年第一次下载 picoCTF 2018 的 rop chain 时,我还停留在“栈溢出就是塞 shellcode”的阶段。程序里明晃晃一个 gets,我以为又是一个五分钟就能搞定的送分题,结果把自己写的一堆机器码打进栈里,程序直接 Segfault,连个水花都没有。后来才反应过来,这道题真正的门槛不在于找漏洞,而在于系统已经把栈设置成了不可执行,你精心准备的 shellcode 永远只会被当成普通数据,根本轮不到 CPU 去执行它。

这道题的价值也正在这里:它逼着你离开“复制一段 shellcode 进栈”的舒适区,老老实实理解 ROP 链到底是怎么工作的。如果你手里已经有一份 Linux x64 的 pwn 基础,知道缓冲区溢出但还没系统搞过 ROP,或者刚刷完几道入门题想往深走一步,这篇文章会非常适合你。我尽量不绕弯子,从拿到文件的第一眼开始,到最终用 pwntools 打出 flag 的完整链路,重走一遍。中间会穿插我实际调试时踩过的坑,那些才是写 writeup 的人经常懒得讲的东西。

1. 拿到题目文件后的第一轮侦查

1.1 文件类型与安全机制决定了整道题的走向

不管题目叫什么,拿到二进制后我的顺序永远是三件事:file看类型,checksec看保护,然后立刻确认有没有符号表。先别急着逆向,这几条信息能帮你省掉大量无用功。

我本地把题目给的二进制命名为ropchain,file输出大概是这样的:

ropchain: ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped

一个 64 位动态链接的 ELF,最重要的是最后的not stripped——符号表没有被剥掉。这意味着你不需要跟 IDA 较劲,直接objdump -t就能把所有函数名、全局变量名看得清清楚楚。对于入门题来说,这基本等于出题人把答案放在了桌面上。

接着是checksec,我用的是 pwntools 自带的那个,输出信息整理成下面这张表:

项目状态对这个题目意味着什么
Canary关闭栈上可以放心覆盖,不需要先泄露金丝雀
NX开启栈和堆数据不可执行,shellcode 方案直接作废
PIE关闭代码段地址固定,gadget 地址可以直接写死在 payload 里
Relro部分开启对这道题影响不大,重点是上面三行

先说 NX。它开启之后,你往栈里写任何机器码都只是“数据”,CPU 一旦打算以指令方式去执行它,就会被硬件拦下来。所以传统思路彻底没戏,这也是为什么这道题被称为入门 ROP 题而不是入门 shellcode 题。再配合 PIE 关闭,意味着程序运行时 .text 段的加载地址是固定不变的,你从反汇编里看到的地址是多少,运行时就是多少。这两个条件凑在一起,才是 ROP 能“可复现”的前提。

Canary 关闭则解决了最后一个顾虑:gets 读入时发生的缓冲区溢出不会被程序察觉,你可以一口气把返回地址覆盖成任意值。如果这题开了 Canary,你就得先想办法泄露它,难度直接上一个台阶。这道题没开,那么就往下走。

1.2 反汇编打底:三个函数和一个隐藏的全局变量

因为有符号表,直接在 gdb 里info functions,或者用objdump -t ropchain | grep F,能看到除了 main 之外的三个关键函数,名字非常直白:win、flag、val。初学者看到这种命名通常会犯一个错误:以为调用 win 就能拿 flag。别急着下结论,把反汇编逐条看一遍才是正经。

我用objdump -d ropchain把.text段拉出来,重点看了这几个函数。main 很短,做的事情就是打印一行提示,然后调用 vuln;vuln 里就是一个典型的栈溢出点,声明了 64 字节的局部缓冲区,然后 gets 往里读,没有任何长度限制。win 函数确实存在,但它做的事情是在终端打印一句“You win!”,并不包含读 flag 文件的逻辑。真正藏着 flag 的是 flag 函数,而 flag 函数又不是无条件执行的,它里面有一连串的比较指令,条件全部满足后才会打开 flag 文件输出内容。

再往下看,val 函数同样有参数校验,校验通过后会修改某个全局变量。把几个函数拼起来,逻辑就清楚了:这道题需要你先调用一次 val,让它把全局标志位置为满足条件的状态;然后再调用 flag,让它读 flag 文件。而且两次调用都需要传入特定的参数,参数不对,函数内部依然不会走真正的分支。

也就是说,这道题表面上是“栈溢出控制返回地址”,实际考的是两件事:一是你能不能按正确的顺序调用多个函数,二是你能不能给函数设置正确的参数。前者需要你对栈布局有感觉,后者需要你对 x64 传参约定有肌肉记忆。这两个点,恰恰就是 ROP 链的核心。

2. 为什么一条 ret 指令就能“变”出任意执行流

2.1 ret 的真正语义:它是一台“弹栈跳转机”

很多刚开始学 pwn 的同学对 ret 的理解停留在“函数结束就该 ret”。这个理解没有错,但太粗了。实际上 ret 是一条非常机械的指令,它做两件事:把栈顶的 8 字节弹出来,塞进指令指针寄存器 RIP,然后跳过去执行。也就是说,栈顶是什么地址,CPU 就跳到什么地址。

放到正常程序里,函数返回地址是 call 指令自动压栈的,所以 ret 能把控制权还给调用者。但一旦这块内存可以被你改写,情况就完全变了。你往栈里写多少字节、写什么值,ret 就会老老实实地把那个“伪造的返回地址”当作下一站目的地。你可以把它理解为一张被调包的机票:登机口不看你是谁,只看你手里的票上写着哪里。

回到这道题,vuln 函数的缓冲区溢出发生在栈上,gets 读入的数据从低地址往高地址覆盖,最终可以一路覆盖到函数返回地址。配合 Canary 关闭这一点,我们可以精确计算偏移,让 vuln 函数结束时的 ret 直接跳到我们指定的函数。

2.2 从“跳一次”到“跳一串”:gadget 的概念

如果只是跳一次,那栈溢出问题就简单了:直接把返回地址改成 flag 函数地址就完事。但 flag 函数要求参数满足特定条件,x64 架构下函数调用的第一个参数放在 RDI 寄存器,第二个参数放在 RSI 寄存器。问题来了:ROP 链只能控制栈上的数据,你没有机会直接写 RDI 寄存器,怎么办?

答案是从程序现有的代码里找“片段”。一个以 ret 结尾的指令序列被称为 gadget,比如pop rdi; ret。pop rdi 会把栈顶的 8 字节弹进 RDI,然后 ret 又把新栈顶弹进 RIP。这样一小段代码就把“往 RDI 里塞值”这个原本做不到的操作变成了“在栈上放一个值就行”。

把多个 gadget 串起来,就是 ROP 链。每一个 ret 都相当于接力棒交接,执行完一个片段后自动跑到下一个片段。你在栈上布置的不是单一返回地址,而是一整条“指令流水线”:先是 pop rdi,接着是它要弹出的参数,再是 pop rsi,跟着是第二个参数,最后是目标函数地址。理解这一点后,再看网上那些又长又怪的 payload,就不会觉得它们神秘了,本质上就是往栈上按顺序摆好“片段地址”和“参数预留位”。

2.3 x64 传参约定:绕不开的 System V ABI

x64 下的函数调用约定和 x86 完全不同。x86 时代参数靠压栈,所以栈溢出攻击里只要在栈上布置好参数就行;x64 下前六个整型参数分别通过 RDI、RSI、RDX、RCX、R8、R9 传递,更多参数才会用栈。这意味着攻击者不仅要控制跳转地址,还要控制这些寄存器。

这就是为什么 ROP 工具里最常见的 gadget 是pop rdi; ret和pop rsi; ret。你可以把 RDI 类比成快递单上的收件人,RSI 是收件人手机号,POP 指令则是一个临时中转站:栈上的值先被卸到中转站,再被 ret 带着去往下一个目的地。掌握了这个思维方式,后面遇到传三个参数、四个参数的题目,思路是一致的,只是需要找对应更多寄存器的 gadget。

2.4 偏移量 72 是怎么来的:别背答案,用 cyclic 实测

构建 ROP 链之前,最基础的一步是确定“覆盖到返回地址需要多少字节”。很多人喜欢背答案,觉得这题缓冲区是 64 字节,加上保存的 RBP 8 字节,所以偏移是 72。这个说法对应这道题确实没错,但它不是规律,只是结果。编译器的栈帧布局、是否开启栈帧指针、优化级别都会改变偏移量,真正靠谱的做法是用工具实测。

我习惯用 pwntools 的 cyclic 生成一段有规律的长字符串,把它喂给程序,然后看程序崩溃时 RIP 寄存器里出现的“乱码”是哪四个字节,再用cyclic -l反查偏移。比如生成 200 字节,跑出崩溃地址的片段是0x6161616c,那么:

cyclic -l 0x6161616c

输出的数字就是偏移量。我在这道题上实测得到 72,但这 72 只对当前这个二进制有效。换个编译选项、换个 GCC 版本,结果就可能变成 80 甚至 88。所以我的建议永远是:每个新题目都老老实实跑一遍 cyclic,两分钟的事情,不要省。

3. 找 gadget:工具推荐与手工核对的经验

3.1 用 ROPgadget 快速筛选目标片段

地址偏移确认之后,接下来就是找能够控制参数的 gadget。这类工具很多,我用得最多的是 ROPgadget,其次是 ropper。针对这道题,我只需要两类片段:能把栈顶值弹进 RDI 的,和能把栈顶值弹进 RSI 的。

命令非常直观:

ROPgadget --binary ./ropchain --only "pop|ret"

这条命令会列出所有由 pop 和 ret 组成的指令序列,里面既有pop rdi; ret,也可能有pop rsi; pop r15; ret这种带“多余操作”的片段。只看这个输出还不够,还要注意pop rsi后面跟了什么:有的二进制里找不到干净的pop rsi; ret,只能找到pop rsi; pop r15; ret。后者同样能用,但使用的时候有一个关键细节:你需要为 R15 在栈上多留一个 8 字节的占位值,否则 pop r15 会把你原本放好的“下一个函数地址”给吃掉,整条链就会断掉。

我在这道题里的实际选择是一个pop rdi; ret和一个带pop r15的pop rsi片段。第一次构建 payload 时我就吃过亏,少塞了一个占位,结果执行到一半程序跳到 0x4141414141414141 去了。这个教训后面专门讲。

3.2 手工核对地址:工具不是万能的

工具输出的地址,我强烈建议你用反汇编再核对一遍。别以为工具不会出错,我自己遇过 ROPgadget 把一段数据误报成指令的情况。反汇编的姿势很简单,拿到地址后用 objdump 直接看这段内存里的字节:

objdump -d --start-address=0x400xxx --stop-address=0x400xxx+16 ./ropchain

或者用 gdb 的x/2i 地址查看前两条指令。确认你看到的确实是pop rdi和ret,而不是工具“脑补”出来的伪序列。这个习惯能帮你省下大量无效调试时间。

其实从反汇编的角度,你也可以完全不依赖工具,直接用 objdump 搜索5f c3(pop rdi; ret对应的机器码)这样的字节序列。但现实是手工搜索在大文件里太费眼睛,工具筛选后再人工验证,效率和准确率都能兼顾。

3.3 为什么地址可以直接写死:PIE 关闭的红利

找 gadget 的时候你会发现,ROPgadget 输出的地址都是 0x400xxx 这种固定值。这正是 PIE 关闭带来的红利:程序每次加载,代码段都落在同一个虚拟地址上。你反汇编时看到是什么地址,运行时就是什么地址,payload 里直接p64(0x400xxx)写死即可。

对比一下开了 PIE 的情况,你就会珍惜这个条件。PIE 开启时,每次运行代码段基址都会随机化,你必须先通过泄露函数地址来算出基址,再在远程脚本里动态计算 gadget 地址,payload 不能一次写死。而这题连 PIE 都没有,所以本地打通的脚本,把交互对象从 process 换成 remote 基本就通吃,这对新手来讲非常友好。

4. 组装 ROP 链:先 val 后 flag 的完整操作

4.1 反汇编确认每个函数的参数需求

很多人在这一步急着写 payload,结果链子搭了一半发现参数不够用。我建议先把目标函数的反汇编看完,搞清楚它到底要几个参数、比较的是什么常量。我在这道题反汇编里看到的逻辑可以概括成下面这张表(不同镜像的具体校验值可能略有差异,以你手里的文件为准):

函数需要的参数内部检查逻辑效果
val第一个参数与 0xdeadbeef 比较相等则设置全局标志位
flag第一、第二个参数先检查全局标志位,再比较两个参数与 0xdeadbeef、0xc0debabe全部满足则读取并输出 flag
win无无条件执行打印提示文本,无实际 flag

看到这张表,构建链条的思路就清晰了:不能直接调 flag,因为全局标志位一开始是默认值;必须先调一次 val,让它把标志位置成合法状态,然后再调 flag,并且两次调用都要给 RDI 传 0xdeadbeef,给 RSI 传 0xc0debabe。

这里就引出一个关键点:ROP 链是严格按顺序执行的,栈上的地址从低到高依次被消费。所以 payload 里必须先放 val 相关的片段,再放 flag 相关的片段。顺序反了,或者把 val 调用漏了,flag 函数里的分支检查就会失败,程序不会崩溃,但它只会安静地走 else 分支,你连报错都看不到。

4.2 完整的 payload 构建:逐段拆解

假设我已经拿到了如下几个关键地址(用占位符表示,实际以你反汇编的结果为准):

  • pop rdi; ret 的地址:POP_RDI
  • pop rsi; pop r15; ret 的地址:POP_RSI
  • val 的地址:VAL_ADDR
  • flag 的地址:FLAG_ADDR

那么 payload 可以这样写:

from pwn import * context.binary = elf = ELF('./ropchain') # 本地调试 io = process('./ropchain') # 远程连线时改成: # io = remote('目标地址', 端口) # 占位地址,务必替换成你的文件里实际 gadget 地址 POP_RDI = 0x400000 POP_RSI = 0x400000 VAL_ADDR = elf.symbols['val'] FLAG_ADDR = elf.symbols['flag'] # 1) 覆盖 64 字节缓冲区 + 8 字节 RBP payload = b'A' * 72 # 2) 第一次调用 val:设置全局标志位 payload += p64(POP_RDI) payload += p64(0xdeadbeef) # val 的第一个参数 payload += p64(POP_RSI) payload += p64(0xc0debabe) # val 的第二个参数(如果 val 只需要一个参数,此段可省略) payload += p64(0) # 占位:给 pop r15 用 payload += p64(VAL_ADDR) # 3) 第二次调用 flag:参数满足后输出 flag payload += p64(POP_RDI) payload += p64(0xdeadbeef) # flag 的第一个参数 payload += p64(POP_RSI) payload += p64(0xc0debabe) # flag 的第二个参数 payload += p64(0) # 占位:给 pop r15 用 payload += p64(FLAG_ADDR) io.sendline(payload) print(io.recvall().decode())

这里每一个 p64 都是 8 字节。建议你把 payload 打印出来,对照栈布局数一数每个字段的位置,这一步做熟了,后面遇到 Ret2Libc、Ret2dl_resolve 之类的复杂链就不会发怵。

需要额外强调占位符的作用。因为我用的 pop rsi 片段是pop rsi; pop r15; ret,所以每用完这个 gadget,后面必须紧跟一个 8 字节的“垃圾值”去填 R15。如果没有这个占位,ret 会把原本属于 VAL_ADDR 或 FLAG_ADDR 的那个地址当成返回地址弹出去,程序要么跑到参数值 0xdeadbeef 上去执行,要么直接崩溃。这类问题排查起来特别隐蔽,因为崩溃地址看起来毫无规律。

4.3 栈布局速查:每个 8 字节在干什么

为了直观,我把上面 payload 的内存布局按“栈地址从低到高、执行顺序从上到下”列出来:

顺序栈上的 8 字节内容作用
1AAAA... (共72字节)填满缓冲区与保存的 RBP
2POP_RDIvuln 的 ret 跳到此处
30xdeadbeef被 pop rdi 弹入 RDI
4POP_RSIret 跳转到此
50xc0debabe被 pop rsi 弹入 RSI
60x0000000000000000被 pop r15 弹入 R15(占位)
7VAL_ADDR进入 val 函数
8POP_RDIval 函数 ret 后进入
90xdeadbeef第二次参数一
10POP_RSI再次准备参数二
110xc0debabe第二次参数二
120x0000000000000000R15 占位
13FLAG_ADDR进入 flag 函数

这张表就是整个攻击链的地图。我调试时会在 gdb 里按这个顺序逐项核对栈上内容,而不是一次性把整段 payload 跑完然后看结果。分段核对的方式能让你精确定位到是哪个环节出了问题,比瞎猜效率高很多。

4.4 跑通之后的验证与常见崩溃表现

如果一切都顺利,程序会先执行 val,把全局标志位改掉,再调 flag,进入读 flag 文件的分支,最终把 flag 字符串打印到终端。但我第一次写完脚本运行时,程序直接卡在 printf 附近报段错误,而不是正常输出。如果你也遇到这个现象,先别怀疑 gadget 地址抄错了,最大的嫌疑是栈对齐问题。

这是 x64 下 ROP 最容易忽视的细节:System V ABI 要求函数调用前栈指针按 16 字节对齐。glibc 从某个版本开始,printf 这类函数内部会用到需要对齐的 SIMD 指令,一旦栈没对齐,程序会在库函数内部触发段错误。解决办法是在整条链的最前面额外插入一个裸retgadget。裸 ret 本身不做任何事,但它会把栈指针推进 8 字节,从而把对齐状态调整正确。所以我的最终 payload 里,第一个跳转目标加了一个裸 ret,然后才是 pop rdi。这个细节在很多入门 writeup 里不会专门讲,但实际调试中几乎必踩。

5. 我在实际调试中踩过的四个坑

5.1 pop rsi 后面带着一个多余的 r15,差点让我怀疑人生

第一次用工具搜pop rsi时,我把输出里的pop rsi; pop r15; ret直接当成简单版pop rsi; ret用了。结果很惨:payload 执行完pop rsi后,紧接着pop r15把栈顶的VAL_ADDR弹走了,ret 于是跳到下一个值——也就是我精心准备的 0xdeadbeef。你能想象调试时看到 RIP 变成 0x00000000deadbeef 时的表情么。

这个坑的本质是没有理解 gadget 是“按字节序列匹配”的,它不一定符合你的理想形状。遇到pop rsi; pop r15; ret这种片段,正确姿势是老老实实在后面补一个 8 字节占位。同样的道理适用于pop rdi; pop rbp; ret、pop rdx; pop rbx; ret这类组合 gadget。我给自己定的规矩是:每用一个带多余 pop 的 gadget,就在它后面数出相应数量的占位字段,并在注释里标明每个字段对应的寄存器。

5.2 栈对齐问题:不加一个裸 ret 就是过不了 printf

这个问题前面已经提到,但值得单独拿出来再强调一遍,因为它是 x64 ROP 里最隐蔽的一类问题。现象是:payload 逻辑看着完全正确,gadget 地址也没错,但程序在运行到 flag 函数里调用 puts 或 printf 时突然 SIGSEGV。我用 gdb 回溯一看,崩溃地址在一个 movaps 指令上,而 movaps 要求内存地址按 16 字节对齐,否则直接抛异常。

这里附上对齐的快速判断方法:在调用目标函数之前,观察栈指针 RSP 的值,如果它除以 16 余 8,通常就需要在前面补一个裸 ret 来调整。不同工具链、不同版本的 glibc 对对齐的敏感度还不一样,所以更稳妥的做法是:先不加裸 ret 试跑一次,如果崩溃在库函数内部且报错涉及 movaps,再加。不要凭空猜,直接用失败结果说话。

5.3 小端序:地址和参数不是你肉眼看到的样子

写 ROP payload 时,新手最容易犯的认知错误是用人脑的方式理解内存。0xdeadbeef 这个数,在 x86 小端序下实际写入内存的字节顺序是ef be ad de 00 00 00 00。你用 gdb 看栈上的数据时,看到的可能是0x00000000deadbeef,也可能看到一堆反序的字节,这两者其实是一回事。

理解小端序不只是一个理论问题,它直接影响你排查 payload 的速度。我在调试时会用x/20gx $rsp查看栈内容,屏幕上按 8 字节一组显示的数值是用小端序还原后的结果,看起来和源码里的 p64 参数一致,但如果你用x/40bx逐字节看,就会看到完全倒序的字节。如果你还在用十六进制编辑器手工构造 payload,这一点尤其致命。最好的做法是从一开始就用 pwntools 的 p64 处理,别自己拼字节。

5.4 本机打通了,远程为什么拿不到 flag?

本地用 process 跑通了 payload,输出 flag 干净利落,结果一换 remote 就各种诡异。这种情况我遇到过三次,原因各不相同,但基本都能归到几类:远程程序的工作目录里没有 flag 文件,或者 flag 文件名不一样;远程系统用的是不同的 glibc 版本,对栈对齐更敏感;远程程序可能开启了不同的终端交互方式,需要多读一次输出。

最典型的坑是盲等。有些远程题目不会打印“输入 payload”这样的提示,你 sendline 之后直接recvall就能拿到输出,但如果你在交互时先 recv 等待提示字符串,程序可能因为已经输出完毕而挂在那里,你就再也等不到它的返回了。我的建议是先用recvall(timeout=5)这种带超时的方式收数据,收不到再分析是交互时序还是 payload 本身的问题。

还有一类问题是本地不注意对齐、远程才崩溃。本地系统 glibc 版本旧,printf 内部没用 movaps,所以没触发;远程是较新系统,马上就崩。这提醒我,最佳实践是无论本地跑不跑得通,都保留那个裸 ret。多加 8 字节对栈布局没有负面影响,却能避免一类很难排查的环境差异问题。

5.5 用 gdb 逐段跟踪,而不是依赖“跑一次看结果”

最后算是一条方法论建议。ROP 链调试,最忌讳的是一次性把整段 payload 发出去,然后盯着终端看它到底是成功还是崩溃。我现在的做法是:在关键跳转点打断点,比如 val 函数入口、flag 函数入口,用 gdb 的x/10gx $rsp看一眼当前栈顶即将被消费的地址,确认和预期一致后再continue。

这样做的最大好处是把“整条链黑盒”拆成了“每一跳白盒”。如果某一次跳转后的地址和预期不符,立刻就能定位到是哪个 gadget 的偏移算错了,还是哪个占位少写了。这个习惯一开始会显得慢,但多调几条链之后,你会发现它比蒙头试错要快得多。尤其当你从这种入门题转向更复杂的题目时,ROP 链动辄几十个字段,没有逐段跟踪的能力,几乎没法干活。

最后再分享一点个人体会

这道题我前后写了三个版本才跑通,第一版栽在忘记补 R15 占位,第二版栽在栈对齐,第三版才把所有细节理顺。回过头看,最大的收获不是记住了几个 gadget 地址,而是学会了把每 8 字节拆开理解:哪个是弹参数用的,哪个是占位,哪个是真正要跳的函数。这个“逐字段拆解”的习惯,后来看更复杂的 ROP 链时也一直在用。

如果你现在正卡在类似的问题上,我的建议是不要急着把网上 writeup 里的 payload 抄过来跑。打开 gdb,对着栈一个字段一个字段地走,等你能闭着眼说出“这个字段会被 pop 进哪个寄存器、下一个 ret 会跳到哪”,你就真正掌握了这一题,也能顺带掌握 x64 ROP 最核心的思维方法。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进