MDL突破 SSDT的只讀訪問限制(一)

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

在 rootkit 與惡意軟件開發中有一項基本需求,那就是hook Windows內核的系統服務描述符表(下稱 SSDT),把該表中的特定系統服務函數替換成我們自己實現的惡意例程;當然,為了確保系統能夠正常運作,我們需要事先用一個函數指針保存原始的系統服務,並且在我們惡意例程的邏輯中調用這個函數指針,此後才能進行 hook,否則損壞的內核代碼與數據結構將導致一個 BugCheck(俗稱的藍屏)。

儘管 64 位 Windows 引入了像是 PatchGuard 的技術,實時監控關鍵的內核數據,包括但不限於 SSDT,IDT,GDT 等等,保證其完整性,但在 32 系統上修改 SSDT 是經常會遇到的場景,所以本文還是對此做出了介紹。

OS 一般在系統初始化階段把 SSDT 設定成只讀訪問,這也是為了避免驅動與其它內核組件無意間改動到它;所以我們的首要任務就是設法繞過這個只讀屬性。

在此之前,先複習一下與 SSDT 相關的幾個數據結構,並解釋定位 SSDT 的過程。

我們知道,每個線程的 _KTHREAD 結構中,偏移 0xbc 字節處是一枚叫做ServiceTable的泛型指針(亦即 PVOID 或 void*),該字段指向一個全局的數據結構,叫做KeServiceDescriptorTable,它就是 SSDT。

SSDT 中首個字段又是一枚指針,指向全局的數據結構KiServiceTable,而後者是一個數組,其內的每個成員都是一枚函數指針,持有相應的系統服務例程入口地址。

有的時候,用言語來描述內核的一些概念過於抽象和詞窮,還是來看看下圖吧,它很形象地展示了上述關係:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

根據上圖我們有了思路:首先設法獲取當前運行線程的 _KTHREAD 結構,然後即可逐步定位到KiServiceTable,它就是我們最終

hook 的對象!

鑑於ServiceTable是一枚指針,持有另一枚指針KeServiceDescriptorTable的地址(亦即 “指向指針的指針”,往後我會不加以區分 “持有” 與 “指向” 術語),而 KiServiceTable 則是一個函數指針數組;

在 Rootkit 源碼中,它們可以分別用三個全局變量(在驅動的入口點DriverEntry()之外聲明 )表示,如下圖,我使用了“自注釋” 的變量名,很易於理解;而且我把星號緊接類型保留字後面,避免與 “解引” 操作混淆(所以星號是一個重載的運算符):

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

對於內核模式驅動程序開發人員來講,自己實現一個例程來獲取當前運行線程的 _KTHREAD 結構顯然並不輕鬆,幸運的是,文檔化的 PsGetCurrentThread()例程能夠完成這一任務。

(事實上,PsGetCurrentThread()的反彙編代碼恰恰說明了這很簡單,如下代碼,僅僅只是把fs:[00000124h]地址處的內容移動到 eax 寄存器作為返回值,而且 KeGetCurrentThread() 的邏輯與它如出一撤!)

1 kd> u PsGetCurrentThread

2

3 nt!PsGetCurrentThread:

4 83c6cd19 64a124010000 mov eax,dword ptr fs:[00000124h]

5 83c6cd1f c3 ret

6 83c6cd20 90 nop

7 83c6cd21 90 nop

8 83c6cd22 90 nop

9 83c6cd23 90 nop

10 83c6cd24 90 nop

11 nt!KeReadStateMutant:

12 83c6cd25 8bff mov edi,edi

13

14

15 kd> u KeGetCurrentThread

16

17 nt!PsGetCurrentThread:

18 83c6cd19 64a124010000 mov eax,dword ptr fs:[00000124h]

19 83c6cd1f c3 ret

20 83c6cd20 90 nop

21 83c6cd21 90 nop

22 83c6cd22 90 nop

23 83c6cd23 90 nop

24 83c6cd24 90 nop

