
写在前面在上一节我们学习了操作系统的各种安全保护机制NX, Canary等。但在我们真正开始构造 Payload 进行栈溢出之前还有一个极其重要的基础知识点必须吃透函数调用时参数是怎么传递的栈内存到底长什么样32位和64位在这个问题上有着巨大的差异这直接决定了我们写 Exploit 的方式。本文将详细对比 32/64 位的调用约定与栈帧结构。 目录前置知识什么是栈它是怎么工作的32位 (x86) 调用约定与栈结构64位 (x64) 调用约定与栈结构栈溢出实战对比32位 vs 64位总结1. 前置知识什么是栈它是怎么工作的在程序运行时内存中有一块区域叫做栈它主要用于管理函数的调用关系、传递参数以及保存局部变量。栈有两个最重要的特性先进后出 (FILO)就像叠盘子最后放上去的盘子最先被拿走。向下生长在 x86/x64 架构中栈是从高地址向低地址增长的。也就是说压入数据时栈顶指针ESP/RSP会减小。涉及的关键寄存器ESP/RSP (栈顶指针)永远指向当前栈的最顶部最低地址。EBP/RBP (栈底指针)指向当前函数栈帧的底部用于寻址局部变量和参数在函数执行期间通常固定不变。EIP/RIP (指令指针)指向下一条要执行的指令地址。这是栈溢出攻击的核心目标2. 32位 (x86) 调用约定与栈结构在 32 位 Linux 系统中C 语言默认使用的是cdecl调用约定。2.1 核心规则传参方式所有的参数都通过栈来传递。参数顺序从右向左依次压栈arg3先入栈arg1后入栈。清栈责任由调用者负责清理栈上的参数。2.2 32位栈帧结构图假设我们有函数int add(int a, int b)当main函数调用add时此时的栈结构如下高地址在上低地址在下[高地址] | ... | | arg2 | - 参数2 (先压栈) | arg1 | - 参数1 (后压栈此时在栈顶) | Ret | - call指令自动压入的返回地址 (EIP) | EBP | - 被调用函数开头 push ebp 压入的旧栈底 | Var1 | - 局部变量 | Var2 | - 局部变量 | ... | [低地址] (ESP指向这里)2.3 关键分析在 32 位下因为参数就老老实实地待在栈上的返回地址上方。如果我们发生栈溢出覆盖了局部变量、EBP 之后接着就能覆盖返回地址Ret如果继续往后覆盖就会覆盖掉arg1,arg2…这就是为什么 32 位的栈溢出极其简单只要把想要执行的函数地址覆盖到Ret的位置然后在Ret后面紧跟着填入该函数需要的参数即可。3. 64位 (x64) 调用约定与栈结构到了 64 位系统寄存器数量增加了为了提高效率传参方式发生了翻天覆地的变化。Linux/Mac 采用的是System V AMD64 ABI调用约定。3.1 核心规则传参方式优先使用寄存器传参。参数顺序前 6 个参数依次放入寄存器RDI, RSI, RDX, RCX, R8, R9。多余参数如果超过 6 个参数剩下的参数才通过栈传递同样从右向左压栈。清栈责任依然由调用者清理。3.2 64位栈帧结构图假设我们调用void func(int a, int b)只有两个参数此时的栈结构如下[高地址] | ... | | Ret | - call指令自动压入的返回地址 (RIP) | RBP | - 被调用函数开头 push rbp 压入的旧栈底 | Var1 | - 局部变量 | ... | [低地址] (RSP指向这里)3.3 关键分析注意在 64 位下对于前 6 个参数栈上根本没有参数的备份参数a和b此刻正舒舒服服地躺在RDI和RSI寄存器里。这意味着什么意味着我们即使通过溢出覆盖了返回地址Ret跳转到了我们想执行的函数比如system(/bin/sh)但是因为system需要从RDI寄存器里读取/bin/sh的地址如果我们没有提前修改RDI的值system就拿不到正确的参数4. 栈溢出实战对比32位 vs 64位这部分是 PWN 新手最容易卡壳的地方必须深刻理解。场景我们要通过溢出调用system(/bin/sh) 32位下的 Payload 构造因为参数在栈上我们可以直接把参数布置在返回地址后面。栈溢出覆盖前| buf (局部变量) | - 溢出点 | EBP | | 返回地址 (Ret) | - 目标覆盖点Payload 结构[填充字符 (如 A * offset)] [system地址] [fake_ret (system返回地址随便填)] [/bin/sh字符串地址]解释当程序返回时EIP跳到system地址。system执行时会去栈上找它的返回地址也就是我们填的fake_ret和它的第一个参数也就是我们填的/bin/sh地址。非常顺理成章。 64位下的 Payload 构造因为 64 位需要参数在寄存器里直接覆盖Ret为system地址是行不通的。我们必须想办法把/bin/sh的地址塞进RDI寄存器里。这就引入了 PWN 中最经典的技术ROP (Return-Oriented Programming)。我们需要在程序里寻找这样一段汇编指令称为 Gadgetpop rdi ; 将栈顶数据弹入 RDI 寄存器 ret ; 再次执行返回指令栈溢出覆盖前| buf (局部变量) | - 溢出点 | RBP | | 返回地址 (Ret) | - 目标覆盖点Payload 结构[填充字符 (如 A * offset)] [pop_rdi_gadget地址] [/bin/sh字符串地址] [system地址]执行流程解析重点理解程序返回RIP指向Ret此时Ret被我们覆盖成了pop rdi; ret的地址。CPU 执行pop rdi把栈顶的数据弹入RDI。此时栈顶的数据正是我们布置的/bin/sh字符串地址。于是RDI被成功修改同时栈指针RSP自动向上移动 8 字节。CPU 执行ret相当于执行了一次pop rip。此时栈顶的数据是system地址。于是RIP跳转到了system。system开始执行它去RDI寄存器读取参数发现是/bin/sh成功获取 Shell5. 总结理解 32 位和 64 位的差异是脱离“脚本小子”的必经之路。特性32位 (x86)64位 (x64)参数传递全部通过栈传递优先通过寄存器 (RDI, RSI, RDX, RCX, R8, R9)栈结构栈上有参数数据返回地址上方紧接参数栈上通常只有返回地址和局部变量溢出难点找参数地址计算偏移即可必须借助 ROP Gadget 给寄存器赋值典型攻击ret2text / ret2shellcode (直接覆盖返回地址和参数)ROP 链构造 (找pop rdi; ret等)如果本篇文章对您有帮助请点赞收藏支持一下感谢阅读