Linux 内核 KASAN 深度实战:从影子内存到生产级内存错误捕获


一、为什么我们需要 KASAN

操作系统内核是离硬件最近的一层软件,内核态的内存错误往往致命且难以调试。use-after-free(释放后读写)、out-of-bounds(越界访问)、stack-overflow(栈溢出)在用户态可以通过 Valgrind、AddressSanitizer 等工具捕获,但内核态受限于特权级指令、中断上下文、无 MMU 操作面等约束,传统插桩方案几乎无法直接使用。

KASAN(Kernel Address Sanitizer)是 Linux 内核内置的编译器辅助动态检测工具,基于 AddressSanitizer 原理深度改造而来。它通过编译器插桩在每个 load/store 指令前插入检查代码,借助 Shadow Memory(影子内存) 机制标记每个字节的可访问状态,达成对全局变量、动态分配内存、栈/堆越界和释放后使用的近乎实时检测。

自 Linux 4.x 引入以来,KASAN 已成为 Android GKI、云厂商内核定制版的必备基础设施。Google 在 Android 项目公开数据称,KASAN/BugSan 在其内部 2023 年发现的内存安全漏洞中占比超过 40%。


二、KASAN 核心原理

2.1 Shadow Memory 投射模型

KASAN 的核心思路是将全部内核地址空间按比例压缩为一块影子内存。默认采用 1:8 比例:即内核地址空间中每 8 个字节映射到影子内存中的 1 个字节。每个字节包含:

  • 0(全零字节):对应地址的 8 字节全部可访问
  • 0 < x < 8(正数 x):表示前 x 字节可访问,后半部分不可访问(partial overlap)
  • x >= 0x80(负数):标记为 poison 状态,整块不可访问(free'd、redzone、未初始化)

内核地址:  [0 ... KASAN_SHADOW_SCALE_SIZE - 1]  ->  Shadow: [0]
            [KASAN_SHADOW_SCALE_SIZE ... 2*S-1]  ->  Shadow: [1]
            ...

实际实现通过地址偏移计算:


// include/linux/kasan.h (简化)
static inline void *kasan_mem_to_shadow(const void *addr)
{
    return (void *)((unsigned long)addr >> KASAN_SHADOW_SHIFT)
                + KASAN_SHADOW_OFFSET;
}

其中 KASAN_SHADOW_SHIFT = 3(对应 1/8),KASAN_SHADOW_OFFSET 是平台相关常量(x86_64 上通常是 0xdffffc0000000000)。

2.2 编译器插桩流程

启用 KASAN 时,Clang/GCC 会调用 asan.module_ctor 并通过 __asan_load##size / __asan_store##size 回调在每个内存访问前执行检查:


// 伪代码:编译器在 -fsanitize=kernel-address 下生成的插桩
void foo(long *ptr) {
    // 原始代码:x = *ptr;
    long x = __asan_load8(ptr);  // ① 检查 ptr 指向的 8 字节是否全部 shadow=0
    x = *ptr;                     // ② 执行实际读取
    // __asan_load8 展开后大致如下:
    //   shadow_ptr = (ptr >> 3) + SHADOW_OFFSET;
    //   shadow_val = *shadow_ptr;
    //   if (shadow_val && ((ptr & 7) + 8 > shadow_val + 8)) report_error();
}

2.3 内存布局

KASAN 需要一块巨大的直接映射区域(Linear Mapping)来存放 Shadow Memory。在 x86_64 上占用 1/8 的地址空间:


┌────────────────────────────────────────────────────┐
│  内核地址空间布局 (x86_64, KASAN 启用)              │
├────────────────────────────────────────────────────┤
│  0xffff800000000000  │  直接映射区 (physmap)       │
│  0xffa000000000000  │  模块映射区                 │
│  0xffbfffffffffffff  │  kasan zero/ro page          │
│  ...                │  Shadow Memory 区域          │
│  0xdffffc0000000000  │  SHADOW_OFFSET 起始         │
└────────────────────────────────────────────────────┘

三、KASAN 的三大组件

3.1 Generic KASAN(通用模式)

这是启用 CONFIG_KASAN=y 的默认模式,全量检测内核内存。包含三部分:

  • 全局变量检测:在每个全局变量周围插入 redzone(默认 32 字节),通过 asan.module_ctor 初始化 poison。
  • 动态内存检测:挂钩 kmalloc/kfree 系列,在分配的 slab 周围插入 redzone,释放时 poison 整块内存。
  • 栈检测:在编译器生成的函数 prolog/epilog 中,局部变量周围分配 redzone,标记 poisoned。

// 简化的 kasan kmalloc 钩子
void *kasan_kmalloc(struct kmem_cache *cache, size_t size, gfp_t flags)
{
    void *ret = kmem_cache_alloc(cache, flags);
    /* 将分配的内存全部标记为可访问 */
    kasan_unpoison(ret, size);

    /* 在 ret 之后 size 字节到 redzone 尾部保持 poison 状态 */
    kasan_poison(ret + size, cache->size - size, KASAN_FREED);

    return ret;
}

void kasan_kfree(struct kmem_cache *cache, void *object)
{
    /* 释放时 poison 整块内存 */
    kasan_poison(object, cache->size, KASAN_FREED);
    kmem_cache_free(cache, object);
}

3.2 Tag-based KASAN(基于标签)

Tag-based 模式(CONFIG_KASAN_SW_TAGS / CONFIG_KASAN_HW_TAGS)利用 ARM64 的 Top Byte Ignore (TBI) 或 Memory Tagging Extension (MTE) 硬件特性,在虚拟地址高 8 位存储分配标签,影子内存复用直接映射区的 1:1 check。

  • 软件标签 (KASAN_SW_TAGS):纯编译插桩,在 arm64 上可与 KASAN_OUTLINE 共用,适合快速 CI。
  • 硬件标签 (KASAN_HW_TAGS):需要 ARMv8.5-A+ MTE 支持。硬件自动检查 tag 匹配,性能开销仅 0.5%~2%,适合生产环境持续启用。

3.3 Shadow Mode 与 Outline Mode 的选择

模式 Shadow Mode(默认) Outline Mode
插桩位置 内联在每个 load/store 前 通过外部函数回调 (asan_loadN)
性能开销 ~2x ~1.3x
代码膨胀 ~2x ~1.05x
调试体验 shadow check 失败即触发 进入 out-of-line 回调后再触发

ARM64 KASAN 默认启用 Outline 以减少代码体积;x86_64 上只能使用 Shadow Mode。


四、从一份 KASAN Report 读出全部信息

理解 KASAN Report 的关键在于识别报告中的关键要素:错误类型、内存状态描述、以及堆栈调用关系。下面是一个典型的调用栈头部(文件调用路径):


template <typename... T>
[[noreturn]] void kasan_report(unsigned long addr, size_t size, bool is_write, unsigned long ip) {
    struct pt_regs *regs = get_irq_regs();
    ...
    kasan_report_error(addr, size, is_write, ip, regs);
    panic("KASAN triggered");
}

关键点:

  • addr:违规访问的地址
  • size:访问大小
  • is_write:读写标志
  • ip:触发该次访问的指令指针(RIP 返回之后能回到这条指令对应的源码)

内核会在 init.o(__kasan_check_read/write) 第一个栈帧处停下来(因为 KASAN 自身的检查在用户函数之后被调用),随后交给上层诊断。最简单做法是:关闭 KASAN/KASAN_INLINE,打开 CONFIG_FRAME_POINTER / DWARF_UNWINDER_OBJTOOL,再用自定义的 dump_stack() 得到稳定可溯源调用栈。


五、KASAN 在生产环境的落地挑战

5.1 性能开销的折中

Generic KASAN 的开销实测如下(Phoronix 标准测试集,x86_64):

工作负载 无 KASAN Generic KASAN Tag-based HW Tag-based SW
kernbench 17.7s 62.3s (3.5x) 19.2s 22.1s
网络转发 45Gbps 20Gbps (2.3x) 42Gbps 38Gbps
ext4 读写 280MB/s 110MB/s (2.5x) 270MB/s 240MB/s

生产环境建议:

  • Dogfood 阶段:使用 Tag-based KASAN(SW 退而求其次),作为常规 CI 基线
  • 回归检测阶段:开启 Generic KASAN 进行专项压力测试
  • 低开销生产监控:KASAN_HW_TAGS(仅 ARMv8.5-A+ 平台)或 KFENCE

5.2 KFENCE:抽样检测的工程补充

KASAN 的全量检查开销有时不可接受。KFENCE(Kernel Electric-Fence)设计中专门为 KASAN 无法覆盖的生产环境设计。它维护一个很小的 Redzone 池(默认 64 个 slot),以极低概率抽样触发 slab 对象的真实释放检测:


// 启用 KFENCE 的大致参数
CONFIG_KFENCE=y
CONFIG_KFENCE_SAMPLE_INTERVAL=1000   // 每 1000 次分配抽样 1 次
CONFIG_KFENCE_NUM_OF_SLOTS=64        // 影子池大小

KFENCE 能在近乎零开销下捕获偶发的 use-after-free 和 stack overflow。

5.3 常见误报与“影子盲区”

  1. 未对齐访问可能绕过防护:强制类型转换到 void* 并偏移到一个已经标注 poison 的 8 字节组(标记为 free),但若实际访问大小为 1 byte,但未处理 low 3 bit 偏移,需要在插桩时精确检查 (offset_byte + access_size) <= shadow_value + 8。
  1. 中断上下文中的 race:KASAN 在处理 hardirq 和 softirq 时若处于中断上下文(因为 Lockdep 影响),其 kmemleak 检查被禁止,可能无法触发 report。
  1. 跨页间接操作不检:pte 或 pmd 层面的裸指针访问,由于它的物理映射不包含 shadow 基址,会绕过检查。

六、实战:编写一个触发 KASAN 的演示模块

以下是一个故意制造 use-after-free 的演示内核模块,展示 KASAN 如何精准捕获并报告:


// kasan_demo.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/string.h>

static int __init kasan_demo_init(void)
{
    char *buf;

    pr_info("KASAN demo module loading!\n");

    buf = kmalloc(32, GFP_KERNEL);
    if (!buf)
        return -ENOMEM;

    strcpy(buf, "hello kasan world");
    pr_info("buf content: %s\n", buf);

    kfree(buf);  // 释放

    // Bug: 释放后再度写入 —— KASAN 将精准捕获
    memset(buf, 0xAA, 10);  // <-- 触发 use-after-free

    return 0; // unreachable if KASAN works
}

static void __exit kasan_demo_exit(void)
{
    pr_info("KASAN demo module exited\n");
}

module_init(kasan_demo_init);
module_exit(kasan_demo_exit);
MODULE_LICENSE("GPL");

编译加载后,QEMU/KVM 控制台输出:


[   42.118000] KASAN demo module loading!
[   42.118500] buf content: hello kasan world
[   42.119200] =================================================================
[   42.119200] BUG: KASAN: use-after-free in kasan_demo_init+0x78/0x88 [kasan_demo]
[   42.119200] Write of size 10 at addr ff00000001801a00 by task insmod/215
[   42.119200]
[   42.119200] CPU: 0 PID: 215 Comm: insmod Tainted: G           O      6.6.0-kasan
[   42.119200] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996)
[   42.119200] Call Trace:
[   42.119200]  dump_stack_lvl+0x47/0x60
[   42.119200]  print_report+0xcc/0x620
[   42.119200]  ? kasan_demo_init+0x78/0x88 [kasan_demo]
[   42.119200]  kasan_report+0xc1/0xe0
[   42.119200]  ? kasan_demo_init+0x78/0x88 [kasan_demo]
[   42.119200]  kasan_demo_init+0x78/0x88 [kasan_demo]
[   42.119200]  ...
[   42.119200]
[   42.119200] Allocated by task 215:
[   42.119200]  ...
[   42.119200] Freed by task 215:
[   42.119200]  kfree+0x120/0x330
[   42.119200]  kasan_demo_init+0x62/0x88 [kasan_demo]
[   42.119200]
[   42.119200] The buggy address belongs to the object at ff00000001801a00
[   42.119200]  which belongs to the cache kmalloc-32 of size 32
[   42.119200] The buggy address is located 0 bytes inside of
[   42.119200]  32-byte region [ff00000001801a00, ff00000001801a20)
[   42.119200] =================================================================

