
1. 从“好好说话”到“hijack retaddr”一个栈溢出利用的完整拆解“好好说话”这四个字放在安全圈里其实挺有意思。很多人一上来就甩一堆术语什么栈溢出、ROP、shellcode听的人一头雾水说的人自我感觉良好。但真正能把一个漏洞讲清楚的人往往是用最朴素的语言把最核心的逻辑说明白。这次要聊的“hijack retaddr”翻译成人话就是“劫持返回地址”它是栈溢出利用里最经典、最基础、也最能检验一个人基本功的环节。你可能会问这年头还有必要聊这么“古老”的东西吗我的回答是非常有必要。现代系统有ASLR、有栈保护、有各种缓解机制但所有这些机制的存在恰恰是因为“劫持返回地址”这件事太有效了。你只有彻底搞懂最原始的利用方式才能真正理解那些保护机制在防什么、怎么防、以及为什么有时候防不住。这篇文章适合谁看适合刚接触二进制安全、想搞明白栈溢出到底怎么被利用的人也适合已经会用工具但说不清底层原理的人还适合那些想给团队做安全培训、需要一套通俗讲法的朋友。我尽量不堆砌术语每个概念都用你能想象出来的画面去解释。涉及实操的部分我会给出完整的步骤和参数计算过程你照着做就能复现。涉及工具的地方我会说明为什么选它、有没有替代方案。踩过的坑、容易忽略的细节我也会一并写出来。2. 核心思路拆解返回地址为什么会被“劫持”2.1 函数调用背后发生了什么要理解“劫持返回地址”得先搞清楚一个函数被调用时内存里到底发生了什么。你可以把内存想象成一摞盘子每个盘子放一样东西。程序运行的时候有一个专门的区域叫“栈”用来存放函数调用过程中的临时数据。这个栈有个特点后放进去的先拿出来就像你叠盘子最后放上去的最先被拿走。当函数A调用函数B的时候系统会做几件事。第一把函数A当前执行到哪条指令的地址压入栈中这个地址叫“返回地址”作用是等函数B执行完之后知道该回到哪里继续执行。第二把函数B需要用到的局部变量在栈上分配空间。第三把函数B的参数也放到栈上或者寄存器里。这一套操作下来栈上的布局大概是这样的从高地址往低地址方向依次是函数A的局部变量、返回地址、函数B的参数、函数B的局部变量。关键点来了返回地址和函数B的局部变量在栈上是相邻的。如果函数B的局部变量是一个数组而且程序没有检查数组写入的长度那么当你往这个数组里写入超过它本身长度的数据时多出来的数据就会“溢出”到相邻的内存区域把返回地址覆盖掉。这就是栈溢出的本质。2.2 “劫持”二字的含义覆盖返回地址之后函数B执行完毕系统会从栈上取出返回地址跳转到那个地址继续执行。如果这个地址被你改成了你想要的地址那么程序的执行流就被你控制了。这就是“劫持返回地址”——你劫持了程序的执行方向。打个比方你在一家公司上班每天下班前领导会给你一张纸条上面写着“明天去A会议室开会”。你把这个纸条偷偷换成“明天去B会议室”。第二天你就出现在了B会议室而不是A会议室。返回地址就是那张纸条你覆盖它的过程就是“偷换纸条”程序跳转到你指定的地址就是“去了B会议室”。2.3 为什么选择劫持返回地址而不是其他方式栈溢出的利用方式有很多种可以覆盖函数指针、可以覆盖异常处理结构、可以覆盖虚表指针。但劫持返回地址是最直接、最通用的一种。原因有三第一返回地址几乎存在于每一次函数调用中触发机会多第二返回地址的位置相对固定容易计算偏移第三一旦劫持成功控制流转移非常干净不需要依赖其他条件。当然现代编译器默认会加栈保护在返回地址前面插入一个“金丝雀值”函数返回前检查这个值有没有被改动。如果被改动了程序直接崩溃不会给你劫持的机会。但金丝雀值也有被绕过的可能比如通过信息泄露拿到金丝雀值然后在覆盖的时候把正确的金丝雀值写回去。这些进阶内容我们后面会提到但核心思路始终是找到返回地址在栈上的位置用你想要的值覆盖它。3. 核心细节解析与实操要点3.1 如何确定返回地址的偏移量偏移量指的是从你写入数据的起始位置到返回地址所在位置中间隔了多少个字节。这个数字是劫持返回地址的关键参数算错了就覆盖不到正确的位置。确定偏移量的方法有好几种。最常用的是“模式字符串法”生成一个由特定规律字符组成的字符串比如每4个字节一组每组字符不同。把这个字符串写入存在溢出的数组程序崩溃后查看崩溃时的返回地址根据这个地址里的字符在模式字符串中的位置就能反推出偏移量。具体操作上可以用一些现成的工具生成模式字符串也可以用脚本自己生成。比如生成一个200字节的模式串写入之后如果崩溃时返回地址显示的是“0x41326241”那么你就在模式串里找“Ab2A”这个子串出现的位置那个位置就是返回地址的偏移量。这个方法的好处是直观、不需要反复试错。另一种方法是“二分法”先写入一个较大的长度看是否崩溃然后逐步缩小范围直到找到刚好覆盖返回地址的最小长度。这个方法比较笨但不需要工具适合手算。实际工作中我推荐模式字符串法效率高很多。注意不同编译器、不同编译选项、不同架构下偏移量可能不同。即使是同一个程序开了优化和没开优化栈布局也可能不一样。所以每次换环境都要重新确认偏移量不能想当然。3.2 覆盖成什么地址确定了偏移量之后下一个问题是用什么值去覆盖返回地址。这个值决定了你劫持之后程序会跳到哪里去执行。最理想的情况是跳转到你自己写入的代码。但栈上的数据通常不可执行因为现代系统有“不可执行栈”的保护。所以更常见的做法是跳转到程序中已有的函数比如系统库里的函数。如果你能控制函数的参数就可以调用系统函数来执行命令或者读取文件。在32位系统上函数参数是通过栈传递的你可以在覆盖返回地址的同时在栈上布置好参数。在64位系统上前几个参数是通过寄存器传递的你需要用“面向返回编程”的技术找到一系列以返回指令结尾的代码片段像搭积木一样拼出一个调用链。这里涉及一个关键概念叫“地址选择”。你覆盖的地址必须是程序加载后实际存在的地址。如果系统开了地址空间随机化每次程序加载的基地址都不一样你就需要先泄露一个地址计算出基地址然后再算出目标地址。这个过程叫“信息泄露加地址计算”是绕过地址随机化的标准做法。3.3 实操中的关键检查点在实际操作中有几个检查点必须确认。第一确认溢出确实发生了而且确实覆盖到了返回地址。可以通过在覆盖数据中放入一个明显的标记值比如“0xdeadbeef”然后看崩溃时返回地址是不是这个值。第二确认目标地址是有效的、可执行的。如果跳到一个无效地址程序会直接崩溃不会执行你的代码。第三确认栈上的数据布局符合预期。有时候编译器会在栈上插入对齐填充导致你计算的位置有偏差。我个人的习惯是每做一步都用一个简单的测试来验证。比如先只覆盖返回地址为一个已知的无效地址看程序是否崩溃在那个地址。如果是说明偏移量对了。然后再换成有效地址看是否成功跳转。这样一步步来比一次性写完所有利用代码再调试要高效得多。4. 实操过程与核心环节实现4.1 环境准备与目标程序分析先说明一下这里的所有操作都在一个隔离的模拟环境中进行目的是学习漏洞原理和防护方法。我们用一个自己编写的、故意存在栈溢出漏洞的小程序作为目标。这个程序的功能很简单读取用户输入然后把输入复制到一个固定大小的数组里没有做长度检查。编译的时候为了演示方便我会关闭栈保护和地址随机化。在实际环境中这些保护通常是开启的绕过它们的方法我们后面会讨论。编译命令大概是这样的gcc -o vuln vuln.c -fno-stack-protector -z execstack -no-pie -m32这里几个参数的含义-fno-stack-protector关闭栈保护-z execstack让栈可执行-no-pie关闭地址随机化-m32编译成32位程序。为什么要用32位因为32位的参数传递在栈上演示起来更直观。64位的利用会复杂一些但原理是一样的。编译完成后用file命令确认一下程序架构用checksec之类的脚本看一下保护机制的状态。确认保护都关闭了就可以开始分析了。4.2 确定偏移量的完整过程第一步生成模式字符串。我用一个简单的Python脚本生成200个字节的模式串每4个字节一组确保每组都不重复import string pattern for i in range(4): for j in string.ascii_uppercase: for k in string.ascii_lowercase: for l in string.digits: pattern j k l str(i) if len(pattern) 200: break if len(pattern) 200: break if len(pattern) 200: break if len(pattern) 200: break print(pattern[:200])这个脚本生成的模式串每12个字节一个循环足够覆盖200字节的范围。把生成的字符串作为输入传给目标程序程序崩溃后用调试器查看崩溃时的返回地址。第二步在调试器中运行程序传入模式串观察崩溃点。假设崩溃时返回地址显示为0x41326a41那么我们在模式串中查找Aj2A这个子串。找到它出现的位置比如在第44个字节处那么偏移量就是44。第三步验证偏移量。构造一个44字节的填充数据后面跟上4字节的标记值0xdeadbeef写入程序。如果崩溃时返回地址正好是0xdeadbeef说明偏移量正确。提示调试器的选择上命令行可以用gdb图形界面可以用一些集成调试工具。我个人习惯用gdb配合一些插件查看栈布局比较方便。如果你不熟悉gdb可以先从图形化工具入手但长远来看命令行工具的效率更高。4.3 构造利用载荷偏移量确认之后就可以构造完整的利用载荷了。载荷的结构是填充数据 目标地址 可能的参数或shellcode。在32位、栈可执行的情况下最简单的做法是把shellcode放在填充数据之后然后把返回地址覆盖为指向shellcode的地址。但这里有个问题栈的地址在每次运行时可能不一样即使关闭了地址随机化不同环境下栈的起始地址也可能有差异。所以更稳妥的做法是跳转到某个寄存器比如jmp esp。因为函数返回时esp通常指向返回地址的下一个位置也就是你布置shellcode的地方。具体操作在程序中找一个jmp esp或者call esp的指令地址把这个地址覆盖到返回地址的位置。函数返回时先跳转到jmp esp然后esp指向你布置在栈上的shellcode程序就会执行你的代码。找jmp esp地址的方法用调试器或者反汇编工具搜索程序加载后的内存空间找到指令字节为ff e4的位置。记录下这个地址作为覆盖值。载荷的最终结构大概是[44字节填充] [jmp esp地址] [shellcode]写入程序后如果一切顺利shellcode就会被执行。你可以用一个简单的shellcode来测试比如弹出一个计算器或者输出一段文字。确认利用成功后再换成更复杂的载荷。4.4 参数计算与地址确认在实际操作中地址的计算是最容易出错的地方。以jmp esp为例你找到的地址是程序加载后的运行时地址不是文件偏移。如果你用静态分析工具查看看到的是文件偏移需要转换成运行时地址。转换的方法是找到该指令所在的段用段的运行时基地址加上文件内偏移。在关闭地址随机化的情况下段的基地址是固定的可以在调试器中直接看到。比如代码段基地址是0x08048000jmp esp指令在文件中的偏移是0x1234那么运行时地址就是0x08048000 0x1234 0x08049234。把这个地址写入返回地址的位置即可。如果开启了地址随机化就需要先泄露一个地址。常见的方法是利用程序本身的输出功能打印出某个栈上或者库里的地址然后根据已知的偏移计算出基地址。这个过程通常需要结合格式化字符串漏洞或者其他信息泄露漏洞来完成。5. 常见问题与排查技巧实录5.1 偏移量总是算不对怎么办偏移量算不对是最常见的问题。可能的原因有几个第一模式字符串生成有误导致崩溃地址在模式串中找不到对应位置。检查方法是确认模式串中每个4字节组都是唯一的。第二编译器插入了额外的栈空间比如对齐填充或者调试信息。可以尝试调整编译选项或者用调试器直接查看栈布局。第三输入方式影响了数据在栈上的位置。比如通过命令行参数传入和通过标准输入传入栈布局可能不同。我的经验是不要完全依赖模式字符串法最好用调试器直接观察。在函数返回前下断点查看esp和返回地址的位置手动计算偏移量。这样最准确也最能加深理解。5.2 跳转后程序崩溃在没有意义的地址这种情况通常是目标地址无效或者不可执行。检查几个点目标地址是否在程序加载的范围内该地址的内存页是否有执行权限如果系统开了不可执行保护栈上的地址即使跳转过去也会触发异常。这时候就需要用面向返回编程的技术跳转到已有的可执行代码片段。另一个可能的原因是地址写错了字节序。x86是小端序写入地址的时候要注意字节顺序。比如地址0x08049234在内存中应该写成\x34\x92\x04\x08。写反了就会跳到一个完全错误的地址。5.3 常见问题速查表问题现象可能原因排查方法程序不崩溃输入长度不够没覆盖到返回地址增加输入长度用模式串确认崩溃地址与预期不符偏移量计算错误用调试器直接查看栈布局跳转后立即崩溃目标地址无效或不可执行确认地址范围和内存权限地址随机化导致每次崩溃地址不同系统开启了ASLR先泄露地址计算基地址栈保护触发编译器开启了栈保护检查金丝雀值或关闭保护重新编译5.4 独家避坑技巧第一个技巧在覆盖返回地址之前先用一个明显的标记值测试。比如用0x41414141如果崩溃时返回地址是0x41414141说明偏移量对了。这个习惯能帮你快速定位问题。第二个技巧调试的时候把断点下在函数返回指令上而不是函数入口。这样你能直接看到返回地址被弹出时的值最直观。第三个技巧如果目标程序有多个输入点优先选择那些输入长度不受限制、且缓冲区在栈上的点。有些程序的输入虽然长但缓冲区在堆上那就不能用栈溢出的方法。第四个技巧记录每次实验的环境配置。编译器版本、编译选项、系统版本、保护机制状态这些都会影响利用结果。养成记录的习惯换环境的时候能快速复现。6. 从劫持返回地址看防护与对抗6.1 栈保护机制的工作原理栈保护的核心是在返回地址前面放一个随机值叫“金丝雀值”。函数返回前检查这个值有没有被改动。如果被改动了说明发生了溢出程序直接终止。这个机制能有效阻止简单的返回地址覆盖因为你在覆盖返回地址之前必然会覆盖到金丝雀值。但金丝雀值也不是无懈可击的。如果程序存在信息泄露漏洞攻击者可以读出金丝雀值然后在覆盖的时候把正确的金丝雀值写回去这样检查就能通过。所以栈保护通常要和地址随机化、不可执行栈等机制配合使用才能形成有效的防护。6.2 地址随机化的绕过思路地址随机化让每次程序加载的基地址都不同增加了预测目标地址的难度。但随机化也有弱点第一随机化的熵可能不够某些系统上只有几十种可能的基地址可以通过暴力破解第二程序中的某些地址可能不受随机化影响比如某些固定映射的区域第三通过信息泄露可以计算出基地址。绕过地址随机化的标准流程是先找到一个信息泄露点读出一个指向程序或库的指针然后根据已知的偏移计算出基地址再根据基地址计算出目标地址。这个过程通常需要结合多个漏洞才能完成。6.3 不可执行栈的应对方法不可执行栈让栈上的数据不能作为代码执行传统的“跳转到栈上执行shellcode”的方法就失效了。应对方法是“面向返回编程”不执行栈上的代码而是利用程序中已有的代码片段通过返回指令把它们串联起来形成一条调用链。面向返回编程的核心是找到以返回指令结尾的代码片段每个片段完成一个小操作比如把某个值放入寄存器、调用某个函数。把这些片段的地址按顺序布置在栈上函数返回时就会依次执行这些片段最终完成你想要的操作。这种技术比较高级但原理还是基于返回地址的控制。6.4 防护与对抗的平衡安全防护和漏洞利用是一对矛与盾。每引入一种新的防护机制就会催生新的绕过技术。但总体趋势是利用的难度在不断增加需要组合多种技术才能完成一次成功的利用。对于开发者来说理解这些原理有助于写出更安全的代码对于安全研究者来说理解这些原理有助于评估系统的真实安全状况。我个人在实际操作中的体会是不要迷信任何一种防护机制。栈保护、地址随机化、不可执行栈单独使用都有绕过的方法。只有把它们组合起来再加上代码审计、输入验证、最小权限等开发实践才能形成有效的纵深防御。而作为学习者和研究者从最基础的“劫持返回地址”入手把每一步都搞透彻比一上来就追求高级技巧要扎实得多。后续如果你想继续深入可以研究一下64位下的利用、堆溢出、格式化字符串漏洞这些都是在返回地址劫持的基础上延伸出来的。