资讯动态

软件安全实验三:缓冲区溢出攻击实战详解——从栈溢出到shellcode与ret2libc

发布时间:2026/9/15 16:34:43 来源:尧图企业网站定制
说到软件安全这门课十个学校里有八个会把缓冲区溢出攻击实验列为必修的“硬菜”BUPT的软件安全实验三更是把这道菜端到了每一位同学面前。回头看这门课的核心关键词无非就是“软件安全”四个字如何让程序更健壮、如何理解攻击者的思路、如何从汇编和内存布局的层面看透漏洞本质。实验三正是整个课程里最“动手”的一环——你不再止步于概念而是要用调试器、反汇编工具和脚本亲手构造一个能劫持程序执行流程的输入让目标程序乖乖交出控制权。这个实验适合所有正在修软件安全、系统安全、二进制安全方向的同学参考哪怕你不是北邮的只是被“南邮软件安全实验”或者其他学校的同类实验折磨过内容也完全适用。我当年做这个实验的时候花了不少时间在“看着段错误一脸懵”和“不知道偏移怎么算”上这篇文章干脆把我踩过的坑、用顺手的套路、还有那些老师没写在PPT里的细节一次性整理出来给你。1. 实验三到底在做什么先看清这门课的定位很多人拿到实验指导书的第一反应是这不就是个“往程序里塞超长字符串”的活儿吗理论上确实如此但如果你只用“塞字符串”的心态去做实验报告写出来一定干巴巴而且稍不留神就会被系统里开启的防护机制搞得怀疑人生。1.1 从“软件安全”的角度看为什么要学缓冲区溢出先聊一个底层问题为什么软件安全课程里一定要涉及缓冲区溢出因为这类漏洞在真实世界里“历史悠久且影响深远”。缓冲区溢出的本质是程序没有对写入内存的数据量做边界检查导致超出预定长度的数据覆盖了栈上相邻区域的内容包括返回地址、局部变量、保存的寄存器值等。攻击者只要精心构造这段超长数据就能改变程序的执行流向让它去执行攻击者指定的代码。软安实验三通常就是给你一个编译好的、带漏洞的可执行文件让你完成从“定位漏洞”到“构造利用”的完整链路。这个过程涉及栈帧布局、函数调用约定、ELF文件结构、进程内存映射、shellcode编写等一系列知识可以说是软件安全实验课里最能体现“知攻善防”的一环。1.2 BUPT软件安全实验三的常见形态与考核要求根据BUPT历届实验指导书以及同类院校比如南邮的软安实验的公开资料实验三最常见的目标程序是一个通过strcpy或gets读取输入的程序要求你完成至少一个完整的利用链比如通过栈溢出覆盖返回地址跳转到恶意代码shellcode位置获得shell在关闭特定防护机制的条件下利用ret2libc等方式执行system(/bin/sh)根据实验报告模板绘制漏洞触发流程图也就是热搜里提到的“软件安全架构图”那类产物。考核点通常包括能否成功弹出shell、能否说清楚偏移量的计算过程、能否画出漏洞利用的调用关系图、能否分析程序原本的函数调用栈布局。说到底老师想看的不是“你最终拿到了什么”而是“你知不知道你为什么会拿到”。提示不同学校的实验指导书细节会有差异但核心永远是“理解栈的结构和程序的控制流”。先把这两点吃透不管题目怎么换你都能应对。2. 环境准备与工具链没有趁手兵器就别上战场缓冲区溢出实验对环境的要求说高也高说低也低。如果你直接在Windows上装了个开发环境就想跑通我劝你趁早打消这个念头——这个实验从目标程序的编译到payload构造几乎每一步都是围绕Linux的内存布局展开的。2.1 实验环境的选型Ubuntu 16.04 或 SEED镜像最省心我建议优先准备一个32位Linux环境。原因很简单32位程序的栈布局直观、地址空间小、构造shellcode时不用处理太多寄存器传参的弯弯绕绕非常适合教学实验入门。我自己用的是Ubuntu 16.04 i386虚拟机如果你手头有SEED Labs官方提供的Ubuntu镜像那更省事里面很多安全工具和编译选项都是现成的。如果你用的是64位系统也不是不行但要注意64位程序的第一个参数通过rdi寄存器传递利用ret2libc时需要找pop rdi; ret这样的gadget栈对齐要求也更严苛新手非常容易在地狱般的“段错误”里耗尽耐心。所以我的建议是16.04 32位镜像 VMware/VirtualBox这是性价比最高的开局。2.2 必备工具清单checksec、gdb、objdump、python做实验前把下面这些工具装好基本就够用了工具用途安装/获取方式checksec查看程序防护机制NX、ASLR、Canary、PIEsudo apt install checksec或用pwntools自带版gdb动态调试观察寄存器和栈状态sudo apt install gdbobjdump静态反汇编查看函数和汇编指令binutils自带python3 pwntools构造payload、交互、自动化利用pip install pwntoolsvim / nano写脚本和记录分析过程系统自带看个人习惯这里尤其要老生常谈一句会debugger的人做这个实验可以少走一个星期的弯路。很多同学第一反应是把程序丢进IDA里看伪代码但动态调试得到的栈帧数据才是计算偏移量的金标准。gdb里的一条x/20wx $esp命令直观程度远超任何静态分析的猜测。2.3 防护机制实验该不该关ASLR、栈保护和NX拿到目标程序后第一件正事是用checksec看它的防护情况。你会发现现代Linux编译器默认会开启一堆防护机制比如栈金丝雀Stack Canary在局部变量和返回地址之间放置一个随机值函数返回前检查该值是否被篡改NXNo-eXecute栈被标记为不可执行注入的shellcode即使被跳转到也无法运行ASLR地址空间布局随机化栈、堆、共享库的加载地址每次运行都不同PIE位置无关可执行程序本身的基址也会随机化。教学实验一般会要求你先关闭这些防护机制比如gcc -fno-stack-protector -z execstack -no-pie编译或者直接给你一个老式编译风格的二进制文件目的是让你先理解漏洞利用的“最原始形态”再逐步加上防护做进阶练习。注意关闭ASLR可以用sudo sysctl -w kernel.randomize_va_space0但记得实验结束后恢复成2别在公共服务器上乱改。3. 核心实验拆解从“摸清程序”到“弹出shell”的完整路径现在进入正题。下面我以一个经典的32位栈溢出程序为例带你从头到尾走一遍“拿到黑盒程序 → 定位漏洞 → 计算偏移 → 构造payload → 获取shell”的完整流程。3.1 第一步拿到程序先“摸个底”——file、checksec、运行观察假设目标程序叫vuln第一步我会执行三条命令file vuln checksec --file./vuln ./vulnfile告诉你这是不是32位ELF、是否strip过checksec告诉你防护机制全貌直接运行则能观察到程序的输入逻辑——它可能是读文件、读环境变量、读命令行参数或标准输入这个信息直接决定你后面payload的投递方式。我的习惯是先随便输入一串AAAA观察程序是否崩。如果崩溃再用gdb启动并输入长串字符立刻就能看到eip被覆盖成了什么。这一步看似简单却是整个实验的关键起跑线。3.2 第二步反汇编定位漏洞函数理解栈帧布局目标程序大概率有一个看起来就很危险的函数比如void vulnerable(char *input) { char buf[64]; strcpy(buf, input); }用objdump -d vuln反汇编一下找到vulnerable的反汇编代码重点看leave和ret指令之前的栈帧清理逻辑。这里的关键是算出buf到返回地址之间的字节数也就是常说的“偏移量”。手工计算方法栈帧中buf从esp 某偏移开始往上先是旧的ebp4字节再往上才是返回地址。如果buf长度为64那么偏移量通常是64 4 68。但实际还要考虑编译器是否做了栈对齐、是否有其他局部变量占用空间所以最稳妥的方法还是动态调试。3.3 第三步用gdb精确计算偏移量别再瞎猜了手工算偏移容易翻车我强烈建议用gdb或者peda插件直接算。流程如下gdb ./vuln gdb-peda$ pattern create 200 gdb-peda$ run pattern.txt输入“pattern create”生成的200字节去跑程序崩溃后看gdb报出的EIP值再用pattern offset 该值就能精确得到覆盖返回地址所需的位置偏移。没有peda的话也可以用最朴素的二分法分别输入64、68、72、76、80个字符观察EIP被覆盖成了什么模式再确定精确偏移。我自己的心得是偏移量一定要算两遍一遍用pattern一遍用“手填A精确地址”验证。第二遍如果程序没有正确跳转说明第一步算得有问题或者说payload里还有坏字节影响了传递。3.4 第四步构造第一发payload——栈地址定位与shellcode注入拿到偏移量之后逻辑很简单padding 返回地址 一段shellcode。但这里最坑的是“栈地址怎么知道”。因为shellcode放在栈上而返回地址必须精确指向shellcode的起始位置我们需要拿到运行时栈地址。常用的做法是用gdb在vuln函数的ret指令处下断点运行后查看$esp的值再结合buf的位置估算出shellcode的地址。经典脚本大致长这样#!/usr/bin/env python3 import struct # 根据gdb调试得到的实际栈地址调整 shellcode b\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80 padding bA * 68 # 偏移量 ret_addr struct.pack(I, 0xffffd240) # 栈地址 nop_sled b\x90 * 100 # NOP滑梯提高命中率 payload nop_sled shellcode padding ret_addr with open(payload.txt, wb) as f: f.write(payload)为什么要加一长串NOP因为栈地址的精确值很难一步到位通常在几十字节范围内浮动。NOP滑梯的原理是只要程序跳转到滑梯上的任意一个NOP指令CPU就会顺着NOP一路滑到shellcode开头大大降低对精确地址的要求。这是新手最实用的容错技巧。3.5 第五步进阶玩法——NX开启时改用ret2libc如果实验把NX打开栈不可执行注入shellcode的路就断了这时候要用ret2libc。核心思路不执行栈上代码而是劫持程序流程去调用libc里现成的system(/bin/sh)。32位下最简单padding system地址 返回地址 /bin/sh地址。关键是提前在gdb里找好这两个地址gdb-peda$ p system gdb-peda$ searchmem /bin/sh64位下稍微复杂一点需要借助pop rdi; ret这个gadget把/bin/sh的地址放进rdi寄存器。用ROPgadget或者ropper可以快速搜索gadgetROPgadget --binary ./vuln --only pop|ret凑好gadget链之后payload格式是padding pop_rdi_ret binsh_addr system_addr。这里有一个特别容易翻车的坑64位System V调用约定要求栈16字节对齐如果不小心在system调用前栈没对齐glibc内部某些指令会直接触发movaps段错误。解决办法是在gadget链后面补一个额外的ret占位地址。4. 实验中的常见坑与排查技巧实录这部分是我最想跟你聊的。我先说一个令人沮丧的事实缓冲区溢出实验里90%的时间都花在“为什么又段错误”和“为什么跳转地址总是差一点”上。但反过来只要你能静下心来用对工具问题往往几分钟就能定位。4.1 现象一程序一跑就Segmentation fault但明明地址填对了可能性最高的原因是栈地址在gdb和正常执行时不一样。gdb默认的环境变量、argv长度都会影响栈的初始位置你在调试器里看到的地址直接拿到终端跑往往会有几字节到几十字节的偏差。解决办法让payload“宽容一点”。要么加NOP sled要么在脚本里采用“地址试探法”——把返回地址写成一个范围内的多个候选值挨个试。更规范的思路是利用core dump或者减少环境变量数量来让栈地址更稳定。4.2 现象二0x00或0x0a被截断payload长度对但内容变了strcpy家族函数遇到0x00就停止拷贝gets和fgets对换行符也有特殊对待。如果你构造的返回地址里包含\x00比如0x08048400这个地址在字符串里会表现为\x00\x84\x04\x08strcpy会在第一个字节就停下来payload直接被截断。解决方案分几个层次换用不受0x00影响的函数作为漏洞点现实中很难利用地址本身不含坏字节的libc函数避免往payload里写入0x00像\x00开头的地址就要尽量绕开。我在实验里会先用xxd payload.txt检查一遍所有字节凡是遇到0x00、0x0a、0x0d这种危险字节都要重新规划和设计payload。4.3 现象三能跳到shellcode区域却始终执行不起来这个问题一般出在环境变量的栈地址偏移或者shellcode本身带坏字节。32位经典的execve(/bin/sh)shellcode里如果混进了0x0a在某些输入函数处理下就会被破坏。我习惯用一个经过严格挑选、不含坏字节的23字节shellcode版本并且用shellcraft工具在实验环境里现场生成、现场验证而不是从网上随便抄一段。另外一个容易被忽视的点是bash 4.4以上版本在setuid程序上有降权保护实验里明明弹出root shell了却显示uid1000。这不算实验失败但如果你要验证提权效果记得在编译目标程序时加上setuid位并注意bash版本。4.4 问题速查表我把实验过程中遇到的高频问题整理成了表格方便你对着排查现象可能原因解决思路程序没崩溃输入照常返回payload太短没覆盖到返回地址增大payload长度确认偏移量崩溃但EIP不是预期地址有金丝雀保护或偏移算错用checksec确认是否开启canary地址在调试器里正确终端跑不通ASLR或环境变量差异关闭ASLR减小环境变量数量跳到了NOP滑梯但还是崩shellcode内有坏字节逐字节检查payload的十六进制ret2libc一直段错误64位栈未对齐在system前面补一个ret地址弹出shell马上退出标准输入被管道关闭用cat payload - | ./vuln保持交互5. 实验做完之后从“攻击成功”反向看软件安全体系最后一个部分我想把视角拔高一点。很多人做完实验三拿到shell那一刻非常兴奋然后就把实验报告一交彻底忘掉这件事。但从软件安全课程的角度看实验三的真正价值恰恰在终点之后。5.1 现代防护体系是如何“围剿”缓冲区溢出的回到实验最开始你把ASLR关掉、把栈保护关掉、把NX关掉才成功利用了漏洞。这一整套“暴力破解”的过程反过来正好证明了现代防护机制的必要性Canary让“覆盖返回地址”这个动作本身暴露无遗NX让“执行栈上代码”变成不可能ASLR让“跳到固定地址”的策略失效PIE进一步把程序基址也随机化攻击者连目标函数地址都拿不准。如果实验三后面还安排了进阶题目你可能会面对所有这些防护同时开启的情况那时候的利用难度是指数级上升的需要组合使用信息泄露、ROP链、partial overwrite等技巧。所以我不建议把实验三当做一个“过关任务”它更像是一把钥匙帮你打开二进制安全的大门。5.2 从攻击者视角回到防御者视角安全编码是怎么来的花了一整个下午研究怎么绕过防护你会在某个瞬间突然理解那些安全编码准则为什么存在不要用strcpy和gets改用限制长度的strncpy、fgets这些函数能避免溢出使用编译器自带的栈保护选项_FORTIFY_SOURCE能在编译期插入边界检查代码开启ASLR和PIE让每个进程的内存布局都“同程序、不同世界”用现代内存安全语言Rust、Go或者在C代码里引入安全库从语言层面消灭整类问题。这些知识在教材上只是一句“建议”但当你自己亲手打穿过一次之后你就会明白它们每一条背后都对应着一种真实可复现的攻击。5.3 给准备开始做实验的同学三个实用建议第一先补汇编基础。不需要你成为汇编大佬但push、pop、call、ret、leave这几条指令的行为以及栈从高地址向低地址生长的方向必须烂熟于心。我见过太多同学卡在“为什么我填的地址在内存里是反着排的”这个问题上本质就是对大小端存储不熟悉。第二多动手调试少猜答案。遇到问题别急着换payload先用gdb看看寄存器和栈的真实状态。很多时候你离答案只差一条x/20wx $esp命令。第三把实验报告当成技术博客来写。画清楚函数调用栈、标注好每条指令的执行结果、说明你每一步决策的依据这些比你最后截图一个“root shell”要重要得多。也是因为这个原因我后来在整理BUPT软安实验三复盘以及看到南邮软安实验的相关资料时都特别留意别人是怎么把“架构图”和“攻击路径”画明白的这对自己的理解提升非常明显。最后再分享一个小技巧准备一个模板化的python攻击脚本把偏移量、返回地址、shellcode都抽成变量每次实验只改关键参数。这个习惯能帮你从“每次重新造轮子”中解放出来把精力放在更核心的漏洞原理和利用思路优化上。希望在实验报告的最后一页你能不只会写“我成功执行了shellcode”还能写清楚“为什么这段shellcode会以这样的方式被执行”。做到这一步实验三就算真正通关了。

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

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

免费获取报价