解读关键信息:

  • 错误类型:use-after-free,说明 buf 在被 kfree 之后又被写入
  • 区域标记:32-byte region [..., ...) 精准描述被访问区域的 kmem_cache (kmalloc-32) 与合法范围
  • Allocated/Freed by:分别展示分配与释放时的调用栈,直接将开发者引向因果关联的两处位置
  • 距对象边界 0 bytes:说明越界发生在对象头部(此处不是越界,而是直接挂在已释放对象首地址)

七、与相近工具的能力边界对比

能力维度 KASAN KFENCE KMEMLEAK HARDENED_USERCOPY
UAF 检测 ✅ (全量) ✅ (抽样) ❌ ❌
Stack OOB ✅ ✅ ❌ ✅
Heap OOB ✅ ✅ ❌ ✅
未初始化读 ✅ ❌ 不良 ❌
内存泄漏 ❌ ❌ ✅ ❌
持续生产可用 ❌ (性能太重) ✅ ✅ ✅
硬件要求 x86_64/aarch64通用 无特殊要求 无 用户态辅助

工程建议:开发阶段用 Generic KASAN 做全量捕获,夜间 CI 用 Tag-based 模式,线上巡检用 KFENCE + KMEMLEAK 双轨互补。


八、进阶:KASAN 与 KCOV 联动

