英语

内核内存净化器 (KMSAN)

KMSAN 是一种动态错误检测器,旨在查找未初始化值的使用。它基于编译器插桩,并且与用户空间的 MemorySanitizer 工具非常相似。

一个重要的注意事项是,KMSAN 不适用于生产环境,因为它会大幅增加内核内存占用并降低整个系统的速度。

用法

构建内核

为了使用 KMSAN 构建内核,您需要一个较新的 Clang (14.0.6+)。有关如何构建 Clang 的说明,请参阅 LLVM 文档

现在配置并构建启用了 CONFIG_KMSAN 的内核。

示例报告

这是一个 KMSAN 报告的示例

=====================================================
BUG: KMSAN: uninit-value in test_uninit_kmsan_check_memory+0x1be/0x380 [kmsan_test]
 test_uninit_kmsan_check_memory+0x1be/0x380 mm/kmsan/kmsan_test.c:273
 kunit_run_case_internal lib/kunit/test.c:333
 kunit_try_run_case+0x206/0x420 lib/kunit/test.c:374
 kunit_generic_run_threadfn_adapter+0x6d/0xc0 lib/kunit/try-catch.c:28
 kthread+0x721/0x850 kernel/kthread.c:327
 ret_from_fork+0x1f/0x30 ??:?

Uninit was stored to memory at:
 do_uninit_local_array+0xfa/0x110 mm/kmsan/kmsan_test.c:260
 test_uninit_kmsan_check_memory+0x1a2/0x380 mm/kmsan/kmsan_test.c:271
 kunit_run_case_internal lib/kunit/test.c:333
 kunit_try_run_case+0x206/0x420 lib/kunit/test.c:374
 kunit_generic_run_threadfn_adapter+0x6d/0xc0 lib/kunit/try-catch.c:28
 kthread+0x721/0x850 kernel/kthread.c:327
 ret_from_fork+0x1f/0x30 ??:?

Local variable uninit created at:
 do_uninit_local_array+0x4a/0x110 mm/kmsan/kmsan_test.c:256
 test_uninit_kmsan_check_memory+0x1a2/0x380 mm/kmsan/kmsan_test.c:271

Bytes 4-7 of 8 are uninitialized
Memory access of size 8 starts at ffff888083fe3da0

CPU: 0 PID: 6731 Comm: kunit_try_catch Tainted: G    B       E     5.16.0-rc3+ #104
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
=====================================================

该报告指出,局部变量 uninitdo_uninit_local_array() 中创建时未初始化。第三个堆栈跟踪对应于创建此变量的位置。

第一个堆栈跟踪显示了未初始化值的使用位置(在 test_uninit_kmsan_check_memory() 中)。该工具显示了局部变量中未初始化的字节,以及在使用前将值复制到另一个内存位置的堆栈。

在以下情况下,KMSAN 会报告未初始化值 v 的使用

  • 在条件中,例如 if (v) { ... }

  • 在索引或指针解引用中,例如 array[v]*v

  • 当它被复制到用户空间或硬件时,例如 copy_to_user(..., &v, ...)

  • 当它作为参数传递给函数时,并且启用了 CONFIG_KMSAN_CHECK_PARAM_RETVAL(见下文)。

从 C11 标准的角度来看,上述情况(除了将数据复制到用户空间或硬件,这是一个安全问题)被认为是未定义行为。

禁用插桩

可以使用 __no_kmsan_checks 标记一个函数。这样做会让 KMSAN 忽略该函数中未初始化的值,并将其输出标记为已初始化。因此,用户将不会收到与该函数相关的 KMSAN 报告。

KMSAN 支持的另一个函数属性是 __no_sanitize_memory。将此属性应用于函数将导致 KMSAN 不对其进行插桩,如果我们不希望编译器干扰某些底层代码(例如,用 noinstr 标记的代码,它隐式添加 __no_sanitize_memory),这将很有帮助。

然而,这样做是有代价的:来自此类函数的堆栈分配将具有不正确的影子/来源值,这可能会导致误报。从非插桩代码调用的函数也可能会收到其参数的错误元数据。

根据经验,避免显式使用 __no_sanitize_memory

也可以为一个单独的文件(例如 main.o)禁用 KMSAN

KMSAN_SANITIZE_main.o := n

或者为整个目录禁用 KMSAN

KMSAN_SANITIZE := n

在 Makefile 中。可以将其视为将 __no_sanitize_memory 应用于文件或目录中的每个函数。大多数用户不需要 KMSAN_SANITIZE,除非他们的代码被 KMSAN 破坏(例如,在早期启动时运行)。

也可以使用 kmsan_disable_current()kmsan_enable_current() 调用暂时禁用当前任务的 KMSAN 检查。每个 kmsan_enable_current() 调用都必须以 kmsan_disable_current() 调用为先导;这些调用对可以嵌套。需要谨慎使用这些调用,保持区域简短,并尽可能优先选择其他方式来禁用插桩。

支持

为了使 KMSAN 工作,必须使用 Clang 构建内核,到目前为止,Clang 是唯一支持 KMSAN 的编译器。内核插桩通道基于用户空间的 MemorySanitizer 工具

