栈迁移利用

作者:Vesper Vei
3 分钟阅读

目录

  1. 栈迁移
  2. 技术概述
  3. 技术原理深度解析
    1. 核心本质
    2. 为什么需要栈迁移
    3. 程序为什么会配合完成迁移
  4. 模式识别
    1. 溢出空间极其有限
    2. 存在“第二块”可控区域
    3. 程序使用了标准栈帧
  5. 栈迁移构造方式
    1. 1. 经典双 leave; ret
    2. 2. pop rsp; ret
    3. 3. 寄存器交换
    4. 4. 栈迁到栈本身
  6. 利用理解重点
  7. 常见误区
    1. 把栈迁移误认为独立终点技术
    2. 只记 leave; ret 模板,不理解栈变化过程
    3. 只看 Gadget,不看新栈是否真的可用
  8. 小结

栈迁移

技术概述

栈迁移(Stack Pivoting)是一种在栈溢出利用中极其常见的辅助技术,用来解决一个非常现实的问题:==当前栈帧里可用的溢出空间太小,根本写不下完整的利用链==。 一句话解释就是:当当前栈帧只能覆盖很少几个关键位置,无法直接布置长 ROP 链时,攻击者会想办法修改栈指针寄存器(rspesp),把“程序接下来要使用的栈”强行移动到另一块自己能够充分控制的内存区域,例如 .bss 段、堆区,或者栈上的其他位置。
这样,后续的 retpop、函数调用等行为,都会在这块新的伪造栈上继续进行。

从定位上看,栈迁移并不是直接完成利用目标的终点技术,而是一种 ==扩展利用空间== 的关键手段。它通常不会单独出现,而是和 ROP、ret2libc、ret2syscall、ret2dlresolve 等技术配合使用。

技术原理深度解析

核心本质

栈迁移最核心的一句话可以概括为: ==栈在哪里,程序后续基于 ret 的执行流就会在哪里。== 这是因为在典型的函数返回过程中,处理器会把当前 rsp / esp 所指向的位置视为“下一条控制流地址”的来源。
也就是说,ret 指令本质上做的事情,就是从栈顶取出一个地址,然后跳过去执行。

因此,只要能够改变栈指针,让它不再指向当前真实栈帧,而是指向一块攻击者事先布置好的“伪造栈”,那么程序接下来的返回流程就会自动沿着这块新区域展开。
从这个角度看,栈迁移并不是在“多写一点 payload”,而是在 ==重新定义程序眼中的栈==。

为什么需要栈迁移

在很多题目里,漏洞虽然足以覆盖返回地址,但可利用空间却极其有限。
常见情况是:只能多溢出 0x100x18 或类似长度,刚好够改写:

==如何在第一次跳转之后,继续拥有足够大的执行舞台。==

程序为什么会配合完成迁移

很多栈迁移题之所以成立,本质上是因为函数返回过程本来就依赖栈指针和栈帧指针。
最常见的关键指令是 leave; ret。 其中:

这意味着,如果攻击者已经控制了 rbp,那么执行 leave 时,rsp 就会被强行改到攻击者指定的位置;随后再经过一次 pop rbpret程序就会开始把那块新区域当作真正的栈来使用

因此,在很多题目里,栈迁移的本质并不是直接“控制 rsp”,而是通过控制 rbp,再借助程序原本就存在的 leave; ret 流程,完成对栈顶位置的重定向

模式识别

在做题时,如果下面几个信号同时出现,基本就应该优先考虑栈迁移。

溢出空间极其有限

这是最明显的信号。 例如题目里只能溢出 0x100x18 甚至更短的长度,刚好够覆盖 Saved RBPRIP,之后就没有多余空间继续写长 payload 了。 这种情况下,直接在当前栈帧中铺开完整 ROP通常不现实。
如果继续硬塞,只会发现:

存在“第二块”可控区域

栈迁移一定不是无中生有,它的前提是程序里存在另一块更适合作为伪造栈的内存区域。
常见来源包括:

程序使用了标准栈帧

如果函数结尾存在标准的栈帧回收过程,例如 leave; ret,那么它通常就是最天然的栈迁移入口。

这是因为 leave 会先执行 mov rsp, rbp,也就是直接把栈顶改成当前帧指针的位置;如果攻击者又恰好控制了保存下来的 rbp,那么整个迁移过程就会变得非常自然。

因此,当你在反汇编里看到:

就应该立刻联想到:这题很可能要走栈迁移路线。

栈迁移构造方式

栈迁移的核心不是某一条固定写法,而是 想办法让栈指针落到可控区域
根据题目环境不同,常见的构造方式主要有以下几类。

1. 经典双 leave; ret

这是最常见、也最有代表性的栈迁移方式,通常建立在对 rbp / ebp 的控制之上。

