内核地址消毒剂 (KASAN)

概述

内核地址消毒剂 (KASAN) 是一种动态内存安全错误检测器,旨在发现越界(out-of-bounds)和释放后使用(use-after-free)错误。

KASAN 有三种模式

  1. 通用 KASAN

  2. 基于软件的标签 KASAN

  3. 基于硬件的标签 KASAN

通过 CONFIG_KASAN_GENERIC 启用的通用 KASAN 是用于调试的模式,类似于用户空间的 ASan。此模式受许多 CPU 架构支持,但它具有显着的性能和内存开销。

基于软件的标签 KASAN(Software Tag-Based KASAN 或 SW_TAGS KASAN),通过 CONFIG_KASAN_SW_TAGS 启用,可用于调试和 dogfood 测试,类似于用户空间的 HWASan。该模式仅支持 arm64,但其适度的内存开销允许将其用于在具有真实工作负载的内存受限设备上进行测试。

基于硬件的标签 KASAN(Hardware Tag-Based KASAN 或 HW_TAGS KASAN),通过 CONFIG_KASAN_HW_TAGS 启用,是用作现场(in-field)内存错误检测器或安全缓解措施的模式。此模式仅适用于支持 MTE(内存标记扩展,Memory Tagging Extension)的 arm64 CPU,但它具有较低的内存和性能开销,因此可用于生产环境。

有关每种 KASAN 模式对内存和性能影响的详细信息,请参阅相应 Kconfig 选项的描述。

通用模式和基于软件的标签模式通常统称为软件模式。基于软件的标签模式和基于硬件的标签模式统称为基于标签的模式。

支持

架构

通用 KASAN 在 x86_64、arm、arm64、powerpc、riscv、s390、xtensa 和 loongarch 上受支持,而基于标签的 KASAN 模式仅在 arm64 上受支持。

编译器

软件 KASAN 模式使用编译时插桩(instrumentation)在每次内存访问之前插入有效性检查,因此需要提供对此支持的编译器版本。基于硬件的标签模式依赖硬件来执行这些检查,但仍然需要支持内存标记指令的编译器版本。

通用 KASAN 需要 GCC 8.3.0 或更高版本,或者内核支持的任何 Clang 版本。

基于软件的标签 KASAN 需要 GCC 11+ 或内核支持的任何 Clang 版本。

基于硬件的标签 KASAN 需要 GCC 10+ 或 Clang 12+。

内存类型

通用 KASAN 支持在 slab、page_alloc、vmap、vmalloc、栈(stack)和全局内存中查找错误。

基于软件的标签 KASAN 支持 slab、page_alloc、vmalloc 和栈内存。

基于硬件的标签 KASAN 支持 slab、page_alloc 和不可执行的 vmalloc 内存。

用法

要启用 KASAN,请通过以下方式配置内核:

CONFIG_KASAN=y

并选择 CONFIG_KASAN_GENERIC(以启用通用 KASAN)、CONFIG_KASAN_SW_TAGS(以启用基于软件的标签 KASAN)和 CONFIG_KASAN_HW_TAGS(以启用基于硬件的标签 KASAN)之一。

对于软件模式,还需在 CONFIG_KASAN_OUTLINECONFIG_KASAN_INLINE 之间进行选择。Outline 和 inline 是编译器插桩类型。前者生成较小的二进制文件,而后者速度快达 2 倍。

要将受影响 slab 对象的分配和释放栈追踪包含在报告中,请启用 CONFIG_STACKTRACE。要包含受影响物理页面的分配和释放栈追踪,请启用 CONFIG_PAGE_OWNER 并通过 page_owner=on 启动。

启动参数

KASAN 受通用的命令行参数 panic_on_warn 的影响。启用后,KASAN 在打印错误报告后会使内核 panic。

默认情况下,KASAN 仅为第一次无效内存访问打印错误报告。通过设定 kasan_multi_shot,KASAN 会对每次无效访问打印报告。这实际上禁用了针对 KASAN 报告的 panic_on_warn

