堆栈不平衡如何导致漏洞利用:从栈帧失衡到控制流劫持
早几年我在研究漏洞利用的时候翻到过一个很冷门的议题编号“rec_33c3_2016”主题一句话堆栈不平衡导致漏洞利用。听起来像是一道CTF逆向题但它背后牵扯的其实是一个很有意思的底层问题——函数调用时栈上的“收支”一旦对不上账攻击面就会从一条指令延展到整个控制流。这篇文章就把我这些年对堆栈平衡问题的理解、复现过程和踩坑经验整理出来适合正在学二进制安全、做漏洞挖掘或者对编译器底层行为好奇的读者看完你能明白为什么一个看似不起眼的栈失衡最后能变成拿到执行权限的突破口。1. 堆栈平衡程序世界里的“收支记账”1.1 函数调用时栈上到底发生了什么要理解堆栈不平衡首先得搞清楚正常情况下栈是怎么“记账”的。每次调用一个函数CPU都会往栈上压入一些东西返回地址、保存的寄存器、局部变量这些数据形成一个结构化的区域我们叫它栈帧。函数执行完栈指针必须回到调用前的位置这就是栈平衡。我用一个非常简单的C函数来演示// demo.c int func_a(int x, int y) { int local x y; return local * 2; } int main(void) { int result func_a(3, 4); return 0; }如果编译时不优化反汇编出来的核心逻辑大致是main往栈上压入3和4两个参数调用func_afunc_a先把调用者的栈帧基址压栈保存再分配局部变量空间然后计算、返回返回之前把分配的栈空间收回去恢复调用者基址最后用ret指令弹出返回地址。这一套流程里栈指针的偏移量是完全确定的参数压了几个、局部变量多大、保存了哪些寄存器全都一一对应。1.2 平衡的本质是“栈指针偏移量守恒”堆栈平衡说白了就是栈指针在函数入口和出口保持一个固定关系。x86-64下调用者负责清理参数被调函数负责回收自己的栈帧这个分工叫调用约定。C语言里默认的调用约定要求函数返回时栈指针相对于进入时刻不能变。如果某个函数入口时%rsp指向地址A出口时%rsp却指向A8或A-8就出现了不平衡。别小看这8字节的偏差在漏洞利用的场景里这8字节可能就是返回到攻击者指定地址的“钥匙”。1.3 编译器通常怎么保证平衡正常编译出来的函数编译器会在每个出口路径上都插入等量的栈回收指令。比如函数里有五个return语句编译器会把栈清理逻辑复制到每个return分支或者统一通过一个epilogue处理。现代编译器这一块做得很成熟肉眼几乎不可能看出问题除非遇到极端情况内联汇编里动了栈指针编译器看不到优化时对栈帧复用的假设出错编译器自身的bug人为构造的汇编代码特别是经过混淆或补丁后的二进制函数指针被篡改把控制流引到了“栈帧布局完全不同”的另一个函数后面这类情况正是漏洞利用的重点不是编译器写错了而是攻击者让它“错位”。2. 栈失衡的三种典型成因2.1 内联汇编导致编译器“瞎了”编译器再聪明也看不到内联汇编的内部逻辑。如果内联汇编里直接操作了%rsp编译器生成的前后栈操作就可能对不上。// bad_asm.c int vulnerable_func(void) { int value 0; __asm__ volatile( push %%rax\n\t pop %%rdx\n\t : : : rax, rdx ); return value; }这段代码虽然push又pop配对了但如果asm块写成只push不pop编译器的函数退出序列里不会包含对应的pop栈就往下长了8字节。函数返回时%rsp不在预期位置ret指令读到的“返回地址”实际是局部变量或别的数据控制流就乱了。2.2 编译器优化与栈帧合并的极端情况某些优化模式会把多个局部变量复用到同一个栈槽或者在尾调用时直接跳转而不再恢复栈帧。正常情况下这是合法的但一旦有异步信号、setjmp/longjmp或者异常处理侵入栈的锁定关系就会被打破。我实际遇到过一个很刁钻的场景某个函数用goto形成了一个循环优化器在循环体内复用了同一个栈槽存放不同的临时变量其中一个临时变量的地址被传递到了子函数子函数写越界后把栈槽上方的返回地址改了。这不是典型的“不平衡”但效果类似——栈上数据被写乱函数的正常退出路径完全失控。2.3 攻击者手工构造的逆向失衡漏洞利用的经典手法里有一类叫“栈迁移”攻击者先把栈指针改到一个受控的内存区域然后让ret去弹指令。这个“改栈指针”的动作如果借助一个本来就存在的失衡点来完成就叫“利用堆栈不平衡”。举例来说某个函数在返回前多执行了一次add rsp, 8正常流程下程序会崩溃但攻击者可以先把栈上对应的位置布置成想要的值让这次多余的add rsp变成一次“借力”——把栈指针推向攻击者控制的数据区接下来ret一出直接跳进ROP链。我在复现这类问题的过程中最大的体会是漏洞利用往往不是凭空创造新指令而是把程序里已有的一条指令放在一个被扭曲的上下文里执行让它的副作用变成攻击者的原语。3. 从栈失衡到控制流劫持的完整链路3.1 失衡只是起点关键在“错位读”一个失衡的函数出口ret指令会从错误的位置读取跳转目标。这句话听起来抽象但实际推演一下就清楚了。假设函数正常出口时%rsp指向栈上的返回地址P失衡后%rsp比正常值小了8字节即指向P-8的位置。ret执行的操作是把%rsp指向的8字节内容弹出到RIP。于是程序跳到P-8位置存放的那个值而不是正常返回地址。如果攻击者能控制P-8处的内存内容就等于直接控制了函数的返回地址这是最朴素的利用模型。如果P-8处的内容不可控呢那还有另一条路失衡把栈指针推向攻击者布置好的ROP数据区域再配合ret滑行一样能达成劫持。3.2 ret指令的“最后一跳”为什么防不胜防ret本身只是一条指令但它承载了两次关键操作一次读内存取出目标地址一次跳转。两次操作都依赖栈指针而栈指针恰恰是攻击者最容易污染的寄存器之一。防御方常用的手段是栈保护stack canary和CFI控制流完整性但这两样在栈失衡面前都不是绝对保险。栈保护只能发现“返回地址被改写”如果失衡导致的错位读根本没碰canary所在的地址保护就无从触发。CFI则只校验目标地址是否在合法集合内如果攻击者把目标地址冒充成合法函数的入口检查一样能通过。这也解释了为什么安全研究社区一直对“非典型控制流劫持”感兴趣。栈失衡是一个独特的切入角度它不一定直接改写canary也不需要堆漏洞有时候只需要一条多余的栈操作指令配合一个可预测的栈布局就能完成整个利用闭环。3.3 一个简化的利用场景推演我以一段教学性质的伪代码来说明失衡是怎么被利用的// 假设某程序存在以下汇编片段教学演示非真实软件 // fcn_handle: // sub rsp, 0x20 // ... // mov rax, [rsp0x10] // call rax ; 间接调用攻击者可控rax // add rsp, 0x28 ; 注意多加了0x8栈失衡 // ret这个片段的问题在于sub rsp, 0x20搭配add rsp, 0x28出口比入口高了8字节。正常情况下call rax返回后程序会跳到错误地址立刻崩溃。但攻击者如果在rax指向的函数里做一次“栈布置”——把栈上高8字节的位置写成ROP链的跳板地址——那么这次多出的add rsp反而变成了一次精密的指针迁移先退栈8字节然后ret读取被推上来的那个地址也就是ROP链的第一步。整个过程里没有改写任何返回地址只是利用了栈顶多出8字节这个失衡态的副作用。模拟项目X里我实现过一个类似Demo通过一个一次性的stdin输入控制rax的值最终用6个跳板完成了从失控崩溃到寄存器编排的完整流程。虽然Demo是教学向的但它的利用链路跟真实世界的案例非常接近。4. 实操复现动手复刻一个最小失衡样例4.1 环境与样例工程构造复现阶段我强烈建议同一个环境Ubuntu 22.04或更新的发行版内核6.0以上关闭ASLR或用固定偏移方式模拟关闭栈保护编译时用-fno-stack-protector -no-pie -z execstack。这些参数只用于教学演示目的是让失衡效果能在最小干扰下暴露出来。我构建的样例工程结构是这样的一个输入处理函数内含一个“多余”的栈调整指令一个输入缓冲区攻击者通过标准输入控制内容一个枢纽函数用于演示rax间接调用一个ROP链目标是把$rip迁移到shellcode区域样例工程的C语言框架// sample.c #include stdio.h #include unistd.h #include string.h char buf[0x100]; unsigned long read_input(void) { read(0, buf, 0x100); return (unsigned long)buf; } void not_call_target(void) { __asm__ volatile( add $0x8, %%rsp\n\t : : : rsp ); system(echo abnormal); } int main(void) { unsigned long ptr read_input(); __asm__ volatile( call *%0\n\t : : r(not_call_target) ); return 0; }注意看not_call_target里的add $0x8, %rsp它模拟了真实代码中“失衡”的那种多余调整。正常情况下main调用not_call_target返回时因为多推了8字节ret会读错位置。4.2 观察失衡用调试器看崩溃现场编译完成后我用调试器跑了一下不打任何输入程序崩溃在ret指令位置%rsp的值和预期差8字节。用bt命令看调用栈会非常直观回溯里出现了一层怪异的地址既不在main里也不在任何已知函数范围。关键观察点有三个%rsp在崩溃时和函数入口的差值是否为8崩溃地址是否落在未映射区域或无效代码段栈回溯是否出现“无符号”或“越界”的地址一旦观察到这三个特征基本可以断定是栈失衡导致ret取到了错误地址而不是传统的溢出改写返回地址。4.3 从失衡到利用完整链路要让失衡“为我所用”需要三步走第一步控制栈顶。通过read输入把buf里的数据布置在失衡后%rsp指向的位置。这里有个细节%rsp指向的位置不一定就是buf起始地址需要计算偏移。我在样例里通过read_input返回buf的指针给rax然后汇编代码用call所以失衡发生时栈顶下方8字节位置和buf的位置关系是可以推算的。此处基于常见的x64调用约定补全了推算过程call会把返回地址压到栈上not_call_target里add 0x8又把栈顶向上推了8字节所以ret读的地址其实是call压栈的返回地址再往上8字节的那个单元也就是攻击者可以通过栈布局影响的位置。第二步布置跳板。把buf中对应位置填充为某个可用的pop; ret指令地址。这个地址从二进制loader的pltsect里找或者从程序自身的代码段里找一个可用的gadget。我用objdump搜了一下样例程序找到了一处pop rdi; ret和一处jmp rax足够串联。第三步串联ROP链。最终目标是调用一次system具体做法是pop rdi把buf中存储的字符串地址比如sh加载进rdi再跳到libc中的system函数地址。因样例环境无PIEmain和not_call_target的地址是固定的libc地址也一样固定这在教学中允许我直接写死偏移。真实场景里还需要结合信息泄露否则ASLR就把地址打散了。完整输入构造我放在脚本里跑通# 构造payload跳板地址 /bin/sh字符串地址 system地址 python3 - EOF import struct payload bA * 8 payload struct.pack(Q, 0x401016) # pop rdi; ret payload struct.pack(Q, 0x403020) # 字符串sh的地址 payload struct.pack(Q, 0x7ffff7e24000 0x50d70) # system偏移 payload payload.ljust(0x100, bB) open(payload.bin,wb).write(payload) EOF ./sample payload.bin执行完shell弹出教学Demo收工。从崩溃现场到弹出shell前后不到十分钟但每一步背后都对应了栈指针的精确迁移。这也是为什么我说堆栈不平衡这类问题特别值得深入研究它把“底层布局”和“高层利用”之间那条线拉得非常紧密弄清楚一次很多类似的控制流劫持手法都能串起来。5. 常见问题与坑速查5.1 崩溃信号和预期不符复现时最常遇到的坑程序不是SIGSEGV而是SIGILL或者直接跑了很久没反应。原因通常是失衡后ret跳到了一个可执行但语义错误的位置CPU执行了非法指令或陷入了某种循环。排查技巧用调试器在ret处打断点看一下%rsp指向的内容解析出来的地址是否合理。如果地址落在代码段内多半是跳到了某个函数中间这种比SIGSEGV更迷惑人建议把所有可能落到非法地址的字节都填充成0xccint3让崩溃点更可见。5.2 调试器回溯完全混乱栈失衡后帧指针链已经断裂bt打出来的调用栈会是“血统不明”的一堆地址。这不是调试器的问题是栈本身的意义被破坏了。此时不要依赖bt改用单步指令级调试在ret前看%rspret后看$rip一条条推。我自己的习惯是失衡复现时全程关闭优化用-O0编译这样函数结构和栈布局与源码对应关系更紧密理解起来省很多事。开启-O2后栈帧可能被合并观察点会变多新手很容易把自己绕进去。5.3 偏移计算总是差一两个字节栈失衡的最底层问题就是“字节错位”差8字节和差0字节是两个完全不同的世界。很多初学者在构造ROP链时payload里少了或多了8字节导致跳板地址进错位置。排查时先固定一个变量先把pop; ret路径去掉让失衡后ret直接跳到buf地址然后看能否执行到我们填充的shellcode开头。如果跳不到优先检查buf地址和栈顶位置的偏移关系而不是检查ROP链本身。我的经验是在payload最前面先放8个字节的滑板指令如nop这样即使偏移差一点也有容错空间能更快定位问题。6. 防御视角把栈重新“焊稳”6.1 编译期防护的底层逻辑栈保护、PIE、RELRO这些防御手段在“常规攻击”下非常有效但面对栈失衡这类非常规控制流问题时作用要打折扣。栈保护检查的是canary是否被改写而失衡利用并未改写canary它只是让ret读错位置。PIE/ASLR则迫使攻击者需要额外的信息泄露但漏洞利用者最不缺的就是耐心。真正能有效对抗这类问题的方案是CFI比如Intel CET的shadow stack或Clang的CFI实现。shadow stack会在内存维护一份“影子返回地址”ret执行前比对真实栈和影子栈的内容不一致就触发异常。我在某跨平台项目中测试过这个机制失衡导致的ret错位几乎当场被拦下这在演示中让我印象深刻。6.2 代码审计与动态校验的思路没有硬件的场景下写出安全的汇编逻辑更重要。审计时关注的模式很明确所有push和pop是否严格配对所有函数入口的sub rsp和出口的add rsp是否一致间接调用前后是否篡改过%rsp内联汇编的clobber列表是否声明完整我在Code Review时常用一个笨办法把每个函数的入口和出口指令摘出来写成一个伪代码清单手工检查配对。虽然慢但一定能发现失衡点。编译器也有栈校验工具比如利用SInst检查异常栈行为但不会在默认rel腾版本里开所以纯汇编代码的审查责任在写代码的人身上。6.3 给安全研究者和开发者的建议如果你在做漏洞挖掘不要把注意力全放在堆缓冲区溢出上栈失衡这类“轻量级”结构问题往往隐藏在一些非常不起眼的差异中。建议养成看反汇编的习惯哪怕只是一个小函数也拆开看看栈操作是否对称。发现不对称的地方先别急着当噪音跳过那可能就是漏洞的起点。如果你是开发工程师给嵌入式平台写汇编或者特别底层的代码时务必显式地保证栈平衡不要依赖“看起来没问题”的直觉。内核态代码尤其如此栈失衡在驱动代码里能直接导致系统崩溃危害比用户态更大。7. 最后分享一点我的个人体会接触堆栈不平衡这个问题之前我一直以为漏洞利用的核心是“把数据写成代码”。直到复现了这类失衡样本我才意识到很多时候利用的本质是“借用程序里已有的机制来完成攻击者想让机制做的事”。栈失衡只是诸多“机制扭曲”中的一种类似的思路还可以迁移到异常处理、信号处理器、setjmp/longjmp甚至协程切换的上下文上。每次研究这一类问题我都在想同一个问题程序对自身状态的假设哪些是铁的、哪些只是惯例铁的假设很难撬动惯例就可以。栈平衡在多数体系结构下其实只是调用约定里的一个惯例而不是CPU强制的东西这一点决定了它一定存在被扭曲和利用的空间。Debugger还在单步走的时候攻击者早就顺着那条偏移8字节的缝隙穿过了整个防御体系。写代码的人多看一眼反汇编比多写一百行防御代码都值。