栈迁移利用
目录
栈迁移
技术概述
栈迁移(Stack Pivoting)是一种在栈溢出利用中极其常见的辅助技术,用来解决一个非常现实的问题:==当前栈帧里可用的溢出空间太小,根本写不下完整的利用链==。
一句话解释就是:当当前栈帧只能覆盖很少几个关键位置,无法直接布置长 ROP 链时,攻击者会想办法修改栈指针寄存器(rsp 或 esp),把“程序接下来要使用的栈”强行移动到另一块自己能够充分控制的内存区域,例如 .bss 段、堆区,或者栈上的其他位置。
这样,后续的 ret、pop、函数调用等行为,都会在这块新的伪造栈上继续进行。
从定位上看,栈迁移并不是直接完成利用目标的终点技术,而是一种 ==扩展利用空间== 的关键手段。它通常不会单独出现,而是和 ROP、ret2libc、ret2syscall、ret2dlresolve 等技术配合使用。
技术原理深度解析
核心本质
栈迁移最核心的一句话可以概括为:
==栈在哪里,程序后续基于 ret 的执行流就会在哪里。==
这是因为在典型的函数返回过程中,处理器会把当前 rsp / esp 所指向的位置视为“下一条控制流地址”的来源。
也就是说,ret 指令本质上做的事情,就是从栈顶取出一个地址,然后跳过去执行。
因此,只要能够改变栈指针,让它不再指向当前真实栈帧,而是指向一块攻击者事先布置好的“伪造栈”,那么程序接下来的返回流程就会自动沿着这块新区域展开。
从这个角度看,栈迁移并不是在“多写一点 payload”,而是在 ==重新定义程序眼中的栈==。
为什么需要栈迁移
在很多题目里,漏洞虽然足以覆盖返回地址,但可利用空间却极其有限。
常见情况是:只能多溢出 0x10、0x18 或类似长度,刚好够改写:
- Saved
RBP/EBP - Return Address
但这点空间远远不够容纳一条完整的 ROP 链,更别说继续控制多个寄存器、布置参数、组织多阶段调用。
这时就会出现一个很典型的局面: - 已经具备初步控制流劫持能力
- 但当前栈帧太小,不足以承载真正的利用逻辑 栈迁移的作用,就是先利用这点极小空间完成“切换战场”,再把真正完整的利用链放到另一块更大的可控内存中执行。 所以从利用思路上看,栈迁移解决的问题并不是“如何获得第一次跳转”,而是:
==如何在第一次跳转之后,继续拥有足够大的执行舞台。==
程序为什么会配合完成迁移
很多栈迁移题之所以成立,本质上是因为函数返回过程本来就依赖栈指针和栈帧指针。
最常见的关键指令是 leave; ret。
其中:
这意味着,如果攻击者已经控制了 rbp,那么执行 leave 时,rsp 就会被强行改到攻击者指定的位置;随后再经过一次 pop rbp 和 ret,程序就会开始把那块新区域当作真正的栈来使用。
因此,在很多题目里,栈迁移的本质并不是直接“控制 rsp”,而是通过控制 rbp,再借助程序原本就存在的 leave; ret 流程,完成对栈顶位置的重定向。
模式识别
在做题时,如果下面几个信号同时出现,基本就应该优先考虑栈迁移。
溢出空间极其有限
这是最明显的信号。
例如题目里只能溢出 0x10、0x18 甚至更短的长度,刚好够覆盖 Saved RBP 和 RIP,之后就没有多余空间继续写长 payload 了。
这种情况下,直接在当前栈帧中铺开完整 ROP 链通常不现实。
如果继续硬塞,只会发现:
- 能跳一次
- 但跳完以后没有后续链条可走 这往往就是典型的“空间不够,需要迁栈”。
存在“第二块”可控区域
栈迁移一定不是无中生有,它的前提是程序里存在另一块更适合作为伪造栈的内存区域。
常见来源包括:
- 程序在前面已经把用户输入读进
.bss - 某次
read/gets/fgets把数据写进了堆区 - 程序允许多轮输入,其中前一轮可以提前布置数据
- 当前栈上的某个更早位置本身就是可控的
这类区域的共同点在于:==你可以提前往里面放好一整段后续利用链==。
如果没有这块“第二空间”,那么栈迁移本身也就失去了意义。
程序使用了标准栈帧
如果函数结尾存在标准的栈帧回收过程,例如 leave; ret,那么它通常就是最天然的栈迁移入口。
这是因为 leave 会先执行 mov rsp, rbp,也就是直接把栈顶改成当前帧指针的位置;如果攻击者又恰好控制了保存下来的 rbp,那么整个迁移过程就会变得非常自然。
因此,当你在反汇编里看到:
- 标准函数序言和结尾
leave; ret- 或者其他能显式改写
rsp/esp的 Gadget
就应该立刻联想到:这题很可能要走栈迁移路线。
栈迁移构造方式
栈迁移的核心不是某一条固定写法,而是 想办法让栈指针落到可控区域。
根据题目环境不同,常见的构造方式主要有以下几类。
1. 经典双 leave; ret
这是最常见、也最有代表性的栈迁移方式,通常建立在对 rbp / ebp 的控制之上。
它的核心思路是:先利用程序原本函数返回时的那一次 leave,再结合后续链里布置好的第二次 leave,形成一次“接力式”的栈切换。
第一次切换把程序拉到攻击者准备的伪造栈附近,第二次切换则进一步把执行流稳定地带入那块新区域内部。
这种方式的关键在于两点:
- Saved
RBP需要被改写为一个精心计算过的位置 - Return Address 需要改成某个
leave; retGadget 重点: 之所以经常要写成Target_Address - 8或Target_Address - 4一类形式,本质上是因为leave里还包含一次pop rbp,这会让栈顶继续向高地址移动一个指针宽度。
如果不把这个偏移提前算进去,最终ret取到的位置就会错开,导致迁移失败。
从理解上说,双 leave; ret 的精髓不是“背公式”,而是要弄明白:
==leave 不只是改 rsp,它还会顺手消耗掉一个栈槽位。==
2. pop rsp; ret
这种方式最直接,也最粗暴。
如果二进制或其依赖库中存在 pop rsp; ret 这样的 Gadget,那么利用会变得非常干脆:只要把它放在返回地址位置,再把后一个栈槽写成目标地址,就能直接把 rsp 改到伪造栈上。
它的优点在于逻辑非常直观:
- 执行
pop rsp - 把下一个栈槽内容弹进
rsp - 栈顶瞬间切到目标区域
- 随后的
ret开始在新栈上继续执行
这种方式的限制也很明显:
真正可用的 pop rsp; ret Gadget 并不总是容易找到,而且某些架构或题目里这类 Gadget 非常稀缺。
因此它虽然是最理想化的栈迁移方式之一,但并不是每题都能依赖。
3. 寄存器交换
有些题目中,没有直接控制 rsp 的 Gadget,但却存在类似 xchg rax, rsp; ret、xchg esp, eax; ret 这样的交换指令。
这时如果某个通用寄存器恰好已经指向可控区域,或者能够先通过其他 Gadget 把它调到目标地址,那么就可以通过寄存器交换实现栈迁移。
这类方式的特点是:
- 对 Gadget 组合要求更高
- 往往要先解决“目标寄存器怎么控制”这个前置问题
- 适合当前溢出空间很小,但寄存器状态又比较有利的场景
它不像 leave; ret 那样依赖标准栈帧,而更像是一种 ==借助特殊 Gadget 强行改栈顶== 的思路。
因此在某些空间极小、返回流程受限的题目里,寄存器交换反而会成为更实用的突破口。
4. 栈迁到栈本身
有时题目并没有明显的 .bss 区输入机会,也没有理想的堆块可以提前铺链。
这时仍然可能存在一种特殊思路:把栈再次迁移回当前栈上的另一个可控位置,也就是所谓的“栈迁栈”。
这种做法的关键不在于寻找外部空间,而在于:
- 想办法泄露当前栈地址
- 精确定位当前输入缓冲区在栈上的真实位置
- 再通过
leave; ret或其他迁移方式,把rsp改到这块已经写好内容的栈区域上
从表面上看,栈还是栈;但从利用本质上说,程序已经不再沿着原本的调用栈正常返回,而是在攻击者重新布置好的栈布局里继续执行。
这类题目通常对地址计算精度要求更高,因为一旦偏移判断错误,就会直接落到错误位置。
利用理解重点
学习栈迁移时,最重要的不是死记某种固定模板,而是建立下面这个判断顺序:
- 当前栈帧的空间够不够放完整链
- 有没有第二块足够大的可控区域
- 有没有能改动
rsp/esp的关键机制 - 程序最终能不能顺利在新栈上继续执行
其中,前两个问题决定“要不要迁”,后两个问题决定“能不能迁”。
很多新手一看到 leave; ret 就想当然地套模板,但真正稳定的思路应当是:
先确认为什么当前栈不够用,再确认新栈放在哪里,最后才是选择具体迁移方式。
也就是说,栈迁移本质上是在解决三个问题:
- 空间问题:当前栈帧太小
- 落点问题:新栈应该放在哪
- 切换问题:如何把栈顶改过去 只要把这三层想清楚,大部分栈迁移题的结构都会变得非常清晰。
常见误区
把栈迁移误认为独立终点技术
栈迁移本身通常并不直接完成利用目标。
它更多只是为后续的 ROP、ret2libc 或其他调用链创造空间。
所以在理解时,不要把它看成“单独拿 shell 的技术”,而应把它看作 ==为后续利用链让路== 的中间步骤。
只记 leave; ret 模板,不理解栈变化过程
很多人会机械记忆“Saved RBP 要写成某个地址减去 8”之类的经验结论,但一旦题目从 x64 换到 x86,或者栈布局稍有变化,就立刻混乱。
真正该记住的是:
leave会先把rsp设为rbp- 然后再执行一次
pop rbp - 所以栈顶会额外前进一个指针宽度 理解了这个过程,很多看似“模板公式”的细节自然就能自己推出来。
只看 Gadget,不看新栈是否真的可用
(ps:可以通过 vmmap 看段的权限划分)
有些题目虽然确实存在迁移手段,但如果目标区域本身:
- 没有提前布置好后续链
- 地址不稳定
- 会被后续函数覆盖
- 对齐不正确
那么即使 rsp 真切过去了,利用也一样跑不起来。
所以判断新栈是否可持续使用,和判断能否完成切换本身同样重要。
小结
栈迁移的意义在于:当攻击者已经拿到初步控制流劫持能力,但当前栈空间又不足以承载完整利用链时,通过修改 rsp / esp,把程序后续使用的栈切换到另一块更大的可控区域中,从而继续完成后续利用。
它最核心的理解点不是某种固定 Gadget,而是这句非常重要的话: ==栈顶在哪里,后续的返回控制流就会在哪里。==
也正因为如此,栈迁移几乎可以被看作所有“受限空间下的复杂利用”里最重要的过渡技术之一。
只要题目同时具备 溢出空间不足、存在第二块可控区域、存在可改栈顶的关键机制 这几个条件,就应当优先考虑这条路线。