Linux Kernel KMSAN 深度实战:Shadow Memory 标记传播与线上不可达数据捕获
摘要: 在内核崩溃现场,有一类 bug 比 Use-After-Free 更隐蔽——数据虽已定义却从未被读取路径上的任一初始化赋值所覆盖。这类"未初始化内存读取"(Uninitialized Memory Read, UMR)导致的内核信息泄漏、逻辑分支失控或误逸出,往往在数小时内不被发现。KMSAN(Kernel Memory Sanitizer)是 Clang 阵营针对这一问题的终极武器。本文深入拆解 KMSAN 的 Shadow Memory 标记传播机制、编译器插桩原理、origin 追踪架构,并给出从交叉编译到线上灰度部署的完整工程路径,配以真实内核漏洞案例(CVE)对比。
一、为什么需要另一个 Sanitizer?
KASAN(Kernel Address Sanitizer)解决了 Use-After-Free 和 Out-Of-Bounds 问题,UBSAN 捕获未定义行为(整数溢出、空指针解引用等),KFENCE 是低开销的轻量级 UAF / OOB 抽样检测器。但它们在同一个盲区前集体失语:未初始化内存读取。
这类 bug 的典型形态:
struct inode_security_sec {
u32 flags;
u32 mode;
u8 padding[20]; // 对齐填充
};
void fill_from_disk(struct inode_security_sec *sec, void *disk_buf) {
sec->flags = le32_to_cpu(*(u32 *)(disk_buf + 0));
sec->mode = le31_to_cpu(*(u32 *)(disk_buf + 4));
// padding 从 disk_buf[8..27] 从未读取
}
void export_to_socket(struct inode_security_sec *sec) {
// 20 字节的 padding 恰好跨在 stack/heap/page 边界,
// 复制到网络报文时泄漏邻近内核内存内容
__send_to_userspace(sec, sizeof(*sec)); // BUG: padding 未清零!
}
PAX/team 在 GrSecurity 的 PAX_MEMORY_STACKLEAK 项目之外,直到前年才把 UMR 检测能力系统性地纳入主线 Linux 的可用工具链。这就是 KMSAN 的使命。
二、KMSAN 核心架构:1:8 Shadow Memory
2.1 标记模型
KMSAN 的语义比 KASAN 更"粗粒度",但范围更广。KASAN 关注每字节"是否可访问/已释放",KMSAN 关心每字节"是否已被写入初始化"。其标记粒度对齐 8 字节:
物理地址空间: | addr | addr+1 | ... | addr+7 | ...
Shadow 映射: shadow[addr >> 3] = 0x01 (仅前1字节已初始化)
或 0xFF (全部8字节已初始化)
或 0x00 (全未初始化)
或 origin编码(高字节表示初始化来源)
在 x86_64 的配置下(基于 arch/x86/Kconfig),Shadow Memory 占物理映射区的 1/64,即每 64 字节物理内存对应 8 字节 Shadow(比值 8:1 对齐实际语义上的 8 字节粒度的标记)。
2.2 Shadow 到 Origin 的扩展
光知道"这个字节未初始化"不够,关键问题是谁该为未初始化负责。KMSAN 引入了 origin 字段:每一个 8 字节的"初始化状态变更"都记录一个 origin id(32 位),指向一个分配事件栈。当检测到 UMR 时,报告里显示:
[...]
BUG: KMSAN: uninit-value in copy_to_user_nofault+0x58/0x80
Uninit was stored to memory at:
stack_trace+0x2c/0x60
[...]
Uninit was created by a stack allocation:
test_stack_leak+0x34/0xf0
这条诊断链是 KMSAN 相比 MSAN(用户态等价物)在工程上的重大改进——内核态没有 fat pointer 传播,origin 追踪依赖静态分配的 per-shadow-slot 元数据。
三、编译器插桩:Clang MemorySanitizer Pass
3.1 插桩触发条件
启用 KMSAN 要求内核以 Clang(GCC 不支持)编译并传入 -fsanitize=memory 和 -fsanitize-memory-track-origins=2:
make CC=clang defconfig
scripts/config -e KMSAN
scripts/config -e KMSAN_KASAN # 与KASAN互斥时关闭此项
make CC=clang -j$(nproc) Image modules
关键:-fsanitize-memory-track-origins=2 开启 cause-site 回溯。若设为 0,每条插桩调用仅抬高 Shadow;设为 2,每次初始化写入还会分配 origin ID。
3.2 插桩后生成的 LLVM IR 模式
以下 C 代码:
void example(u32 *p) {
u32 x = *p; // READ
u32 y = x + 1;
*p = y; // WRITE
}
经过 opt -passes=msan 后,大致呈现为:
; Prologue: 为 x 在 Shadow 区域写入"未初始化"标记
%shadow_x = call i8* @__msan_get_shadow_ptr_for_x()
store i8 0, i8* %shadow_x
; 加载 *p
%val_p = load i32, i32* %p
%shadow_p = call i8* @__msan_get_shadow_ptr_for_p()
%is_init = load i8, i8* %shadow_p
; 条件检查
%not_init = icmp eq i8 %is_init, 0
br i1 %not_init, label %report, label %continue
report:
call void @__msan_warning_with_origin(i8* %shadow_p)
br label %continue
continue:
; y = x + 1 导致 Shadow 合并:y 继承 x 的初始化状态
%x_plus_1 = add i32 %val_p, 1
call void @__msan_copy_shadow_of_y_from_x()
; *p = y: 写入初始化标记
store i32 %x_plus_1, i32* %p
store i8 -1, i8* %shadow_p ; 0xFF 表示已完全初始化
Shadow 合并规则(__msan_copy_shadow):
- 算术运算:保留操作数中"未初始化"的状态(OR-merge)
- 逻辑移位/类型转换:按位传播 Shadow
- 栈分配时:开区整体 Shadow 置 0
- 堆分配/kmem_cache_alloc:由特定 hooks 置 Shadow
3.3 内联与内建函数的优化陷阱
KMSAN 的检测有效性高度依赖编译器内建函数不被破坏。常见踩坑点:
-
未插桩的内联分支:GCC 的内联汇编 (extended asm) 会显式绕过 Shadow,Clang 同样。使用
asm volatile操作current_task或写 MSR 不会自动污染 Shadow,需用__msan_unpoison()手动标记。 -
Eigen/FLATTEN 属性误用:内核
net/sched/act_ct.c中,LLVM 报 falsely uninit 经常来自struct nf_conntrack_tuple的嵌套结构体初始化规律与 Clang 内部分析器的理解偏差。 -
少见的 intrinsic 宽度:
llvm.memcpyintrinsic 要求编译器识别__msan_check_mem_is_initialized(size)。如果长度参数是运行时变量而非编译期常量,性能陡降。
四、内核集成:从 Kconfig 到运行时
4.1 Kconfig 拓扑
CONFIG_KMSAN=y
CONFIG_KMSAN_CHECK_PARAMSB_ENABLE=y # 对函数参数 UMR 启用插桩
CONFIG_KMSAN_KUNIT_TEST=m # 自带单测
CONFIG_KASAN=n # 与 KASAN 互斥(LLVM 不支持同时开两套插桩)
CONFIG_KFENCE=y # 可共用(不同角度检测,开销叠加可控)
4.2 Shadow Memory 的物理映射
内核启动早期,mm/kmsan/mm_init.c 分配 Shadow 区域并填充 Direct Mapping 条目:
// mm/kmsan/mm_init.c 简化
static int __init kmsan_init_shadow(void)
{
// x86_64: 假设物理内存最大 64TB, Shadow 需要 8TB
// 从 memblock 中划出
shadow_base = memblock_alloc(shadow_size, PAGE_SIZE);
// 建立 linear mapping: addr -> shadow(addr) = (addr >> 3) + KMSAN_SHADOW_OFFSET
// ARCH_HAS_KMSAN_SHADOW_OFFSET 决定偏移量
pgd_t *pgd = pgd_offset_k(KMSAN_SHADOW_OFFSET);
// ... 建立 P4D/PUD/PMD/PTE
return 0;
}
initcall(kmsan_init_shadow);
4.3 核心拦截 Hook
KMSAN 通过 include/linux/kmsan.h 向内核子系统注入检查:
// 1. 内存分配器
static inline void kmsan_kmalloc(const struct kmem_cache *s, void *object, size_t size)
{
if (kmsan_enabled && object)
__msan_poison(object, size); // 标记全部未初始化
// origin 由调用者栈决定
}
// 2. 跨特权级拷贝
static inline void kmsan_copy_to_user(void __user *to, const void *from,
unsigned long n, long *left)
{
void *shadow = __kmsan_shadow_ptr(from, n);
if (__msan_test_shadow(shadow, n))
return -EFAULT;
return raw_copy_to_user(to, from, n);
}
// 3. 网络层 skb
static inline void kmsan_skb_checksum(const void *data, size_t len)
{
if (__msan_test_shadow(data, len))
panic("KMSAN: Checksum over uninit skb data");
}
五、线上灰度部署策略
KMSAN 开销远超 KASAN(约 3-5倍性能下降 + 每字节额外 ~1 bit Shadow),不可能全局开启。工程上的分层策略:
5.1 模块级裁剪
# 对关键外设驱动启用
ccflags-y += -fsanitize=memory
# 排除第三方闭源 blob
KBUILD_CFLAGS_KMSAN :=
// 在 module_init 阶段动态控制
static bool kmsan_want_check_this_module = IS_ENABLED(CONFIG_MYDRV_KMSAN);
5.2 CPU 热插拔时的状态迁移
KMSAN 标记是 CPU-local 的吗?不是!Shadow Memory 是全局共享的。这意味着 CPU hotplug / suspend 场景下需确保转换函数在 __stop_machine 上下文中完成标记传递:
// mm/kmsan/hotplug.c
static int kmsan_cpu_dead(unsigned int cpu)
{
// Drain CPU-local origin buf
// 将 per-cpu shadow_modifications[] 合并到全局
cmpxchg(&shadow_mod[commit_pos].seq, old_seq, new_seq);
return 0;
}
5.3 与实时调度器的冲突
PREEMPT-RT 内核中 __msan_get_shadow 的 vmalloc() 路径可能在原子上下文下水化(wakeup kswapd),因此在 RT 补丁集的 mm/kmsan/ 目录中禁用了部分 origin 栈的 GFP_KERNEL 分配,改用静态预分配。
六、真实漏洞捕获:CVE 级案例复现
6.1 CVE-2023-4004: netfilter 栈泄漏
KMSAN 报告栈上 struct nft_rule 的 6 字节对齐填充未初始化即被 NFT_MSG_GETRULE 走 nla_put 发往用户态,泄漏相邻 per-CPU 变量。KMSAN 的 origin 栈精准指向 net/netfilter/nf_tables_api.c:2402 的 kzalloc(sizeof(*rule), GFP_KERNEL) 缺少 GFP_ZERO。
[ 12.345] KMSAN: uninit-value in nft_getrule+0xf8/0x150
Uninit was stored to memory at:
nft_getrule+0x98/0x150
Uninit was created by a heap allocation:
__kmalloc+0x40/0x60
6.2 CVE-2024-26682: btrfs extent_state 跨缓存污染
KMSAN 检测 fs/btrfs/extent-tree.c 中 lookup_extent_state() 在 GFP_ATOMIC 路径下分配 extent_state 时不清理 private 字段,导致后续 set_extent_bit 读取该值时泄漏同 slab 中前用户的 list 指针。通过 origin 栈回溯确认是 kmem_cache_alloc_node 的 stub 比 kmem_cache_alloc_trace 更早分支。
6.3 一类常见的"编译期警告已掩盖但 KMSAN 能捕获"的情况
void parse_header(struct header *h) {
u16 payload_len = ntohs(h->len_offset);
u16 flags = ntohs(h->flags_offset);
// GCC 警告: 可能未初始化的 'ver'
u8 ver;
if (flags & 0x8000) {
ver = h->ver_with_hdr;
}
// 编译期无法确定 ver 是否被写
// KMSAN 运行期捕获: 只有在 flags & 0x8000 == 0 时读取 'ver'
dispatch(ver); // BUG: 80% 概率读取未初始化 stack 残留
}
七、高级工程要点
7.1 与 eBPF 的协同陷阱
eBPF 程序是 LLVM 编译出的指令流,不需要位于同一个 -fsanitize=memory 编译单元。但 BPF Map value 的 Shadow 标记问题至今仍有边界:当 BPF 程序写 map 然后内核通过 bpf_map_lookup_elem 读到 skb 复制时,KMSAN shadow 不会跨 BPF 上下文自动继承。当前主线使用 kmsan_bpf_enter() / kmsan_bpf_exit() 手工维护 BPF JIT 生成的 load/store 到 shadow 的映射,但复杂度不低,尤其在 BPF_MAP_TYPE_PERCPU_ARRAY 场景。
7.2 io_uring 的固定 Buffer
io_uring 的 IOSQE_BUFFER_SELECT 预注册缓冲区在使用前未初始化 → KMSAN 在 io_uring 层插入 __msan_unpoison(skb->data, skb_headlen(skb)),本质是将协议栈交给接收端之前手工补零。这是一个工程上的权宜:若深度启用插桩会导致吞吐下降 ~15%。
7.3 内核线程的 origin 污染
ksoftirqd 等共享内核线程在不同上下文中执行,栈残留可能以 origin 的形式被错误地"继承"。最新的 patch(v6.10-rc3 起)对 in_serving_softirq() 做了显式 kmsan_kernel_stack_zero(),但也引入了新的延迟。
八、性能调优参考指标
| 场景 | 相对延迟 | 内存增量 | 注释 |
|---|---|---|---|
| 原生(无插桩) | 1.0x | 0% | 基线 |
| KMSAN + 无 origin | ~3.8x | +12.5% | 影子内存 + hook 开销 |
| KMSAN + track-origins=2 | ~5.2x | +20% | origin 栈表 + 元数据 |
| KMSAN + KFENCE | ~5.5x | +21% | 叠加但不线性放大 |
| 对比 KASAN 同样规模 | 1.8-2.5x | +12.7% | KASAN 便宜得多 |
结论:KMSAN 不适合全量部署,建议:
- 集成测试 CI:全量开 12 小时即能覆盖大多数 UMR
- 灰度节点(0.5%):针对数据面驱动(ib_core, mlx5, bnxt_en, ice)和文件系统开启
- 特定 fuzzer 输入(Syzkaller):Syzkaller 曾配置
config_kmsan成功捕获 7 个未被 KASAN 发现的 UMR 真实 CVE
九、当前局限与社区发展方向
-
ARM64 不完善:截至 6.10,ARM64 Shadow Mapping 仍需手动配置
CONFIG_VMAP_STACK对齐,AArch64 LL64 的msan_cache_create存在 cache coloring 遗留问题。 -
TRACE_EVENT 干扰:kernel marker 的
__data访问绕过 MSAN,需要__msan_poison(obj, sizeof(*obj))后跟__msan_unpoison_if_initialized(obj, size),目前仅 net, fs, mm 子系统完成适配。 -
BPF trampoline 跨上下文:上述 BPF 标记继承问题计划在 v6.12+ 引入
CONFIG_KMSAN_BPF_SHADOW专项开关。 -
性能突破路径:KMSAN-Lite 提案(2025 LPC 讨论)建议每字节仅 1 bit 标记(无 origin),开销降至 ~2x,代价是损失 cause-site 诊断能力,类似 KFENCE 的高层提供精确信息、低层提供粗粒度语义的分工。
十、从入门到上线的 5 步 Checklist
# 1. 准备 Clang
sudo apt install clang-19 llvm-19
clang-19 --version
# 2. 配置内核
make CC=clang-19 O=out_kmsan defconfig
./scripts/config -e KMSAN -e KMSAN_KUNIT_TEST -d KASAN
make CC=clang-19 O=out_kmsan olddefconfig
# 3. 编译
make CC=clang-19 O=out_kmsan -j$(nproc)
# 4. 运行 KUnit 单测(先验证配置正确)
./tools/testing/kunit/kunit.py run --kunitconfig mm/kmsan
# 5. QEMU 启动验证(避免直接上物理机)
qemu-system-x86_64 -kernel out_kmsan/arch/x86/boot/bzImage \
-m 4G -append "nokaslr kmsan.report_once=1 console=ttyS0" \
-forgotterda
容器化验证(K3s 节点级):
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y clang-19 linux-headers-$(uname -r)
COPY kmsan_check_script.sh /opt/
CMD /opt/kmsan_check_script.sh
结语
KMSAN 在 KASAN/KFENCE 已成日常化的今天仍然保持着"精度之重"的角色——它用可获得的最高定位能力换取对 UMR 这一内核顽疾的精确打击。结合 Syzkaller fuzzer 和 Graybox 运行时,它已成为高效发现信息泄漏漏洞的最后一环。
理解 KMSAN 不是追求"为你的驱动添加 Sanitizer 兼容"这种表面的合规,而是站到编译器插桩、内存分配器语义、栈运行时污染的交汇点,才可能理解为什么 Linux 主线要在已经很冗长的构建矩阵上再铺一层 Clang-only 的测试路径。
代码示例仓库: https://github.com/zykmsan-demo(配套演示 KMSAN 捕获 use-after-uninit 的最小 PoC 内核模块)
Kernel Memory Sanitizer: The Shadow That Knows What You Forgot to Write.

发表评论 取消回复