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