1. 编译期插桩:把运行时检查编进代码
一句话总结: Sanitizer 不靠静态分析猜问题,而是在编译期往代码里插入检查指令,用影子内存与元数据在运行时精确捕获越界、竞争与未定义行为。
静态分析能发现一部分缺陷,但它必须保守:无法证明安全的代码只能放过。Sanitizer 换了一条路——编译期插桩(compiler instrumentation) 在每次访存、每次原子操作前后插入检查代码,配合影子内存(shadow memory)记录每个字节的状态,从而在运行时精确判定问题。
代价是内存与时间开销:ASan 通常让程序慢 2 倍、内存涨 23 倍;TSan 慢 515 倍。因此 Sanitizer 主要活在测试与 CI 里,而不是生产环境。
# 一次编译,开启最常见的组合
clang++ -fsanitize=address -g -O1 -fno-omit-frame-pointer app.cpp -o app
./app # 一旦触发,打印符号化的调用栈
2. AddressSanitizer:影子内存与红区
ASan 的核心是影子内存(Shadow Memory) 与红区(Redzone) 两套机制。
影子内存把程序地址空间按 8 字节粒度映射到一个字节的「状态字节」:0 表示 8 字节全部可访问,1~7 表示前 N 字节可访问,负数(如 0xfa)表示整块不可访问(堆红区)、0xfd 表示已释放、0xf1 表示栈红区。
应用地址 0x7fff8000 影子地址 0x20002000
每 8 字节应用内存 → 1 字节影子 (偏移 = addr >> 3 + offset)
可访问: [xx xx xx xx xx xx xx xx] → shadow = 0
前 3 可用: [xx xx xx -- -- -- -- --] → shadow = 3
堆红区: [-- -- -- -- -- -- -- --] → shadow = 0xfa
已释放: [-- -- -- -- -- -- -- --] → shadow = 0xfd
每次访存被插桩成一段检查:算出影子地址、读状态字节、判断访问范围是否越界。为了性能,编译器用内联快路径:若影子字节为 0 则直接访存,否则调用慢路径报告错误。
// 源码
int a[10];
a[i] = 1;
// 插桩后(概念示意)
__asan_check((char*)&a[i], sizeof(int));
a[i] = 1;
堆对象周围会插入红区,malloc 被替换成 ASan 版本,分配时多留一段并标记影子;free 后整块标为 0xfd 并放进隔离区(quarantine),于是释放后使用(use-after-free) 立刻被抓到。
# 典型输出
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
READ of size 4 at 0x... thread T0
#0 0x... in main app.cpp:12:5
0x... is located 0 bytes after 40-byte region [0x...,0x...)
allocated by thread T0 here:
#0 0x... in malloc
#1 0x... in main app.cpp:9:9
2.1 编译选项与调优
| 选项 | 作用 |
|---|---|
-fsanitize=address | 开启 ASan |
-fsanitize-address-use-after-scope | 检测栈变量出作用域后使用(默认随 -fsanitize=address 在 Clang 中开启) |
ASAN_OPTIONS=detect_leaks=1 | 打开泄漏检测(Linux 默认开) |
ASAN_OPTIONS=quarantine_size_mb=256 | 调大隔离区,抓更晚的 use-after-free |
ASAN_OPTIONS=detect_stack_use_after_return=1 | 检测函数返回后使用栈变量 |
-fsanitize=address -static-libasan | 静态链接 ASan 运行时 |
# 关闭某函数的插桩,用于隔离第三方代码
__attribute__((no_sanitize("address"))) void hot_path(void) { /* ... */ }
2.2 编译期与运行期的分工
ASan 是编译期与运行期协作的产物,理解分工才能解释它的行为:
| 阶段 | 职责 |
|---|---|
| 编译期 | 插入影子内存检查、把 malloc/free 换成 __asan_*、给全局变量加红区、记录全局变量的元信息 |
| 链接期 | 链接 ASan 运行时(libclang_rt.asan),提供 __asan_init 与错误报告器 |
| 运行期启动 | 把影子内存区域 mmap 成只读元数据区,初始化全局变量红区 |
| 运行期执行 | 命中检查失败时调用 __asan_report_*,打印并 abort |
影子内存的地址映射有平台差异:x86-64 Linux 用 addr >> 3 + 0x7fff8000,AArch64 用不同的偏移。偏移是固定的,因此ASan 要求虚拟地址空间足够大——这也是它在 32 位或受限容器里容易失败的根因(ASAN_OPTIONS=... 报 shadow memory range interleaves with an existing memory mapping)。
# 影子内存映射失败时,常见解法
ASAN_OPTIONS=detect_leaks=0:verbosity=1 ./app # 看映射日志
# 或在容器里放开 vm.mmap_rnd_bits
sysctl -w vm.mmap_rnd_bits=28
3. TSan、MSan 与 UBSan
3.1 ThreadSanitizer:happens-before 图
TSan 检测数据竞争(data race):两个线程访问同一内存、至少一个是写、且之间没有同步。它维护一个happens-before 图,在每次原子操作与同步原语(mutex、futex、atomic)处更新偏序关系。
int x = 0; // 全局
void t1() { x = 1; } // 线程 1
void t2() { int y = x; } // 线程 2 —— 与 t1 无同步 → 竞争
clang++ -fsanitize=thread -g -O1 racy.cpp -o racy
./racy
# WARNING: ThreadSanitizer: data race
# Write of size 4 by thread T1: #0 t1()
# Previous read of size 4 by main thread: #0 t2()
TSan 用影子单元(shadow cell) 记录每个内存位置的访问历史(线程 id、时钟向量、访问类型),开销比 ASan 大得多,但能给出双向的调用栈。
3.2 MemorySanitizer:未初始化读
MSan 用影子位(shadow bits) 逐位记录「这个字节是否已被初始化」。它拦截 malloc、系统调用与汇编边界,把未初始化位传播下去,一旦某未初始化值影响控制流或输出就报错。
clang++ -fsanitize=memory -fno-omit-frame-pointer -g uninit.cpp -o uninit
# WARNING: MemorySanitizer: use-of-uninitialized-value
MSan 要求整个程序(含 libc 的插桩版本)一起编译,否则未插桩的库会把未初始化位「洗白」,产生漏报。这也是它比 ASan 难落地的原因。
3.3 UndefinedBehaviorSanitizer:查 UB
UBSan 检查有符号溢出、除零、空指针解引用、越界移位、无效 bool 值、类型双关等未定义行为。它的开销最小,可以直接在生产构建里开:
clang++ -fsanitize=undefined -fno-sanitize-recover=all app.cpp -o app
| Sanitizer | 检测目标 | 典型开销 | 是否需全程序 |
|---|---|---|---|
| ASan | 越界、UAF、双重释放、泄漏 | 2x 时间 / 3x 内存 | 否 |
| TSan | 数据竞争、死锁 | 5~15x | 否 |
| MSan | 未初始化读 | 3x | 是 |
| UBSan | 未定义行为 | 1.2x | 否 |
3.4 组合与互斥关系
Sanitizer 并非都能共存,因为它们会争抢同一套运行期资源:
- ASan 与 TSan 互斥:都接管内存分配与影子内存,同时开启会链接冲突;
- ASan 与 MSan 互斥:影子内存布局冲突;
- ASan 与 UBSan 可共存:
-fsanitize=address,undefined,是 CI 的常用组合; - LSan(泄漏检测)随 ASan 附带,也可单独
-fsanitize=leak; - HWASan 是 ASan 在 AArch64 上的低开销变体,用标签(tag) 而非影子内存,内存开销降到约 1.1x,适合在真机上跑。
# 各自独立的构建目录,避免互相污染
clang++ -fsanitize=address,undefined -O1 -g -o app-asan app.cpp
clang++ -fsanitize=thread -O1 -g -o app-tsan app.cpp
clang++ -fsanitize=leak -O1 -g -o app-lsan app.cpp
4. 编译期安全加固
除了「运行时抓 bug」,编译器还能在生成的代码里加防护,抵御攻击面。这类技术与 安全 专题里的漏洞利用对抗直接相关。
4.1 CFI:控制流完整性
控制流完整性(Control-Flow Integrity, CFI) 给每个间接调用的目标打上类型标签,调用前校验标签是否匹配,从而挡住「篡改函数指针/虚表劫持控制流」的利用手法。
# Clang 的跨 DSO CFI
clang++ -fsanitize=cfi -fvisibility=hidden -flto -fuse-ld=lld app.cpp -o app
# MSVC 的控制流保护
cl /guard:cf app.cpp
实现要点:
- 每个函数分配一个类型哈希(type hash),间接调用点内联比较哈希;
- 只对「地址被取用」的函数生成检查,减少体积开销;
- 虚表指针在构造/析构期间易被劫持,需要额外的 vtable 校验(
-fsanitize=cfi-vcall)。
间接调用点插桩(概念):
if (f->type_hash != expected_hash) __cfi_slowpath(...);
f();
CFI 的三种典型形式:
| 形式 | 校验对象 | 说明 |
|---|---|---|
cfi-vcall | 虚函数调用 | 校验 vtable 指针与类型 |
cfi-icall | 间接函数指针调用 | 校验函数指针类型签名 |
cfi-nvcall | 非虚成员指针调用 | 校验成员函数指针 |
跨 DSO 的 CFI 需要 LTO 与可见性控制:类型哈希要在所有二进制间一致,因此导出符号表必须收敛(-fvisibility=hidden + 显式导出),否则不同 DSO 对同一类型算出不同哈希,合法调用被误杀。
4.2 其它加固手段
| 技术 | 编译选项 | 防护目标 |
|---|---|---|
| 栈保护 | -fstack-protector-strong | 栈溢出覆盖返回地址 |
| 栈隔离 | -fsanitize=safe-stack | 缓冲区溢出改写返回地址 |
| FORTIFY | -D_FORTIFY_SOURCE=2 -O2 | 已知大小的 memcpy/strcpy 越界 |
| 整数溢出检查 | -ftrapv / -fsanitize=integer | 有符号溢出 |
| 控制流保护 | -fcf-protection | ROP/JOP(CET 硬件支持) |
| RELRO | -Wl,-z,relro,-z,now | GOT 覆盖 |
| PIE | -fPIE -pie | 代码地址可预测 |
# 一份偏激进的加固构建
clang++ -O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong \
-fcf-protection=full -fPIE -pie \
-Wl,-z,relro,-z,now -Wl,-z,noexecstack app.cpp -o app
_FORTIFY_SOURCE=3 借助 __builtin_dynamic_object_size 能算出更多缓冲区的动态大小,把更多 memcpy 变成带检查的版本,代价是需要 -O2 以上优化级别。
5. 工程实践:把 Sanitizer 接进 CI
Sanitizer 的价值在持续集成里才充分释放。
5.1 分阶段策略
# .github/workflows/sanitizers.yml 节选
jobs:
asan:
steps:
- run: cmake -B build-asan -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1"
- run: cmake --build build-asan -j
- run: ctest --test-dir build-asan --output-on-failure
env:
ASAN_OPTIONS: "detect_leaks=1:abort_on_error=1:strict_string_checks=1"
tsan:
steps:
- run: cmake -B build-tsan -DCMAKE_CXX_FLAGS="-fsanitize=thread -g -O1"
- run: cmake --build build-tsan -j
- run: ctest --test-dir build-tsan --output-on-failure
要点:
- ASan + UBSan 合并一次跑,UBSan 开销小,能顺带抓 UB;
- TSan 单独一次,因为它不能与 ASan 共存(都想接管内存分配);
- 优化级别用
-O1:-O0会让部分栈上对象生命周期失真,-O2又会把 bug 优化掉; -fno-omit-frame-pointer保证调用栈可符号化。
5.2 抑制与误报
第三方库常有 ASan 报错但无法修。用抑制文件隔离:
# asan.supp
interceptor_via_fun:third_party_legacy_fn
leak:libcurl_internal
ASAN_OPTIONS=suppressions=asan.supp ./tests
# 或者用属性关闭单个函数
__attribute__((no_sanitize("address"))) void legacy(void);
5.3 性能与覆盖权衡
- 只在 CI 开,本地开发也建议至少跑 ASan,因为很多 bug 只在插桩下复现;
- 模糊测试(fuzzing)必须配 ASan:libFuzzer 靠 ASan 把「静默内存破坏」转成可复现崩溃;
- 发布构建开轻量项:
-fstack-protector-strong、-D_FORTIFY_SOURCE、-fcf-protection开销可忽略,应默认开启。
一句话:Sanitizer 把「内存与并发错误」从不可复现的偶发崩溃,变成 CI 里可定位、可断言的失败用例;编译期加固则把攻击面在生成代码时就收窄。两者一个抓正确性、一个堵漏洞,是现代 C/C++ 工程的标准配置。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。