内核内存清理器 (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
=====================================================
该报告显示局部变量 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
) 称为污染 (poisoning),将其标记为已初始化 (将影子字节设置为 0x00
) 称为解毒 (unpoisoning)。
当在堆栈上分配新变量时,默认情况下,编译器插入的插桩代码会对其进行污染 (除非它是一个立即初始化的堆栈变量)。 任何没有 __GFP_ZERO
完成的新堆分配也会被污染。
编译器插桩还会跟踪影子值,因为它们会沿着代码使用。 在需要时,插桩代码会调用 mm/kmsan/
中的运行时库以持久化影子值。
基本类型或复合类型的影子值是相同长度的字节数组。 当一个常量值写入内存时,该内存会被解毒。 当从内存读取值时,也会获取其影子内存并将其传播到使用该值的所有操作中。 对于每个采用一个或多个值的指令,编译器都会生成代码,以根据这些值及其影子计算结果的影子。
示例
int a = 0xff; // i.e. 0x000000ff
int b;
int c = a | b;
在这种情况下,a
的影子是 0
,b
的影子是 0xffffffff
,c
的影子是 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_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: fast detector of uninitialized memory use in C++. In Proceedings of CGO 2015.