或者,独立于 panic_on_warn,可以使用 kasan.fault= 启动参数来控制 panic 和报告行为:

  • kasan.fault=report=panic=panic_on_write 用于控制是仅打印 KASAN 报告、使内核 panic,还是仅在无效写入时使内核 panic(默认值:report)。即使启用了 kasan_multi_shot,也会发生 panic。请注意,当使用基于硬件的标签 KASAN 的异步模式时,kasan.fault=panic_on_write 始终会在异步检查的访问(包括读取)上触发 panic。

软件和基于硬件的标签 KASAN 模式(请参见下文关于各种模式的章节)支持更改栈追踪收集行为:

  • kasan.stacktrace=off=on 禁用或启用分配和释放栈追踪的收集(默认值:on)。

  • kasan.stack_ring_size=<条目数> 指定栈环(stack ring)中的条目数(默认值:32768)。

基于硬件的标签 KASAN 模式旨在作为安全缓解措施在生产环境中使用。因此,它支持额外的启动参数,允许完全禁用 KASAN 或控制其功能:

  • kasan=off=on 控制是否启用 KASAN(默认值:on)。

  • kasan.mode=sync=async=asymm 控制 KASAN 配置为同步、异步还是非对称执行模式(默认值:sync)。同步模式:当发生标签检查故障时,会立即检测到不良访问。异步模式:不良访问的检测会延迟。当发生标签检查故障时,信息存储在硬件中(对于 arm64,存储在 TFSR_EL1 寄存器中)。内核定期检查硬件,并且仅在这些检查期间报告标签故障。非对称模式:在读取时同步检测不良访问,在写入时异步检测。

  • kasan.write_only=offkasan.write_only=on 控制 KASAN 是仅检查写入(store)访问还是检查所有访问(默认值:off)。

  • kasan.vmalloc=off=on 禁用或启用对 vmalloc 分配的标记(默认值:on)。

  • kasan.page_alloc.sample=<采样间隔> 使 KASAN 仅对阶数(order)大于或等于 kasan.page_alloc.sample.order 的每第 N 个 page_alloc 分配进行标记,其中 N 是 sample 参数的值(默认值:1,即标记每个此类分配)。该参数旨在减轻 KASAN 带来的性能开销。请注意,启用此参数会使基于硬件的标签 KASAN 跳过对通过采样选择的分配的检查,从而错过对这些分配的不良访问。为准确检测错误,请使用默认值。

  • kasan.page_alloc.sample.order=<最小页阶数> 指定受采样影响的分配的最小阶数(默认值:3)。仅在 kasan.page_alloc.sample 设置为大于 1 的值时适用。此参数旨在仅对大的 page_alloc 分配进行采样,这是性能开销的最大来源。

错误报告

典型的 KASAN 报告如下所示

==================================================================
BUG: KASAN: slab-out-of-bounds in kmalloc_oob_right+0xa8/0xbc [kasan_test]
Write of size 1 at addr ffff8801f44ec37b by task insmod/2760

CPU: 1 PID: 2760 Comm: insmod Not tainted 4.19.0-rc3+ #698
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.10.2-1 04/01/2014
Call Trace:
 dump_stack+0x94/0xd8
 print_address_description+0x73/0x280
 kasan_report+0x144/0x187
 __asan_report_store1_noabort+0x17/0x20
 kmalloc_oob_right+0xa8/0xbc [kasan_test]
 kmalloc_tests_init+0x16/0x700 [kasan_test]
 do_one_initcall+0xa5/0x3ae
 do_init_module+0x1b6/0x547
 load_module+0x75df/0x8070
 __do_sys_init_module+0x1c6/0x200
 __x64_sys_init_module+0x6e/0xb0
 do_syscall_64+0x9f/0x2c0
 entry_SYSCALL_64_after_hwframe+0x44/0xa9