老生常談,fs 寄存器通常用來存放 “段選擇符”,“段選擇符” 用來索引 GDT 中的一個 “段描述符”,後者有一個 “段基址” 屬性,也就是KPCR(Kernel Processor Control Region,內核處理器控制區域)結構(nt!_KPCR)的起始地址;

nt!_KPCR偏移 0x120 字節處是一個 nt!_KPRCB 結構,後者偏移 0x4 字節處的 “CurrentThread” 字段就是一個 _KTHREAD 結構,每次線程切換都會更新該字段,這就是 fs:[00000124h]簡潔的背後隱藏的強大設計思想!

注意,PsGetCurrentThread()返回一枚指向 _ETHREAD 結構的指針(亦即 “PETHREAD”,如你所見,微軟喜歡在指針這一概念上大玩 “頭文字 P” 遊戲),而 _ETHREAD 結構的首個字段 Tcb 就是一個 _KTHREAD 實例——這意味著,我們無需計算額外的偏移量,只要考慮那個 ServiceTable 的偏移量 0xbc 即可,如下圖:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

而我們需要在這枚指針上執行加法運算,移動它到 ServiceTable 字段處,所以不能聲明一個 PETHREAD 變量來存儲PsGetCurrentThread() 的返回值,因為 “指針加上數值 n ” 會把指針當前持有的地址加上( n * 該指針所指的數據類型大小 )個字節—— 表達式

1 PETHREAD ethread_ptr += 0xbc;

實際上把起始地址加上了0xbc * sizeof(ETHREAD)個字節,遠遠超出了我們的預期......怎麼辦呢?

好辦,聲明一個字節型指針來保存PsGetCurrentThread()的返回值,同時把返回值強制轉型為一致的即可!如此一來,表達式

1 BYTE* byte_ptr += 0xbc;

就是把起始地址加上0xbc * sizeof(BYTE)個字節,符合我們的預期。注意,這要求我們添加相關的類型定義,如下圖:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

這表明 BYTE 與 無符號字符型等價(還等於微軟自家的 UCHAR),大小都是單字節;DWORD 則與無符號長整型等價,大小都是四字節——我們用一個 DWORD 變量存儲數組 KiServiceTable 的地址。

接下來就是通過一系列的指針轉型和解引操作,定位到 KiServiceTable 的過程,再次凸顯了指針在 C 編程中的地位,無論是應用程序還是內核......經過如下圖的賦值運算,最終,全局變量os_ki_service_table持有了KiServiceTable的地址。

注意,除了那個偏移量的宏定義外,所有的運算都在我們的驅動入口例程 DriverEntry() 中完成,而且為了支持動態卸載,我註冊了Unload() 回調,稍後你會看到 Unload() 的內部實現——大致就是卸載時取消對 KiServiceTable 的寫權限映射。

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

為了驗證定位 KiServiceTable 過程的準確性,我添加了下列打印輸出語句,注意,DbgPrint() 的輸出需要在被調試機器上以 DbgView.exe 查看;抑或直接輸出到調試機器上的 windbg.exe/kd.exe 屏幕上:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

結合上圖,在調試器中進行驗證—— “dd” 命令可以按雙字(四字節)顯示給定虛擬內存地址處的內容;“dps” 命令可以按照函數符號顯示從給定內存地址開始的例程地址——它就是專為函數指針數組(例如 KiServiceTable)設計的,如下圖:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

現在,KiServiceTable 可以經由全局變量 os_ki_service_table 以只讀形式訪問,在我們 hook 它之前,需要設法更改為可寫。先來看看嘗試向只讀的 KiServiceTable 寫入時會發生什麼事情,如下圖所示,我通過 RtlFillMemory() 試圖向 KiServiceTable 持有的第一個四字節(亦即系統服務 nt!NtAcceptConnectPort )填充 4 個 ASCII 字符 “A”:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

注意,RtlFillMemory() 的第一個參數是一個指針,指向要被填充的內存塊,後面二個參數分別是填充的長度與數據;由於我們的變量 os_ki_service_table 是 DWORD 型,所以我把它強制轉型為匹配的指針,再作為實參傳入......