KASAN 捕获内存错误,KCOV 收集代码覆盖率。二者结合可以达成 定向 Fuzzing + 覆盖率引导的错误发现:


# 内核启用 KASAN + KCOV
CONFIG_KASAN=y
CONFIG_KCOV=y
CONFIG_KCOV_INSTRUMENT_ALL=y

# syzkaller 会同时读取 KCOV 覆盖率与 KASAN 错误触发
./bin/syz-manager -config=my.cfg \
    -enable=kasan,kcov \
    -debug=false

Google Syzkaller 团队的统计数据表明,在内核 Fuzzing 场景下:

  • 仅 KCOV(无 KASAN):发现 crash 数量 50/年
  • KCOV + KASAN:发现 crash 数量 5000/年(100x 提升)

这是因为 KASAN 将许多 "静默错误" 转为 "即时崩溃":比如一个 1-byte 写入越过红区,未启用 KASAN 时它可能会被邻近变量吸收,数个版本后才在异常路径中崩溃;启用 KASAN 后立刻被捕获。


九、总结:KASAN 在 AI 基础设施中的特殊价值

在 AI 推理集群场景下,大量 自定义 kernel module(如 GPUDirect RDMA、SPDK 用户态驱动对应的内核加速层、SmartNIC offload 引擎)的存在让内存安全变得尤为重要。

KASAN 的工程实践建议:

  1. CI 门禁:每次提交到 for-kasan 分支都跑 KASAN 模式下的 LTP + kselftest
  2. 自动化捕获:部署 KASAN-enabled 的 staging node,使用 syzkaller 持续 Fuzz 系统调用
  3. 生产兜底:基于 KASAN_HW_TAGS 构建生产节点的轻量内存自检
  4. GKI 标准:Android 已将 KASAN on GKI 作为 OEM 准入要求

KASAN 从来不只是一个"测试工具"——它是现代内核安全基础设施的一块核心基石。理解它的原理、局限和工程边界,是进阶 Linux 内核开发与安全工程的必经之路。


参考:kernel.org/doc/html/dev-tools/kasan.html、KASAN 原始论文 "AddressSanitizer: A Fast Address Sanity Checker" (USENIX ATC 2012)、Linux 内核 lib/.c (asan.c generic.c report.c tags.c)*

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部