RIP: 0033:0x7f96443109da
RSP: 002b:00007ffcf0b51b08 EFLAGS: 00000202 ORIG_RAX: 00000000000000af
RAX: ffffffffffffffda RBX: 000055dc3ee521a0 RCX: 00007f96443109da
RDX: 00007f96445cff88 RSI: 0000000000057a50 RDI: 00007f9644992000
RBP: 000055dc3ee510b0 R08: 0000000000000003 R09: 0000000000000000
R10: 00007f964430cd0a R11: 0000000000000202 R12: 00007f96445cff88
R13: 000055dc3ee51090 R14: 0000000000000000 R15: 0000000000000000

Allocated by task 2760:
 save_stack+0x43/0xd0
 kasan_kmalloc+0xa7/0xd0
 kmem_cache_alloc_trace+0xe1/0x1b0
 kmalloc_oob_right+0x56/0xbc [kasan_test]
 kmalloc_tests_init+0x16/0x700 [kasan_test]
 do_one_initcall+0xa5/0x3ae
 do_init_module+0x1b6/0x547
 load_module+0x75df/0x8070
 __do_sys_init_module+0x1c6/0x200
 __x64_sys_init_module+0x6e/0xb0
 do_syscall_64+0x9f/0x2c0
 entry_SYSCALL_64_after_hwframe+0x44/0xa9

Freed by task 815:
 save_stack+0x43/0xd0
 __kasan_slab_free+0x135/0x190
 kasan_slab_free+0xe/0x10
 kfree+0x93/0x1a0
 umh_complete+0x6a/0xa0
 call_usermodehelper_exec_async+0x4c3/0x640
 ret_from_fork+0x35/0x40

The buggy address belongs to the object at ffff8801f44ec300
 which belongs to the cache kmalloc-128 of size 128
The buggy address is located 123 bytes inside of
 128-byte region [ffff8801f44ec300, ffff8801f44ec380)
The buggy address belongs to the page:
page:ffffea0007d13b00 count:1 mapcount:0 mapping:ffff8801f7001640 index:0x0
flags: 0x200000000000100(slab)
raw: 0200000000000100 ffffea0007d11dc0 0000001a0000001a ffff8801f7001640
raw: 0000000000000000 0000000080150015 00000001ffffffff 0000000000000000
page dumped because: kasan: bad access detected

Memory state around the buggy address:
 ffff8801f44ec200: fc fc fc fc fc fc fc fc fb fb fb fb fb fb fb fb
 ffff8801f44ec280: fb fb fb fb fb fb fb fb fc fc fc fc fc fc fc fc
>ffff8801f44ec300: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 03
                                                                ^
 ffff8801f44ec380: fc fc fc fc fc fc fc fc fb fb fb fb fb fb fb fb
 ffff8801f44ec400: fb fb fb fb fb fb fb fb fc fc fc fc fc fc fc fc
==================================================================

报告头总结了发生了哪种错误以及是什么样的访问引发了该错误。后面紧接着是错误访问的栈追踪、访问的内存分配位置的栈追踪(若访问的是 slab 对象),以及对象被释放位置的栈追踪(若是释放后使用错误报告)。接下来是对被访问的 slab 对象的描述以及有关被访问内存页的信息。

最后,报告显示了被访问地址周围的内存状态。在内部,KASAN 针对每个内存颗粒单独跟踪内存状态,根据 KASAN 模式的不同,颗粒大小为 8 或 16 个对齐字节。报告内存状态部分中的每个数字都显示了围绕被访问地址的其中一个内存颗粒的状态。

对于通用 KASAN,每个内存颗粒的大小为 8。每个颗粒的状态编码在一个影子字节中。这 8 个字节可以是可访问的、部分可访问的、已释放的,或者是红区(redzone)的一部分。KASAN 对每个影子字节使用以下编码:00 表示相应内存区域的所有 8 个字节都是可访问的;数字 N(1 <= N <= 7)表示前 N 个字节是可访问的,其余 (8 - N) 个字节不可访问;任何负值都表示整个 8 字节字不可访问。KASAN 使用不同的负值来区分不同种类的不可访问内存,例如红区或已释放的内存(参见 mm/kasan/kasan.h)。

