字符串存储与安全
# 11.字符串存储与安全
C 字符串以
'\0'结尾的设计代价与收益、字符串字面量在.rodata的去重池化、char arr[]在栈 vschar *ptr指向.rodata的本质区别、strcpy/strcat/sprintf缓冲区溢出根因、strncpy的'\0'不保证写入陷阱、snprintf/strlcpy安全替代方案、ASCII → Unicode → UTF-8 编码演进与 C 的宽字符wchar_t
# 目录
- 1. 案例引入
- 2. 架构概览
- 3. \0 结尾的设计代价
- 4. 字面量栈数组
- 5. 缓冲区溢出根因分析
- 6. strncpy 的陷阱
- 7. 安全字符串操作方案
- 8. ASCII到Unicode编码演进
- 9. 综合案例串讲
# 1. 案例引入
# 1.1 日志系统离奇崩溃
先看一段在某金融后台跑了两年没出过事的代码,上线新业务后 第 3 天凌晨 收到告警——进程 SIGABRT,堆栈指向一个不可能出错的地方:
// log_router.c —— 日志路由分发模块
#include <stdio.h>
#include <string.h>
#define LOG_PATH_MAX 128
#define MSG_MAX 512
void route_log(const char* service, const char* msg) {
char filepath[LOG_PATH_MAX];
char content[MSG_MAX];
/* 拼接日志文件路径 */
strcpy(filepath, "/var/log/bank/");
strcat(filepath, service); // ← 假设 service 不会太长
strcat(filepath, ".log");
/* 拼接日志内容 */
snprintf(content, sizeof(content),
"[%s] %s\n", service, msg);
/* 写文件... */
FILE* fp = fopen(filepath, "a");
if (fp) {
fputs(content, fp);
fclose(fp);
}
}
int main() {
/* 新业务上线:service 名从上游透传,不再是我们内部定义的 */
route_log(
"payment-gateway-v3-asia-pacific-southeast-region-extended",
"transaction completed"
);
return 0;
}
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
现象:
- 测试环境跑了一个月:零崩溃
- 生产环境新业务上线第 3 天:SIGABRT —
stack smashing detected - 事发时的调用参数:
service长度 58 字节,LOG_PATH_MAX只有 128
直觉怀疑:是不是 snprintf 的长度算错了?打开 core 一看,__stack_chk_fail 触发——说明 canary 被踩了。filepath 只有 128 字节,"/var/log/bank/" 已经占了 14 字节,".log" 占 4 字节,剩下的 110 字节看似够用……但 service 这次传了 58 字节过来,加上前面的 14 + .log 的 4 + 中间 strcat 累积的 \0 覆盖 = 14 + 58 + 4 + 1(\0) = 77 字节,理论上是安全的。
那为什么还会 stack smashing?别急——这里至少藏着 5 个字符安全相关的坑,每一个单独看都不致命,叠在一起就出事了。
# 1.2 顺藤摸到根因
带着 core dump 往深处走:
- 假设 1:是不是
strcpy + strcat把\0弄丢了?——strcat从目标字符串的\0开始追加,如果目标里没有\0,它就一路找下去,可能找十几 KB 才找到,最终写的地址远超filepath的 128 字节。 - 假设 2:
filepath在栈上的初始值是未定义的——char filepath[128]没有初始化,栈上残留的是之前函数调用的垃圾数据。如果垃圾数据恰好一个\0都没有,那strcpy找\0的行为是未定义的——但这里strcpy是往filepath写,不会被读影响。真正危险的是后续的strcat——它从filepath的末尾\0开始追加,但如果strcpy后filepath的\0在哪是确定的(就在拷贝内容的末尾),所以这条排除。 - 假设 3:等等——
strcpy(filepath, "/var/log/bank/")写入 15 字节(含\0)。strcat(filepath, service)从第 14 字节覆盖\0,追加 service 的 58 字节 + 新\0,此时 filepath 用了 14 + 58 + 1 = 73 字节。strcat(filepath, ".log")追加 5 字节(含\0),总计 73 + 5 = 78 字节。78 < 128,没溢出。 - 假设 4:那为什么还有 stack smashing?—— 重新审查:
content是 512 字节,snprintf(content, sizeof(content), "[%s] %s\n", service, msg)——这里service58 字节,加上前缀[(1) +](2) + msg +\n(1) +\0(1)……如果msg来自某个超长日志呢?但日志显示 msg 只有 22 字节。 - 假设 5:真正的凶手——案发时
service的实际值是 62 字节(前面的 58 是我简化了描述),而且生产环境那个版本里有一个隐藏的"血崩点":上游通过sprintf拼了一个超长的 service 名再传给route_log,而中间层有一个char buf[64]的临时缓冲区……上游的溢出踩坏了调用者的栈帧,使得route_log的 canary 校验值在main还没进route_log之前就已经被破坏了。
这个案例的根因链条:
上游 sprintf 溢出 (64 字节 buf 装不下 70 字节的拼接结果)
→ 溢出覆盖 main 的栈帧 → canary 被改
→ 进入 route_log → 函数入口处 canary 被保存
→ 函数返回时检查 canary → 发现不一致 → __stack_chk_fail → SIGABRT
2
3
4
真正的凶手不是 route_log 内部,而是调用链上游的一个 64 字节临时缓冲区。
这个事故里藏着至少 8 个原理点:
① "hello" 写在进程地址空间的哪里?为什么不能改? → 第 4 章
② char arr[] 和 char *ptr 的存储位置区别是什么? → 第 4 章
③ strcpy/strcat 为什么是定时炸弹? → 第 5 章
④ snprintf 以为安全,坑在哪? → 第 7 章
⑤ stack canary 是怎么检测溢出的? → 第 5.5 节
⑥ strncpy 加了长度参数就安全了吗? → 第 6 章
⑦ \0 结尾的设计到底给 C 带来了什么? → 第 3 章
⑧ 中文、emoji 在 C 里怎么处理?为什么 strlen("你好") ≠ 2? → 第 8 章
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 8 个问号往下走,每讲完一段原理就解开一两个;最后在第 9 章把案例彻底剖开,并给出生产级安全方案。
本篇路线:
架构总图 (第 2 章)
↓
\0 结尾设计 (第 3 章) ─→ 解开"为什么 C 字符串这么危险但又改不得"
↓
字面量 vs 栈数组 (第 4 章) ─→ 解开""写在哪、能不能改"
↓
缓冲区溢出根因 (第 5 章) ─→ 解开"strcpy/get 为什么是代码炸弹"
↓
strncpy 陷阱 (第 6 章) ─→ 解开"号称安全的函数为何更坑"
↓
安全方案 (第 7 章) ─→ 武器库
↓
编码演进 (第 8 章) ─→ 解开"ASCII/Unicode/UTF-8 在 C 里的全貌"
↓
综合案例 (第 9 章) ─→ 案例彻底剖开 + 速查卡
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:字符串是 C 语言的"阿喀琉斯之踵"——大约 70% 的 C 程序安全漏洞(NVD 统计)源于字符串处理不当。本篇从内存布局、API 语义、编码演进三个维度,把 C 字符串的每一个暗坑剖到字节级。读完本篇后,对
strcpy/strncpy/snprintf的选择题、char*与char[]的辨析题、以及 UTF-8 在 C 中的长度谜题,都能从原理出发给出准确回答。
# 2. 架构概览
# 2.1 字符串的三种住所
C 语言中,字符串可以住在进程地址空间的三个不同位置:
高地址
┌────────────────────────────────────────────────┐
│ 栈区 (stack) │ ← char buf[64] 住这
│ 函数局部数组,随函数进入分配、退出释放 │ rw-
│ char arr[] = "hello" 把 "hello" 拷贝进来 │
├────────────────────────────────────────────────┤
│ 共享库 / mmap 区 │
│ 动态分配的字符串 (malloc) │ ← strdup 的产物住这
│ char* p = malloc(64) → heap │ rw-
├────────────────────────────────────────────────┤
│ 堆区 (heap) │
│ malloc / realloc / strdup 的字符串 │
├────────────────────────────────────────────────┤
│ bss 未初始化全局 char arr[100] = "" 住这 │
├────────────────────────────────────────────────┤
│ data 已初始化全局 char arr[] = "init" 住这 │
├────────────────────────────────────────────────┤
│ rodata 常量只读段 │ ← "hello" 的真正老家
│ 字符串字面量被编译器合并去重 │ r--
│ char *p = "hello" → p 指向这里! │
├────────────────────────────────────────────────┤
│ text 机器指令 │
├────────────────────────────────────────────────┤
│ 保留区 │
└────────────────────────────────────────────────┘
低地址
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
核心结论:char *p = "hello" 和 char arr[] = "hello" 的区别不是语法糖——指针指向 rodata(不可写),数组在栈/data(可写)。这个区别是本章最重要的地基。
三种住所速查:
| 写法 | 字符串内容在哪 | 指针/数组名在哪 | 可修改? | 生命周期 |
|---|---|---|---|---|
char *p = "hello" | .rodata (只读) | 栈 (局部) 或 data (全局) | ❌ 写就崩 | 程序全周期 |
char arr[] = "hello" | 栈 (局部) 或 data (全局) | 栈/数据段 | ✅ | 随作用域 |
char *p = malloc(64) | 堆 | 栈 | ✅ | 直到 free |
static char arr[] = "hi" | .data | .data | ✅ | 程序全周期 |
const char* p = "hello" | .rodata | 栈/data | ❌ | 程序全周期 |
# 2.2 C字符串软肋
疑惑:Java/Python/Go 的字符串都"安全",为什么 C 的字符串会成为"漏洞之王"?
论证:
- C 没有真正的字符串类型——C 的"字符串"是
char*,一个指针而已。编译器不知道它指向的内存有多大,运行时也不知道。长度信息不在类型系统里,存在程序员的脑子里。 - 长度的唯一信息来源是
\0——要遍历字符串找到\0才知道长度。这意味着strlen是 O(n),更重要的是:如果\0丢了,所有基于它的操作全部失控。 - 库函数设计于"信任时代"——1970 年代的 Unix 程序员默认"调用者知道自己在干什么",
strcpy不检查目标缓冲区大小,gets甚至不检查源长度。这些函数在"局域网互信"时代没问题,在互联网时代是定时炸弹。 - 缓冲区的生命周期管理完全靠人工——没有 GC、没有 RAII、没有引用计数。
char*可以指向已释放的栈、野指针、未初始化的垃圾——编译器不帮忙。 - 对比验证:
| 语言 | 字符串长度信息 | 边界检查 | 内存管理 | 编码感知 |
|---|---|---|---|---|
| C | \0 结尾,O(n) 扫描 | 无(全靠程序员) | 手动 | 无(字节流) |
| C++ | std::string 存 O(1) size | at() 抛异常 | RAII | 无(需外部库) |
| Java | 对象头存 length | 数组越界抛异常 | GC | UTF-16 原生 |
| Go | string 结构体存 len | 切片越界 panic | GC | UTF-8 原生 |
| Rust | &str 编译期 + 运行时 | 编译期拒绝 | 所有权系统 | UTF-8 校验 |
结论:C 语言在"不做类型编码"与"零成本抽象"之间做了极端选择——字符串不是一种类型,只是一个字节序列的约定。这种设计在 1970 年是一种"最小化实现"的智慧,但在 2026 年仍然是 C 安全漏洞的头号土壤。后续所有章节,本质上都在回答一个问题:在 C 的类型系统不帮忙的情况下,我们怎么把字符串管住。
# 3. \0 结尾的设计代价
# 3.1 哨兵字节的哲学起源
疑惑:为什么 C 选择用 \0 而不是像 Pascal 那样在前面存长度?
论证:
历史事实:C 语言 1972 年诞生于 PDP-11 小型机,内存只有 16 KB。Dennis Ritchie 在设计 B 语言(C 的前身)时就面临一个选择——每个字符串额外存一个长度字段,还是用一个特殊字符标记结尾。
Pascal 的方案(存长度):
(* Pascal:字符串第一个字节存长度 *)
var s: string[255]; (* 实际分配 256 字节,s[0] 存长度 *)
// 优点:O(1) 取长度,字符串可以包含任意字节
// 缺点:最大长度被长度字段的字节数锁死(Pascal 用 1 字节存长度 → 最长 255)
2
3
4
- C 的方案(哨兵字节
\0):
char s[] = "hello"; // 实际 6 字节: 'h','e','l','l','o','\0'
// 优点:字符串长度无上限(理论),指针就是首地址,极简
// 缺点:O(n) 取长度,不能包含 \0 字节
2
3
关键洞察:Ritchie 选择
\0不是随机的——\0在 ASCII 里是 NUL 字符,天然是"什么都不是"的意思;而且 PDP-11 的字符串指令(如MOVC)天然支持 NUL 终止的字符串遍历,硬件层本身就是这么设计的。连锁反应:一旦 C 选择了
\0结尾,整个 Unix 生态就绑定了这一决定——strlen、strcpy、strcmp、printf("%s")……所有标准库函数都基于这个假设。50 年后,这一决定的阴影依然笼罩着每一个char buf[64]。
结论:\0 结尾不是设计缺陷,而是 1972 年约束下的最优解——Pascal 的长度前缀法同样有自己的天花板(固定位宽限制长度)。真正的问题是后来的 C 程序员把这一设计用在 2026 年的互联网场景中,而库函数没有与时俱进。
# 3.2 以空间换简洁
\0 结尾的核心代价用一个例子说清楚:
#include <string.h>
size_t my_strlen(const char* s) {
const char* p = s;
while (*p) p++; // ← 每次比较一个字节,直到碰到 \0
return p - s;
}
2
3
4
5
6
假设 s 指向一个 1 MB 的字符串,这个循环跑 100 万次。但这不是最糟的——最糟的是:
char* p = malloc(1024 * 1024);
memset(p, 'A', 1024 * 1024); // 全填 'A',末尾没有 \0
size_t len = strlen(p); // 💀 读越界!一直读到碰巧遇到 \0 或 SIGSEGV
2
3
strlen、strcpy、strcat、strcmp……它们的共同特征是:不知道目标缓冲区的真实大小,只认 \0。
更隐蔽的代价:C 字符串不能包含 \0 字节——这是"非二进制安全"的根源:
char data[] = {0x48, 0x00, 0x4F}; // H\0O
printf("%s\n", data); // 输出只有 "H"——\0 截断了!
size_t len = strlen(data); // len = 1,不是 3
2
3
任何需要存储二进制数据(图片、加密结果、网络包)的场景,都不能直接用 C 字符串——必须用 memcpy/memcmp 系列 + 显式长度。这就是 Redis 选择自定义 SDS 字符串的根本原因(第 7.3 节详述)。
# 3.3 字节性能
疑惑:只是一个字节的 \0 而已,真的有性能问题?
论证:
\0 最阴险的性能陷阱是 O(n²) 级联:
char buf[4096] = ""; // buf = "" (1 个 \0)
for (int i = 0; i < 1000; i++) {
strcat(buf, "a"); // 每次 strcat 都要 O(n) 找到末尾 \0
}
2
3
4
这段代码的总时间复杂度:
第 1 次:strlen(buf) = 0 → 找 \0 0 步
第 2 次:strlen(buf) = 1 → 找 \0 1 步
第 3 次:strlen(buf) = 2 → 找 \0 2 步
...
第 1000 次:strlen(buf) = 999 → 找 \0 999 步
总计:0+1+2+...+999 ≈ 500,000 步
2
3
4
5
6
7
而如果 C 字符串存了长度字段,这个循环是 O(n) 总时间。这就是为什么"循环中用 strcat 拼接字符串"是 C 性能面试题的经典陷阱。
实测对比(10000 次拼接一个字符):
# strcat 逐次拼接
$ time ./strcat_naive
real 0m2.847s ← O(n²),逐次 O(n) 找末尾
# 手动维护尾指针
$ time ./strcat_fast
real 0m0.003s ← O(n),始终知道末尾在哪
2
3
4
5
6
7
修复办法——手动维护写指针:
char buf[4096];
char* p = buf;
for (int i = 0; i < 1000; i++) {
*p++ = 'a';
}
*p = '\0'; // 只写一次 \0
2
3
4
5
6
或者用 snprintf 的返回值(返回"如果 buf 够大会写入多少字节")来追踪末尾位置(第 7.1 节详述)。
# 3.4 二进制安全之痛
因为 \0 被当作字符串终结符,包含 \0 的任何数据都不能用 C 字符串 API 处理:
// 场景:网络包中取一个 32 字节的 HMAC 摘要
uint8_t hmac[32]; // 二进制数据,很可能包含 0x00 字节
recv(sock, hmac, 32, 0);
// ❌ 错误做法
printf("hmac: %s\n", hmac); // 输出在第一个 0x00 处截断
size_t len = strlen(hmac); // len 不一定是 32!
// ✅ 正确做法 —— 用显式长度的 API
fwrite(hmac, 1, 32, stdout); // 写入正好 32 字节
print_hex(hmac, 32); // 转成 hex 字符串再打印
2
3
4
5
6
7
8
9
10
11
二进制安全(binary-safe) 的定义:字符串操作函数不依赖 \0 终止符,而是显式传入长度。C 标准库的 mem* 系列(memcpy、memset、memcmp、memchr)就是二进制安全的。
这直接催生了 Redis 的核心数据结构 SDS(Simple Dynamic String),我们将在第 7.3 节深入剖析。
# 4. 字面量栈数组
# 4.1 两条写法天壤之别
这是 C 语言最容易被误解的一道选择题:
char *p = "hello"; // ← 写法 A:指针指向字面量
char a[] = "hello"; // ← 写法 B:数组初始化为字面量的副本
2
它们在内存里的样子:
写法 A: char *p = "hello";
栈/数据段 .rodata 段 (只读)
┌─────────┐ ┌──┬──┬──┬──┬──┬──┐
│ p │──────────────│ h│ e│ l│ l│ o│ \0│
│ (8字节) │ └──┴──┴──┴──┴──┴──┘
└─────────┘ 如果你写 p[0]='H' → SIGSEGV
写法 B: char a[] = "hello";
栈 (局部变量 a)
┌──┬──┬──┬──┬──┬──┐
│ h│ e│ l│ l│ o│ \0│ ← 编译时把 rodata 的 "hello" 拷贝到栈上
└──┴──┴──┴──┴──┴──┘ 如果写 a[0]='H' → 安全,a 在栈上可写
2
3
4
5
6
7
8
9
10
11
12
13
14
汇编视角最能说明问题:
// test.c
void f() {
char *p = "hello";
char a[] = "hello";
}
2
3
4
5
# gcc -S -O2 test.c
f:
# 写法 A:把 rodata 的地址赋给 p
lea rax, .LC0 # .LC0 是 rodata 中 "hello" 的地址
mov QWORD PTR [rsp+8], rax # p = .LC0 的地址
# 写法 B:把 rodata 的 "hello" 拷贝到栈上
mov eax, DWORD PTR .LC0[rip] # 拷贝 "hell" (4 字节)
mov DWORD PTR [rsp], eax
movzx eax, WORD PTR .LC0[rip+4] # 拷贝 "o\0" (2 字节)
mov WORD PTR [rsp+4], ax
ret
.section .rodata
.LC0:
.string "hello" # "hello\0" 在只读段
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键洞察:
- 写法 A 的
p是一个指针变量(8 字节在栈),其值是.rodata段的地址 - 写法 B 的
a直接就是栈上的 6 字节数组,编译期把.rodata的 6 字节memcpy到栈上 - 写法 A 的
sizeof(p)= 8(指针大小),写法 B 的sizeof(a)= 6(数组大小含\0)
# 4.2 rodata去重池化揭秘
疑惑:同一个程序中多次写 "hello",会生成多份副本吗?
论证:
const char* s1 = "hello";
const char* s2 = "hello";
const char* s3 = "hello world" + 6; // 指向 "world"
2
3
编译器(GCC/Clang 默认开启 -fmerge-constants)会做字符串池化:
$ gcc -S test.c && grep LC test.s
leaq .LC0(%rip), %rax # s1 → .LC0
leaq .LC0(%rip), %rax # s2 → 同一个 .LC0!
leaq .LC0+6(%rip), %rax # s3 → .LC0 偏移 6 = "world"
# 只有一份 .LC0: "hello\0world\0"(合并 + 去重)
2
3
4
5
GCC 的字符串池化策略(gcc/varasm.c 源码逻辑):
所有字符串字面量 → 去重(相同字符串只存一份)
↓
如果 -fmerge-constants 开启 → 尝试前缀合并
例如 "hello" 和 "hello world" → 合并为 "hello world\0"
"hello" 指向偏移 0
"world" 指向偏移 6
2
3
4
5
6
陷阱:
char* p = "hello";
// 如果整个程序的字符串都被合并成一个巨大的 rodata 块,
// p 的"邻居"可能是一个完全不相关的字符串。
// 这时候如果 p 被当成可变 string 写了(UB),
// 破坏的不只是 "hello",还可能是其他字符串。
2
3
4
5
-fno-merge-constants 可以关闭去重,但一般不需要——你不应该写 rodata,所以去重对正常程序是纯收益。
# 4.3 修改字面量为何崩
char* p = "hello";
p[0] = 'H'; // 💀 未定义行为 → 大概率 SIGSEGV
2
这个崩溃的完整链路:
1. 编译期:"hello" 落在 ELF 的 .rodata 节
2. 加载期:.rodata 被 mmap 到进程地址空间,权限为 r-- (只读)
3. 运行时:p[0] = 'H' 翻译成 mov BYTE PTR [rax], 'H'
4. MMU 查页表:发现该页权限位没有 W(写)标志
5. CPU 触发 #PF (页错误),内核检查 VMA → 写只读页 → 非法
6. 内核发 SIGSEGV → 进程崩溃
2
3
4
5
6
为什么有的平台不崩?
在某些嵌入式平台(没有 MMU)上,.rodata 和 .text 没有页级保护,写字面量可能不崩,但仍然是 UB——另一个变量如果恰好被分配到同一物理地址,就被静默破坏了。
历史遗留:C 语言标准将字符串字面量的类型定义为 char[N](不是 const char[N]),这是为了兼容 1970 年代的 K&R 代码——当时连 const 关键字都没发明。C++ 修正了这一点:字面量类型是 const char[N],写它连编译都过不了。
// C++:
char* p = "hello"; // ❌ 编译错误:const char[6] 不能转 char*
const char* p = "hello"; // ✅
2
3
# 4.4 编译期常量折叠
更高级的优化:
const char* s = "hello" " " "world"; // 编译期拼接 → "hello world"
// ISO C 规定:相邻的字符串字面量自动拼接
2
这常用于跨行字符串:
const char* sql =
"SELECT id, name, amount "
"FROM transactions "
"WHERE date > '2024-01-01' "
"ORDER BY id DESC;";
// 等价于一个长字符串,没有运行时拼接开销
2
3
4
5
6
编译器还会做常量传播:
if ("hello"[0] == 'h') { // 编译期求值,"hello"[0] 直接换成 'h'
do_something(); // if ('h' == 'h') → 编译期优化成无条件调用
}
2
3
# 5. 缓冲区溢出根因分析
# 5.1 strcpy的死亡证明
strcpy 是我们将详细解剖的第一个"危险函数":
char* strcpy(char* dest, const char* src);
// 把 src 的字节(包括 \0)拷贝到 dest
// 前提:dest 有足够的空间
// 问题:谁保证了 dest 有足够空间?——没有人
2
3
4
溢出示例:
void handle_packet(const char* user_input) {
char filename[32];
strcpy(filename, user_input); // ← user_input 可能 1000 字节
open_file(filename);
}
2
3
4
5
溢出发生时栈帧的样子:
高地址
┌────────────────────────────────┐
│ 调用者的栈帧 │
│ ... 返回地址 ... │ ← 溢出可能覆盖这里 → 控制流劫持
├────────────────────────────────┤
│ 保存的 RBP │ ← 溢出可能覆盖这里
├────────────────────────────────┤ ← RBP
│ local int x │
├────────────────────────────────┤
│ filename[32] │ ← strcpy 从低地址向高地址写
│ ┌──┬──┬──┬──┬──┬──┬──┬──...──┐│
│ │ h │ e │ l │...│ 超 │ 出 │ 部 ││ ← 溢出!继续往上写
│ └──┴──┴──┴──┴──┴──┴──┴──...──┘│ 踩到栈上的其他数据
├────────────────────────────────┤ ← filename 起始地址
│ canary (8B) │ ← 编译器插入的金丝雀值
└────────────────────────────────┘
低地址
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
为什么 strcpy 必须被"开除":
| 属性 | strcpy | 现代替代 |
|---|---|---|
| 长度检查 | ❌ 无 | strlcpy (长度参数) |
| 截断行为 | ❌ 不截断 | snprintf (截断) |
| \0 保证 | ✅ 会写(如果 dest 够大) | strlcpy (始终保证) |
| C11 标准 | 保留但标注危险 | strcpy_s (边界检查接口) |
| 编译器警告 | -Wstringop-overflow | 大多数安全替代免警告 |
# 5.2 strcat叠加灾难
strcat 的问题比 strcpy 更隐蔽——它需要先找到目标字符串的 \0:
char* strcat(char* dest, const char* src);
// 1. 扫描 dest 找到 \0
// 2. 从那个位置开始,把 src 拷贝过去(包括 \0)
2
3
双重风险:
char buf[16] = "hello"; // buf = "hello\0" + 10 字节未定义
strcat(buf, " world of pain"); // 写入 17 字节(含 \0)
// "hello" = 5," world of pain" = 17 → 总计 22 字节 > 16
// 溢出 6 字节
2
3
4
更阴险的场景——未初始化缓冲区:
void log_it(const char* msg) {
char buf[256]; // ⚠️ 没有初始化!
strcpy(buf, "[LOG] "); // 写入 "[LOG] \0"
strcat(buf, msg); // 从 \0 继续追加,安全
// ... 但如果 buf 没初始化且后续代码跳过了 strcpy ...
}
2
3
4
5
6
组合灾难:
char buf[64];
strcpy(buf, argv[1]); // 危险1:无长度检查
strcat(buf, argv[2]); // 危险2:从 \0 追加,继续无检查
strcat(buf, ".config"); // 危险3:累积溢出
// argv[1] = 50 字节,argv[2] = 20 字节,总计 50 + 20 + 7 = 77 > 64
2
3
4
5
修复:用 snprintf 一次性构造,或者用显式传递当前长度的方式(第 7 章)。
# 5.3 sprintf格式炸弹
sprintf 在三个维度上都不安全:
int sprintf(char* str, const char* format, ...);
// 1. 不检查 str 的缓冲区大小
// 2. format 如果是用户可控的 → 格式字符串漏洞
// 3. 返回值是"写入的字节数",但溢出时返回值同样没有意义
2
3
4
经典格式字符串攻击:
void unsafe_log(const char* user) {
char buf[256];
sprintf(buf, user); // 💀 如果 user 是 "%x%x%x%x",会泄露栈内容
// 如果 user 是 "%n",会写任意地址
}
2
3
4
5
正确做法:
snprintf(buf, sizeof(buf), "%s", user); // user 只是一条数据,不是格式
sprintf 的返回值陷阱:
int written = sprintf(buf, "%s", some_string);
// 如果 some_string 太长导致溢出,written 的值是"如果 buf 够大会写入的字节数"
// 但这个值可能 > sizeof(buf) —— 你拿到的是"已写作废的数字"
2
3
对比 snprintf:
int needed = snprintf(buf, sizeof(buf), "%s", some_string);
// needed = 如果 buf 无限大会写入的字节数(不含 \0)
// 你可以用这个值判断是否发生了截断:
if (needed >= (int)sizeof(buf)) {
// 发生截断!some_string 的尾部没写进去
}
2
3
4
5
6
# 5.4 gets为什么被开除
gets 在 C11 标准中被正式移除——这在标准的演进中非常罕见:
// C99: gets 还在,但手册上写"永远别用"
// C11: gets 被删除,彻底不能用
char* gets(char* s); // ← 已从标准中移除
// 从 stdin 读一行,存到 s
// 问题:没有传入 s 的大小 → 无论多长的输入都往里写
2
3
4
5
6
传奇漏洞——Morris Worm (1988):
世界上第一个互联网蠕虫正是利用了 gets——fingerd 守护进程用 gets 从网络读取输入,攻击者发送超长字符串覆盖返回地址,跳转到 shellcode。结果:1988 年 11 月 2 日,互联网 10% 的机器瘫痪。
现代替代:
char buf[256];
fgets(buf, sizeof(buf), stdin); // ✅ 长度限制,永远只读 sizeof(buf)-1
// 注意:fgets 会把 \n 也读进去(如果缓冲区够大),你可能需要手动 strip
2
3
# 5.5 溢出后的栈帧惨状
回看第 1 章的案例,我们用一个"溢出攻击模拟"来展示溢出到底做了什么:
#include <string.h>
#include <stdio.h>
void innocent() {
printf("innocent: I should never be called!\n");
}
void victim(char* input) {
char buf[16];
strcpy(buf, input); // ← 溢出在此发生
printf("victim: buf = %s\n", buf);
}
int main() {
char payload[64];
memset(payload, 'A', 16); // 填满 buf
*(void**)(payload + 24) = innocent; // 覆盖返回地址 → 指向 innocent
victim(payload);
// 输出:victim: buf = AAAAAAAAAAAAAAAA
// innocent: I should never be called!
// ← 控制流被劫持了!
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
栈帧在 strcpy 前后的变化:
strcpy 前: strcpy 后(溢出):
┌──────────────────────┐ ┌──────────────────────┐
│ main 的栈帧 │ │ main 的栈帧 │
├──────────────────────┤ ├──────────────────────┤
│ 返回地址 = main+8 │ │ 返回地址 = &innocent │ ← 被覆盖!
├──────────────────────┤ ├──────────────────────┤
│ 旧 RBP │ │ 'AAAA' │ ← 被覆盖!
├──────────────────────┤ ├──────────────────────┤
│ buf[12..15] │ │ 'AAAA' │
│ buf[8..11] │ │ 'AAAA' │
│ buf[4..7] │ │ 'AAAA' │
│ buf[0..3] │ │ 'AAAA' │
└──────────────────────┘ └──────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
现代防护 (GCC 默认开启):
-fstack-protector-strong→ canary 检测-Wstack-protector→ 警告哪些函数被插了 canary- ASLR → 攻击者不知道
innocent的真实地址
但 canary 不是万能的——它只能检测"覆盖返回值的溢出"。如果溢出只覆盖了本地变量(没越过 canary),canary 检测不到。
# 6. strncpy 的陷阱
# 6.1 名为安全实为陷阱
strncpy 的名字让很多人误以为它是 strcpy 的"安全版",但真相是——strncpy 最初是为固定宽度的文件记录设计的,不是为 C 字符串安全设计的。
char* strncpy(char* dest, const char* src, size_t n);
// 从 src 拷贝最多 n 个字节到 dest
// 行为(关键!):
// 如果 strlen(src) < n:dest 的剩余字节全部填充 \0
// 如果 strlen(src) >= n:dest 的前 n 字节被覆盖,但 dest[n] 不写入 \0!
2
3
4
5
核心陷阱:strncpy 不保证 dest 是一个合法的 C 字符串(以 \0 结尾)。
# 6.2 不写\0的五种场景
char small[5];
/* 场景 1:src 正好等于 n */
strncpy(small, "hello", 5); // small = "hello" —— 没有 \0!
small[4] = 'o'; // strlen(small) 会读越界
/* 场景 2:src 大于 n */
strncpy(small, "hello world", 5); // small = "hello" —— 没有 \0!
/* 场景 3:忘加 \0 的惯用错误 */
char name[32];
strncpy(name, "John", 31); // 正确:strlen("John")=4 < 31,name 余下 27 字节都被填 \0
strncpy(name, "Doe", 3); // 错误:只覆盖前 3 字节,第 4 字节仍是 'n'!
// name = "Doe\0n\0\0..." → "Doen"??
/* 场景 4:你以为安全其实不安全 */
char username[64];
strncpy(username, user_input, sizeof(username));
// 还在心存侥幸 sizeof(username) = 64,如果 user_input 是 64 字节 → username 最后没有 \0!
// strlen(username) 会跑到 username[64] 之后,读越界
/* 场景 5:和 strlen 配合的灾难 */
char path[256];
strncpy(path, base, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0'; // 正确写法——手动保证末尾 \0
size_t len = strlen(path); // 现在安全了
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
正确使用 strncpy 的完整咒语:
strncpy(dest, src, dest_size - 1);
dest[dest_size - 1] = '\0';
2
但这样一来你已经在做 strlcpy 做的事了——多了两次操作且更易错。下面会讲 strlcpy 是最佳选择。
# 6.3 零填充的性能灾难
strncpy 最坑人的性能问题:
char buf[4096];
strncpy(buf, "hi", sizeof(buf)); // buf 的前 2 字节 = "hi",后 4094 字节全部填 \0
2
这意味着:每次拷贝一个短字符串,strncpy 都会把目标缓冲区的剩余空间全部置零。如果你在一个 4 KB 的缓冲区内频繁拷贝短字符串,性能会暴跌:
# strcpy 拷贝 4 字节
$ time ./strcpy_test # ~0.01s
# strncpy 拷贝 4 字节到 4KB 缓冲区
$ time ./strncpy_test # ~0.15s —— 慢了 15 倍!因为写了 4096 字节
2
3
4
5
什么时候用 strncpy:
- 写入固定宽度的二进制记录(这正是它的原始用途)
- 你已经明确知道 dest 是一个固定长度的结构体字段
什么时候绝不用 strncpy:
- 任何以
\0结尾的 C 字符串操作 - 不确定 dest 大小的情况
# 6.4 源码级行为解剖
strncpy 在 glibc 中的实现逻辑(简化):
char* strncpy(char* dest, const char* src, size_t n) {
size_t i;
for (i = 0; i < n && src[i] != '\0'; i++)
dest[i] = src[i];
for ( ; i < n; i++)
dest[i] = '\0'; // ← 零填充的根源
return dest;
}
2
3
4
5
6
7
8
注意这两个独立的 for 循环——第一个循环在 src 有 \0 时终止,第二个循环把剩余的 dest 全部置零。这就是为什么它不适合通用字符串拷贝。
与 strlcpy(BSD 风格)对比:
size_t strlcpy(char* dest, const char* src, size_t size) {
size_t srclen = strlen(src);
if (size > 0) {
size_t copy_len = (srclen >= size) ? size - 1 : srclen;
memcpy(dest, src, copy_len);
dest[copy_len] = '\0'; // ← 始终保证 \0 结尾
}
return srclen; // ← 返回 src 的完整长度,便于检测截断
}
2
3
4
5
6
7
8
9
关键区别:
strlcpy始终保证\0结尾(如果 size > 0)strlcpy不填充剩余空间——性能友好strlcpy返回值是 src 的完整长度,方便调用者检测是否发生截断
# 7. 安全字符串操作方案
# 7.1 snprintf一把梭
sprintf 是地雷,snprintf 是排雷工具——但你自己还得把它用对:
int snprintf(char* str, size_t size, const char* format, ...);
// 核心承诺:
// - 最多写入 size-1 个有效字符到 str
// - 始终以 \0 结尾(即使被截断)
// - 返回"如果 str 无限大会写入的字符数"(不含 \0)
2
3
4
5
snprintf 的"瑞士军刀"用法:
char buf[128];
int needed;
/* 1. 安全拼接路径 */
needed = snprintf(buf, sizeof(buf), "%s/%s/%s", base, dir, file);
if (needed >= (int)sizeof(buf)) {
// 发生了截断!路径 > 127 字节被丢弃
fprintf(stderr, "path too long: needed %d bytes\n", needed);
}
/* 2. 两次调用的经典模式:先预测大小,再分配 */
needed = snprintf(NULL, 0, "%s/%s", base, file) + 1; // +1 for \0
char* dynamic = malloc(needed);
snprintf(dynamic, needed, "%s/%s", base, file); // 确保不截断
/* 3. 连续追加(利用返回值追踪当前长度) */
char* p = buf;
size_t remain = sizeof(buf);
int n;
n = snprintf(p, remain, "[%d] ", level);
p += n; remain -= n;
n = snprintf(p, remain, "%s: ", module);
p += n; remain -= n;
n = snprintf(p, remain, "%s", msg);
// p += n; // 可选
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
snprintf 的暗坑:
glibc 2.0 之前的 bug:旧版本
snprintf不保证\0结尾。生产环境确保 glibc ≥ 2.1。Windows 的
_snprintf不兼容:_snprintf在截断时不保证\0结尾!始终用_snprintf_s或StringCchPrintf。性能:
snprintf要解析格式字符串,比memcpy慢——不要在高频路径用它拷贝简单字符串。格式字符串中的
%s如果参数是 NULL:行为未定义。始终保证传给%s的指针非空。
# 7.2 strlcpy与strlcat
BSD 系的 strlcpy/strlcat 是 C 语言社区公认的"最接近正确答案的字符串操作函数":
size_t strlcpy(char* dst, const char* src, size_t dstsize);
size_t strlcat(char* dst, const char* src, size_t dstsize);
// 语义:
// strlcpy:最多拷贝 dstsize-1 个字符到 dst,始终 \0 结尾
// strlcat:最多追加 dstsize-strlen(dst)-1 个字符,始终 \0 结尾
// 返回值:src 的完整长度(用于检测截断)
2
3
4
5
6
7
对比一图胜千言:
| 函数 | \0 保证 | 零填充 | 截断检测 | 性能 | 标准 |
|---|---|---|---|---|---|
strcpy | ✅(假设够大) | ❌ | ❌ | 快 | C89 |
strncpy | ❌(不保证) | ✅(严重) | ❌ | 慢 | C89 |
snprintf | ✅ | ❌ | ✅(返回值) | 中 | C99 |
strlcpy | ✅ | ❌ | ✅(返回值) | 快 | BSD |
strcpy_s | ✅ | ❌ | ✅(errno) | 快 | C11 Annex K |
strlcpy 使用示例:
char buf[64];
size_t needed = strlcpy(buf, user_input, sizeof(buf));
if (needed >= sizeof(buf)) {
// 截断发生!user_input 的后面部分被丢弃了
log_truncation(user_input, sizeof(buf));
}
2
3
4
5
6
但 strlcpy 不在 POSIX 标准中——它在 BSD 和 macOS 上原生支持,在 Linux 上需要自行实现或链接 libbsd:
# Linux 上使用 strlcpy
$ sudo apt install libbsd-dev
$ gcc -o prog prog.c -lbsd
2
3
或嵌入自己的实现(只有 4 行):
size_t my_strlcpy(char* dst, const char* src, size_t size) {
size_t srclen = strlen(src);
if (size) {
size_t len = srclen >= size ? size - 1 : srclen;
memcpy(dst, src, len);
dst[len] = '\0';
}
return srclen;
}
2
3
4
5
6
7
8
9
# 7.3 动态字符串与sds
Redis 的作者 antirez 设计了一套二进制安全的动态字符串 SDS(Simple Dynamic String),直接解决了 C 字符串的所有痛点:
// SDS 的内存布局(sdshdr5/8/16/32/64,根据长度自适应)
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; // 已用长度(不含 \0)
uint64_t alloc; // 分配的总容量(不含 \0 和 header)
unsigned char flags; // 头部类型标记
char buf[]; // 柔性数组,存放实际字符串 + \0
};
// sds 字符串 = buf 的首地址(兼容所有 C 字符串 API!)
2
3
4
5
6
7
8
SDS 如何根除 C 字符串的七大罪状:
| C 字符串问题 | SDS 如何解决 |
|---|---|
| O(n) 取长度 | len 字段 → O(1) |
| 缓冲区溢出 | 自动扩容,sdscat 之前检查容量 |
| 非二进制安全 | len 存实际长度,不依赖 \0 |
| 内存泄漏 | 与 Redis 的内存分配器配合,显式 sdsfree |
| 频繁分配 | 预分配策略:扩容时多申请一倍,减少 realloc |
| 碎片 | 惰性空间释放(sdstrim 只改 len 不改 alloc) |
| 与 C API 互操作 | buf 末尾始终有额外 \0,可以传给 printf |
SDS 的分配策略(核心性能优化):
// sds.c 中的扩容逻辑(简化)
sds sdsMakeRoomFor(sds s, size_t addlen) {
size_t avail = sdsavail(s);
if (avail >= addlen) return s; // 已有空间够用
size_t newlen = sdslen(s) + addlen;
if (newlen < SDS_MAX_PREALLOC) // 小于 1 MB
newlen *= 2; // 翻倍扩容
else
newlen += SDS_MAX_PREALLOC; // 大于 1 MB,每次加 1 MB
// realloc...
// 更新 alloc 字段...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
启示:如果你在 C 项目中大量操作字符串(解析协议、构建 JSON、日志拼接),实现一个简化版 SDS 是投资回报率最高的重构——50 行代码换来缓冲区溢出的零风险。
# 7.4 编译器内置防护
现代编译器提供了多层字符串安全检查,把它们全部打开:
GCC/Clang 编译选项:
# 基础防护(生产必须)
-O2
-Wall -Wextra
-Wformat=2 # 格式字符串漏洞检测
-Wformat-security # 格式字符串安全
-Wstringop-overflow=4 # 字符串操作溢出检测
-Warray-bounds=2 # 数组越界
-fstack-protector-strong # Stack canary
-D_FORTIFY_SOURCE=2 # 运行时检测(注入 __memcpy_chk 等)
-fsanitize=address # ASan(开发/测试环境)
2
3
4
5
6
7
8
9
10
_FORTIFY_SOURCE 是最强编译器级防线:
// 当编译时加上 -D_FORTIFY_SOURCE=2 时:
// strcpy(dest, src) 被替换为:
char* __strcpy_chk(char* dest, const char* src, size_t destlen) {
// 如果编译器能推断 dest 的大小(如 char dest[32]),
// destlen 在编译期就是 32
if (strlen(src) >= destlen) {
__chk_fail(); // ← 直接 abort,而不是溢出后让程序继续跑
}
return strcpy(dest, src); // 长度已检查,安全
}
2
3
4
5
6
7
8
9
10
11
效果对比:
#include <string.h>
void test(char* user) {
char buf[16];
strcpy(buf, user); // 不加 FORTIFY: 静默溢出
}
2
3
4
5
$ gcc -O2 -D_FORTIFY_SOURCE=2 -c test.c
# 编译器判断 buf 大小为 16,注入 __strcpy_chk(buf, user, 16)
# 运行时:如果 strlen(user) >= 16 → 直接 abort,不溢出
# 效果:溢出变崩溃,崩溃比静默错误好
2
3
4
# 7.5 静态分析与Sanitizer
AddressSanitizer (ASan):运行时在每次字符串操作前后插入"影子内存"检查:
$ gcc -fsanitize=address -g -o test test.c
$ ./test
=================================================================
==12345==ERROR: AddressSanitizer: stack-buffer-overflow
#0 0x... in strcpy
#1 0x... in handle_input test.c:42
Address 0x7fff... is located in stack of thread T0
This frame has 1 object(s):
[32, 48) 'buf' (line 40) <== Memory access at offset 48 overflows
this variable
2
3
4
5
6
7
8
9
10
ASan 的报告精确到哪个变量的哪个字节被溢出覆盖了。
静态分析工具:
# Clang Static Analyzer
$ scan-build gcc -c file.c
# Coverity / CodeQL (CI 集成)
# 自动扫描 strcpy/strcat/sprintf/gets 并标记为缺陷
2
3
4
5
生产环境检查清单:
# 1. 确保编译器防护全开
$ gcc -O2 -Wall -Wextra -Wformat=2 -D_FORTIFY_SOURCE=2 \
-fstack-protector-strong -Warray-bounds=2 ...
# 2. CI 中跑静态分析
$ scan-build make
# 3. 测试环境跑 ASan
$ gcc -fsanitize=address -g ...
# 4. 检查二进制安全特性
$ checksec --file=./binary
STACK CANARY : yes
FORTIFY : yes
NX : yes
PIE : yes
RELRO : full
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 8. ASCII到Unicode编码演进
# 8.1 ASCII的128个格子
ASCII (American Standard Code for Information Interchange, 1963) 用 7 bits 表示 128 个字符:
0x00-0x1F: 控制字符 (NUL, LF, CR, TAB, ESC...)
0x20: SPACE (空格)
0x30-0x39: '0'-'9' (十进制 48-57)
0x41-0x5A: 'A'-'Z' (十进制 65-90)
0x61-0x7A: 'a'-'z' (十进制 97-122)
0x7F: DEL (删除)
2
3
4
5
6
关键事实:
'A'和'a'的二进制差异只有 第 5 位(0x20)'0'不是数字 0,是 0x30(48)——'0' - 48 = 0- C 语言的
\0就是 ASCII 的第一个字符 NUL (0x00)
ASCII 时代的 C 代码:
char c = 'A'; // c = 65
c += 32; // c = 97 = 'a'(大小写转换)
if (c >= '0' && c <= '9') { // 判断数字
int digit = c - '0'; // '5' - '0' = 5
}
2
3
4
5
ASCII 对英语完美,但对 é(法语)、ß(德语)、ñ(西班牙语)、ø(挪威语)……128 个格子根本不够。
# 8.2 多字节编码战国时代
ASCII 不够用,各国各自扩展:
Latin-1 (ISO 8859-1):用满 8 bits→256 个字符,覆盖西欧拉丁字母。
GB2312 (1980, 中国):用 2 字节表示一个汉字:
字节 1: 0xA1-0xF7(高字节)
字节 2: 0xA1-0xFE(低字节)
例: "中" = 0xD6 0xD0
2
3
Shift_JIS (日本):另一种双字节编码。
EUC-KR (韩国):另一种双字节编码。
问题:同一个字节序列,在不同编码下是完全不同的字符:
// 一个字节序列:0xD6 0xD0
// GB2312 解释为: "中"
// Latin-1 解释为: "ÖÐ" (两个西欧字符)
// 程序怎么知道该用哪个编码?——不知道,除非你在"带外"传编码名
2
3
4
这就是"乱码"的根本原因——字节序列没有自描述编码的能力。后来又出现了 BOM (Byte Order Mark) 等带外标记,但始终治标不治本。
# 8.3 Unicode统一字符集
Unicode 的目标:给全世界每一个字符一个唯一的数字(码点,code point)。
U+0000 - U+007F: ASCII (完全兼容)
U+0080 - U+00FF: Latin-1 补充
U+4E00 - U+9FFF: 中日韩统一表意文字 (CJK,最常用的 ~20000 汉字)
U+1F600 - U+1F64F: Emoji 😀😁😂...
2
3
4
Unicode 码点范围:
| 范围 | 名称 | 容量 |
|---|---|---|
| U+0000 - U+FFFF | BMP(基本多语言平面) | 65536 个 |
| U+10000 - U+10FFFF | 辅助平面(16 个) | ~1,000,000 个 |
码点 ≠ 编码:Unicode 规定了"哪个数字代表哪个字符",但没有规定"怎么把数字写成字节"。这需要具体的编码方案——UTF-8、UTF-16、UTF-32。
# 8.4 UTF-8的变长编码艺术
UTF-8 是 Ken Thompson 在 1992 年设计的——对,就是那个写 Unix、B 语言的 Ken Thompson。它是 Unicode 最成功的编码方案:
| 码点范围 | UTF-8 字节数 | 字节模板 |
|---|---|---|
| U+0000 - U+007F | 1 | 0xxxxxxx |
| U+0080 - U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800 - U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 - U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
UTF-8 编码实例:
"A" (U+0041) → 0x41 (1 字节,与 ASCII 完全一样)
"é" (U+00E9) → 0xC3 0xA9 (2 字节)
"中" (U+4E2D) → 0xE4 0xB8 0xAD (3 字节)
"😀" (U+1F600)→ 0xF0 0x9F 0x98 0x80 (4 字节)
2
3
4
UTF-8 设计的精妙之处:
ASCII 完全兼容:任何 ASCII 字符串都是合法的 UTF-8。
strcmp、strstr、strchr在绝大多数场景下对 UTF-8 字符串同样工作。自同步:从任意字节开始,最多往前找 3 字节就能定位到一个字符的起始。数据损坏不会导致无限前向错误。
字节序无关:不需要 BOM 来标记大小端——UTF-8 天然没有字节序问题。
C 语言的适配性:
char*天然就是 UTF-8 字符串的载体——strlen返回的是字节数,不是字符数。
UTF-8 在 C 中的陷阱:
char* s = "你好"; // UTF-8: 0xE4 0xBD 0xA0 0xE5 0xA5 0xBD
strlen(s); // = 6 —— 2 个汉字 × 3 字节 = 6
// ☠️ 不是 2 个"字符"!
s[1]; // = 0xBD —— 这是"你"的中间字节
// ☠️ 不是一个完整的"字符"!
// 截断"你好"到 4 字节:
char buf[5];
memcpy(buf, s, 4);
buf[4] = '\0';
printf("%s\n", buf); // 输出"你"后面跟一个乱码
// 因为"好"被截成两半了
2
3
4
5
6
7
8
9
10
11
12
13
14
UTF-8 安全截断:
size_t utf8_truncate(char* s, size_t max_bytes) {
if (strlen(s) <= max_bytes) return strlen(s);
size_t len = max_bytes;
// 从截断点往前找,确保不在多字节字符的中间
while (len > 0 && (s[len] & 0xC0) == 0x80) {
len--; // 跳过连续字节(10xxxxxx)
}
s[len] = '\0';
return len;
}
2
3
4
5
6
7
8
9
10
# 8.5 C语言的wchar_t与char16_t
C 语言的宽字符类型试图在类型系统层面支持多字节字符:
#include <wchar.h>
wchar_t wc = L'中'; // 宽字符字面量,前缀 L
wchar_t ws[] = L"你好"; // 宽字符串字面量
size_t len = wcslen(ws); // = 2(在大多数平台上)
2
3
4
5
6
wchar_t 的可移植性噩梦:
| 平台 | wchar_t 大小 | 编码 | wcslen(L"中") |
|---|---|---|---|
| Linux/x86-64 | 4 字节 | UTF-32 | 1 |
| Windows/x86-64 | 2 字节 | UTF-16 | 1 |
| macOS/x86-64 | 4 字节 | UTF-32 | 1 |
| 某些嵌入式 | 2 字节 | UTF-16 | 1 |
结论:wchar_t 不能写可移植的代码。如果需要遍历 Unicode 字符,用第三方库(如 ICU、utf8proc)或 C11 的 char16_t/char32_t:
#include <uchar.h> // C11
char16_t u16 = u'中'; // UTF-16,始终 2 字节(在 BMP 中)
char32_t u32 = U'😀'; // UTF-32,始终 4 字节
char16_t s16[] = u"你好"; // UTF-16 字符串
char32_t s32[] = U"你好"; // UTF-32 字符串
2
3
4
5
6
7
生产建议:除非你在写 Windows API(必须 UTF-16),否则统一使用 UTF-8 + char*。这是 Linux、macOS、Web 的事实标准,也是 C 语言生态中路径阻力最小的选择。
# 9. 综合案例串讲
# 9.1 案例真相揭晓
回到第 1 章 route_log 崩溃,八个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
① "hello" 写在进程地址空间的哪里?为什么不能改? | 第 4.1/4.3:在 .rodata 段,权限 r--,写即 SIGSEGV |
② char arr[] 和 char *ptr 的区别? | 第 4.1:数组在栈/data 可写,指针指向 rodata 不可写 |
| ③ strcpy/strcat 为什么是定时炸弹? | 第 5.1/5.2:不检查边界,长输入直接溢出 |
| ④ snprintf 以为安全,坑在哪? | 第 7.1:Windows 的 _snprintf 不保证 \0;格式字符串漏洞 |
| ⑤ stack canary 怎么检测溢出? | 第 5.5:函数入口存随机值,返回前校验,不一致即 abort |
| ⑥ strncpy 加了长度参数就安全了吗? | 第 6.2:不保证 \0 结尾,零填充性能灾难 |
⑦ \0 结尾的设计给了 C 什么? | 第 3:极简实现、零成本抽象;代价是 O(n) 取长度、非二进制安全 |
| ⑧ 中文/emoji 在 C 里怎么处理? | 第 8:UTF-8 变长编码,strlen("你好")=6 不是 2 |
第 1 章案例的真正根因:
上游 sprintf 向 64 字节 buf 写入 70 字节
→ 溢出覆盖 main 的栈帧 → canary 被篡改
→ route_log 进入时 canary 被保存
→ 返回前 canary 校验失败 → __stack_chk_fail → SIGABRT
2
3
4
修复方案(按代价从小到大):
方案 A:消除上游溢出(治本)
// 上游代码改造
char buf[64];
snprintf(buf, sizeof(buf), "%s_%s_%s_%s", a, b, c, d);
// 即使部分截断,也不会溢出到相邻变量
2
3
4
方案 B:route_log 内部全面防御
void route_log(const char* service, const char* msg) {
char filepath[LOG_PATH_MAX];
// 用 snprintf 一次构造,避免多次 strcat 级联
int n = snprintf(filepath, sizeof(filepath),
"/var/log/bank/%s.log", service);
if (n >= (int)sizeof(filepath)) {
// 截断处理:记录告警,使用截断后的路径
syslog(LOG_WARNING, "filepath truncated: service=%s (len=%zu)",
service, strlen(service));
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
方案 C:生产级防御——FORTIFY + Stack Protector
# 编译时开启所有防护
gcc -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong \
-Wformat=2 -Wstringop-overflow=4 \
-o log_router log_router.c
2
3
4
方案 D:动态字符串替代静态缓冲区
// 不用固定长度缓冲区,而是按需分配
char* filepath;
asprintf(&filepath, "/var/log/bank/%s.log", service);
// asprintf 是 GNU 扩展,自动分配所需大小的内存
// ...
free(filepath);
2
3
4
5
6
# 9.2 一份字符串的一生
把 "hello" 从一个字节序列到最终生命结束的全过程串起来:
编译期
├─ gcc 把 "hello" 放进 ELF 的 .rodata 节
├─ 去重:"hello" 如果出现多次,合并为一个副本
└─ 常量传播:"hello"[0] 在编译期求值为 'h'
加载期 (execve)
├─ 内核 mmap .rodata 进进程地址空间,权限 r--
├─ 如果有 wchar_t 字面量,映射到对应的只读段
└─ 此时"hello"就在内存里了,但程序还没跑到它
运行期
├─ char *p = "hello";
│ └─ 栈上分配 8 字节指针,值 = rodata 中 "hello" 的地址
├─ char a[] = "hello";
│ └─ 栈上分配 6 字节数组,编译期把 rodata 的 6 字节 memcpy 到栈
├─ p[0] = 'H' → CPU 写只读页 → MMU 触发 #PF → SIGSEGV
└─ a[0] = 'H' → 正常写入栈,a = "Hello"
安全链(如果发生溢出)
├─ FORTIFY_SOURCE 在编译期注入 __strcpy_chk → 运行时长度检查
├─ ASan 在每次字符串操作前后检查影子内存
├─ Stack canary 在返回前校验栈完整性
├─ NX bit 阻止跳到栈上执行 shellcode
└─ ASLR 让攻击者无法预测函数/gadget 地址
退出期
├─ 栈上的 char a[] 随函数返回自动释放
├─ rodata 中的 "hello" 随进程退出一起被内核回收
└─ 堆上的字符串在 free 之后归还给 ptmalloc/内核
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
# 9.3 面试高频问题清单
1. char *p = "hello" 和 char a[] = "hello" 的区别?
指针 p 指向
.rodata只读段,不可修改;数组 a 在栈(局部)或数据段(全局),是字面量的副本,可以修改。sizeof(p) = 8(指针大小),sizeof(a) = 6(数组大小含\0)。
2. strcpy 和 strncpy 的区别?strncpy 安全吗?
strcpy不检查目标大小,溢出即灾难。strncpy加了长度参数但有两个致命陷阱:① 如果 src ≥ n,不保证\0结尾;② 如果 src < n,剩余空间全部填\0(性能灾难)。strncpy是为固定宽度记录设计的,不是通用的安全字符串拷贝工具。
3. 为什么 strcat 在循环里拼接字符串是 O(n²)?
每次
strcat都要从 dest 头部扫描找\0,循环 n 次拼接的总时间约 n(n-1)/2 步。修复:手动维护尾指针,或使用snprintf的返回值累积当前位置。
4. sprintf vs snprintf vs strlcpy 怎么选?
永远不用
sprintf。需要格式化的用snprintf(标准 C99,跨平台最好)。简单字符串拷贝用strlcpy(BSD 风格,始终\0结尾,返回截断信息)。绝对不用strncpy做字符串拷贝。
5. C 字符串为什么不能存 \0 字节?
\0被设计为字符串终止符。printf("%s")、strlen、strcpy等所有标准库函数都在第一次看到\0时停止。要存二进制数据(含\0),必须用mem*系列 + 显式长度,或 SDS 等二进制安全方案。
6. strlen("你好") 在 UTF-8 下返回多少?为什么?
返回 6。"你" = 3 字节 (0xE4 0xBD 0xA0),"好" = 3 字节 (0xE5 0xA5 0xBD)。
strlen统计的是字节数,不是字符数——因为 C 语言没有"字符"的概念,只有char。
7. \0 结尾和 Pascal 的前缀长度,各有什么优劣?
\0结尾:长度无上限、实现极简、指针就是字符串;代价是 O(n) 取长度、不能存\0。前缀长度:O(1) 取长度、二进制安全;代价是长度上限被前缀字段位宽限制(如 Pascal 的 1 字节前缀 → 最长 255 字符)。
8. 为什么 gets 被 C11 标准删除了?
gets不接收缓冲区大小参数,无法阻止溢出——这是 1988 年 Morris 蠕虫的直接原因。C11 正式移除,替代为fgets(buf, size, stdin)。
9. snprintf(NULL, 0, ...) 有什么用途?
用于先计算所需缓冲区大小:返回值是"如果 buf 无限大会写入的字节数"(不含
\0)。常见模式:snprintf(NULL, 0, fmt, ...)→ 拿到长度 →malloc(len+1)→ 第二次snprintf实际写入。
10. 如何检测 UTF-8 字符串是否被非法截断?
从截断位置往前检查每个字节的高位——如果碰到
10xxxxxx模式,说明当前字节是多字节字符的后续字节,需要继续往前找起始字节(11xxxxxx或0xxxxxxx),直到找到后再截断。详见第 8.4 节的utf8_truncate实现。
# 9.4 安全编码速查卡
永远别用(CVE 制造机):
| 函数 | 为什么 | 替代 |
|---|---|---|
gets | 无缓冲区大小 | fgets |
sprintf | 无大小 + 格式漏洞 | snprintf |
谨慎使用(手写防御代码):
| 函数 | 风险 | 防御 |
|---|---|---|
strcpy | 溢出 | 确保 strlen(src) < sizeof(dest) |
strcat | 溢出 + O(n²) | 维护尾指针 / 用 snprintf |
scanf("%s") | 溢出 | 用 %Ns 限制宽度 |
推荐使用(现代安全实践):
| 函数 | 适用场景 | 注意 |
|---|---|---|
snprintf | 格式化拼接 | Windows 用 _snprintf_s |
strlcpy | 字符串拷贝 | Linux 需 libbsd 或自行实现 |
strlcat | 字符串追加 | 同上 |
fgets | 读取用户输入 | 会保留 \n,需要 strip |
memcpy | 二进制数据 | 显式传长度,最安全的操作 |
asprintf | 动态分配 + 格式化 | GNU 扩展,需手动 free |
编译器防御编译选项:
gcc -O2 \
-Wall -Wextra \
-Wformat=2 \
-Wformat-security \
-Wstringop-overflow=4 \
-Warray-bounds=2 \
-D_FORTIFY_SOURCE=2 \
-fstack-protector-strong \
-fPIE -pie \
-o prog prog.c
2
3
4
5
6
7
8
9
10
60 秒诊断命令:
# 检查二进制内置了哪些安全特性
checksec --file=./binary
# 静态分析:扫描危险函数调用
grep -rn 'gets\|sprintf\|strcpy\|strcat' src/
grep -rn 'scanf("%[^n]' src/ # 潜在的无限 scanf
# ASan 跑测试
gcc -fsanitize=address -g -o test test.c
ASAN_OPTIONS=detect_stack_use_after_return=1 ./test
# 检查字符串字面量是否在只读段
readelf -x .rodata a.out | head -20
# 查看字符串池化的效果
objdump -s -j .rodata a.out | grep -A5 'hello'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
下一篇:12.文件IO缓冲与系统调用 —— 我们已经知道"字符串怎么写才安全",下一步进入 IO 层:
printf到底经过了几层缓冲才到磁盘?write和fwrite的效率差在哪?mmap零拷贝是怎么绕过内核缓冲区的?