3.21 缓冲区溢出
缓冲区溢出是程序向固定大小的缓冲区写入超过其容量的数据,导致相邻内存被覆盖的漏洞。这是 C 语言中最经典、最危险的安全漏洞类型。
原理
栈上缓冲区布局
void vulnerable() {
char buf[8]; // 局部缓冲区在栈上
gets(buf); // 没有边界检查!输入可超过 8 字节
}栈帧布局(调用 vulnerable 后):
高地址
┌────────────────────────┐
│ 调用者的栈帧 │
├────────────────────────┤
│ 返回地址 (8字节) │ ← 攻击目标:覆盖此区域
├────────────────────────┤
│ 保存的 %rbp (8字节) │ ← 也可覆盖
├────────────────────────┤
│ buf[7] buf[4] │
│ buf[3] buf[0] │ ← 8 字节缓冲区
└────────────────────────┘ ← %rsp (栈顶)
低地址
溢出过程
正常输入 "ABCD"(4 字节,安全):
buf: [A][B][C][D][ ][ ][ ][ ] [保存 %rbp] [返回地址]
↑ buf 起始 ↑ 溢出 8 字节后到达 √
危险输入 16 个 'A':
buf: [A][A][A][A][A][A][A][A] [AAAAAAAA] [AAAAAAAA]
↑ buf 起始 ↑ %rbp 被覆盖 ↑ 返回地址被覆盖!
当函数执行 ret 时:
→ ret 读取被覆盖的返回地址 → 跳转到攻击者控制的位置!
攻击利用
经典攻击方式
- 注入 shellcode:在溢出数据中包含恶意机器代码
- 覆盖返回地址:使函数返回到 shellcode
- 获得控制权:执行攻击者的代码
// 攻击者构造的输入结构
// [NOP sled][shellcode][padding][覆盖的返回地址 → shellcode]输入缓冲区布局:
┌──────────────────────────────────────────────────────┐
│ NOP sled (0x90) | shellcode | 填充 | 伪造返回地址 │
└──────────────────────────────────────────────────────┘
↑ 指向 NOP sled 区域
NOP sled(NOP 滑梯):一串 0x90(x86 NOP 指令),增加攻击成功率。只要跳转到 NOP sled 中的任意位置,最终都会滑入 shellcode。
蠕虫与病毒的利用方式
| 漏洞类型 | 典型目标 | 后果 |
|---|---|---|
| 栈缓冲区溢出 | local buffer | 覆盖返回地址 → 任意代码执行 |
| 堆缓冲区溢出 | malloc 分配的区域 | 覆盖堆管理结构 → 任意写 |
| 格式化字符串 | printf 未指定格式 | 读取/写入任意内存 |
| 整数溢出 | size 计算错误 | 导致堆溢出 |
著名的蠕虫 Morris Worm(1988) 就是利用 gets() 缓冲区溢出传播。
防御机制
1. 栈随机化(ASLR)
原理:每次程序运行时栈的起始地址随机变化。
# Linux: 查看 ASLR 级别
cat /proc/sys/kernel/randomize_va_space
# 2 = 完全随机化(默认)效果:攻击者无法预知栈的地址,使得构造精确的返回地址更加困难。
局限性:
- 攻击者可以通过暴力搜索(尝试大量地址)绕过
- 32 位系统地址空间有限,更容易猜测
- 通过信息泄露(如格式化字符串漏洞)可以获取随机化的地址
2. 栈保护(Canary / Stack Guard)
原理:在返回地址之前放置一个金丝雀值(canary),函数返回前检查其完整性。
void protected_func() {
// 编译器插入的代码(用户不可见)
// 从 fs:0x28 读取 canary 值到栈上
char buf[8];
gets(buf);
// 函数返回前检查 canary
// 如果 canary 被修改 → 调用 __stack_chk_fail()
}栈帧(启用栈保护后):
┌────────────────────────┐
│ 返回地址 │
├────────────────────────┤
│ canary(金丝雀值) │ ← 检查此区域是否被修改
├────────────────────────┤
│ 保存的 %rbp │
├────────────────────────┤
│ buf │
└────────────────────────┘ ← %rsp
# 启用了栈保护的函数汇编
protected_func:
subq $24, %rsp
# 设置 canary
movq %fs:40, %rax # 从线程本地存储读取随机 canary
movq %rax, 8(%rsp) # 将 canary 放在 rsp+8
# ... 函数体 ...
# 返回前检查 canary
movq 8(%rsp), %rax
subq %fs:40, %rax # 与原始值比较
jne .Lfail # 不相等 → 检测到溢出
addq $24, %rsp
ret
.Lfail:
call __stack_chk_fail # 终止程序编译控制:
gcc -fno-stack-protector # 禁用栈保护
gcc -fstack-protector # 仅对含大缓冲区的函数启用
gcc -fstack-protector-all # 对所有函数启用
gcc -fstack-protector-strong # 对含局部数组的函数启用(默认)局限性:
- Canary 值也是从栈上读取,如果攻击者可以读取栈内容(如通过格式化字符串漏洞),可以计算出 canary 值并包含在攻击载荷中
- 某些攻击不覆盖 canary(如溢出到相邻变量而非返回地址)
3. 不可执行内存(NX 位 / DEP)
原理:将栈和堆标记为不可执行,即使注入 shellcode 也无法执行。
# Linux: 检查 NX 支持
grep nx /proc/cpuinfo硬件支持:
- x86 页表条目的第 63 位(NX bit)
- Windows 上称为 DEP(Data Execution Prevention)
- 通常默认启用
# 查看段权限(Linux)
cat /proc/self/maps
# 输出示例:
# 08048000-08049000 r-xp ... ← .text: 读+执行,不可写
# 08049000-0804a000 rw-p ... ← .data: 读+写,不可执行
# b7e21000-b7e22000 rw-p ... ← 栈: 读+写,不可执行
绕过技术:Return-Oriented Programming(ROP) — 攻击者不注入新代码,而是拼接已有代码段中的指令片段(gadgets)构成攻击链。
4. 安全编码实践
// ❌ 危险函数(无边界检查)
gets(buf);
strcpy(dst, src);
strcat(dst, src);
sprintf(buf, "%s", data);
scanf("%s", buf);
// ✅ 安全替代
fgets(buf, BUF_SIZE, stdin); // 指定最大读取长度
strncpy(dst, src, DST_SIZE); // 指定最大复制长度
strncat(dst, src, REMAIN_SIZE);
snprintf(buf, BUF_SIZE, "%s", data);// 指定缓冲区大小
scanf("%" STR(MAX_LEN) "s", buf); // 指定最大输入长度// 安全字符串复制的正确模式
void safe_copy(char *dst, const char *src, size_t dst_size) {
size_t src_len = strlen(src);
size_t copy_len = (src_len < dst_size - 1) ? src_len : (dst_size - 1);
memcpy(dst, src, copy_len);
dst[copy_len] = '\0'; // 确保以空字符结尾
}攻击与防御演进
| 时代 | 攻击技术 | 防御技术 |
|---|---|---|
| 1990s | 简单 shellcode 注入(固定地址) | 无(或手动保护) |
| 2000s | NOP sled + 精确地址计算 | 栈保护(canary) |
| 2005+ | ROP / Return-to-libc | ASLR + NX 位 |
| 2010+ | JIT-ROP / Heap spray | CFI(Control Flow Integrity) |
相关笔记
-
- 栈帧结构(理解溢出目标的基础)
-
- 栈段在进程内存中的位置
-
- 更多内存安全内容