核心記憶體清理器 (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.