资讯动态

缓冲区溢出攻防实验:jmp esp跳转与函数指针劫持复盘

发布时间:2026/10/21 7:32:55 来源:尧图企业网站定制
先明确一个前提这篇内容面向的是受控实验环境下的安全教学场景我用它来复现经典的缓冲区溢出利用路径目标是为了真正理解漏洞的成因与防御逻辑。任何实验都应当放在与真实工作环境隔离的虚拟机里完成严禁对未授权的系统做类似尝试。这次实验的核心有两个一是通过jmp esp或call esp技巧绕过返回地址的精确定位问题二是通过覆盖函数指针来劫持程序执行流。两者都是经典的软件攻击路径放在今天的防御环境中虽然会被大部分缓解机制拦下但理解它们仍然是打牢二进制安全基础的关键一步。1. 实验背景为什么现在还在反复讲缓冲区溢出很多刚接触软件安全的人都会问同一个问题缓冲区溢出已经是上个世纪的攻击手段了现代系统不都有各种防护吗为什么课程里还要用整整两个实验来做它这个问题其实问得非常好而答案也很直接因为边界错误依然是今天最危险的安全漏洞来源之一。不管是C/C生态里的字符串处理还是近年来大量曝出的堆溢出、栈溢出变种其根因都和我在这个实验里要研究的常识性问题一致——程序没有验证写入数据的长度是否超过了目标内存区域的容量。这个实验的全称是软件安全实验——lab6前一个实验做的是最基础的溢出利用这次是在那个基础上加了两道坎。第一道坎是返回地址的具体位置不好确定需要借助jmp esp或call esp这类指令来完成跳转第二道坎是溢出不光可以覆盖返回地址还可以覆盖函数指针让程序在间接调用时跳去执行攻击者指定的代码。这两条路分别对应了代码如何被引导执行和控制流信息如何被篡改两个核心问题。适合看这篇内容的人包括正在上软件安全课的学生、准备相关方向面试的开发者以及那些在写底层代码时想知道编译器警告到底在怕什么的C/C程序员。我不打算把文章写成纯粹的漏洞利用教程因为那既危险也脱离现实更希望把它定位成一次实验复盘每一步为什么这么做、防护机制是怎么拦的、我们又能从中学到什么。2. 环境准备在隔离虚拟机里搭一套可复现的实验台实验平台的搭建是整个过程中最枯燥但又最不能省的环节。我的做法是直接用虚拟机装了一个32位的系统镜像来做测试原因后面会讲到。物理机上跑这些实验存在污染宿主环境的隐患所以虚拟机是底线。2.1 为什么非要用32位环境这个实验里涉及的内存地址、寄存器宽度和栈布局全部是以32位为前提的。如果用64位环境首先地址长度翻倍栈对齐规则完全不同其次很多经典的利用手法在64位下走不通会给初学阶段带来大量不必要的干扰。32位环境下eip寄存器正好存放的是下一条要执行的指令地址理解起来非常直观而在64位下对应的rip加上各种额外约束会让溢出后跳去哪这个问题复杂不少。提示有条件的话尽量选择和实验指导书匹配的32位系统镜像。用64位系统硬跑32位程序虽然也可以但会遇到缺少32位运行库之类的小麻烦平白消耗实验时间。就虚拟机软件的选择来说常见的那几款都可以。需要注意的关键设置有两处一是把虚拟机的网络模式设为仅主机或直接断开网络因为实验程序本身不需要联网二是给虚拟机分配的内存不用很大512MB到1GB就够了我们跑的都是特意编译出来的小型测试程序。2.2 关闭系统缓解机制的目的与边界在实验开始前需要关闭几项安全缓解机制包括地址空间布局随机化、栈不可执行保护等。很多人一听到关闭防护就觉得这是为了教大家怎么攻击但实际上关闭这些机制是为了先降低变量数量把核心的栈布局和跳转逻辑搞明白然后再逐步打开防护去观察每层保护的效果。用Linux环境来举例临时关闭地址随机化的方法之一是通过sysctl调整内核参数。这个操作在实验系统里做没问题但我强烈不建议在任何可能存在重要数据的系统上尝试。正确的打开方式是想清楚自己要观察什么现象没有随机化时栈地址是稳定的这方便我们定位返回地址的位置而打开了随机化后同一个溢出代码可能每次跑的效果都不一样这就是防护在起作用的直观证据。比赛用的编译参数我会在下面列出来这个参数组合可以消除编译器的栈保护同时明确告诉链接器栈段不需要可执行权限。具体来说-fno-stack-protector去掉栈金丝雀-z execstack让栈可执行-no-pie关闭位置无关可执行文件特性。不加上-no-pie时程序加载基址会随机化同样会干扰实验观察。# 使用gcc编译一个关闭常见保护的测试程序 # 注意这只能在隔离的实验环境里使用 gcc -m32 -fno-stack-protector -z execstack -no-pie -o test_prog test_prog.c编译过程中可能会遇到32位头文件缺失的情况比如gcc-multilib没装。报错信息通常是找不到某个头文件或者链接阶段失败这时候补装对应的32位开发包就行。这个环节看似琐碎但很多初学者会在这里卡住实际上只要耐心看编译器的提示信息很快就能解决。2.3 测试程序的构造思路实验需要一个功能简单、漏洞明确的测试程序。我在程序里故意使用了一个不安全的字符串复制函数把用户输入直接拷贝到一个小的局部缓冲区里然后再用一个函数指针变量来完成一次间接调用。程序的设计刻意让输入和调用这两件事同时出现给后续两种利用手法留足空间。需要注意的是这种故意留洞的写法在真实项目中是绝对的反面教材。偏偏很多老旧代码里就是存在类似的模式未限制长度的复制操作、可以被外部数据影响的函数指针、缺少对输入长度的校验。这也是为什么这个实验虽然手法很老但思路至今仍然有研究价值。3. 从栈帧布局说起理解溢出目标在内存中的位置在动手之前必须先理解程序运行时栈上发生了什么。这一部分是整个实验的地基后面的所有跳转技巧都是建筑在它之上的。3.1 函数调用时的栈空间变化当一个函数被调用时处理器会依次完成以下几件事把调用者的返回地址压入栈中然后跳转到被调用函数的入口接着被调用函数会保存上一帧的栈基址再在自己的栈帧里分配局部变量空间。在32位环境下这些动作的结果就是在栈上形成了一个非常规整的布局esp寄存器指向当前栈顶也就是栈上最新被使用的那个位置ebp寄存器保存当前函数的栈帧基址局部变量通常用ebp减去偏移量来访问返回地址存放在ebp 4的位置而ebp位置存放的是上一层函数的栈基址所以一个典型的栈帧从高地址到低地址的方向排列是调用者栈帧中的局部变量、调用者传入的参数、函数的返回地址、上一层栈基址、当前函数的局部变量。这也意味着如果当前函数的局部缓冲区发生溢出数据是从低地址向高地址覆盖的而返回地址正好躺在缓冲区上方不远处。这个过程其实很像在一张纸上往下写草稿纸的开头部分是当前的数据越往下越是更高层的管理信息一旦写的字数超出了纸的范围就可能把下方的汇总信息涂改掉。缓冲区溢出就是典型的写字超出边界。3.2 溢出的距离计算要精准地覆盖返回地址需要知道缓冲区起始位置到返回地址之间有多少字节。这个距离不经过实际调试基本无法凭想象确定因为编译器的对齐规则、局部变量的排列顺序都会影响最终结果。实际操作的时候我有两个办法。第一种是看反汇编代码用调试器单步跟踪到缓冲区复制完成的位置直接观察ebp的值和缓冲区起始地址之间的差值。第二种是模糊测试法用一个很长的模式字符串去填充输入然后触发崩溃再看调试器报告的程序计数器指向了哪个位置通过模式串的位置偏移反推出距离。第二种方法在实验中非常实用因为当程序崩溃时调试器会显示出执行流被劫持后试图跳去的地址而这个地址恰好是模式串中某个位置的字符。把这个位置找出来缓冲区到返回地址的距离就一目了然了。这个过程也直观地展示了一件事崩溃信息里往往藏着大量攻击者需要的情报。3.3 为什么精准定位地址是难点实验做到了这里第一个真正让人头疼的问题就出现了就算知道了距离我要跳到哪里去呢如果只是想验证溢出存在随便填一些无效垃圾数据让程序崩溃就行但要让程序继续执行我们注入的代码就必须知道注入的代码在内存中的确切地址。在没有地址随机化的实验环境里这个地址可以通过调试器直接看到然后硬编码进溢出数据里。但这正是最脆弱的环节任何一行代码调整、任何一个编译选项变化都可能导致栈地址改变让之前算好的地址全部失效。于是在真实场景中攻击者往往没法依赖某个固定地址他们需要更巧妙的办法。这就是jmp esp和call esp技巧的用武之地。4. 使用jmp esp或call esp跳转解决地址不固定问题的思路这一手法流行了很多年核心思想非常优雅巧妙之处在于把跳去哪里这个问题转变成跳去代码当前位置附近。4.1 核心思想跳向栈顶的活动区域当一个函数即将返回时esp指向的位置恰好是返回地址存放的地方。如果我们把返回地址覆盖为某条jmp esp指令的地址那么函数执行ret指令后处理器会先弹出我们填写的这个地址跳转去执行jmp esp此时这段指令会让处理器跳转到esp当前的指向也就是返回地址之后的空间。那返回地址之后通常放着什么呢对我们来说那正好是我们构造的输入数据的后续部分。攻击者的数据在被复制时往往是从缓冲区底部往上覆盖的返回地址位于中间而返回地址上面的高位部分依然可以被精心设计成一段可执行的机器指令。当程序一路跳转到这里时它就开始执行我们布置在栈上的代码了。整个过程的精妙之处在于我们不再需要知道注入代码的绝对地址只需要知道某条jmp esp指令在哪里。而jmp esp指令本身只有短短几个字节在许多系统或程序自带模块中都能找到现成的片段这就是为什么这个手法在当时那么流行。4.2 在程序中寻找可用的跳板指令要在实验程序里找jmp esp或call esp指令有一个非常直接的办法把程序加载进调试器里直接搜索对应机器码的十六进制字节。以jmp esp为例它的机器码是FF E4。在32位程序的代码段里搜这两个字节通常能找到不止一处。为什么因为正常程序里也可能出现恰好由多条指令拼接而成的这种字节序列不一定真有一条独立的jmp esp指令。不过对我们来说这不重要处理器解码时只认字节序列不管它本来是什么指令。同样的逻辑也适用于call esp其机器码是FF D4。选择用jmp还是call实际效果略有差别call esp会先压入一个返回地址再跳转所以执行完被调代码后还能回来而jmp则没有返回逻辑。在实验环境里用jmp居多逻辑更干净。经验搜索跳板指令时优先在非随机的固定加载模块里找。如果程序启用了PIE或ASLR跳板指令自身的位置也会变化等于又回到了原问题。4.3 利用过程中的细节设计找到跳板地址后溢出数据的布局变得非常清晰基本结构如下[填充垃圾数据] [跳板指令地址] [注入的机器指令代码]填充垃圾数据的长度必须精确等于缓冲区起始位置到返回地址的距离接着填上跳板地址最后的机器指令就是希望目标程序执行的代码。这部分内容里容易出错的一个地方是跳板地址必须按小端序写入。32位环境下地址的低位字节在前。如果直接在输入文件里写0xffffd4ff这样的文本表示那程序解析出来的是完全错误的值。还需要注意的一点注入的代码在栈上的位置是否对齐。在一些实现对内存访问有对齐要求如果栈指针没有对齐到合适的边界某些指令的执行会触发异常。实验环境的栈在进入函数时一般是 16 字节对齐的但经过溢出数据填充和各种跳转之后对齐状态可能被破坏。遇到指令执行异常时优先检查对齐状态往往是定位问题的最快途径。4.4 为什么这段代码必须自己写在这个实验里注入的代码需要完成的核心任务是启动一个系统调用比如弹出一个调试用的提示窗口或者执行一条简单的命令。这个过程要手工编写因为stack上的代码不能依赖正常程序加载链接的过程它必须是一个自包含的、不引用外部符号的指令序列。代码的编写和测试本身就是实验中最能加深理解的一步。你要清楚每个字节的作用哪个字节是系统调用号哪个字节是参数哪些字节负责把字符串地址放到正确的寄存器里。任何一个字节出错代码执行起来都可能直接崩溃而不是预期地完成任务。调试这类代码时有一个非常实用的技巧在关键位置设置断点单步执行到注入代码的区域逐条检查寄存器状态和内存内容。因为栈上的代码不像程序原有代码那样有符号信息调试器里看起来就是一串原始的机器码所以耐心地对齐寄存器值会节约大量定位问题的时间。5. 修改函数指针不打返回地址也能劫持执行流如果说用jmp esp跳转是顺着返回地址的思路往下走那么修改函数指针这条路就完全是另一种思维不去碰返回地址而是攻击程序在其他地方保存的控制信息。函数指针本质上就是一个保存了函数入口地址的变量程序调用它时处理器会取出这个地址并跳转过去。如果能把这个变量的值改掉程序在执行间接调用时就会跳到我们指定的位置。5.1 函数指针在内存中的位置在我的测试程序里函数指针被声明成一个局部变量。根据栈帧的布局特点局部变量和缓冲区都在同一个栈帧里。如果缓冲区紧挨着函数指针变量或者两者之间的偏移距离恰好是溢出能够覆盖的范围那么一个简单的越界复制就能把函数指针的值改写掉。比较有意思的是函数指针的位置和返回地址的相对关系并不固定取决于局部变量的声明顺序、编译器的栈帧布局。有时候函数指针在缓冲区下方那普通的向下溢出可能碰不到它需要使用负偏移或越界从低地址写入的技巧更多时候它在缓冲区上方那正向溢出一路覆盖过去就能改到。我在实验里特意把函数指针声明在缓冲区之后这样正向溢出可以直接触达。5.2 覆盖后的目标选择覆盖函数指针之后要把执行流引向哪里最简单的目标就是前面用过的跳板地址。逻辑其实和覆盖返回地址如出一辙只是因为函数指针是程序自己取出来用的所以省掉了ret指令弹出地址的过程程序会直接跳到我们填入的地址。这个实验里我选择把函数指针覆盖为注入代码在栈上的绝对地址。因为实验环境已经关闭了地址随机化所以栈地址是稳定的这种方式最直观。但从这个实验也能看出为什么真实系统里要引入那么多缓解机制一旦地址被随机化绝对地址就不好用了攻击者要么寻找跳板要么采用信息泄露来获取地址利用难度会明显上升。一种更巧妙的做法是把函数指针指到程序里已有的、对我们有利的代码片段上。比如程序里可能有一段函数会打印敏感信息或者有一段函数会把某个文件内容读出来。通过覆盖函数指针把程序转到这些已有函数上就形成了所谓的面向返回编程思想的前身——不注入新代码而是复用程序原有的功能模块。这种思路在真实漏洞利用中被广泛采用。5.3 函数指针溢出的完整链路在实验中完整利用过程可以拆成这样几个阶段构造输入数据使缓冲区溢出精确覆盖函数指针变量的值程序继续执行直到遇到间接调用的指令处理器取出已被篡改的函数指针跳转到我们指定的地址执行注入代码或复用已有代码达成实验目标这条链路和前面覆写返回地址的链路有一个显著差别不需要等待函数返回而是在调用点就完成控制流劫持。这意味着在一些检测逻辑中两者暴露出的特征并不相同。也正因如此函数指针覆盖在很多历史漏洞中成为绕过某些栈回溯检测的手段。5.4 实验中的易错点函数指针实验里最容易翻车的地方在于函数指针和缓冲区之间的距离计算不能照搬上个实验的偏移量。编译器的局部变量布局可能把两者安排得出乎意料必须用调试器实际确认。我就遇到过声明顺序很合理、但编译器为了对齐在中间插入填充字节的情况导致实际偏移比预想的多了几个字节。还有一个常见问题覆盖函数指针之外的多余数据可能会进一步覆盖到栈上的其他重要信息比如寄存器保存区的值。这些值在函数返回时会被恢复如果被改坏程序可能在跳转之前就因为栈帧校验失败而崩溃。所以实验里建议精确控制溢出长度不多写一个字节。6. 现代防御机制如何让这些攻击寸步难行完成两种利用路径之后我特意做了一层对比实验把之前关闭的保护一项一项重新打开观察攻击效果在哪个环节被拦下。这一步虽然不是实验指导书里明确要求的内容但我强烈建议所有人都做一下。理解攻击是如何被阻止的有助于从更高的视角理解整个安全对抗格局。6.1 栈金丝雀直接检测覆盖行为第一个打开的防护是栈金丝雀。编译器会在函数的栈帧里、缓冲区与返回地址之间插入一个随机数并且在函数返回前检查这个值是否被改动。如果缓冲区溢出一路覆盖到了金丝雀的位置返回值就会发生变化程序会立即终止运行。用实验来测试这个保护的效果非常直观在打开金丝雀保护后重新编译同样的测试程序然后用之前的溢出数据去触发程序会直接报错退出而不是跳到我们指定的地址。这个机制对覆盖返回地址类攻击防护得非常彻底因为它检测的本质是返回路径附近是否被越界写入。金丝雀对函数指针覆盖的防护效果要弱一些因为函数指针如果位于缓冲区与金丝雀之间它可能被覆盖但金丝雀没有被碰触。不过这类局部变量通常也会被重新排列以降低风险所以效果还是明显的。从这个细节也能看出安全设计是一个层层配合的工程不是单一机制的胜负。6.2 栈不可执行阻止注入代码运行第二项保护是标记栈内存为不可执行。我实验里用的execstack选项恰恰是去掉了这个保护让栈上的代码可以执行。在真实系统中栈默认是不可执行的即使在栈上正确写入了完整的机器指令代码处理器也会在尝试执行时抛出异常。这时候需要用调试器观察在跳转到栈地址的瞬间程序是不是因为试图执行不可执行内存而崩溃。这个实验能够很好地说明为什么现代的利用技术越来越依赖复用已有代码——因为新代码没地方执行了只好捡起程序里原有的指令片段来拼凑出想要的功能。6.3 地址随机化让跳板地址难以预知第三项保护是地址空间布局随机化。它让栈、堆、共享库、程序加载基址等在每次运行时都有不同的起始位置。之前我的实验里能找到稳定的跳板地址完全是因为关闭了这项保护。打开它之后同样的跳板指令地址每次运行都在变化之前的利用数据会直接失效。通常的实际防护组合是地址随机化配合栈不可执行一起使用前者让你找不到稳定的跳板地址后者让你即使跳过去也执行不了代码。这两项叠加之后传统的jmp esp思路基本就失去效果了这也推动安全社区研究出了更加复杂的利用手段。但基础的实验依然有它的意义理解跳板思想才能理解后来的复杂手法是怎么从它演变出来的。6.4 从攻击路径推导加固方案如果从前面的攻击路径反推防御策略我们其实可以得到一整套很有价值的加固清单编译器层面始终开启栈保护、地址随机化等安全编译选项代码层面优先使用带长度限制的字符串函数避免危险复制模式架构层面启用栈不可执行、控制流完整性校验等机制运行时层面及时更新系统组件因为很多更复杂的攻击还需要借助其他漏洞来泄露地址这个清单里的每一项对应的都是我在实验里实际演示过的某个攻击环节被卡住的位置。理解攻击与防御的配合关系是这个实验比单纯学会某个跳转技巧更重要的收获。7. 实验回顾从崩溃到推理踩过的坑和收获的规律最后一部分我想聊聊实验过程中自己摸索出来的经验。这些内容不会出现在任何实验指导书里但如果你也在做类似实验大概率会碰到其中某几条。7.1 调试器是唯一的可靠信息来源很多初学者在遇到为什么溢出没有按预期跳转这个问题时第一反应是反复修改填充数据的长度去猜测。我的建议是用调试器把每一段内存的变化看清楚往往一眼就能发现到底是距离算错了、地址写反了、还是小端序搞错了。在调试器中观察栈帧变化时重点看三个数据缓冲区起始地址、返回地址位置、函数指针位置。把这三个点连成一条轴所有的溢出数据布局其实都是沿着这条轴来排的。调试器里随时可以查看但前提是你知道每个值应该在哪个位置这种先预判后验证的习惯做安全工作会受用很久。7.2 崩溃地址包含大量侦查信息当程序溢出后崩溃时调试器显示的崩溃地址并不只是一个错误信息。在我做模糊定位时用它反推缓冲区的偏移在做跳转实验时用它确认程序是否到达了预期的跳板地址。一个看似简单的崩溃信息其实是整个实验最好的反馈信号。只要按照崩溃在哪 → 为什么在这里 → 应该在哪的顺序去思考绝大多数问题都能自己找到答案。7.3 实验日志的记录价值我做这个实验时维护了一份简单的日志记录了每次修改的参数、使用的填充长度、跳板地址、观察到的现象。乍看是额外工作量但事实证明它的价值极大。因为实验中的所有数值都是相互关联的错了一步就会导致后续的现象变得无法解释。有了日志对比不同次实验之间的差异就变得非常快能节省大量重复劳动。7.4 防御视角的习惯要提前养成最后一个体会是不要等实验快结束了才去想防御层面的问题。我在做的过程中每看完一种利用手法就顺手在笔记里写一句对应的缓解机制是什么、为什么能防住。这样实验结束时攻击与防御两条脉络已经在我脑子里自然织成了一张网而不是把若干个孤立的知识点割裂地存着。如果你正在做同一个实验而且时间充裕额外试一下打开各项保护后观察攻击失败的模式收获会远远超出实验本身的要求。安全防御从来不是靠一条规则的死守而是多条机制相互配合的结果这一点实验做得越深体会就越清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