在上面的报告中,箭头指向影子字节 03,这意味着被访问的地址是部分可访问的。

对于基于标签的 KASAN 模式,这最后一个报告部分显示了被访问地址周围的内存标签(参见实现细节章节)。

请注意,KASAN 错误标题(如 slab-out-of-boundsuse-after-free)是尽力而为的(best-effort):KASAN 根据其拥有的有限信息打印最可能的错误类型。错误的实际类型可能会有所不同。

通用 KASAN 还报告最多两个辅助调用栈追踪。这些栈追踪指向代码中与对象交互但未直接出现在错误访问栈追踪中的位置。目前,这包括 call_rcu() 和工作队列(workqueue)排队。

CONFIG_KASAN_EXTRA_INFO

启用 CONFIG_KASAN_EXTRA_INFO 允许 KASAN 记录和报告更多信息。当前支持的额外信息是分配和释放时的 CPU 编号以及时间戳。更多信息有助于查找错误原因并将错误与其他系统事件关联起来,代价是使用额外的内存来记录更多信息(有关成本的更多细节,请参见 CONFIG_KASAN_EXTRA_INFO 的帮助文本)。

以下是启用了 CONFIG_KASAN_EXTRA_INFO 的报告(仅显示不同的部分)

==================================================================
...
Allocated by task 134 on cpu 5 at 229.133855s:
...
Freed by task 136 on cpu 3 at 230.199335s:
...
==================================================================

实现细节

通用 KASAN

软件 KASAN 模式使用影子内存来记录内存的每个字节是否可以安全访问,并使用编译时插桩在每次内存访问之前插入影子内存检查。

通用 KASAN 将内核内存的 1/8 专门用于其影子内存(在 x86_64 上用 16TB 覆盖 128TB),并使用带有缩放比例和偏移量的直接映射将内存地址转换为其对应的影子地址。

以下是将地址转换为其对应影子地址的函数

static inline void *kasan_mem_to_shadow(const void *addr)
{
    return (void *)((unsigned long)addr >> KASAN_SHADOW_SCALE_SHIFT)
            + KASAN_SHADOW_OFFSET;
}

其中 KASAN_SHADOW_SCALE_SHIFT = 3

编译时插桩用于插入内存访问检查。编译器在大小为 1、2、4、8 或 16 的每次内存访问之前插入函数调用(__asan_load*(addr)__asan_store*(addr))。这些函数通过检查相应的影子内存来检查内存访问是否有效。

通过内联插桩,编译器不进行函数调用,而是直接插入用于检查影子内存的代码。该选项显著增大了内核体积,但与大纲插桩的内核相比,性能提升了 1.1 到 2 倍(x1.1-x2)。

通用 KASAN 是唯一通过隔离区(quarantine)延迟重用已释放对象的模式(实现参见 mm/kasan/quarantine.c)。

基于软件的标签 KASAN

基于软件的标签 KASAN 使用软件内存标记方法来检查访问有效性。目前它仅针对 arm64 架构实现。

基于软件的标签 KASAN 使用 arm64 CPU 的高字节忽略(TBI)特性,将指针标签存储在内核指针的高字节中。它使用影子内存来存储与每个 16 字节内存单元关联的内存标签(因此,它将内核内存的 1/16 专门用于影子内存)。

在每次内存分配时,基于软件的标签 KASAN 会生成一个随机标签,用该标签标记分配的内存,并将相同的标签嵌入到返回的指针中。

基于软件的标签 KASAN 使用编译时插桩在每次内存访问之前插入检查。这些检查确保正在访问的内存标签等于用于访问此内存的指针标签。如果标签不匹配,基于软件的标签 KASAN 会打印错误报告。

