Linux內核(x86)入口代碼模糊測試指南Part 1
在本系列文章中,我們將為讀者分享關于內核代碼模糊測試方面的見解。
簡介
對于長期關注Linux內核開發或系統調用模糊測試的讀者來說,很可能早就對trinity(地址:https://lwn.net/Articles/536173/)和syzkaller(地址:https://lwn.net/Articles/677764/)并不陌生了。近年來,安全研究人員已經利用這兩個工具發現了許多內核漏洞。實際上,它們的工作原理非常簡單:向內核隨機拋出一些系統調用,以期某些調用會導致內核崩潰,或觸發內核代碼中可檢測的漏洞(例如緩沖區溢出漏洞)。
盡管這些Fuzzer能夠對系統調用自身(以及通過系統調用可訪問的代碼)進行有效的模糊測試;但是,對于在用戶空間和內核之間的邊界上發生的事情,這兩款工具卻鞭長莫及。實際上,這個邊界處發生的事情比我們想象的更為復雜:這里的代碼是用匯編語言編寫的,在內核可以安全地開始執行其C代碼之前,必須對各種體系結構狀態(CPU狀態)進行安全檢查,或者說是“消毒”。
本文將同讀者一起,探索如何為x86平臺上的Linux內核入口代碼編寫Fuzzer工具。
在繼續之前,不妨先簡單了解一下64位內核涉及的主要兩個文件:
· entry_64.S:64位進程的入口代碼。
· entry_64_compat.S:32位進程的入口代碼。
總的來說,入口代碼大約有1700行匯編代碼(其中包括注釋),所以,閱讀這些代碼的工作量并不算小,同時,這也只是整個內核代碼中很小的一部分。
memset()示例
首先,我想給出一個從用戶空間進入內核時,內核需要進行驗證的CPU狀態的具體例子。
在x86平臺上,memset()通常是由rep stos指令實現的,因為在連續的字節范圍內進行寫操作方面,該指令已經被CPU/微碼進行了高度的優化。從概念上講,這是一個硬件循環,它重復(rep)一個存儲操作(stos)若干次;目標地址由%RDI寄存器指定,迭代次數由%RCX寄存器給出。例如,您可以使用內聯匯編實現memset(),具體如下所示:
- static inline void memset(void *dest, int value, size_t count)
- {
- asm volatile ("rep stosb" // 4
- : "+D" (dest), "+c" (count) // 1, 2
- : "a" (value) // 3
- : "cc", "memory"); // 5
- }
對于上述內聯匯編代碼來說,其作用就是告訴GCC:
1. 將變量dest保存到%rdi寄存器中(+表示該值可能會被內聯匯編代碼所修改);
2. 將變量count保存到%rcx寄存器中;
3. 將變量value保存到%eax寄存器中(無論我們將其放入%rax、%eax、%ax還是%al寄存器中,這都是無關緊要的,因為rep stosb指令只使用與%al寄存器中的值相對應的低位字節);
4. 將rep stosb指令插入到匯編代碼中;
5. 重載任何可能依賴于條件碼(“cc”,即x86平臺上的%rflags寄存器)或內存的值。
作為參考,你也可以考察一下memset()在x86平臺上的主流實現代碼。
重要的是,在%rflags寄存器中含有一個很少使用的位,叫做DF位(即方向標志位)。這個標志位決定了每寫入一個字節后,rep stos會令%rdi的值遞增或遞減。當DF位被設為0時,受影響的內存范圍是從%rdi到(%rdi + %rcx);而當DF位被設為1時,受影響的內存范圍是從(%rdi - %rcx)到%rdi!由于它對memset()的最終結果有重大的影響,所以,我們最好確保DF位總是被設為0。
實際上,按照x86_64 SysV ABI的要求,在進入函數以及從函數返回時,DF位必須始終為0(具體見第15頁):
“必須在進入函數以及從函數返回時清除%rFLAGS寄存器中的方向標志DF(將方向設置為“forward”)。其他用戶標志在標準調用序列中沒有指定的角色,并且在不同的調用中不予保留。”
實際上,這是內核在內部高度依賴的一種約定;如果在調用memset()時以某種方式將DF標志設置為1,它將錯誤地覆蓋某些內存。因此,內核進入代碼的任務之一,就是確保在進入任何內核C代碼之前,DF標志始終為0。我們可以用一條指令cld(即清除方向標志指令)來實現這一點,內核的許多入口路徑就是這么做的,具體請參考paranoid_entry()或error_entry()的實現代碼。
fuzzer
如您所見,哪怕是CPU狀態的一個標志位,都對內核有著巨大的影響。接下來,我們將枚舉入口代碼需要處理的所有CPU狀態變量:
· 標志寄存器 (%rflags)
· 堆棧指針 (%rsp)
· 段寄存器 (%cs, %fs, %gs)
· 調試寄存器 (%dr0到%dr3, %dr7)
到目前為止,我們一直回避的問題是,從用戶空間進入內核有許多不同的方式,而不僅僅是系統調用(也不僅僅是系統調用的一種機制)。這些方式包括:
· int指令
· sysenter指令
· syscall指令
· INT3/INTO/INT1指令
· 被零除
· 調試異常
· 斷點異常
· 溢出異常
· 操作碼無效
· 一般保護故障
· 頁面錯誤
· 浮點異常
· 外部硬件中斷
· 不可屏蔽中斷
Fuzzer的目標應該是測試CPU狀態和用戶空間/內核轉換的所有可能組合。在理想的情況下,我們會進行窮舉搜索,但是如果您考慮寄存器值和入口方法的所有可能組合,搜索空間就太大了。因此,我們將通過兩個主要的策略來提高我們發現bug的機會。
1. 關注那些我們懷疑更有可能導致有趣/不尋常事情發生的值/案例。為此,需要查看x86文檔(維基百科、英特爾手冊等)以及入口代碼本身。例如,入口代碼記錄了幾個處理器勘誤表案例,我們可以直接使用它們來確定已知的邊緣案例。
2. 壓縮我們認為沒有影響的那些類型的值。例如,在挑選要加載到寄存器的隨機值時,重要的是要嘗試不同類型的指針(例如,內核空間、用戶空間、非規范、映射、非映射等類型的指針),而不是嘗試所有可能的值。
值得一提的是,內核已經為x86代碼提供了一個優秀回歸測試套件,它位于tools/testing/selftests/x86/目錄下,主要開發者為Andy Lutomirski。它提供了進入/離開內核的各種方法的測試用例,我們可以從中汲取靈感。
高層架構
我們這里要開發的fuzzer,實際上是一個供內核運行的用戶空間程序,用以完成相應的模糊測試工作。由于我們需要非常精確地控制一些用于觸發向內核過渡的指令,所以,我們實際上不會直接用C語言來編寫這些代碼;相反,我們將在運行時動態地生成x86機器代碼,然后執行它。為了簡單起見,也為了避免在設置好所需的CPU狀態后恢復到一個干凈的狀態(如果可以的話),我們將在一個子進程中執行生成的機器代碼,并且能夠在進入內核后將其丟棄。
下面,我們從一個基本的fork循環開始入手。
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- #include
- static void *mem;
- static void emit_code();
- typedef void (*generated_code_fn)(void);
- int main(int argc, char *argv[])
- {
- mem = mmap(NULL, PAGE_SIZE,
- // prot
- PROT_READ | PROT_WRITE | PROT_EXEC,
- // flags
- MAP_PRIVATE | MAP_ANONYMOUS | MAP_32BIT,
- // fd, offset
- -1, 0);
- if (mem == MAP_FAILED)
- error(EXIT_FAILURE, errno, "mmap()");
- while (1) {
- emit_code();
- pid_t child = fork();
- if (child == -1)
- error(EXIT_FAILURE, errno, "fork()");
- if (child == 0) {
- // we're the child; call our newly generated function
- ((generated_code_fn) mem)();
- exit(EXIT_SUCCESS);
- }
- // we're the parent; wait for the child to exit
- while (1) {
- int status;
- if (waitpid(child, &status, 0) == -1) {
- if (errno == EINTR)
- continue;
- error(EXIT_FAILURE, errno, "waitpid()");
- }
- break;
- }
- }
- return 0;
- }
然后,我們還將實現一個非常簡單的emit_code(),到目前為止,只創建了一個包含單個retq指令的函數:
- static void emit_code()
- {
- uint8_t *out = (uint8_t *) mem;
- // retq
- *out++ = 0xc3;
- }
如果您仔細閱讀代碼,很可能會感到奇怪:為什么要使用MAP_32BIT標志創建映射呢?這是因為我們希望fuzzer在32位兼容模式下運行時進入內核,所以,首先需要能在有效的32位地址下運行。
進行系統調用
在x86平臺上,系統調用的歷史有點混亂。首先,存在這樣一個事實,即系統調用最初是在32位系統上發展起來的,當時使用的是相對較慢的int指令。后來,英特爾和AMD公司分別開發了自己的快速系統調用機制(分別使用全新且互不兼容的sysenter和syscall指令)。更糟的是,64位系統需要同時處理32位進程(使用任何32位系統調用機制)、64位進程以及(可能的)第三種操作模式即x32,其中代碼像像通常那樣是64位的(并且可以訪問64位寄存器),然而,指針卻是32位的——之所以這么做,據說是為了節省內存。由于它們在進入內核模式時保存/修改的CPU狀態各不相同,因此,這些不同的系統調用機制中的大多數在內核的入口碼中采用的路徑也是各不相同的。這也是入口代碼很難理解的原因之一!
有關在x86上進行系統調用的更深入的介紹,可以參閱LWN網站上的優秀文章,比如:
· Anatomy of a system call, part 1
· Anatomy of a system call, part 2
熟悉系統調用的一個好方法是,親自動手通過GNU匯編器來制作匯編代碼片段的原型,然后供fuzzer使用。例如,像下面那樣,對內核執行一次read(STDIN_FILENO, NULL, 0)調用:
- .text
- .global main
- main:
- movl $0, %eax # SYS_read/__NR_read
- movl $0, %edi # fd = STDIN_FILENO
- movl $0, %esi # buf = NULL
- movl $0, %edx # count = 0
- syscall
- movl $0, %eax
- retq
從這段代碼中可以看到,當使用syscall指令時,系統調用號本身通過%rax寄存器傳遞,而參數則通過%rdi、%rsi、%rdx等寄存器進行傳遞。據我所知,Linux x86 SysCall ABI在入口代碼本身的entry_syscall_64()中是有“正式”記錄的(我們在這里使用的是%eXX寄存器,而不是%rXX寄存器,因為這里的機器代碼比較短;將%eXX設置為0時,將清除%rXX的高32位)。
我們可以使用gcc read.S命令來構建上述代碼(假設上述匯編代碼保存在名為read.S的文件中),并可以使用strace檢查它是否正確:
- $ strace ./a.out
- execve("./a.out", ["./a.out"], [/* 53 vars */]) = 0
- [...]
- read(0, NULL, 0) = 0
- exit_group(0) = ?
- +++ exited with 0 +++
要獲得匯編后機器代碼的字節內容,我們可以先使用gcc-c read.s進行編譯,然后使用objdump -d read.o獲取相應的內容:
- 0000000000000000
- 0: b8 00 00 00 00 mov $0x0,%eax
- 5: bf 00 00 00 00 mov $0x0,%edi
- a: be 00 00 00 00 mov $0x0,%esi
- f: ba 00 00 00 00 mov $0x0,%edx
- 14: 0f 05 syscall
- 16: b8 00 00 00 00 mov $0x0,%eax
- 1b: c3 retq
要將這個字節序列添加到我們的JIT匯編函數中,我們可以使用下列代碼:
- // mov $0, %eax
- *out++ = 0xb8;
- *out++ = 0x00;
- *out++ = 0x00;
- *out++ = 0x00;
- *out++ = 0x00;
- [...]
- // syscall
- *out++ = 0x0f;
- *out++ = 0x05;
重新回到memset()和方向標志位
現在,對于上面的memset()示例來說,編寫測試所需的大部分代碼都已經準備就緒了。為了設置df位,我們可以在進行系統調用之前執行std指令(該指令用于設置方向標志):
- // std
- *out++ = 0xfd;
既然我們要寫一個fuzzer,那么,自然需要給這個標志位隨機賦值。如果我們使用的編程語言是C++的話,可以通過如下所示的代碼來初始化PRNG:
- #include
- static std::default_random_engine rnd;
- int main(...)
- {
- std::random_device rdev;
- rnd = std::default_random_engine(rdev());
- ...
- }
然后,我們可以在進行系統調用之前,使用類似下面的方式來設置(或清除)該標志位:
- switch (std::uniform_int_distribution
- case 0:
- // cld
- *out++ = 0xfc;
- break;
- case 1:
- // std
- *out++ = 0xfd;
- break;
- }
同樣,這些字節只是用于手工拼裝一個短測試程序,然后查看objdump的輸出結果。
注意:在子進程中生成隨機數的時候,我們要格外小心;因為我們不希望所有的子進程都生成相同的數字!這就是為什么我們實際上在父進程中生成代碼,并在子進程中簡單地執行它們的原因。
本文翻譯自:https://blogs.oracle.com/linux/fuzzing-the-linux-kernel-x86-entry-code%2C-part-1-of-3如若轉載,請注明原文地址: