一个"读一行输入再打印出来"的小程序,塞进去 80 个字母就崩了;同一个二进制,换台机器跑同样的输入却没事;某些崩溃信息里赫然写着 *** stack smashing detected ***,而另一些则静悄悄地把控制权交给了别人。这三件事背后是同一个机制:C 语言不为数组访问做任何边界检查,而函数的返回地址就躺在局部数组的高地址侧 —— 于是"写越界"这件事,在物理上等同于"改写程序接下来要执行哪条指令"。

这一篇把 CSAPP 3.10 那套攻防逻辑讲透:溢出是怎么发生的、攻击者如何从"改一个地址"升级到"执行自己的代码"、现代编译器和操作系统架了哪三道防线、以及这三道防线各自的缝在哪。

1. 先看清栈帧:返回地址就在缓冲区头顶

函数调用时,x86-64 在栈上给被调函数划一块帧。以 echo() 里有一个 char buf[64] 为例,帧内从高地址到低地址依次是:

echo() 的栈帧:返回地址就压在 buf 的头顶 高地址 0x7fffffffe1a8 返回地址(8 字节) 被保存的 %rbp(8 字节) canary(开启保护时才有) char buf[64] ← 最终目标:改写它 ← 顺带被踩烂 ← 守卫:踩到就 abort ← 合法写入区,越界即溢出 越界写入方向 低地址 0x7fffffffe160 ← %rsp 栈向低地址生长;buf 越界写入(往高地址方向)会依次踩到 canary、%rbp、返回地址

关键点有两条,缺一不可:

  • 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
/* vuln.c */
#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
# 警告:the `gets' function is dangerous and should not be used
# —— 这句警告,就是本节的全部主题

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

# echo() 的栈帧:buf 在低地址,返回地址在高地址,canary 夹在两者中间
CANARY_OFF, RBP_OFF, RET_OFF = 64, 72, 80 # 相对 buf 起始的字节偏移
RET_ADDR = 0x00000000004005b6 # 正常情况下该回到 main 的那条指令
SAVED_RBP = 0x00007fffffffe100
TARGET = 0x00000000004005a0 # secret() 的地址


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. 升级:从"改地址"到"执行我的代码"

改掉返回地址只能跳到程序里已有的函数。真正的攻击要更进一步:把机器码本身塞进缓冲区,再让返回地址指回缓冲区。

代码注入:让 ret 跳进缓冲区里的 shellcode 攻击者构造的输入串(低地址 → 高地址): NOP 滑板 0x90 × N shellcode 填充 A × k 新返回地址 = &buf 写进栈之后: buf:NOP 滑板 + shellcode 填充 返回地址 = &buf ret 时 %rip 跳回缓冲区 gets 把整串字节原样写进 buf;越过 64 字节后继续往上爬,最后 8 字节落进返回地址槽位 NOP 滑板(0x90)是为了容错:只要落点落在滑板任意位置,都会一路滑到 shellcode 前提:栈页必须可执行(-z execstack)。这正是 NX 位要掐死的那条路。

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. 现代系统的三道防线

三道防线:各挡一段,各有各的缝 ① 栈随机化 ASLR 每次启动,栈 / 堆 / 库 的基址都随机偏移。 → 猜不到 &buf 在哪 绕过:地址泄露、 NOP 滑板、穷举爆破 ② 栈破坏检测 Canary 返回地址前放随机值, 取自 %fs:0x28。 → 返回前校验,不符即 abort 绕过:泄露 canary、 改写函数指针 / 堆溢出 ③ 限制可执行区域 NX 栈 / 堆 / 数据页标记为 不可执行(W^X)。 → 注入的 shellcode 跑不起来 绕过:ROP、 ret2libc、JOP 现代默认配置 = 三者全开(-pie + -fstack-protector-strong + 栈默认 NX),攻击成本被抬到很高 但没有一条是根治 —— C 不做边界检查这一事实没变,只要有一处越界写,漏洞就还在那里 代价:canary 每次调用多一次读 + 校验;ASLR 让调试时地址每次都变(可用 setarch -R 临时关掉)

这三招有个共同特征值得记住:它们都不是让溢出不发生,而是让溢出发生后"不好利用"。 溢出本身依然发生了,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; // 最多读 sizeof buf - 1 个
buf[strcspn(buf, "\n")] = '\0'; // 去掉 fgets 保留的换行
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>

// std::span 自带长度,调用方无法只传一个裸指针进来
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 不能

小结

  1. 溢出的本质是"写越界 = 改写控制流":C 不检查边界,而返回地址恰好位于缓冲区的高地址侧,两个事实叠加就成了漏洞。
  2. 攻击分两代:第一代直接改返回地址跳到已有函数;栈不可执行之后进化为 ROP —— 不注入代码,只用 ret 把程序里现成的指令片段串成想要的逻辑。
  3. 现代三道防线(ASLR + Canary + NX)提高的是攻击成本,不是消灭漏洞。 溢出照样发生,内存照样被写坏。
  4. 真正根治要靠类型和边界:fgets/snprintf + 显式长度、std::span 把长度绑进类型、Java/Rust 的边界检查。
  5. 实践口诀:凡是 strcpy / strcat / sprintf / gets,一律替换成带长度的版本;凡是 memcpy,先问一句"这个长度是从哪来的,会不会超过目标"。

下一篇进入链接(Linking) —— 符号解析、重定位、静态库与动态链接,看看多个 .o 是怎么被拼成一个进程的。