基于软件的标签 KASAN 也有两种插桩模式(outline 模式,发出回调以检查内存访问;inline 模式,内联执行影子内存检查)。在 outline 插桩模式下,错误报告由执行访问检查的函数打印。使用 inline 插桩时,编译器会发出 brk 指令,并使用专用的 brk 处理程序来打印错误报告。

基于软件的标签 KASAN 使用 0xFF 作为全匹配指针标签(通过带有 0xFF 指针标签的指针进行的访问不会被检查)。0xFE 值当前保留用于标记已释放的内存区域。

基于硬件的标签 KASAN

基于硬件的标签 KASAN 在概念上与软件模式相似,但它使用硬件内存标记支持,而不是编译器插桩和影子内存。

基于硬件的标签 KASAN 目前仅针对 arm64 架构实现,并且基于 ARMv8.5 指令集架构中引入的 arm64 内存标记扩展(MTE)和高字节忽略(TBI)。

使用特殊的 arm64 指令为每个分配分配内存标签。相同的标签被分配给指向这些分配的指针。在每次内存访问时,硬件都会确保正在访问的内存标签等于用于访问此内存的指针标签。如果标签不匹配,就会产生故障并打印报告。

基于硬件的标签 KASAN 使用 0xFF 作为全匹配指针标签(通过带有 0xFF 指针标签的指针进行的访问不会被检查)。0xFE 值当前保留用于标记已释放的内存区域。

如果硬件不支持 MTE(ARMv8.5 之前),则不会启用基于硬件的标签 KASAN。在这种情况下,所有 KASAN 启动参数都将被忽略。

请注意,启用 CONFIG_KASAN_HW_TAGS 总是会导致启用内核内的 TBI。即使提供了 kasan.mode=off 或者硬件不支持 MTE(但支持 TBI)时也是如此。

基于硬件的标签 KASAN 仅报告第一个发现的错误。之后,MTE 标签检查将被禁用。

影子内存

本节的内容仅适用于软件 KASAN 模式。

内核在地址空间的几个不同部分映射内存。内核虚拟地址的范围很大:没有足够的真实内存来为内核可以访问的每个地址支持真实的影子区域。因此,KASAN 仅为地址空间的某些部分映射真实的影子内存。

默认行为

默认情况下,架构仅为线性映射(以及可能其他的小区域)在影子区域上方映射真实内存。对于所有其他区域(例如 vmalloc 和 vmemmap 空间),单个只读页面会映射到影子区域上方。这个只读影子页面声明所有内存访问都是允许的。

这给模块带来了一个问题:它们不位于线性映射中,而是位于专用的模块空间中。通过挂钩到模块分配器,KASAN 临时映射真实的影子内存来覆盖它们。例如,这允许检测对模块全局变量的无效访问。

这也与 VMAP_STACK 产生不兼容:如果栈位于 vmalloc 空间中,它将被只读页面遮蔽,并且当内核试图为栈变量设置影子数据时会发生故障。

CONFIG_KASAN_VMALLOC

通过 CONFIG_KASAN_VMALLOC,KASAN 可以覆盖 vmalloc 空间,代价是占用更多内存。目前,这在 x86、arm64、riscv、s390 和 powerpc 上受支持。

其工作原理是挂钩到 vmalloc 和 vmap 并动态分配真实的影子内存来支持这些映射。

vmalloc 空间中的大多数映射都很小,所需的影子空间不足一个完整页面。因此,为每个映射分配一个完整的影子页面将是浪费的。此外,为了确保不同的映射使用不同的影子页面,映射必须对齐到 KASAN_GRANULE_SIZE * PAGE_SIZE

相反,KASAN 在多个映射之间共享底层空间。当 vmalloc 空间中的映射使用影子区域的特定页面时,它会分配一个底层页面。此页面稍后可以由其他 vmalloc 映射共享。

KASAN 挂钩到 vmap 基础设施,以惰性清理未使用的影子内存。