运行时库目前只支持 x86_64。

KMSAN 的工作原理

KMSAN 影子内存

KMSAN 将元数据字节(也称为影子字节)与内核内存的每个字节相关联。如果内核内存字节的相应位未初始化,则会设置影子字节中的一位。将内存标记为未初始化(即将影子字节设置为 0xff)称为中毒,将内存标记为已初始化(将影子字节设置为 0x00)称为解毒。

当在堆栈上分配新变量时,默认情况下,会通过编译器插入的插桩代码对其进行中毒(除非它是立即初始化的堆栈变量)。任何未使用 __GFP_ZERO 完成的新堆分配也会中毒。

编译器插桩还会跟踪代码中使用时的影子值。在需要时,插桩代码会在 mm/kmsan/ 中调用运行时库以持久化影子值。

基本类型或复合类型的影子值是相同长度的字节数组。当常量值写入内存时,该内存会被解毒。当从内存读取值时,也会获得其影子内存并将其传播到使用该值的所有操作中。对于每个采用一个或多个值的指令,编译器都会生成代码,该代码根据这些值及其影子来计算结果的影子。

示例

int a = 0xff;  // i.e. 0x000000ff
int b;
int c = a | b;

在这种情况下,a 的影子是 0b 的影子是 0xffffffffc 的影子是 0xffffff00。这意味着 c 的上三个字节未初始化,而低字节已初始化。

来源跟踪

内核内存的每四个字节还有一个与之映射的所谓来源。此来源描述了程序执行中创建未初始化值的位置。每个来源都与完整的分配堆栈(对于堆分配的内存)或包含未初始化变量的函数(对于局部变量)相关联。

当未初始化的变量在堆栈或堆上分配时,会创建一个新的来源值,并且该变量的来源会填充该值。当从内存读取值时,也会读取其来源并与影子一起保存。对于每个采用一个或多个值的指令,结果的来源是与任何未初始化输入相对应的来源之一。如果将中毒值写入内存,则其来源也会写入相应的存储。

示例 1

int a = 42;
int b;
int c = a + b;

在这种情况下,b 的来源在函数入口时生成,并在加法结果写入内存之前存储到 c 的来源中。

如果多个变量存储在同一个四字节块中,则它们可以共享相同的来源地址。在这种情况下,对任一变量的每次写入都会更新所有变量的来源。在这种情况下,我们必须牺牲精度,因为存储单个位(甚至是字节)的来源成本太高。

示例 2

int combine(short a, short b) {
  union ret_t {
    int i;
    short s[2];
  } ret;
  ret.s[0] = a;
  ret.s[1] = b;
  return ret.i;
}

如果 a 已初始化,而 b 未初始化,则结果的影子将为 0xffff0000,结果的来源将为 b 的来源。ret.s[0] 将具有相同的来源,但它永远不会被使用,因为该变量已初始化。

如果两个函数参数都未初始化,则仅保留第二个参数的来源。

来源链

为了方便调试,KMSAN为每次将未初始化值存储到内存的操作创建一个新的来源。新的来源会引用其创建堆栈以及该值之前的来源。这可能会导致内存消耗增加,因此我们在运行时限制来源链的长度。

Clang 插桩 API

Clang 插桩 pass 将对 mm/kmsan/nstrumentation.c 中定义的函数的调用插入到内核代码中。

影子操作

对于每次内存访问,编译器都会发出一个函数调用,该函数返回给定内存的影子地址和来源地址对。

typedef struct {
  void *shadow, *origin;
} shadow_origin_ptr_t

shadow_origin_ptr_t __msan_metadata_ptr_for_load_{1,2,4,8}(void *addr)
shadow_origin_ptr_t __msan_metadata_ptr_for_store_{1,2,4,8}(void *addr)
shadow_origin_ptr_t __msan_metadata_ptr_for_load_n(void *addr, uintptr_t size)
shadow_origin_ptr_t __msan_metadata_ptr_for_store_n(void *addr, uintptr_t size)

函数名称取决于内存访问大小。

编译器确保对于每个加载的值,其影子值和来源值都从内存中读取。当一个值存储到内存中时,它的影子和来源也会使用元数据指针存储。

处理局部变量

一个特殊的函数用于为一个局部变量创建一个新的来源值,并将该变量的来源设置为该值。

void __msan_poison_alloca(void *addr, uintptr_t size, char *descr)

访问每个任务的数据

在每个插桩函数的开头,KMSAN 插入对 __msan_get_context_state() 的调用。

kmsan_context_state *__msan_get_context_state(void)

kmsan_context_stateinclude/linux/kmsan.h 中声明。

struct kmsan_context_state {
  char param_tls[KMSAN_PARAM_SIZE];
  char retval_tls[KMSAN_RETVAL_SIZE];
  char va_arg_tls[KMSAN_PARAM_SIZE];
  char va_arg_origin_tls[KMSAN_PARAM_SIZE];
  u64 va_arg_overflow_size_tls;
  char param_origin_tls[KMSAN_PARAM_SIZE];
  depot_stack_handle_t retval_origin_tls;
};

