内核内存净化器 (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
=====================================================
该报告指出,局部变量 uninit
在 do_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
的影子是 0
,b
的影子是 0xffffffff
,c
的影子是 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_state
在 include/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 会议论文集中。