它的核心思路是:先利用程序原本函数返回时的那一次 leave,再结合后续链里布置好的第二次 leave,形成一次“接力式”的栈切换。
第一次切换把程序拉到攻击者准备的伪造栈附近,第二次切换则进一步把执行流稳定地带入那块新区域内部。

这种方式的关键在于两点:

从理解上说,双 leave; ret 的精髓不是“背公式”,而是要弄明白:

==leave 不只是改 rsp,它还会顺手消耗掉一个栈槽位。==

2. pop rsp; ret

这种方式最直接,也最粗暴。
如果二进制或其依赖库中存在 pop rsp; ret 这样的 Gadget,那么利用会变得非常干脆:只要把它放在返回地址位置,再把后一个栈槽写成目标地址,就能直接把 rsp 改到伪造栈上。

它的优点在于逻辑非常直观:

  1. 执行 pop rsp
  2. 把下一个栈槽内容弹进 rsp
  3. 栈顶瞬间切到目标区域
  4. 随后的 ret 开始在新栈上继续执行

这种方式的限制也很明显:
真正可用的 pop rsp; ret Gadget 并不总是容易找到,而且某些架构或题目里这类 Gadget 非常稀缺。 因此它虽然是最理想化的栈迁移方式之一,但并不是每题都能依赖。

3. 寄存器交换

有些题目中,没有直接控制 rsp 的 Gadget,但却存在类似 xchg rax, rsp; retxchg esp, eax; ret 这样的交换指令。
这时如果某个通用寄存器恰好已经指向可控区域,或者能够先通过其他 Gadget 把它调到目标地址,那么就可以通过寄存器交换实现栈迁移。

这类方式的特点是:

它不像 leave; ret 那样依赖标准栈帧,而更像是一种 ==借助特殊 Gadget 强行改栈顶== 的思路。
因此在某些空间极小、返回流程受限的题目里,寄存器交换反而会成为更实用的突破口。

4. 栈迁到栈本身

有时题目并没有明显的 .bss 区输入机会,也没有理想的堆块可以提前铺链。
这时仍然可能存在一种特殊思路:把栈再次迁移回当前栈上的另一个可控位置,也就是所谓的“栈迁栈”。

这种做法的关键不在于寻找外部空间,而在于:

从表面上看,栈还是栈;但从利用本质上说,程序已经不再沿着原本的调用栈正常返回,而是在攻击者重新布置好的栈布局里继续执行。
这类题目通常对地址计算精度要求更高,因为一旦偏移判断错误,就会直接落到错误位置。

利用理解重点

学习栈迁移时,最重要的不是死记某种固定模板,而是建立下面这个判断顺序:

  1. 当前栈帧的空间够不够放完整链
  2. 有没有第二块足够大的可控区域
  3. 有没有能改动 rsp / esp 的关键机制
  4. 程序最终能不能顺利在新栈上继续执行

其中,前两个问题决定“要不要迁”,后两个问题决定“能不能迁”。

很多新手一看到 leave; ret 就想当然地套模板,但真正稳定的思路应当是:
先确认为什么当前栈不够用,再确认新栈放在哪里,最后才是选择具体迁移方式。

也就是说,栈迁移本质上是在解决三个问题:

常见误区

把栈迁移误认为独立终点技术

栈迁移本身通常并不直接完成利用目标。
它更多只是为后续的 ROP、ret2libc 或其他调用链创造空间。

所以在理解时,不要把它看成“单独拿 shell 的技术”,而应把它看作 ==为后续利用链让路== 的中间步骤。

只记 leave; ret 模板,不理解栈变化过程

很多人会机械记忆“Saved RBP 要写成某个地址减去 8”之类的经验结论,但一旦题目从 x64 换到 x86,或者栈布局稍有变化,就立刻混乱。

真正该记住的是:

只看 Gadget,不看新栈是否真的可用

(ps:可以通过 vmmap 看段的权限划分) 有些题目虽然确实存在迁移手段,但如果目标区域本身:

那么即使 rsp 真切过去了,利用也一样跑不起来。
所以判断新栈是否可持续使用,和判断能否完成切换本身同样重要。

小结

栈迁移的意义在于:当攻击者已经拿到初步控制流劫持能力,但当前栈空间又不足以承载完整利用链时,通过修改 rsp / esp,把程序后续使用的栈切换到另一块更大的可控区域中,从而继续完成后续利用。

它最核心的理解点不是某种固定 Gadget,而是这句非常重要的话: ==栈顶在哪里,后续的返回控制流就会在哪里。==

也正因为如此,栈迁移几乎可以被看作所有“受限空间下的复杂利用”里最重要的过渡技术之一。
只要题目同时具备 溢出空间不足存在第二块可控区域存在可改栈顶的关键机制 这几个条件,就应当优先考虑这条路线。


关系图谱

Loading graph...