重新構建驅動,放入以調試模式運行的虛擬機中加載,宿主機中發生的情況如下圖所示,假設我們編譯好的 rootkit 名稱為UseMdlMappingSSDT.sys,圖中表明出現一個致命系統錯誤,代碼為 0x000000BE,圓括號裡邊是攜帶錯誤信息的四個參數,在故障排查時會用到它們。

事實上,這就是一個 BugCheck,當錯誤檢查發生時,如果目標系統連接著宿主機上的調試器,就斷入調試器,否則目標系統上將執行 KeBugCheckEx() 例程,後者會屏蔽掉所有處理器核上的中斷事件,然後將顯示器切換到低分辯率的 VGA 圖形模式下,繪製一個藍色背景,然後向用戶顯示 “檢查結果” 對應的停機代碼。這就是 “藍屏” 的由來。

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

在此場景中,我們得到一個 0x000000BE 的停機代碼,將其作為關鍵字串搜索 MSDN 文檔,給出的描述如下圖:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

官方講解的很清楚:0x000000BE(ATTEMPTED_WRITE_TO_READONLY_MEMORY)停機代碼是由於驅動程序嘗試向一個只讀的內存段寫入導致的;第一個參數是試圖寫入的虛擬地址,第二個參數是描述該虛擬地址所在虛擬頁-物理頁的 PTE(頁表項)內容;後面兩個參數為保留未來擴展使用,所以被我截斷了。

結合前面一張圖我們知道,嘗試寫入的虛擬地址為 0x83CAFF7C,描述映射它的物理頁的 PTE 內容是 0x03CAF121,後面兩個參數就目前而言可以忽略。

如下圖所示,0x83CAFF7C就是KiServiceTable的起始地址;描述它的 PTE 經解碼後的標誌部分有一個 “R” 屬性,表示只讀;BugCheck 時刻的棧回溯信息顯示,內核中通用的異常處理程序MmAccessFault()負責處理與內存訪問相關的錯誤,它是一個前端解析例程。

如果異常或錯誤能夠處理,它就分發至實際的處理函數,否則,它調用KeBugCheck*()系列函數,該家族函數會根據調試器的存在與否作出決定——要麼調用KiBugCheckDebugBreak() 斷入調試器;要麼執行如前文所述的操作流程來繪製藍屏:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

至此確定了BugCheck是由於在驅動中調用RtlFillMemory()寫入只讀的內核內存引發的。另一個更強大的調試器擴展命令 “!analyze -v” 可以輸出詳細的信息,包括BugCheck “現場” 的指令地址和寄存器狀態、

如下圖所示,導致BugCheck的指令地址為0x9ff990b4,該指令把 eax 寄存器的當前值(0x41414141,亦即我們調用RtlFillMemory()傳入的 4 個 ASCII 字符 “A”)寫入 ecx 寄存器持有的內存地址處,試圖把nt!NtAcceptConnectPort()的入口點地址替換成 0x41414141;另外它會給出驅動源碼中對應的行號——也就是第 137 行的RtlFillMemory() 調用:

ROOTKIT 核心技術—利用 NT!_MDL突破 SSDT的只讀訪問限制(一)

如你所見,微軟 C/C++ 編譯器(cl.exe)把RtlFillMemory()內聯在它的調用者內部,換言之,儘管有公開的文檔描述它的返回值,參數......具體的實現還是由編譯器說了算——為了性能優化,RtlFillMemory()直接實現為一條簡潔的數據移動指令,相關的參數由寄存器傳遞,沒有因函數調用創建與銷燬棧幀帶來的額外開銷!

到目前為止,儘管我們通過一系列步驟從_KTHREAD定位到了系統服務指針表,但以常規手段卻無法 hook 其中的系統服務函數,因為它是隻讀的。下一篇文章我將討論如何使用 MDL(Memory Descriptor List,內存描述符鏈表)來繞過這種限制,隨心所欲地讀寫KiServiceTable!


分享到:


相關文章: