Sanitizer 工具链全景:从 -fsanitize 到内核 KASAN/KFENCE
PS: 这部分内容由大学期间的笔记迁移而来、并经查证核对;用户态 sanitizer 本机实跑,内核态工具本机无法运行、以 kernel.org 官方文档为准。若有疏漏,欢迎 Issue 或 PR。
前面两篇我们已经把用户态的 ASan / UBSan / MSan / TSan 和 Valgrind 拆得很细了:shadow memory 怎么记账、JIT 解释和编译期插桩两条路线差在哪、五种 sanitizer 之间为什么互斥。但如果你只把目光停在「g++ -fsanitize=address 加个 flag」上,会漏掉一个更大的图景:sanitizer 不是用户态的专利,内核里也有一整套对应的工具,而且两者的设计取舍完全不一样**。
这一篇我们要做的,是把整张 sanitizer 工具链拉平了看。用户态的 -fsanitize=* 一边,内核态的 CONFIG_KASAN / CONFIG_KMSAN / CONFIG_KFENCE 一边,它们抓的是同一类 bug(越界、释放后使用、未初始化、数据竞争),但站在完全不同的约束下:用户态可以为了抓 bug 把程序拖慢 2~5 倍,内核态不行,内核一旦拖慢 5 倍整台机器就废了。所以内核这边演化出了「采样」这条路:KFENCE 用极低开销换「能在生产环境一直开着」,和 KASAN 那种「只能调试期开」的重型工具分层共存。
先把用户态这一侧收个口
在往内核走之前,先把用户态 sanitizer 的四个 flag 用真报告钉一下,后面好和内核做对照。详细的 shadow memory 原理和 Heartbleed 故事在上一篇已经讲透,这里只放最小可复现的代码和真实终端输出,方便对照「每一种 bug 对应哪个 flag」。
四个 flag 一句话分工:-fsanitize=address(ASan,越界/UAF/泄漏)、-fsanitize=undefined(UBSan,未定义行为)、-fsanitize=memory(MSan,未初始化读)、-fsanitize=thread(TSan,数据竞争)。
ASan:一次抓到三种错
堆越界、释放后使用、内存泄漏,ASan 一把全收。我们把三个错误分别写成最小例子(放一个程序里 ASan 会在第一个错误处 abort,后面两个看不到,所以拆开):
// uaf.cpp —— 释放后使用(use-after-free)
#include <cstdio>
int main() {
int* p = new int(7);
delete p;
printf("*p = %d\n", *p); // p 已 delete,悬空
return 0;
}用 g++ -std=c++20 -O0 -g -fsanitize=address -fno-omit-frame-pointer uaf.cpp -o uaf 编出来,跑出来:
=================================================================
==118313==ERROR: AddressSanitizer: heap-use-after-free on address 0x72c9e1de0010 at pc 0x5d222d6ed26f bp 0x7ffc31d299a0 sp 0x7ffc31d29990
READ of size 4 at 0x72c9e1de0010 thread T0
#0 0x5d222d6ed26e in main /tmp/sanit/uaf.cpp:6
...
SUMMARY: AddressSanitizer: heap-use-after-free /tmp/sanit/uaf.cpp:6 in main-g 让报告带上 uaf.cpp:5 这种源码定位,这是 ASan 能不能用的分水岭:没有调试符号,报告只剩一串地址,等于白报。栈上的越界它一样抓,换一个跨函数的栈缓冲:
// stack_oob.cpp —— 栈缓冲越界
#include <cstdio>
void fill(char* p) { // 跨函数,检测能跨栈帧
for (int i = 0; i <= 8; ++i) p[i] = 'A'; // 合法下标 0..7,8 越界
}
int main() {
char buf[8];
fill(buf);
printf("done\n");
return 0;
}同样的 flag 编出来跑:
=================================================================
==119120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x6ec9ef2f0028 at pc 0x5f38ab644200 bp 0x7fff6db78e20 sp 0x7fff6db78e10
WRITE of size 1 at 0x6ec9ef2f0028 thread T0
#0 0x5f38ab6441ff in fill(char*) /tmp/sanit/stack_oob.cpp:4
#1 0x5f38ab64429d in main /tmp/sanit/stack_oob.cpp:8
...
Address 0x6ec9ef2f0028 is located in stack of thread T0 at offset 40 in frame
#0 0x5f38ab644220 in main /tmp/sanit/stack_oob.cpp:6注意它不光告诉你越界,还告诉你「这块内存是 main 栈帧里 offset 40 的那个 buf」:栈红区(redzone)连栈上数组的归属都标出来了。这就是 shadow memory 的威力,上一篇详细拆过,这里不再展开。
内存泄漏走的是 ASan 自带的 LeakSanitizer(LSan),程序退出时扫一次:
// leak.cpp —— 忘记 delete
#include <cstdio>
int main() {
int* leak = new int(99);
*leak = 100;
printf("leak = %d (故意不 delete)\n", *leak);
return 0;
}=================================================================
==118322==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4 byte(s) in 1 object(s) allocated from:
#0 0x7c2b9dd2d341 in operator new(unsigned long) (/usr/lib/libasan.so.8+0x12d341)
#1 0x609649f361ba in main /tmp/sanit/leak.cpp:4ASan 的代价是实打实的:程序慢 2~5 倍、内存多 3~5 倍。所以生产构建一定要摘掉 -fsanitize=address,只在调试和测试期开。这条约束听起来无所谓,但到了内核那边,同样的「开销太大」直接催生出了完全不同的工具,这就是后面 KFENCE 的来历。
UBSan:专治未定义行为
ASan 管的是「这块内存能不能碰」,UBSan 管的是「这个操作本身合不合法」。有符号整数溢出、数组下标越界、空指针解引用、错误位移,这些在 C++ 标准里是未定义行为(UB),不一定会崩,但结果不可预测:
// ub.cpp —— 三种 UB
#include <cstdio>
#include <cstdint>
int main() {
int32_t big = 2147483647; // INT32_MAX
int32_t sum = big + 1; // (1) 有符号加法溢出 → UB
int arr[4] = {0,1,2,3};
int idx = 10;
int v = arr[idx]; // (2) 下标越界 → UBSan 的 bounds 检查
printf("sum=%d v=%d\n", sum, v);
return 0;
}用 g++ -std=c++20 -O0 -g -fsanitize=undefined ub.cpp -o ub(默认 recover,把所有 UB 都打出来再继续):
ub.cpp:6:13: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
ub.cpp:9:20: runtime error: index 10 out of bounds for type 'int [4]'
ub.cpp:9:9: runtime error: load of address 0x7fffed140f28 with insufficient space for an object of type 'int'
sum=-2147483648 v=0这里有个真实的坑要先提醒:UBSan 和 ASan 可以一起开(-fsanitize=address,undefined),很多人这么干,因为一个管内存一个管算术,互补。但 UBSan 默认「打印完继续跑」(recover),如果你希望碰到第一个 UB 就 abort(更接近线上行为),加 -fno-sanitize-recover=all。反过来,ASan 是碰到就 abort 的,改不了。
MSan:未初始化读,只有 Clang 有
MSan 抓的是「用了没初始化的值」,这是 ASan 抓不到的一类:内存合法、访问也合法,但值是垃圾。坑在于:MSan 只有 Clang 实现,GCC 压根不支持这个 flag:
// msan.cpp —— 用了没初始化的变量
#include <cstdio>
int main() {
int x; // 故意不初始化
if (x) // 拿垃圾值做分支判断 → MSan 抓
printf("x is truthy\n");
else
printf("x is zero\n");
return 0;
}GCC 直接报错:
$ g++ -std=c++20 -fsanitize=memory msan.cpp -o msan
g++: error: unrecognized argument to '-fsanitize=' option: 'memory'换成 Clang 就能编能跑(clang++ -std=c++20 -O0 -g -fsanitize=memory -fno-omit-frame-pointer msan.cpp -o msan):
==118932==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x58f3129f5677 (/tmp/sanit/msan+0xd7677)
...
SUMMARY: MemorySanitizer: use-of-uninitialized-value踩坑预警:MSan 有个硬限制,整个程序(包括它链接的所有库)都必须用 MSan 插桩编译。你直接
clang++ -fsanitize=memory链一个没插桩的libc++或第三方库,会爆出一堆假阳性,因为 MSan 把库返回的值都当未初始化。所以 MSan 在实际项目里很少用,通常要配合「用 MSan 重新构建整个 toolchain」才能跑干净。这一点上一篇讲过,这里强调一下,因为内核那边的 KMSAN 也有类似的「全链路插桩」要求。
至于 TSan(数据竞争),它和 ASan 互斥、开销 5~15 倍,专门抓并发 bug,讲并发那一卷的「并发程序调试技巧」已经拆透了,这里只标一下它在全景图里的位置,不重复。
现在问题来了:内核怎么办?
把用户态的四个 flag 记牢之后,接下来才是这一篇真正想讲的东西。内核也是 C 代码,也会越界、也会 UAF、也会有数据竞争,能不能直接把 -fsanitize=address 套到内核上?
答案是:能,而且内核确实这么干了,但代价大到你只能在调试时开。这就是 KASAN,Kernel AddressSanitizer。它的底层和用户态 ASan 是同一套(shadow memory + 编译期插桩),但内核有自己的约束:
影子内存要占内核虚拟地址空间的一大块。用户态 ASan 的影子内存是「进程地址空间的 1/8」,内核这边直接划走内核 VAS 的一大段(
KASAN_SHADOW_START到KASAN_SHADOW_END)。在 64 位内核上地址空间够大(128 TB),还能撑住;32 位就紧张得多,所以早期 KASAN 只能跑在 64 位上,直到 5.11 才有 Linus Walleij 给 ARM-32 做的精简版。整机的每一处内存访问都被插桩。内核不是一个进程,是所有进程共享的底层,一旦开 KASAN,整机性能直接塌方,这就是为什么
CONFIG_KASAN只用于调试内核,生产内核绝对不开。它要配特定的内存分配器。内核用 SLAB 或 SLUB 分配器,KASAN 要在分配器里埋红区、给释放的页打「投毒」标记(
KASAN_SANITIZE_*),才能在 UAF/OOB 时立刻逮到。这和用户态 ASan 拦截malloc/free是同一思路,只是换到了kmalloc/kfree。
源笔记里写「KASAN 适用于 x86_64 和 AArch64,4.x 及以上」,这个版本号要核一下。实际上 KASAN 是 Linux 4.0 合入主线(最初支持 x86_64),AArch64 跟进,5.11 才补上 ARM-32 的优化版。机制没错,但别记成笼统的「4.x」。
KASAN 长什么样(官方报告样式)
KASAN 报告长什么样?按 kernel.org dev-tools/kasan 文档的示例结构,把官方例子里的 kmalloc_oob_right 换成虚拟的 buggy_driver_write(字段和层级完全对应官方报告),大致是这样:
展开代码 (共 23 行)收起代码
==================================================================
BUG: KASAN: slab-out-of-bounds in buggy_driver_write+0x3e/0x60 [buggy]
Write of size 1 at addr ffff888006c42185 by task cat/1234
CPU: 0 PID: 1234 Comm: cat Tainted: G B
Call Trace:
dump_stack_lvl+0x49/0x63
print_report+0x171/0x486
kasan_report+0xb1/0x130
buggy_driver_write+0x3e/0x60 [buggy]
...
Allocated by task 1234:
kasan_save_stack+0x1e/0x40
__kasan_kmalloc+0x81/0xa0
kmalloc_trace+0x21/0x30
buggy_driver_init+0x2a/0x60 [buggy]
...
The buggy address belongs to the object at ffff888006c42180
which belongs to the cache kmalloc-8 of size 8
The buggy address is located 5 bytes inside of
8-byte region [ffff888006c42180, ffff88800642188)和用户态 ASan 的报告结构几乎一模一样:先说在哪炸(slab-out-of-bounds、越界写、发生在哪个驱动函数),再给分配栈(这块内存是谁、在哪一次 kmalloc 分出来的)。内核报告多了「属于哪个 slab cache(kmalloc-8)、在对象的第几字节」这种内核分配器专属的信息。看懂了用户态 ASan 报告,内核 KASAN 报告基本也能读。
全景对照表:用户态 ↔ 内核态
到这里把两边对齐,这张表是这一篇的核心:源笔记原是一张外部 PNG,我们用 Markdown 自己画。
| 抓的 bug | 用户态 flag | 内核工具 | 内核合入版本 | 能否上生产 |
|---|---|---|---|---|
| 越界 / UAF / 双重释放 | -fsanitize=address (ASan) | KASAN | 4.0(x86_64)/ 5.11(ARM-32 优化) | 否,仅调试 |
| 未初始化读 | -fsanitize=memory (MSan,仅 Clang) | KMSAN | 5.16 起可用补丁分支,6.1 起主线完整可用,仅 Clang 14.0.6+ + 仅 x86_64 | 否,开销巨大 |
| 未定义行为(溢出/越界/位移) | -fsanitize=undefined (UBSan) | UBSAN | 4.5 合入 | 部分检查可上(见下) |
| 数据竞争 | -fsanitize=thread (TSan) | KCSAN | 5.8 合入,基于采样 | 否,仅调试 |
| 内存泄漏 | ASan 自带 LSan | kmemleak / eBPF memleak | kmemleak 早已存在 | 谨慎,有误报 |
| 采样式内存错误 | (用户态无对应) | KFENCE | 5.12 | 可以,默认就常开 |
| 访问模式分析(非 bug 检测) | (无) | DAMON | 5.15 | 可以,就是为生产设计的 |
这张表里有几个一定要记牢的对应关系:
- ASan ↔ KASAN:同一个 shadow memory 思路搬到内核,代价是整机性能塌方,只能调试开。
- MSan ↔ KMSAN:都只认 Clang,都要全链路插桩,都开销巨大。KMSAN 官方文档明说「not intended for production use, because it drastically increases kernel memory footprint and slows the whole system down」。
- UBSan ↔ UBSAN:内核 UBSAN 在 4.5 合入,而且它的一部分检查(比如
CONFIG_UBSAN_BOUNDS)在现代发行版内核里默认开启,因为这部分开销很低,这是内核 sanitizer 里少数能「常驻」的。 - TSan ↔ KCSAN:注意 TSan 是编译期全插桩,KCSAN 不一样,它基于采样(watchpoint),开销可控,但相应的,它检测数据竞争靠的是「碰巧采到」,不是 TSan 那种「理论上一定检测到」。5.8 合入主线(google/kernel-sanitizers 仓库明说「in mainline since 5.8」)。
源笔记里把 KMSAN 标成「6.1 及以上版本」,这个版本号是对的,别记错。KMSAN 的补丁系列由 Google 的 Alexander Potapenko 维护了多年,直到 2021 年底都还只是分支补丁、未进主线(kernel.org 官方示例报告跑在打了补丁的 5.16.0-rc3+ 上,用的就是 google/kmsan 分支,不是主干);Google 官方仓库 (google/kmsan) 的 README 明确写着「Linux 6.1+ contains a fully-working KMSAN implementation which can be used out of the box」,即 6.1 起主线完整可用。所以 KMSAN 是这一批内核 sanitizer 里进主线最晚的。注意别把「5.16 补丁分支能跑」和「6.1 进主线」搞混,这是这类版本号最常见的误读。
KFENCE:把 sanitizer 搬上生产的关键一招
KASAN 的问题太明显了:只能调试开,但你公司线上跑的内核出了内存 bug 怎么办?你总不能拿一台生产机器换成开了 KASAN 的调试内核去复现,那样业务早就挂了。真正缺的是一个开销低到能一直开着的内存错误检测器。
这就是 KFENCE(Kernel Electric-Fence),Linux 5.12 合入主线。它的思路和 KASAN 完全不同,不再「检测每一次访问」,而是改成采样:
- KFENCE 维护一个固定大小的对象池(默认
CONFIG_KFENCE_NUM_OBJECTS=255,每个对象占 2 页:1 页放对象、1 页当守卫页 guard page,池里对象页和守卫页交错排布,所以每个对象页两边都是守卫页;默认配置下整个池约 2 MiB)。 - 内核的 slab 分配器(
kmalloc)会被一个采样定时器钓进 KFENCE 池:KFENCE 有个以毫秒为单位的采样间隔(启动参数kfence.sample_interval,默认可由CONFIG_KFENCE_SAMPLE_INTERVAL配),每个采样间隔里下一次kmalloc分配就被「钓」交给 KFENCE 来管。 - 一旦进了 KFENCE 池,这次分配就被放在两个守卫页之间,任何越界读写都会踩到守卫页,立刻触发 page fault,内核报出精确的错误和分配栈。
- 释放后,KFENCE 把这页标记成「不可访问」,再有人碰它就是 use-after-free,同样立刻报。
采样的代价是:绝大多数分配根本不经过 KFENCE,所以大部分 bug 它抓不到,你得跑足够长时间、让足够多的分配流经 KFENCE 池,才有机会逮到。但换来的是极低的开销(官方说接近零,实际生产负载几乎感知不到),于是它成了第一个能一直开在生产内核上的内存 sanitizer。事实上,只要架构支持、且开了 SLAB 或 SLUB,KFENCE 在很多发行版里默认就是开的。
源笔记原话「KFENCE 必须运行长时间,但开销足够低,甚至可以在生产环境中运行」,机制描述没错,我们补上版本号(5.12)和「采样」这个关键词,再强调一下「默认常开」这个工程意义。它取代的是更老的 kmemcheck(那个在 4.15 就被删了,因为开销太大、和 KFENCE 思路冲突)。
DAMON:另一条「采样」路线,但目标不是抓 bug
提到「采样」,顺带要把 DAMON(Data Access MONitor)讲一下,因为它和 KFENCE 在哲学上是同一类,不去全量跟踪,而是采样代表性样本。但 DAMON 不是 sanitizer,它不抓 bug,它监控内存访问模式:
- Linux 5.15 合入主线,目的是帮开发者(和内核自己)看清「进程到底在怎么访问内存」,从而优化布局、指导回收。
- DAMON 把目标进程的地址空间切成等大的区域,采样每个区域里的若干代表页,记录访问频次,形成直方图。区域如果是热点,就再细分,这种「智能放大」让它在超大地址空间上也能低成本运行。
- 内核组件是「生产者」(产出访问模式),用户态(或内核)是「消费者」。消费者甚至能根据访问模式反过来调
madvise()改内存属性,比如把确认冷的数据区建议内核换出。
DAMON 的接口有三个:用户态的 damo 工具(来自 awslabs/damo)、/sys/kernel/mm/damon/admin/ 下的 sysfs、以及给内核开发者的内核 API。旧的 debugfs 接口已废弃。它和 KFENCE 放在一起看,你会发现内核在 5.12~5.15 这一波,系统性地用「采样」补上了「全量插桩太贵」这个口子:KFENCE 抓 bug,DAMON 看模式,都能上生产。
三层防御:把工具按场景摆好
把用户态和内核态的 sanitizer 摆在一起,内存安全的工具链其实是个分层的防御纵深,每一层有不同的开销/覆盖权衡:
开发期:全量插桩,抓到为止
开发自测、CI、fuzzing 阶段,开销不是问题,覆盖最重要。用户态开 -fsanitize=address,undefined(再单独跑一轮 -fsanitize=thread),内核调试构建开 CONFIG_KASAN + CONFIG_KCSAN + CONFIG_UBSAN。这一层假设 bug 一定能被全量插桩逮到,代价是程序/整机慢几倍,只在非生产环境承受。
测试/准生产:采样插桩,长期运行
预发、灰度、长时间负载测试,不能接受整机塌方,但要跑足够久才能暴露罕见 bug。这一层用 KFENCE:采样、低开销、能一直开着,让成千上万的分配流经守卫页池,逮到那些「跑一万次才出现一次」的越界和 UAF。用户态这一层目前没有完全对等的东西(Valgrind 太慢、ASan 太重),所以内核这边 KFENCE 的工程价值特别突出。
生产:默认开启的轻量检查 + 事后分析
真正的线上内核,只开开销可忽略的检查:KFENCE(默认常开)、CONFIG_UBSAN_BOUNDS 这类轻量 UBSAN 子集、再加上 DAMON 做访问模式分析指导优化。出了事故靠事后工具:内核 oops 日志、kdump/crash 分析、eBPF 的 memleak-bpfcc 跟踪未释放的分配。这一层不再指望「当场抓 bug」,而是「留够证据,事后能查」。
这套分层就是为什么内核要同时养 KASAN 和 KFENCE 两个看似重复的工具,同一个 bug(比如 UAF),开发期用 KASAN 抓,生产期用 KFENCE 抓,工具不重复,场景不重叠。用户态目前只有第一层(开发期插桩)用得顺手,第二、第三层还没有 kernel 那么成熟的工具,这也是为什么「在 C++ 用户态里彻底搞定内存安全」比内核还难:内核好歹有 KFENCE 能兜底生产,用户态出了线上 UAF,经常只能等它崩了再去看 core dump。
顺带一提:静态分析和事后工具
除了上面这些运行时 sanitizer,内核和用户态都还有一组不靠运行、靠看代码或看日志的工具,源笔记里也提到了,这里收个尾,不展开:
- 静态分析:内核侧有
sparse、smatch、Coccinelle、checkpatch.pl,用户态有clang-tidy、cppcheck。它们不跑代码、开销为零,但只能抓「代码模式上明显有问题」的那类,抓不到运行时才暴露的 UAF/OOB。和 sanitizer 是互补不是替代,静态分析抓规范、sanitizer 抓运行时。 - 事后分析:内核 oops/panic 日志、
kdump/crash工具分析 dump、[K]GDB调试。这些是 bug 已经炸了之后的取证手段,和「提前抓 bug」的 sanitizer 不在一个阶段。
C++ 用户态的事后分析,在「动态内存管理」那一章我们用 -fsanitize=address 在退出时报泄漏见过一次,在「并发程序调试技巧」用 TSan 见过并发 bug 的事后定位。整个工具链是开发期 sanitizer → 生产期轻量检查 → 事后分析一条龙,缺哪一环,对应的那类 bug 就会在那个阶段反复咬你。
参考资源
下面这些资料,是Linux的,笔者因为目前工作主力机是Linux,也对Linux的了解更好一些。请Windows的朋友见谅!笔者后续会慢慢补充的!
- kernel.org: Kernel Address Sanitizer (KASAN) —— KASAN 机制、配置项与示例报告
- kernel.org: Kernel Memory Sanitizer (KMSAN) —— KMSAN 要求 Clang 14.0.6+,仅 x86_64,明确「not for production」
- kernel.org: Kernel Electric-Fence (KFENCE) —— KFENCE 采样机制、
CONFIG_KFENCE_NUM_OBJECTS、生产可用定位 - kernel.org: UndefinedBehaviorSanitizer (UBSAN) —— 内核 UBSAN 各子检查与开销
- kernel.org: Kernel Concurrency Sanitizer (KCSAN) —— KCSAN 基于 watchpoint 的采样竞态检测
- kernel.org: DAMON —— DAMON sysfs/schemes 接口与访问模式监控
- Clang: UndefinedBehaviorSanitizer —— 用户态 UBSan 各子检查清单
- Clang: MemorySanitizer —— MSan 全链路插桩要求与用法