一个"读一行输入再打印出来"的小程序,塞进去 80 个字母就崩了;同一个二进制,换台机器跑同样的输入却没事;某些崩溃信息里赫然写着 *** stack smashing detected ***,而另一些则静悄悄地把控制权交给了别人。这三件事背后是同一个机制:C 语言不为数组访问做任何边界检查,而函数的返回地址就躺在局部数组的高地址侧 —— 于是"写越界"这件事,在物理上等同于"改写程序接下来要执行哪条指令"。
这一篇把 CSAPP 3.10 那套攻防逻辑讲透:溢出是怎么发生的、攻击者如何从"改一个地址"升级到"执行自己的代码"、现代编译器和操作系统架了哪三道防线、以及这三道防线各自的缝在哪。
1. 先看清栈帧:返回地址就在缓冲区头顶
函数调用时,x86-64 在栈上给被调函数划一块帧。以 echo() 里有一个 char buf[64] 为例,帧内从高地址到低地址依次是:
关键点有两条,缺一不可:
buf[0] 在低地址,buf[63] 在高地址。 所以"越界写"不是跑到数组前面去,而是往高地址爬,正好爬向返回地址。
- 返回地址是
call 指令压进去的,函数末尾的 ret 就是把它弹进 %rip。谁改了它,谁就决定了这个函数返回后 CPU 去哪儿。
2. 复现:把返回地址改成另一个函数
下面这段 C 是最经典的漏洞样本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| #include <stdio.h>
void secret(void) { puts("*** 你拿到了 secret() ***"); }
void echo(void) { char buf[64]; gets(buf); printf("你输入了:%s\n", buf); }
int main(void) { echo(); puts("正常返回 main"); return 0; }
|
1 2 3
| gcc -Og -fno-stack-protector -no-pie vuln.c -o vuln
|
gets 的语义是"一直读到换行符为止",它完全不知道 buf 有多大。输入 40 个字符没事,输入 80 个 A 加精心构造的 8 个字节,写越界的部分会依次覆盖 canary 槽位、%rbp,最后 8 字节正好落进返回地址。
本机没有 C 编译器,我用一段 Python 把这块栈帧原样建出来模拟(这段代码是可以直接跑的,地址是编的,但偏移、覆盖顺序、小端编码都是真的):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import struct
CANARY_OFF, RBP_OFF, RET_OFF = 64, 72, 80 RET_ADDR = 0x00000000004005b6 SAVED_RBP = 0x00007fffffffe100 TARGET = 0x00000000004005a0
def frame(canary=0): f = bytearray(88) struct.pack_into("<Q", f, CANARY_OFF, canary) struct.pack_into("<Q", f, RBP_OFF, SAVED_RBP) struct.pack_into("<Q", f, RET_OFF, RET_ADDR) return f
def dump(f, tag): print(f"[{tag}]") print(f" canary = 0x{struct.unpack_from('<Q', f, CANARY_OFF)[0]:016x}") print(f" saved %rbp = 0x{struct.unpack_from('<Q', f, RBP_OFF)[0]:016x}") print(f" ret addr = 0x{struct.unpack_from('<Q', f, RET_OFF)[0]:016x}")
print("=== 场景一:无保护(-fno-stack-protector)===") dump(frame(), "进入 echo(),栈帧初始值") f = frame() f[0:88] = b"A" * 80 + struct.pack("<Q", TARGET) dump(f, "输入 80 个 A + 8 字节 0x4005a0") print(" -> ret 时 rip =", hex(struct.unpack_from("<Q", f, RET_OFF)[0]), ",跳进了 secret()")
print() print("=== 场景二:开启 canary(-fstack-protector-strong)===") dump(frame(canary=0x00a3f1d5c7b93e21), "进入 echo(),canary 就位") f = frame(canary=0x00a3f1d5c7b93e21) f[0:72] = b"A" * 72 dump(f, "输入 72 个 A,刚踩到 canary") now = struct.unpack_from("<Q", f, CANARY_OFF)[0] print(" -> canary 已被改成", hex(now), ",返回前校验失败") print(" -> *** stack smashing detected ***: ./vuln terminated")
|
真实输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| === 场景一:无保护(-fno-stack-protector)=== [进入 echo(),栈帧初始值] canary = 0x0000000000000000 saved %rbp = 0x00007fffffffe100 ret addr = 0x00000000004005b6 [输入 80 个 A + 8 字节 0x4005a0] canary = 0x4141414141414141 saved %rbp = 0x4141414141414141 ret addr = 0x00000000004005a0 -> ret 时 rip = 0x4005a0 ,跳进了 secret()
=== 场景二:开启 canary(-fstack-protector-strong)=== [进入 echo(),canary 就位] canary = 0x00a3f1d5c7b93e21 saved %rbp = 0x00007fffffffe100 ret addr = 0x00000000004005b6 [输入 72 个 A,刚踩到 canary] canary = 0x4141414141414141 saved %rbp = 0x00007fffffffe100 ret addr = 0x00000000004005b6 -> canary 已被改成 0x4141414141414141 ,返回前校验失败 -> *** stack smashing detected ***: ./vuln terminated
|
注意场景二的最后一行:返回地址其实还是完好的,0x4005b6 一个字节都没被碰到 —— 但程序已经 abort 了。这就是 canary 的设计哲学:不等你碰到要害,碰到守门员就喊停。
另外注意 %rbp 那一栏变成 0x4141414141414141(A 是 0x41)—— 小端序下,8 个 A 拼起来就是这么个数。做二进制分析时看到满屏 0x41414141,第一反应就该是"有人在灌 A"。
3. 升级:从"改地址"到"执行我的代码"
改掉返回地址只能跳到程序里已有的函数。真正的攻击要更进一步:把机器码本身塞进缓冲区,再让返回地址指回缓冲区。
shellcode 通常就几十字节,做的是 execve("/bin/sh", NULL, NULL) —— 把当前进程的镜像换成 shell。程序要是 setuid root 或以服务形式在跑,攻击者就拿到了对应的权限。
4. NX 位之后:ROP(面向返回的编程)
既然"往栈里塞代码"这条路被堵了,攻击者换个思路:代码不用我写,程序里本来就有的是。 只要把已有的指令片段按"片段 + ret"的顺序串起来,就能拼出任意逻辑 —— 这就是 ROP(Return-Oriented Programming)。
1 2 3 4 5
| # 从程序自己的代码段里翻出来的 gadget 0x400a13: pop %rdi ; ret # 把栈顶值弹进第一个参数寄存器 0x400a11: pop %rsi ; pop %r15 ; ret 0x400520: <system@plt> # 动态库里的现成函数 0x6b4040: "/bin/sh" # 数据段里现成的字符串
|
栈上被串成这样(从低到高):
1 2
| &gadget1(0x400a13) -> 0x6b4040 -> &gadget2(0x400a11) -> 0x0 -> 0x0 -> &system pop rdi; ret "/bin/sh" 地址 pop rsi/r15; ret argv envp
|
执行链条是:ret 弹进 gadget1 → pop %rdi 拿到 "/bin/sh" 地址,紧接着 ret 又弹进 gadget2 → 清掉 %rsi → 再 ret 弹进 system。全程没有一字节新代码,只用了"控制流"这一种能力。 ROP 说明了攻防里最难受的一点:可执行性可以禁,可"跳转"是 CPU 的本职工作,禁不掉。
5. 现代系统的三道防线
这三招有个共同特征值得记住:它们都不是让溢出不发生,而是让溢出发生后"不好利用"。 溢出本身依然发生了,buf 后面的内存依然被写坏了,只是攻击者更难把它变成一次稳定可控的利用。这也是为什么"开了 canary 就不会被打"是错觉 —— 它是概率与成本的博弈,不是正确性保证。
6. 正确的写法:C / C++ / Java 对照
C:用带长度限制的接口,并且永远自己算长度。
1 2 3 4 5 6
| void echo_safe(void) { char buf[64]; if (fgets(buf, sizeof buf, stdin) == NULL) return; buf[strcspn(buf, "\n")] = '\0'; printf("你输入了:%s\n", buf); }
|
strncpy 不是好选择:目标空间被填满时它不补 \0,反而制造出另一个 bug。要拼接就用 snprintf,它保证结尾有 \0,返回值还能告诉你"如果空间够,本来要写多少字节":
1 2 3 4
| int n = snprintf(buf, sizeof buf, "%s", input); if (n < 0 || (size_t)n >= sizeof buf) { }
|
C++:把长度和指针绑在一起。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| #include <span> #include <string> #include <algorithm>
size_t safe_copy(std::span<char> dst, std::string_view src) { size_t n = std::min(dst.size(), src.size()); std::copy_n(src.begin(), n, dst.begin()); return n; }
void echo_cpp() { std::string line; std::getline(std::cin, line); }
|
std::span<T> 的意义在于让"缓冲区 + 它的长度"成为一个整体类型,从 API 签名上就堵死"只给指针不给长度"的老毛病。
Java:语言层面根本不给这个机会。
1 2 3 4 5
| byte[] buf = new byte[64]; int n = System.in.read(buf, 0, buf.length); if (n > 0) { System.out.println(new String(buf, 0, n, StandardCharsets.UTF_8)); }
|
JVM 对每次数组访问做边界检查,越界就是 ArrayIndexOutOfBoundsException —— 一个异常,而不是一段被改写的执行流。 Rust 走的是另一条路:编译期借用检查 + 切片自带长度,越界在编译期或 panic 时被拦下,同样不存在"静默写坏栈"。
7. 三道防线对照
| 防线 |
挡住的是什么 |
生效时机 |
开销 |
绕过方式 |
| 栈随机化 ASLR |
攻击者无法预知 &buf / 库函数地址 |
进程启动时 |
几乎为零 |
地址泄露、NOP 滑板、穷举 |
| 栈破坏检测 Canary |
覆盖返回地址前就被发现 |
函数返回前 |
每次调用一次读 + 比较 |
泄露 canary、改函数指针、堆溢出 |
| 限制可执行区域 NX |
注入的 shellcode 无法被执行 |
页表属性,硬件强制 |
几乎为零 |
ROP、ret2libc、JOP |
| 控制流完整性 CFI |
非法跳转目标(含 ROP 链) |
每次间接跳转 |
数个百分点 |
仍可绕过,且需编译器全程序支持 |
再看语言层面的差距:
| 语言 |
越界写的后果 |
能否改写返回地址 |
| C |
未定义行为,静默写坏相邻内存 |
能 —— 本文全部内容 |
C++(std::span / std::string) |
边界检查或长度随对象携带 |
不能(前提是别退化成裸指针) |
| Java / C# |
抛 ArrayIndexOutOfBoundsException |
不能 |
| Rust |
编译期借用检查,或运行时 panic |
不能 |
小结
- 溢出的本质是"写越界 = 改写控制流":C 不检查边界,而返回地址恰好位于缓冲区的高地址侧,两个事实叠加就成了漏洞。
- 攻击分两代:第一代直接改返回地址跳到已有函数;栈不可执行之后进化为 ROP —— 不注入代码,只用
ret 把程序里现成的指令片段串成想要的逻辑。
- 现代三道防线(ASLR + Canary + NX)提高的是攻击成本,不是消灭漏洞。 溢出照样发生,内存照样被写坏。
- 真正根治要靠类型和边界:
fgets/snprintf + 显式长度、std::span 把长度绑进类型、Java/Rust 的边界检查。
- 实践口诀:凡是
strcpy / strcat / sprintf / gets,一律替换成带长度的版本;凡是 memcpy,先问一句"这个长度是从哪来的,会不会超过目标"。
下一篇进入链接(Linking) —— 符号解析、重定位、静态库与动态链接,看看多个 .o 是怎么被拼成一个进程的。