为了避免围绕交换映射的困难,KASAN 期望覆盖 vmalloc 空间的影子区域部分不会被早期影子页面覆盖,而是保持未映射状态。这将需要修改架构特定的代码。

这允许在 x86 上支持 VMAP_STACK,并可以简化对没有固定模块区域的架构的支持。

对于开发者

忽略访问

软件 KASAN 模式使用编译器插桩来插入有效性检查。此类插桩可能与内核的某些部分不兼容,因此需要禁用。

内核的其他部分可能会访问已分配对象的元数据。通常,KASAN 会检测并报告此类访问,但在某些情况下(例如在内存分配器中),这些访问是有效的。

对于软件 KASAN 模式,要禁用特定文件或目录的插桩,请将 KASAN_SANITIZE 注解添加到相应的内核 Makefile 中

  • 对于单个文件(例如 main.o)

    KASAN_SANITIZE_main.o := n
    
  • 对于一个目录中的所有文件

    KASAN_SANITIZE := n
    

对于软件 KASAN 模式,要按函数禁用插桩,请使用 KASAN 专用的 __no_sanitize_address 函数属性或通用的 noinstr 属性。

请注意,禁用编译器插桩(无论是基于文件还是基于函数)会使 KASAN 在软件 KASAN 模式下忽略直接发生在该代码中的访问。当访问间接发生(通过对已插桩函数的调用)或使用不使用编译器插桩的基于硬件的标签 KASAN 时,这没有帮助。

对于软件 KASAN 模式,要在当前任务的内核代码某一部分中禁用 KASAN 报告,请用 kasan_disable_current()/kasan_enable_current() 区域标注这部分代码。这也禁用了通过函数调用发生的间接访问的报告。

对于基于标签的 KASAN 模式,要禁用访问检查,请使用 kasan_reset_tag()page_kasan_tag_reset()。请注意,通过 page_kasan_tag_reset() 临时禁用访问检查需要通过 page_kasan_tag/page_kasan_tag_set 保存和恢复每页的 KASAN 标签。

测试

有一些 KASAN 测试允许验证 KASAN 是否正常工作并能检测某些类型的内存损坏。

所有 KASAN 测试都与 KUnit 测试框架集成,可以通过 CONFIG_KASAN_KUNIT_TEST 启用。可以通过几种不同的方式运行测试并部分自动验证它们;请参阅以下说明。

如果检测到错误,每个 KASAN 测试都会打印多个 KASAN 报告之一。然后测试会打印其编号和状态。

当测试通过时

ok 28 - kmalloc_double_kzfree

当测试由于 kmalloc 失败而失败时

# kmalloc_large_oob_right: ASSERTION FAILED at mm/kasan/kasan_test.c:245
Expected ptr is not null, but is
not ok 5 - kmalloc_large_oob_right

当测试由于缺少 KASAN 报告而失败时

# kmalloc_double_kzfree: EXPECTATION FAILED at mm/kasan/kasan_test.c:709
KASAN failure expected in "kfree_sensitive(ptr)", but none occurred
not ok 28 - kmalloc_double_kzfree

最后,会打印所有 KASAN 测试的累积状态。成功时

ok 1 - kasan

或者,如果其中一个测试失败了

not ok 1 - kasan

有几种运行 KASAN 测试的方法。

  1. 可加载模块

    启用了 CONFIG_KUNIT 后,测试可以构建为可加载模块,并通过使用 insmodmodprobe 加载 kasan_test.ko 来运行。

  2. 内置

    CONFIG_KUNIT 内置后,测试也可以内置。在这种情况下,测试将在启动时作为晚期初始化调用运行。

  3. 使用 kunit_tool

    在内置了 CONFIG_KUNITCONFIG_KASAN_KUNIT_TEST 的情况下,还可以使用 kunit_tool 以更具可读性的方式查看 KUnit 测试的结果。这不会打印通过的测试的 KASAN 报告。有关 kunit_tool 的更多最新信息,请参见 KUnit 文档