Linux核心(x86)入口程式碼模糊測試指南Part 1
在本系列文章中,我們將為讀者分享關於核心程式碼模糊測試方面的見解。
簡介
對於長期關注Linux核心開發或系統呼叫模糊測試的讀者來說,很可能早就對trinity(地址:https://lwn.net/Articles/536173/)和syzkaller(地址:https://lwn.net/Articles/677764/)並不陌生了。近年來,安全研究人員已經利用這兩個工具發現了許多核心漏洞。實際上,它們的工作原理非常簡單:向核心隨機丟擲一些系統呼叫,以期某些呼叫會導致核心崩潰,或觸發核心程式碼中可檢測的漏洞(例如緩衝區溢位漏洞)。
儘管這些Fuzzer能夠對系統呼叫自身(以及通過系統呼叫可訪問的程式碼)進行有效的模糊測試
本文將同讀者一起,探索如何為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(),具體如下所示:
staticinlinevoidmemset(void*dest,intvalue,size_tcount) { asmvolatile("repstosb"//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 staticvoid*mem; staticvoidemit_code(); typedefvoid(*generated_code_fn)(void); intmain(intargc,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_tchild=fork(); if(child==-1) error(EXIT_FAILURE,errno,"fork()"); if(child==0){ //we'rethechild;callournewlygeneratedfunction ((generated_code_fn)mem)(); exit(EXIT_SUCCESS); } //we'retheparent;waitforthechildtoexit while(1){ intstatus; if(waitpid(child,&status,0)==-1){ if(errno==EINTR) continue; error(EXIT_FAILURE,errno,"waitpid()"); } break; } } return0; }
然後,我們還將實現一個非常簡單的emit_code(),到目前為止,只建立了一個包含單個retq指令的函式:
staticvoidemit_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 .globalmain 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"],[/*53vars*/])=0 [...] read(0,NULL,0)=0 exit_group(0)=? +++exitedwith0+++
要獲得彙編後機器程式碼的位元組內容,我們可以先使用gcc-c read.s進行編譯,然後使用objdump -d read.o獲取相應的內容:
0000000000000000 0:b800000000mov$0x0,%eax 5:bf00000000mov$0x0,%edi a:be00000000mov$0x0,%esi f:ba00000000mov$0x0,%edx 14:0f05syscall 16:b800000000mov$0x0,%eax 1b:c3retq
要將這個位元組序列新增到我們的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 staticstd::default_random_enginernd; intmain(...) { std::random_devicerdev; rnd=std::default_random_engine(rdev()); ... }
然後,我們可以在進行系統呼叫之前,使用類似下面的方式來設定(或清除)該標誌位:
switch(std::uniform_int_distribution case0: //cld *out++=0xfc; break; case1: //std *out++=0xfd; break; }
同樣,這些位元組只是用於手工拼裝一個短測試程式,然後檢視objdump的輸出結果。
注意:在子程序中生成隨機數的時候,我們要格外小心;因為我們不希望所有的子程序都生成相同的數字!這就是為什麼我們實際上在父程序中生成程式碼,並在子程序中簡單地執行它們的原因。
未完待續……
請務必閱讀本系列文章的第2篇,屆時,我們將深入研究堆疊指標、段暫存器(包括32位相容模式)、除錯暫存器以及實際進入核心的過程!