内核内存清理器 (KMSAN)

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

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

用法

构建内核

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

现在配置并构建启用了 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) 称为污染 (poisoning),将其标记为已初始化 (将影子字节设置为 0x00) 称为解毒 (unpoisoning)。

当在堆栈上分配新变量时,默认情况下,编译器插入的插桩代码会对其进行污染 (除非它是一个立即初始化的堆栈变量)。 任何没有 __GFP_ZERO 完成的新堆分配也会被污染。

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

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

示例

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

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

来源跟踪

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

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

示例 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 插桩传递将对 mm/kmsan/instrumentation.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: fast detector of uninitialized memory use in C++. In Proceedings of CGO 2015.