栈迁移

栈迁移 (Stack Migration),又称为 Stack Pivoting (栈枢纽/栈反转),是二进制漏洞利用中一种非常重要且优雅的技巧。

它的核心目的是:当栈溢出可用的空间非常有限(不足以存放完整的 ROP 链或 Shellcode)时,利用特定的汇编指令,将栈指针(ESP/RSP)“迁移”到攻击者可以控制的、空间更大的内存区域(如 BSS 段、堆、或其他栈位置),从而继续执行攻击。

1. 为什么需要栈迁移?

在标准的栈溢出攻击中,我们通常构造如下 Payload: [ Padding ] + [ ROP Chain / Shellcode ]

但现实中常遇到这种情况:

  1. 溢出空间太小:例如程序只有 20 字节的溢出空间,而一个 execve("/bin/sh") 的 ROP 链可能需要 60 字节以上。
  2. 开启了 NX 保护:无法直接在栈上执行 Shellcode,必须用 ROP,而 ROP 链通常很长。

此时,我们无法在当前的栈帧中塞入攻击代码。但是,如果我们能欺骗 CPU,让它把栈指针 (ESP/RSP) 指向我们提前布置好数据的另一个地方,就可以解除空间限制。

2. 核心原理:leave; ret 指令

栈迁移最常用的利用手段是利用 leave; ret 指令序列。这是大多数函数(特别是 x86/x64 架构下未开启优化时)的标准函数尾声 (Epilogue)

指令分解

leave 指令等价于以下两条指令:

mov esp, ebp  ; 1. 将栈指针 ESP 恢复到 EBP 的位置 (收缩栈帧)
pop ebp       ; 2. 将栈顶的数据弹出给 EBP (恢复上层函数的栈基址)

ret 指令等价于:

pop eip       ; 3. 将栈顶的数据弹出给 EIP (跳转执行)

攻击思路

利用 leave 指令的特性,我们可以发现:EBP 寄存器控制了 ESP 的去向。

  1. 如果我们在溢出时,覆盖了栈上的 Saved EBP,将其改为目标内存地址 (Fake Stack)。
  2. 然后覆盖 Return Address,让程序跳转执行 leave; ret 指令(通常利用程序原本结尾的那条指令即可)。
  3. 当程序执行两次 leave 操作(一次是函数自身的退出,一次是我们 gadget 的执行)或者巧妙配合时,ESP 就会指向我们设定的 Fake Stack。

3. 详细攻击流程 (以 32 位为例)

假设我们要把栈迁移到内存地址 0x0804A000 (BSS 段,可读写且固定)。

阶段 1:内存布局与 Payload 构造

首先,我们需要在那个“宽敞”的地方(0x0804A000)写入我们的 ROP 链。 如果无法提前写入,我们可能需要利用 read 函数先往那里写数据。

当前受限的栈溢出 Payload 结构如下:

[ Padding (填充至 EBP 前) ]
[ Fake EBP ]   <-- 覆盖 Saved EBP,填入 target_addr (如 0x0804A000)
[ Return Addr] <-- 覆盖返回地址,填入 'leave; ret' gadget 的地址
阶段 2:执行过程推演

让我们一步步看 CPU 发生了什么:

1. 函数执行完毕,准备退出 此时栈指针 ESP 指向栈帧底部。 EBP 指向 Saved EBP 的位置。

2. 执行函数原本的 leave

  • mov esp, ebp: ESP 指向了 Saved EBP
  • pop ebp:
    • 栈顶的值(我们覆盖的 0x0804A000)被弹出放入 EBP 寄存器。
    • 此时 EBP = 0x0804A000
    • ESP 向下移动,指向了 Return Address

3. 执行函数原本的 ret

  • pop eip: 栈顶的值(我们覆盖的 leave; ret 的地址)被赋给 EIP
  • 程序跳转去执行这个 leave; ret gadget。

4. 执行 Gadget 中的 leave (迁移发生的关键瞬间)

  • mov esp, ebp:
    • 此时 EBP0x0804A000
    • CPU 将 ESP 更新为 0x0804A000!
    • 栈迁移成功! 现在栈顶指针已经跑到了 BSS 段。
  • pop ebp:
    • 从新的栈顶 (0x0804A000) 弹出一个值给 EBP (通常我们在 fake stack 开头放 4 字节垃圾数据来抵消这一步)。
    • ESP 变为 0x0804A004

5. 执行 Gadget 中的 ret

  • pop eip: 从 0x0804A004 取出地址执行。这通常就是我们 ROP 链的第一条指令。

4. 栈迁移的变体

A. 64位系统的区别 (Ret2csu / Off-by-one)

在 64 位系统中,操作的是 rbprsp,原理完全一样。但需要注意:

  • 地址是 8 字节。
  • 如果只能修改 Saved RBP 的最低字节(Off-by-one 漏洞),也可以控制栈在小范围内迁移。

B. 利用 pop rsp

如果在 64 位程序的 Gadget 库中能找到 pop rsp; ret,那更加直接:

  • 直接覆盖返回地址为 pop rsp; ret 的地址。
  • 紧接着放入 Fake Stack Address
  • ret 跳转到 gadget -> pop rsp 读取下一个值直接修改栈指针。

C. Heap Pivoting

如果我们将 ROP 链放在堆(Heap)上,通过上述方法将 ESP/RSP 指向堆地址,就叫 Heap Pivoting。这在浏览器漏洞利用中非常常见。

5. 一个具体的利用场景示例

场景:

  • 函数 read(0, buf, 0x40),但 bufebp 的距离是 0x40。
  • 这意味着我们只能覆盖 Saved EBPReturn Address,没有多余空间放 ROP。

利用步骤:

  1. 第一次 Payload (写入 ROP): 利用这微小的溢出,先调用 read(0, bss_addr, 100)。把真正的长 ROP 链读入 BSS 段。

    • Payload: [Padding] + [Fake EBP] + [read_plt] + [leave_ret_addr] + [0, bss, 100] (这里利用栈帧结构可能比较复杂,通常用两次迁移)。
  2. 第二次 Payload (迁移): 再次触发溢出。

    • Payload: [Padding] + [bss_addr (Fake EBP)] + [leave_ret_gadget]
  3. 结果: 程序执行 leave; ret 后,ESP 飞到了 bss_addr,开始执行之前写入在那里的 ROP 链。

总结

栈迁移是空间换时间/空间换空间的经典战术。

  • 前提

    1. 存在栈溢出,能覆盖 EBP 和 EIP。
    2. 有一块已知地址、且可控内容的内存区域(BSS、Heap等)。
    3. 程序中有 leave; ret 指令(或者能修改 SP 的 gadget)。
  • 本质:利用 mov esp, ebp 这一机制,通过劫持 ebp 来间接劫持 esp,从而重新定义”栈”的位置。