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 常见误报与“影子盲区”
- 未对齐访问可能绕过防护:强制类型转换到
void*并偏移到一个已经标注 poison 的 8 字节组(标记为 free),但若实际访问大小为 1 byte,但未处理 low 3 bit 偏移,需要在插桩时精确检查(offset_byte + access_size) <= shadow_value + 8。
- 中断上下文中的 race:KASAN 在处理
hardirq和softirq时若处于中断上下文(因为 Lockdep 影响),其 kmemleak 检查被禁止,可能无法触发 report。
- 跨页间接操作不检: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 的工程实践建议:
- CI 门禁:每次提交到
for-kasan分支都跑 KASAN 模式下的 LTP + kselftest - 自动化捕获:部署 KASAN-enabled 的 staging node,使用 syzkaller 持续 Fuzz 系统调用
- 生产兜底:基于 KASAN_HW_TAGS 构建生产节点的轻量内存自检
- 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)*

发表评论 取消回复