Lesson 61: 缓冲区溢出安全分析
练习任务
难度:中
给定 3 段 C 代码片段,完成"缓冲区溢出安全分析"——不执行实际溢出攻击,而是作为安全分析师,系统地分析每段代码的内存布局、溢出风险和防护方案。
你需要填写 overflow_lab.c 中的 7 个 TODO 区域,输出一份固定格式的安全分析报告:
| 片段 | 代码 | 风险等级 |
|---|---|---|
| 1 | gets(buf) — buf[16] | CRITICAL |
| 2 | strcpy(buf, src) — buf[32] | HIGH |
| 3 | fgets(buf, sizeof(buf), stdin) — buf[16] | SAFE |
报告内容包括:ASCII 栈帧图(含地址偏移标注)、偏移量计算、溢出风险判断、Stack Canary(金丝雀)防护原理讲解、六种防护机制对比表(Canary / ASLR / DEP / RELRO / PIE / FORTIFY)、以给出综合风险评估和安全编程准则。
提示:栈从高地址向低地址增长,数组从低地址向高地址增长——这两个方向"对撞"是缓冲区溢出能够覆盖返回地址的根本原因。画图时始终问自己:
buf[N]越界写入的方向,正好指向 rbp 和返回地址。
核心知识点
- x86-64 栈帧结构与调用约定 — call 压返回地址、push rbp 保存基址、sub rsp,N 分配局部变量,理解栈帧 = 理解溢出
- 缓冲区溢出的根本原因 — 栈向低地址增长,数组向高地址增长,buf[N] 越界正好冲向 canary → rbp → 返回地址
- gets 零边界检查的危害 — 唯一参数是缓冲区指针,不知道大小,C11 已正式移理)
- strcpy \0 终止的双刃剑 — 不检查目标大小但 \0 限制 payload 内容
- fgets size 参数的安全边界 — 只需正确传入 sizeof(buf),就能天然阻止溢出
- 偏移量计算公式 — offset = sizeof(buf) + 对齐填充 + (8 if canary) + 8(rbp),掌握其推导过程
- Stack Canary 哨兵机制 — 来自煤矿金丝雀比喻,返回地址前插入随机值,返回前校验,最低字节为 \0 防字符串泄露
- Canary 的局限性 — 信息泄露可精确覆盖、Fork 子进程共享 canary 可暴力破解、不保护数据指针和 GOT 表
- 纵深防御体系 — Canary(检测) → ASLR(隐藏) → DEP/NX(阻止执行) → RELRO(保护 GOT) → PIE(位置无关) → FORTIFY_SOURCE(编译强化)
- 安全替代函数表 — gets→fgets、strcpy→strncpy+手动\0、sprintf→snprintf、strcat→strncat
- 安全编译选项 — -fstack-protector-strong、-D_FORTIFY_SOURCE=2、-fPIE -pie、-Wl,-z,relro,-z,now
代码框架
#include <stddef.h>
#include <stdio.h>
#include <string.h>
/* 栈帧分析数据结构 */
typedef struct {
int buf_size;
int offset_to_ret;
int overflow_risk;
const char *func_name;
const char *description;
} StackAnalysis;
static void print_separator(void) {
printf("══════════════════════════════════════════════════════\n");
}
/* ─── TODO 1: 打印代码片段 1 的栈帧分析 (gets 溢出) ─── */
#error TODO 1-7: 完成所有分析章节
/*
* 片段 1: void vulnerable_gets(void) { char buf[16]; gets(buf); }
*
* 栈帧布局 (x86-64, 无 canary):
* +24 返回地址(8B) ← gets 可覆盖
* +16 保存的 rbp(8B)
* +8 [填充/对齐](8B)
* +0 buf[16] ← gets 从这里写入
*
* buf[0] 到返回地址的偏移 = 16 + 8 + 8 = 32 字节
*/
/* ─── TODO 2: 代码片段 2 栈帧分析 (strcpy 溢出) ────── */
/*
* 片段 2: void vulnerable_strcpy(char *src) { char buf[32]; strcpy(buf, src); }
*
* 偏移 = 32 + 8 = 40 字节
*/
/* ─── TODO 3: 代码片段 3 栈帧分析 (fgets 安全版) ─── */
/*
* 片段 3: void safe_fgets(void) { char buf[16]; fgets(buf, sizeof(buf), stdin); }
*
* 偏移 32 字节,但 fgets 最多写入 16 字节,无法触及返回地址
*/
/* ─── TODO 4: 金丝雀 (Stack Canary) 防护原理 ─── */
/*
* 带 canary 的栈帧: buf → [对齐] → CANARY(8B, 最低字节\0) → rbp → ret
* 入口: mov rax, fs:0x28; 写入栈帧
* 返回前: mov rax, [rbp-8]; xor rax, fs:0x28; je ok → call __stack_chk_fail
*/
/* ─── TODO 5: 防护机制对比表 ─── */
/*
* Canary / ASLR / DEP / RELRO / PIE / FORTIFY_SOURCE 六种机制
*/
/* ─── TODO 6: 综合风险评估 ─── */
/*
* 三段风险评级 + 五条安全准则
*/
/* ─── TODO 7: 学习建议 ─── */
/*
* 动手实验、工具推荐、标准规范、编译器默认选项、经典论文
*/
int main(void) {
#error TODO 1-7: 完成所有分析章节
return 0;
}本题的"代码框架"比较特殊——不是实现算法,而是用 printf 输出一份结构化的安全分析报告。你需要读懂每个 TODO 上方注释中的要求,然后逐步填充,使输出与 expected_output.txt 精确匹配。
TIP
先不往下翻看参考解答。试着在纸上画出三个片段各自的栈帧布局图,标注地址偏移。关键是理解"栈向低地址增长,数组向高地址增长"这个方向差异——它是所有溢出攻击的地理基础。
深度讲解
1. 栈帧结构与 x86-64 调用约定——溢出理解的地基
1.1 函数调用的栈操作全景
每当一个函数被调用,CPU 执行一套固定仪式——理解这套仪式是理解缓冲区溢出的绝对前提。
调用者 (caller) 视角:
push 参数 ← 将第 7 个起的参数压栈 (x86-64 前 6 个用寄存器)
call function ← 将返回地址 (RIP) 压栈,然后跳转到函数
被调用者 (callee) 视角:
push rbp ← 保存调用者的栈帧基址
mov rbp, rsp ← 建立自调用者的栈帧基址
sub rsp, N ← 为局部变量分配 N 字节空间
... 函数体 ...
leave ← mov rsp, rbp; pop rbp (恢复调用者栈帧)
ret ← pop rip (弹出返回地址并跳转)/*
* 用最简单的函数观察栈帧操作:
* $ gcc -O0 -o demo demo.c
* $ objdump -d demo | grep -A15 '<simple_func>'
*/
void simple_func(int x) {
char buf[16];
buf[0] = x;
}
/*
* 反汇编后大约是这样的 (x86-64):
*
* simple_func:
* push rbp ; 保存调用用者的栈帧基址
* mov rbp, rsp ; 建立新栈帧
* sub rsp, 32 ; 分配 32 字节局部空间 (16+对齐)
* mov eax, edi ; 参数 x 在 edi 中
* mov BYTE PTR [rbp-16], al ; buf[0] = x
* leave ; 恢复栈帧
* ret ; 返回
*/NOTE
sub rsp, 32 而不是 sub rsp, 16 说明了 x86-64 ABI 的 16 字节栈对齐要求——即使 buf 只有 16 字节,编译器仍然多分配 16 字节来维持对齐。这些"多出来的"空间在溢出攻击中充当了填充物。
1.2 x86-64 典型栈帧布局
高地址 (栈底方向,地址值更大)
┌──────────────────────┐
│ 调用者的栈帧 │ ← 调用者的局部变量等
├──────────────────────┤
│ 函数参数 (第 7 个起) │ ← x86-64 前 6 个参数用寄存器
├──────────────────────┤
│ 返回回地址 (8 bytes) │ ← call 指令压入的 RIP
├──────────────────────┤
│ 保存的 rbp (8 bytes) │ ← 调用者的栈帧基址
├──────────────────────┤ ← rbp (当前函函数栈帧基址)
│ canary (8 bytes) │ ← -fstack-protector 插入的哨兵
├──────────────────────┤
│ [栈对齐填充] │ ← x86-64 ABI 要求 16 字节对齐
├──────────────────────┤
│ 局部变量 / 数组 │ ← char buf[N] 从这里开始
├──────────────────────┤
│ callee-saved 寄存器 │ ← rbx, r12-r15 (如果使用)
└──────────────────────┘
低地址 (栈顶方向) ← rspIMPORTANT
注意看各区域的相对位置:返回地址在高地址,缓冲区在低地址。这意味着如果从缓冲区向高地址方向溢出(数组索引递增),会依次经过:对齐填充 → canary → rbp → 返回地址。这就是溢出攻击的地理路线图。
1.3 栈增长 vs 数组增长——致命的方向碰撞
这是理解缓冲区溢出的最关键的一张图:
栈增长方向: 高地址 → 低地址 (push 操作递减 rsp)
数组增长方向: 低地址 → 高地址 (buf[i] 的地址 = buf + i)
因此:
buf[0] 在最低地址
buf[N-1] 在较高地址
向 buf[N] 写入 → 向高地址越界 → 覆盖 canary → 覆盖 rbp → 覆盖返回地址
示意:
低地址 buf canary/rbp/ret 高地址
─────────────────────────────────────────────────────────────────────────→
[buf[0]][buf[1]]...[buf[N-1]][超出部分覆盖canary][覆盖rbp][覆盖返回地址]
写入起点 ←──────────────────── 溢出方向 ────────────────────────────→简易记忆口诀:
"push 减 (向低),数组增 (向高)"
二者面对面走 → 越界写入刚好撞上返回地址CAUTION
许多初学者在这里搞反方向——认为溢出是向低地址方向覆盖。如果你把方向画反了,整个安全模型就错了:你会认为 canary 放在 buf 的"另一边",而实际它放在 buf 的"溢出路径"上。
2. gets(buf) — 缓冲区溢出最危险的入口
2.1 gets 为什么如此危险
void vulnerable_gets(void) {
char buf[16];
gets(buf); /* 危险!gets 不检查边界 */
}gets() 只有一个参数——缓冲区指针。它从 stdin 读取字符直到遇到换行符或 EOF,但完全不知道缓冲区有多大。
正常输入 "hello" (5 字节 + \0):
buf: [h][e][l][l][o][\0]... ← 安全,未触及返回地址
恶意输入 'A' × 32 + 恶意地址 (8 字节):
buf: [A][A]...[A][A][A][A]...[A][恶意地址]
└── buf[0..15] ──┘└─填充─┘└─rbp─┘└─返回地址─┘
结果: 函数 "返回" 到攻击者指定的地址 → 控制流劫持gets() 的危险四重奏:
- 零边界检查:
gets只有一个参数(缓冲区指针),不知道缓冲区大小 - 任意长长度输入:从 stdin 读取直到到换行符,可以是任意长度
- 直接可攻击:攻击者通过 stdin 直接控制溢出内容
- 已被标准移除:C11 标准正式移除
gets(),但遗留代码中仍大量存在
IMPORTANT
C11 移除 gets() 是 C 语言标准化史上最重要的安全决策之一。但 GCC 为向后兼容仍支持它(编译时带 -Wimplicit-function-declaration 警告)。任何出现 gets() 的代码都应立即修复。
2.2 片段 1 的完整栈帧分析
/*
* 代码: void vulnerable_gets(void) { char buf[16]; gets(buf); }
*
* 栈帧布局 (x86-64, 无 canary):
*
* 地址偏移 内容 说明
* ─────────────────────────────────────────────────
* +24 ┌──────────────┐
* │ 返回地址 │ ← gets 溢出可覆盖!
* +16 ├──────────────┤
* │ 保存的 rbp │
* +8 ├──────────────┤
* │ [栈对齐填充] │ (8 bytes, 对齐到 16 字节)
* +0 ├──────────────┤
* │ buf[0..15] │ ← gets() 从这里开始写入
* └──────────────┘
*
* 偏移量计算:
* buf[0] 到返回地址的距离 = 16 (buf) + 8 (对齐) + 8 (rbp) = 32 字节
*
* 注:若编译器紧密排列(无对齐填充),则为 24 字节。
* 实际偏移取决于编译器版本和优化级别。本题使用 32 字节作为教学示例。
*/溢出攻击的精确 payload:
输入 "A" × 32 + 8 字节恶意地址
└── 填充 ──┘ └─ 覆盖返回地址 ─┘
当函数执行 ret 指令时:
CPU 从栈上弹出"返回地址"
但这个地址已被替换为恶意地址
→ 跳转到攻击者指定的内存位置
函数体执行攻击者植入的代码 (shellcode) 或 ROP 链3. strcpy(buf, src) — 隐蔽的杀手
3.1 strcpy 的溢出机制
void vulnerable_strcpy(char *src) {
char buf[32];
strcpy(buf, src); /* 危险!strcpy 不检查边界 */
}strcpy 有两个参数(目标和源),但同样不检查目标缓冲区的大小。它从 src 拷贝字节直到遇到 \0,完全无视 buf 的容量。
如果 src 指向一个超长字符串(无 \0 或很晚才有 \0):
strcpy 将持续拷贝直到遇到 \0
→ 覆盖 buf[32..39] (rbp)
→ 覆盖 buf[40..47] (返回地址)
→ 继续覆盖调用者的栈帧...
注意:strcpy 的 \0 终止是一把尴尬的双刃剑:
- 保护侧:限制了 payload 中不能包含 \0 字节 (很多 shellcode 含 \0)
- 危险侧:但依然不能防止溢出本身——源字符串可能长达 MB3.2 片段 2 的完整栈帧分析
/*
* 代码: void vulnerable_strcpy(char *src) { char buf[32]; strcpy(buf, src); }
*
* 栈帧布局 (x86-64, 无 canary):
*
* 地址偏移 内容 说明
* ─────────────────────────────────────────────────
* +40 ┌──────────────┐
* │ 返回地址 │ ← strcpy 溢出可覆盖!
* +32 ├──────────────┤
* │ 保存的 rbp │
* +0 ├──────────────┤
* │ buf[0..31] │ ← strcpy() 从这里开始写入
* └──────────────┘
*
* 偏移量计算:
* buf[0] 到返回地址的距离 = 32 (buf) + 8 (rbp) = 40 字节
* (32 字节已对齐到 16 字节,无需额外填充)
*/3.3 gets vs strcpy 差异对比
| 维度 | gets() | strcpy() |
|---|---|---|
| 输入源 | stdin(标准输入) | 字符串参数(内存中的 src) |
| 终止条件 | 换行符 '\n' | 空字符 '\0' |
| 攻击难度 | 低(直接 stdin 输入) | 中(需控制 src 内容和容和长度) |
\0 限制 | 无(可包含任意字节) | 有(\0 终止拷贝,限制 payload) |
| 典型场景 | 网络服务、命令行工具 | 字符串处理、配置文件解析 |
| C 标准状态 | C11 移除 | 仍保留(但有安全替代) |
TIP
gets 比 strcpy 更危险的原因:gets 的输入源是 stdin——攻击者通过键盘输入就能直接触发。而 strcpy 需要先控制 src 字符串的内容(通常需要组合其他漏洞),攻击链路更长。
4. fgets 安全版本 — 边界受控的正确范例
4.1 fgets 为什么安全
void safe_fgets(void) {
char buf[16];
fgets(buf, sizeof(buf), stdin); /* 安全!限定了最大最大长度 */
}fgets(buf, size, stream) 有三个参数,最关键的第二个参数 size 告诉了函数缓冲区的容量:
fgets(buf, 16, stdin) 的行为:
1. 最多读取 15 个字符(第 2 个参数 - 1)
2. 第 16 个字节固定写入 '\0'
3. 遇到换行符 '\n' 也停止
4. 遇到 EOF 也停止
因此:最大写入范围 = buf[0..15],共 16 字节
返回地址在 +24 偏移处,完全无法触及/*
* 代码: void safe_fgets(void) { char buf[16]; fgets(buf, sizeof(buf), stdin); }
*
* 栈帧布局 (同片段 1,但行为完全不同):
*
* 地址偏移 内容 说明
* ─────────────────────────────────────────────────
* +24 ┌──────────────┐
* │ 返回地址 │ ← fgets 无法到达!
* +16 ├──────────────┤
* │ 保存的 rbp │ ← fgets 无法到达!
* +8 ├──────────────┤
* │ [栈对齐填充] │ ← fgets 无法到达!
* +0 ├──────────────┤
* │ buf[0..15] │ ← fgets 最多写 15+1 字节
* └──────────────┘
*
* 偏移量同样是 32 字节,但 fgets 只写入 16 字节,
* 距返回地址还有 16 字节"安全距离"。
*/4.2 正确使用姿势与常见陷阱
/* ✅ 正确:使用 sizeof 自动适配缓冲区大小 */
char buf[16];
fgets(buf, sizeof(buf), stdin);
/* ✅ 也正确:显式指定(但需确保与声明一致) */
fgets(buf, 16, stdin);
/* ❌ 错误:传入错误的 size(大于大于实际缓冲区) */
char buf[16];
fgets(buf, 256, stdin); /* 缓冲区区只有 16 字节,却告诉 fgets 有 256! */
/* ❌ 错误:忘记 sizeof 是字节数 */
wchar_t wbuf[16];
fgets((char*)wbuf, sizeof(wbuf), stdin); /* 32 字节!可能超出 wbuf 实际大小 */WARNING
fgets 的安全性建立在正确传入 size 参数的前提下。如果你错误地传入了一个比实际缓冲区更大的数值,fgets 就会变成 | gets 一样危险的函数。这就是为什么 sizeof(buf) 如此重要——它让编译器自动追踪真实大小。
5. Stack Canary(栈金丝雀)— 栈上的哨兵
5.1 名称由来:煤矿中的金丝雀
19世纪的煤矿:
┌─────────────────────────────────┐
│ 矿工带着金丝雀笼子下矿 │
│ │
│ 金丝雀对一氧化碳 (CO) 极度敏感 │
│ CO 浓度升高 → 金丝雀先死 │
│ → 矿工看到金丝雀死亡 → 立 立即撤离 │
│ │
│ 金丝雀 = 生物传感器 / 早期预警 │
└─────────────────────────────────┘
计算机中的 Stack Canary:
┌─────────────────────────────────┐
│ 编译器在栈帧中放置一个随机值 │
│ │
│ 溢出覆盖 → canary 值被改变 │
│ → 函数返回回前检测到变化 │
│ → 立即终止程序 (__stack_chk_fail)│
│ │
│ canary = 栈溢出检测器 / 哨兵 │
└─────────────────────────────────┘5.2 带 Canary 的栈帧与检测流程
地址偏移 内容 说明
─────────────────────────────────────────────
+32 ┌──────────────┐
│ 返回地址 │ ← 攻击者的最终目标
+24 ├──────────────┤
│ 保存的 rbp │
+16 ├──────────────┤
│ CANARY ★ │ ← 哨兵值 (8 bytes)
+8 ├──────────────┤
│ [栈对齐填充] │
+0 ├──────────────┤
│ buf[0..15] │ ← 溢出从这里开始
└──────────────┘
溢出路径:
buf[0] → buf[15] → [对齐] → CANARY → rbp → 返回地址
安全区域 ────────→ 触发警报!→ 程序终止/*
* Canary 生命周期 (编译器的视角):
*
* ╔══════════════════════════════════════════════╗
* ║ 函数入口 (prologue): ║
* ║ 1. 从 TLS 读取随机 canary 值 ║
* ║ mov rax, QWORD PTR fs:0x28 ║
* ║ 2. 写入栈帧 (rbp-8) ║
* ║ mov QWORD PTR [rbp-8], rax ║
* ║ ║
* ║ 函数体执行: ... 可能的溢出 ... ║
* ║ ║
* ║ 函数返回前 (epilogue): ║
* ║ 3. 读取栈帧中的 canary ║
* ║ mov rax, QWORD PTR [rbp-8] ║
* ║ 4. 与 TLS 原始值异或 ║
* ║ xor rax, QWORD PTR fs:0x28 ║
* ║ 5. 若零 → je .L_ok → ret ║
* ║ 6. 若非零 → call __stack_chk_fail ║
* ╚══════════════════════════════════════════════╝
*/5.3 Canary 的三个巧思
巧思 1:最低字节为 \0(NULL 字节)
canary 值示例: 0x00 7f 3a 91 4c 2e b8 d5
↑
\0 字节
为什么这样设计?
大多数字符串函数 (strcpy, gets, sprintf 等) 遇到 \0 停止拷贝。
如果攻击者想通过字符串溢出来覆盖 canary:
"AAAA...\x00\x7f\x3a..." ← 在 \0 处 strcpy 停止!
攻击者必须精确知道 canary 值并原样写入,
这大大增加了攻击难度。巧思 2:随机值(每次运行不同)
程序启动时:
canary 由内核的随机数生成器 (getrandom/urandom) 初始化
存储在 TLS (Thread-Local Storage) 中
每次运行:
$ ./vuln → canary = 0x00a3f91c...
$ ./vuln → canary = 0x007b2e4d... (完全不同!)
攻击者无法硬编码 canary 值。巧思 3:存储在 TLS 中
TLS (Thread-Local Storage):
- 每个线程独立的存储区域
- 不能通过普通内存访问直接读取
- x86-64: fs 段寄存器指向 TLS
- 需要内存泄露漏洞才能读取5.4 Canary 的局限性与绕过方法
| 局限性 | 详细说明 | 利用场景 |
|---|---|---|
| 信息泄露 | 若攻击者能先读取 canary 值(如通过 format string 漏洞),则可精确覆盖 | printf("%s", buf) 泄露栈内容 |
| 非连续写入 | Canary 只能检测从低地址向高地址的顺序覆盖。直接写入返回地址(如任意地址写漏洞)不触发 canary | *(int*)(buf+40) = evil_addr |
| Fork 暴力破解 | fork() 后子进程继承父进程的 canary 值。攻击者可逐字节尝试,每次崩溃只杀死子进程 | 网络服务器 fork 模型 |
| 不保护其他目标 | 覆盖函数指针、数据指针、GOT 表、或修改关键局部变量(如 is_admin flag)可能绕过 canary | 逻辑漏洞利用 |
绕过 Canary 的 4 种经典方法:
方法 A: 信息泄露 + 精确覆盖
Step 1: 利用格式化字符串漏洞泄露 canary 值
Step 2: 构造 payload = 填充 + canary原值 + rbp + 恶意返回地址
Step 3: 触发溢出 → canary 检测通过 → 劫持返回地址
方法 B: 改写 GOT 表(不碰 canary)
Step 1: 溢出 buf,但不覆盖返回地址
Step 2: 覆盖 buf 后面的函数指针 → 指向 system()
Step 3: 后续代码调用该函数指针 → 执行 system()
方法 C: 覆盖局部变量
void login() {
char buf[16];
int is_admin = 0; ← 在 buf 之后(更高地址)
gets(buf); ← 溢出覆盖 is_admin 为 1
if (is_admin) { ... } ← 绕过认证!
}
方法 D: 逐字节暴力破解(fork 服务器)
for (byte_pos = 1; byte_pos < 8; byte_pos++) {
for (guess = 0; guess < 256; guess++) {
发送 payload: 填充 + [已破解字节] + guess
if (子进程没有崩溃) → 该字节正确!
}
}
// 平均需要 8 × 128 = 1024 次尝试6. 纵深防御体系——多道防线的协同
6.1 多层防护链
现代操作系统采用多层防护,每层针对不同攻击阶段:
攻击者
│
▼
┌──────────┐
│ Canary │ 第 1 层: 检测栈溢出
└────┬─────┘ (阻止简单的栈粉碎)
│ 绕过
▼
┌──────────┐
│ ASLR │ 第 2 层: 隐藏地址布局
└────┬─────┘ (使 ROP/JOP 难以定位 gadget)
│ 绕过
▼
┌──────────┐
│ DEP/NX │ 第 3 层: 阻止代码注入
└────┬─────┘ (栈/堆不可执行)
│ 绕过
▼
┌──────────┐
│ RELRO │ 第 4 层: 保护 GOT 表
└────┬─────┘ (阻止 GOT 覆写)
│ 绕过
▼
目标6.2 完整机制对比表
| 防护机制 | 全称 | 防护目标 | 工作原理 | 启用方式 | 局限性 |
|---|---|---|---|---|---|
| Stack Canary | Stack Smashing Protector | 栈缓冲区溢出检测 | 返回地址前放置随机哨兵值,返回前校验 | -fstack-protector-strong | 信息泄露可绕过;不保护非栈目标 |
| ASLR | Address Space Layout Randomization | 代码复用攻击 (ROP/JOP) | 随机化栈、堆、mmap、库、可执行文件的基地址 | 内核默认 (/proc/sys/kernel/randomize_va_space) | 信息泄露可绕过;32-bit 熵不足 (~8 bit) |
| DEP / NX-bit | Data Execution Prevention / No-eXecute | 代码注入入攻击 (Shellcode) | CPU 标记数据页为不可执行;栈/堆不能同时写 + 执行 | 硬件 NX-bit + OS 支持 (-z noexecstack) | ROP/JOP 可绕过(复用已有代码);JIT 需要可执行堆 |
| RELRO | Relocation Read-Only | GOT 表覆写攻击 | 将重定位表设为只读;Full RELRO 在启动时解析所有符号 | -Wl,-z,relro / -Wl,-z,now | Partial RELRO 只保护 .got;Full RELRO 增加启动时间 |
| PIE | Position Independent Executable | 固定地址攻击 | 可执行文件编译为位置无关代码,配合 ASLR 随机加载 | -fPIE -pie | 需 ASLR 配合;轻微性能开销 (~1-3%) |
| FORTIFY_SOURCE | Compile-time Fortification | 不安全库函数调用 | 编译时替换 strcpy → __strcpy_chk(带边界检查) | -D_FORTIFY_SOURCE=2 | 需源码重编译;不能检测所有情况;对某些模式无效 |
6.3 实际编译示例
# 最小防护(危险!仅用于教学演示)
gcc -fno-stack-protector -z execstack -no-pie -o vuln vuln.c
# 标准防护(现代 Linux 默认)
gcc -fstack-protector-strong -D_FORTIFY_SOURCE=2 \
-fPIE -pie -Wl,-z,relro -Wl,-z,now \
-o secure secure.c
# 查看二进二进制启用的安全特性
checksec --file=./secure
# 输出示例:
# RELRO: Full RELRO
# STACK CANARY: Found
# NX: Enabled
# PIE: EnabledNOTE
这些编译选项在现代 GCC(≥6)中很多已经是默认开启的。但了解它们仍然重要——当你在嵌入式系统或不完整的工具链上编译时,可能需要手动确保这些防护生效。
7. 安全编程准则——从源头消灭漏洞
7.1 危险函数及安全替代
| 危险函数 | 风险 | | 安全替代 | 注意事项 | | ------------------------ | --------------- | --------------------------------------- | -------------------------------- | | gets(buf) | 零边界检查 | fgets(buf, sizeof(buf), stdin) | fgets 保留换行行符,需手动去除 | | strcpy(dst, src) | 不检查 dst 大小 | strncpy(dst, src, n-1); dst[n-1]='\0' | strncpy 不保证 \0 结尾 | | strcat(dst, src) | 不检查剩余空间 | strncat(dst, src, n-strlen(dst)-1) | 需计算剩余空间 | | sprintf(buf, fmt, ...) | 不检查 buf 大小 | snprintf(buf, sizeof(buf), fmt, ...) | snprintf 保证 \0 结尾(POSIX) | | scanf("%s", buf) | 不检查 buf 大小 | scanf("%15s", buf) 或 fgets | 宽度限定限定符只限字符数,不含 \0 | | vsprintf(buf, fmt, va) | 不检查 buf 大小 | vsnprintf(buf, sizeof(buf), fmt, va) | 同 snprintf |
IMPORTANT
strncpy 虽然在名字里有个 "n",但它的行为让人意外:如果源字符串长度 ≥ n,目标不会以 \0 结尾。安全用法是 strncpy(dst, src, sizeof(dst)-1); dst[sizeof(dst)-1]='\0';——手动在最后一个字节置 \0。
7.2 五条金科玉律
规则 1: 永远使用带边界检查的函数
❌ gets(buf)
✅ fgets(buf, sizeof(buf), stdin)
规则 2: 编译时启用所有安全选项
✅ -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -pie
规则 3: 永远不要假设输入数据的长度是安全的
❌ "用户名不会超过 32 字节"
✅ 无论如何都使用 sizeof() 或已知边界
规则 4: 理解栈帧布局有助于写出更安全的代码
✅ 知道 buf 溢出后会覆盖什么 → 知道如何防护
规则 5: 代码审查时特别关注危险函数
✅ grep -nE '\bgets\b|\bstrcpy\b|\bstrcat\b|\bsprintf\b|\bscanf\("%s"\)' *.c参考解答
练习1: TODO 1 — gets() 栈帧分析
/* ─── TODO 1: 代码片段 1 gets() 栈帧分析 ─── */
printf("╔══════════════════════════════════════════════════════╗\n");
printf("║ 缓冲区溢出安全分析实验报告 ║\n");
printf("║ Buffer Overflow Security Analysis Lab ║\n");
printf("╚══════════════════════════════════════════════════════╝\n");
printf("\n");
printf("【预备知识】x86-64 栈帧结构\n");
printf("\n");
/* ... 完整的栈帧基础介绍 ... */核心输出包括:
- ASCII 框展示代码片段
- 栈帧布局图(标注 +0, +8, +16, +24 偏移)
- 偏移计算:
buf[0]到返回地址 = 16 + 8 + 8 = 32 字节 - 风险判断:gets 无边界检查,CRITICAL
- 攻击示意:
'A' × 32 + 恶意地址劫持返回地址
练习2: TODO 2 — strcpy() 栈帧分析
/* ─── TODO 2: 代码片段 2 strcpy() 栈帧分析 ─── */
print_separator();
printf("\n");
printf("【代码片段 2】strcpy() 缓冲区溢出 — 风险等级:HIGH\n");
printf("\n");
/* ... 代码展示 ... */
/* ... 栈帧图(buf[32] + rbp = +40 偏移)... */
/* ... gets vs strcpy 对比表 ... */核心输出包括:
- ASCII 框展示片段 2 代码
- 栈帧布局图(偏移 +0, +32, +40)
- 偏移计算:
buf[0]到返回地址 = 32 + 8 = 40 字节 - gets vs strcpy 对比表(输入源、终止条件)
\0终止的双刃剑分析
练习3: TODO 3 — fgets() 安全版分析
/* ─── TODO 3: 代码片段 3 fgets() 安全版分析 ─── */
print_separator();
printf("\n");
printf("【代码片段 3】fgets() 安全版本 — 风险等级:SAFE\n");
printf("\n");
/* ... 代码展示 ... */
/* ... 栈帧图(标注"fgets 无法到达")... */
/* ... fgets 安全机制说明 ... */核心输出包括:
- ASCII 框展示片段 3 代码
- 栈帧布局图(标注所有"无法到达")
- 偏移同样是 32 字节,但 fgets 最多写入 16 字节
- fgets 安全机制:size 参数限制、换行/EOF 停止、强制
\0结尾 - 正确使用 vs 误用对比(
sizeof(buf)是核心)
练习4: TODO 4 — Stack Canary 原理讲解
/* ─── TODO 4: 金丝雀 (Stack Canary) 防护原理 ─── */
print_separator();
printf("\n");
printf("【深入分析】金丝雀 (Stack Canary) 防护原理\n");
printf("\n");
/* ... 煤矿金丝雀故事 ... */
/* ... 带 canary 的栈帧图 ... */
/* ... 检测流程 (入口/返回前) ... */
/* ... canary 特征表格 ... */
/* ... 局限性 (4 点) + 绕过方法 (4 种) ... */核心输出包括:
- 名称由来(煤矿金丝雀故事)
- 带 canary 的栈帧布局图(buf → 对齐 → CANARY → rbp → ret)
- 检测流程:入口读取 fs:0x28 → 写入栈帧 → 返回前异或比较
- Canary 三个特征:随机值、
\0最低字节、TLS 存储 - 四种局限性及绕过方法
练习5: TODO 5 — 防护机制对比表
/* ─── TODO 5: 防护机制对比表 ─── */
print_separator();
printf("\n");
printf("【防护机制对比】纵深防御体系\n");
printf("\n");
/* ... 6 种防护机制表格 ... */
/* ... 纵深防御链示意图 ... */核心输出包括:
- 6 行 × 4 列表格(防护机制 | 防护目标 | 原理 | 局限性)
- 覆盖 Canary / ASLR / DEP / RELRO / PIE / FORTIFY_SOURCE
- 纵深防御链示意:攻击者 → [Canary] → [ASLR] → [DEP] → [RELRO] → 目标
练习6: TODO 6 — 综合风险评估
/* ─── TODO 6: 综合风险评估 ─── */
print_separator();
printf("\n");
printf("【综合风险评估】\n");
printf("\n");
/* ... 三段代码风险评级表 ... */
/* ... 五条安全编程准则 ... */核心输出包括:
- 3 行风险评估表(片段 | 等级 | 原因)
- gets → CRITICAL:无边界检查,stdin 直接控制
- strcpy → HIGH:无边界检查,需控制 src
- fgets → SAFE:显式指定最大长度
- 五条安全编程准则(含具体替代函数)
- 安全编译选项汇总
练习7: TODO 7 — 学习建议
/* ─── TODO 7: 学习建议 ─── */
print_separator();
printf("\n");
printf("【进一步学习建议】\n");
printf("\n");
/* ... 动手实验命令 ... */
/* ... 工具推荐 (objdump/checksec/gdb) ... */
/* ... 标准规范 (CWE/CERT/MISRA) ... */
/* ... 编译器默认安全选项 ... */
/* ... 经典论文推荐 ... */核心输出包括:
- 动手实验命令(
-fno-stack-protector观察溢出效果) - 工具推荐(objdump、checksec、gdb)
- 标准规范(CWE-120、CWE-121、CERT C STR31-C、MISRA C Rule 21.6)
- 编译器默认安全选项(GCC ≥6、Clang ≥5)
- 经典论文(Smashing The Stack、StackGuard、ROP)
对照检查:计算偏移时是否考虑了栈对齐填充?犯溢出的栈增长方向是否正确(向高地址覆盖)?Canary 的位置是否在 rbp 之下、局部变量之上?fgets 的 size 参数用了
sizeof(buf)吗?报告中标注了\0字节对字符串函数的影响吗?
课堂讨论
- 为什么 C11 要移除
gets()而不是修复它? - 如果攻击者已经知道 canary 的值,canary 还剩下什么保护作用?
- 为什么 fork 出来的子进程 canary 相同?这个设计合理吗?
strncpy和strcpy的溢出风险一样吗?- 在现代 Linux 上,一个简单的栈溢出还能成功吗?
- 这道题为什么选择"分析"而不是"利用"?
讨论答案
Q1: 为什么 C11 要移除 gets() 而不是修复它?
gets() 的接口设计从根本上就是有缺陷的——它只有一个参数(缓冲区指针),没有缓冲区大小信息。要"修复"它必须改变函数签名(增加 size 参数),这实际上就是 fgets() 的接口。标准化委员会选择移除它并引导程序员使用 fgets(),而不是维护一个向后兼容的危险接口。
这一决策也引发了 C 社区的广泛讨论——部分人认为标准化委员会应该保持向后兼容(即使函数危险,至少程序员知道它的用法),另一部分人认为安全优先(使用危险函数 = 写出危险代码,标准化组织有责任移除)。最终安全派胜出。ISO C11 不仅是移除了 gets(),还引入了 Annex K(bounds-checking interfaces),提供了 gets_s() 等带边界检查的替代函数——虽然 Annex K 本身在业界争议很大。
Q2: 如果攻击者已经知道 canary 的值,canary 还剩下什么保护作用?
几乎没有。canary 的安全模型假设攻击者不知道它的值。一旦通过信息泄露获取了 canary 值,攻击者可以在溢出 payload 中原样放回,__stack_chk_fail 不会被触发。这就是为什么纵深防御如此重要——ASLR 和 DEP 作为第二二、第三道防线继续提供保护。
在真实攻击链中,信息泄露通常是整个攻击的第一步——攻击者先用 format string 漏洞或越界读取来泄漏 canary 值和 libc 基址,然后再用缓冲区溢出来劫持控制流。这种"Info Leak → Canary Bypass → ROP Chain"的攻击模式在 CTF 比赛和 APT 攻击中屡见不鲜。
但 canary 仍然有意义——它把"直接溢出就可以"变成了"必须先信息泄露再溢出",显著提高了攻击门槛。就像你家的门锁——专业窃贼能撬开,但不装锁的话任何人一推就能进。
Q3: 为什么 fork 出来的子进程 canary 相同?这合理吗?
技术上合理(fork 复制整个地址空间),但安全上是一个已知弱点。fork() 之后子进程是父进程的精确副本,包括 TLS 中的 canary 值。对于使用 fork() 模型的网络服务器(如 Apache prefork),攻击者可以通过反复尝试逐字节破解 canary,因为每次崩溃只杀死子进程,父进程继续存活并 fork 出新的(具有相同 canary 的)子进程。
具体攻击方法:攻击者逐字节尝试 canary 值。第 1 字节始终是 \0(已知)。对于第 2 字节,尝试 0x00-0xFF 的每个值,发送到服务器。如果猜测正确,子进程继续执行(canary 检测通过);如果错误,子进程崩溃——但父进程不受影响,立即 fork 出新的子进程准备接收下一次尝试。平均需要 8 × 128 = 1024 次尝试即可破解完整 canary。
这种攻击对使用 fork() 模型的网络服务(如 xinetd、Apache prefork MPM)尤其有效。现代方案如 posix_spawn 或 clone + execve 可以避免这个弱点,因为新进程有全新的地址空间和 canary。
Q4: strncpy 和 strcpy 的溢出风险一样吗?
不完全一样。strncpy(dst, src, n) 最多拷贝 n 个字符,如果 n ≤ dst 的大小则不会溢出。但 strncpy 有一个隐藏陷阱:如果 strlen(src) ≥ n,则 dst 不会以 \0 结尾,后续的字符串操作可能读取越界。
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[8];
strncpy(buf, "Hello, World!", sizeof(buf));
/* buf = {'H','e','l','l','o',',',' ','W'} — 没有 \0! */
printf("%s\n", buf); /* 会继续打印 buf 后面的内存,直到遇到 \0 */
return 0;
}正确的安全用法:
strncpy(dst, src, sizeof(dst) - 1);
dst[sizeof(dst) - 1] = '\0'; // 手动确保 \0 结尾所以答案是:strncpy 可以安全——但前提是你知道并处理了它的 \0 陷阱。许多初学者以为 strncpy 就是"安全的 strcpy",这种误解本身就是安全隐患。
Q5: 在现代 Linux 上,一个简单的栈溢出还能成功吗?
默认情况下非常困难。现代 Linux 发行版默认启用:Stack Canary (-fstack-protector-strong)、ASLR、NX-bit、PIE、Full RELRO。单独的一个 gets() 溢出需要同时绕过 Canary(需要信息泄露)和 ASLR(需要地址泄露),再加上 NX-bit 阻止了 shellcode 直接执行,攻击者需要构造 ROP 链。
但"困难"不等于"不可能"。一个足够有动机的攻击者可以通过组合漏洞完成攻击链:
- 格式化字符串漏洞 → 泄露 canary 值和 libc 地址
- 缓冲区溢出 → 精确覆盖 canary + ROP 链
- ROP 链 → 调用
mprotect使栈可执行 → 跳转到 shellcode
而且,攻击面比你想象的更广:
- IoT 设备、嵌入式系统往往没有这些防护
- 遗留系统(如运行 RHEL 5 的服务器)防护不完整
- 第三方库可能用
-O0编译,canary 被优化掉 - 静态链接的二进制不能享受 ASLR 的完整保护
这也是为什么安全分析如此重要——不能指望防护替你解决一切问题。
Q6: 这道题为什么选择"分析"而不是"利用"?
安全教育的目的是培养安全的开发者,而非攻击者。理解溢出原理和防护机制后,开发者在写代码时就能本能地避免危险模式。相比之下,CTF 式的利用教学虽然有吸引力,但容易让学生将注意力放在攻击技巧上而非防御思维。
两者的视角差异:
| 维度 | CTF 利用教学 | 安全分析教学(本题) |
|---|---|---|
| 目标 | 获取 flag / shell | 理解漏洞根源 |
| 技能 | 编写 exploit、ROP 链 | 栈帧分析、防护评估 |
| 心态 | "我怎么打破它" | "我怎么确保它不会破" |
| 适用人群 | 安全研究员、渗透测试 | 所有 C/C++ 开发者 |
| 副作用 | 可能强化攻击技能 | 建立安全编码本能 |
先学会防御,再了解攻击,这才是安全教育的正确顺序。
课后练习
验证偏移量计算。编写一个简单的程序,在
buf[16]后声明一个标记变量,用printf("%p")打印 buf 和标记变量的地址,计算实际偏移。与课程中的理论值(32 字节)对比,分析差异原因。知识点提示:编译器可能重排局部变量顺序、插入 padding、或省略未使用的变量。使用
-O0 -fno-stack-protector编译以获得最可预测的布局。用objdump -d查看反汇编确认实际栈分配大小。参考解答
c#include <stdio.h> int main(void) { char buf[16]; int marker = 0xDEADBEEF; printf("buf address: %p\n", (void*)buf); printf("marker address: %p\n", (void*)&marker); printf("difference: %ld bytes\n", (unsigned long)((char*)&marker - buf)); return 0; } /* 编译: gcc -O0 -fno-stack-protector -o verify verify.c */ /* 运行: ./verify */ /* 期望: difference >= 16 (可能更大,取决于编译器) */fgets 的安全边界验证。编写程序测试
fgets(buf, 16, stdin)在输入不同长度时的行为。分别输入 5、14、15、20、100 个字符,用strlen和内存 dump 观察buf[15]位置的值。知识点提示:用管道或重定向提供不同长度的输入。验证 fgets 的行规:最多读 (size-1) 个字符、总是
\0结尾、超长输入时剩余部分留在输入缓冲区中。参考解答
c#include <stdio.h> #include <string.h> int main(void) { char buf[16]; if (fgets(buf, sizeof(buf), stdin)) { printf("strlen: %zu\n", strlen(buf)); printf("buf[15] value: 0x%02x\n", (unsigned char)buf[15]); /* 检查 buf[15] 总是 \0 */ if (buf[15] == '\0') printf("OK: buf[15] is null terminator\n"); else printf("BUG: buf[15] is NOT null!\n"); } return 0; } /* 测试: echo "12345" | ./a.out → strlen: 5 (含 \n) */ /* echo "12345678901234567890" | ./a.out → strlen: 15 */ /* 注意: 输入超长时,剩余部分留在 stdin 缓冲区中 */观察 Canary 在反汇编中的体现。写一个含有
char buf[16]的函数,用gcc -fstack-protector-strong -O0编译,然后用objdump -d查看反汇编。找出 canary 从 TLS 读取和返回前校验的汇编指令。知识点提示:在函数序言中找
mov rax, QWORD PTR fs:0x28和mov QWORD PTR [rbp-8], rax;在函数尾声找mov rax, QWORD PTR [rbp-8]、xor rax, QWORD PTR fs:0x28和条件跳转到__stack_chk_fail。参考解答
c#include <stdio.h> void demo_canary(void) { char buf[16]; fgets(buf, sizeof(buf), stdin); printf("%s", buf); } /* 编译: gcc -fstack-protector-strong -O0 -S -o canary.s canary.c */ /* 或: gcc -fstack-protector-strong -O0 -o canary canary.c */ /* 然后: objdump -d canary | grep -A20 '<demo_canary>' */asm; 关键汇编片段 (x86-64): ; ; 函数序言: ; push rbp ; mov rbp, rsp ; sub rsp, 32 ; mov rax, QWORD PTR fs:0x28 ← 从 TLS 读取 canary ; mov QWORD PTR [rbp-8], rax ← 写入栈帧 ; ; ... 函数体 ... ; ; 函数尾声: ; mov rax, QWORD PTR [rbp-8] ← 从栈帧读取 canary ; xor rax, QWORD PTR fs:0x28 ← 与 TLS 原始值比较 ; je .L_ok ← 相同 → 安全退出 ; call __stack_chk_fail ← 不同 → 终止 ; .L_ok: ; leave ; ret编写
is_stack_safe检查函数。实现一个函数,接受字符串长度和缓冲区大小,返回 0(安全)或 1(可能溢出)。考虑fgets、strncpy(需\0)、snprintf等不同函数的边界语义。知识点提示:不同的安全函数的边界语义不同——
fgets(buf, N, ...)最多写 N 字节(含\0),要求N <= sizeof(buf);strncpy(dst, src, N)最多写 N 字节但不保证\0,安全条件是N <= sizeof(dst)且strlen(src) < N。参考解答
c#include <stddef.h> /* 检查 fgets 是否安全 */ int fgets_is_safe(size_t buf_size, int fgets_n) { /* fgets(buf, n, ...) 最多写 n 字节 (含 \0) * 安全条件: n <= buf_size */ return fgets_n <= (int)buf_size; } /* 检查 strncpy 是否安全 (含 \0 语义) */ int strncpy_is_safe(size_t buf_size, size_t src_len, int n) { /* strncpy(dst, src, n): 最多拷贝 n 字节 * 安全条件: n <= buf_size AND src_len < n (确保 \0) */ return n <= (int)buf_size && src_len < (size_t)n; } /* 检查 snprintf 是否安全 */ int snprintf_is_safe(size_t buf_size, int n) { /* snprintf(buf, n, ...): 最多写 n 字节 (含 \0) * 安全条件: n <= buf_size */ return n <= (int)buf_size; }用 checksec 分析系统二进制。在自己的 Linux 系统上,使用
checksec --file=/bin/ls(或readelf -l/readelf -d)查看系统二进制的安全特性。记录哪些防护已启用,哪些未启用,并思考原因。知识点提示:
checksec是 pwntools 中的工具。也可用readelf -l binary | grep GNU_STACK检查 NX、readelf -d binary | grep BIND_NOW检查 Full RELRO、readelf -h binary | grep Type检查 PIE。参考解答
bash# 安装 checksec (来自 pwntools) pip install pwntools checksec --file=/bin/ls # 手动检查 (无需 checksec): # PIE: ELF type 应为 DYN (shared object file) readelf -h /bin/ls | grep Type: # NX: GNU_STACK 段应缺少 E (Execute) 标志 readelf -l /bin/ls | grep GNU_STACK # 期望输出: GNU_STACK 0x... RW 0x... (没有 E) # RELRO: 查找 BIND_NOW 标志 readelf -d /bin/ls | grep -E 'BIND_NOW|FLAGS_1' # 期望: (BIND_NOW) 或 (FLAGS_1) 包含 NOW # Full RELRO: 需要有 BIND_NOW # Partial RELRO: 有 GNU_RELRO 段但没有 BIND_NOW思考:为什么
/bin/ls的一些防护可能不如预期全面?可能是因为它不处理不可信输入,性能优先于安全;也可能是因为某些防护(如 Full RELRO)对简单二进制收益不大。
参考资料
- Aleph One. "Smashing The Stack For Fun And Profit." Phrack Magazine, Vol 7, Issue 49, 1996. — 栈溢出利用的经典开山之作
- Cowan, C. et al. "StackGuard: Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks." USENIX Security, 1998. — Stack Canary 的原始论文
- Shacham, H. "The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86)." ACM CCS, 2007. — ROP 的奠基论文
- CWE-120: Buffer Copy without Checking Size of Input. https://cwe.mitre.org/data/definitions/120.html
- CERT C Coding Standard: STR31-C — Guarantee that storage for strings has sufficient space for character data and the null terminator
- MISRA C:2012, Rule 21.6 — "The Standard Library function gets shall not be used."
- GCC Manual: Program Instrumentation Options — -fstack-protector, -fstack-protector-strong, -fstack-protector-all
"Those who cannot remember the past are condemned to repeat it." — George Santayana