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 的检测有效性高度依赖编译器内建函数不被破坏。常见踩坑点:

  1. 未插桩的内联分支:GCC 的内联汇编 (extended asm) 会显式绕过 Shadow,Clang 同样。使用 asm volatile 操作 current_task 或写 MSR 不会自动污染 Shadow,需用 __msan_unpoison() 手动标记。

  2. Eigen/FLATTEN 属性误用:内核 net/sched/act_ct.c 中,LLVM 报 falsely uninit 经常来自 struct nf_conntrack_tuple 的嵌套结构体初始化规律与 Clang 内部分析器的理解偏差。

  3. 少见的 intrinsic 宽度:llvm.memcpy intrinsic 要求编译器识别 __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

九、当前局限与社区发展方向

  1. ARM64 不完善:截至 6.10,ARM64 Shadow Mapping 仍需手动配置 CONFIG_VMAP_STACK 对齐,AArch64 LL64 的 msan_cache_create 存在 cache coloring 遗留问题。

  2. TRACE_EVENT 干扰:kernel marker 的 __data 访问绕过 MSAN,需要 __msan_poison(obj, sizeof(*obj)) 后跟 __msan_unpoison_if_initialized(obj, size),目前仅 net, fs, mm 子系统完成适配。

  3. BPF trampoline 跨上下文:上述 BPF 标记继承问题计划在 v6.12+ 引入 CONFIG_KMSAN_BPF_SHADOW 专项开关。

  4. 性能突破路径: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.

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部