KMSAN 使用此结构在插桩函数之间传递参数影子和来源(除非参数立即被 CONFIG_KMSAN_CHECK_PARAM_RETVAL 检查)。

将未初始化值传递给函数

Clang 的 MemorySanitizer 插桩有一个选项 -fsanitize-memory-param-retval,它可以使编译器检查按值传递的函数参数以及函数返回值。

该选项由 CONFIG_KMSAN_CHECK_PARAM_RETVAL 控制,默认情况下启用,以便让 KMSAN 更早地报告未初始化的值。请参阅 LKML 讨论 以了解更多详细信息。

由于检查在 LLVM 中的实现方式(它们仅应用于标记为 noundef 的参数),并非所有参数都保证被检查,因此我们不能放弃 kmsan_context_state 中的元数据存储。

字符串函数

编译器将对 memcpy()/ memmove()/ memset() 的调用替换为以下函数。当数据结构初始化或复制时,也会调用这些函数,从而确保影子值和来源值与数据一起复制。

void *__msan_memcpy(void *dst, void *src, uintptr_t n)
void *__msan_memmove(void *dst, void *src, uintptr_t n)
void *__msan_memset(void *dst, int c, uintptr_t n)

错误报告

对于每个值的使用,编译器都会发出一个影子检查,如果该值被污染,则会调用 __msan_warning()

void __msan_warning(u32 origin)

__msan_warning() 使 KMSAN 运行时打印错误报告。

内联汇编插桩

KMSAN 使用对以下函数的调用来插桩每个内联汇编输出:

void __msan_instrument_asm_store(void *addr, uintptr_t size)

,它会取消对内存区域的污染。

这种方法可能会掩盖某些错误,但它也有助于避免在位操作、原子操作等中出现大量误报。

有时传递到内联汇编中的指针不指向有效的内存。在这种情况下,它们在运行时会被忽略。

运行时库

代码位于 mm/kmsan/ 中。

每个任务的 KMSAN 状态

每个 task_struct 都有一个关联的 KMSAN 任务状态,其中包含 KMSAN 上下文(见上文)和一个禁用 KMSAN 报告的每个任务的计数器。

struct kmsan_context {
  ...
  unsigned int depth;
  struct kmsan_context_state cstate;
  ...
}

struct task_struct {
  ...
  struct kmsan_context kmsan;
  ...
}

KMSAN 上下文

在内核任务上下文中运行时,KMSAN 使用 current->kmsan.cstate 来保存函数参数和返回值的元数据。

但是,如果内核在中断、软中断或 NMI 上下文中运行,其中 current 不可用,则 KMSAN 会切换到每个 CPU 的中断状态。

DEFINE_PER_CPU(struct kmsan_ctx, kmsan_percpu_ctx);

元数据分配

内核中有几个地方用于存储元数据。

1. 每个 struct page 实例都包含两个指向其影子页面和来源页面的指针。

struct page {
  ...
  struct page *shadow, *origin;
  ...
};

在启动时,内核为每个可用的内核页面分配影子页面和来源页面。这是在内核地址空间已经碎片化之后相当晚的时候完成的,因此普通数据页面可能会与元数据页面任意交错。

这意味着通常对于两个连续的内存页面,它们的影子/来源页面可能不是连续的。因此,如果内存访问跨越内存块的边界,则对影子/来源内存的访问可能会损坏其他页面或从中读取不正确的值。

在实践中,同一 alloc_pages() 调用返回的连续内存页面将具有连续的元数据,而如果这些页面属于两个不同的分配,则它们的元数据页面可能会碎片化。

对于内核数据(.data.bss 等)和每个 CPU 的内存区域,也不能保证元数据的连续性。

如果 __msan_metadata_ptr_for_XXX_YYY() 命中两个元数据不连续的页面之间的边界,它将返回指向伪影子/来源区域的指针。

char dummy_load_page[PAGE_SIZE] __attribute__((aligned(PAGE_SIZE)));
char dummy_store_page[PAGE_SIZE] __attribute__((aligned(PAGE_SIZE)));

dummy_load_page 已初始化为零,因此从中读取的值始终为零。对 dummy_store_page 的所有存储都被忽略。

2. 对于 vmalloc 内存和模块,内存范围、其影子和来源之间存在直接映射。KMSAN 将 vmalloc 区域减少 3/4,仅将第一个四分之一提供给 vmalloc()。vmalloc 区域的第二个四分之一包含第一个四分之一的影子内存,第三个四分之一包含来源。第四个四分之一的一小部分包含内核模块的影子和来源。请参阅 arch/x86/include/asm/pgtable_64_types.h 以了解更多详细信息。

当页面数组映射到连续的虚拟内存空间时,它们的影子页面和来源页面也会类似地映射到连续的区域。

参考文献

E. Stepanov, K. Serebryany. MemorySanitizer: C++ 中未初始化内存使用的快速检测器。在 CGO 2015 会议论文集中。