———————————————————————————————————————————————————————— 在 rootkit 與惡意軟體開發中有一項基本需求,那就是 hook Windows 內核的系統服務描述符表(下稱 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,而後者是一個數組,其內的每個成員都是一枚函數指針,持有相應的系統服務常式入口地址。
有的時候,用言語來描述內核的一些概念過於抽象和詞窮,還是來看看下圖吧,它很形象地展示了上述關係:
根據上圖我們有了思路:首先設法獲取當前運行線程的 _KTHREAD 結構,然後即可逐步定位到 KiServiceTable,它就是我們最終
hook 的對象!
鑒於 ServiceTable 是一枚指針,持有另一枚指針 KeServiceDescriptorTable 的地址
(亦即“指向指針的指針”,往後我會不加以區分“持有”與“指向”術語),而 KiServiceTable 則是一個函數指針數組;
在 Rootkit 源碼中,它們可以分別用三個全局變數(在驅動的入口點 DriverEntry() 之外聲明 )表示,如下圖,我使用了
“自註釋”的變數名,很易於理解;而且我把星號緊接類型保留字後面,避免與“解引”操作混淆(所以星號是一個重載的運算
符):
————————————————————————————————————————————————————————
對於內核模式驅動程式開發人員來講,自己實現一個常式來獲取當前運行線程的 _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 即可,如下圖:
而我們需要在這枚指針上執行加法運算,移動它到 ServiceTable 欄位處,所以不能聲明一個 PETHREAD 變數來存儲
PsGetCurrentThread() 的返回值,因為“指針加上數值 n ”會把指針當前持有的地址加上( n * 該指針所指的數據類型大小 )個
位元組—— 表達式
1 PETHREAD ethread_ptr += 0xbc;
實際上把起始地址加上了 0xbc * sizeof(ETHREAD) 個位元組,遠遠超出了我們的預期。。。。
怎麼辦呢?好辦,聲明一個位元組型指針來保存 PsGetCurrentThread() 的返回值,同時把返回值強制轉型為一致的即可!
如此一來,表達式
1 BYTE* byte_ptr += 0xbc;
就是把起始地址加上 0xbc * sizeof(BYTE) 個位元組,符合我們的預期。
註意,這要求我們添加相關的類型定義,如下圖:
這表明 BYTE 與 無符號字元型等價(還等於微軟自家的 UCHAR),大小都是單位元組;DWORD 則與無符號長整型等價,大小都是
四位元組——我們用一個 DWORD 變數存儲數組 KiServiceTable 的地址。
————————————————————————————————————————————————————————
接下來就是通過一系列的指針轉型和解引操作,定位到 KiServiceTable 的過程,再次凸顯了指針在 C 編程中的地位,無論是應用
程式還是內核。。。。。經過如下圖的賦值運算,最終,全局變數 os_ki_service_table 持有了 KiServiceTable 的地址。註意,除
了那個偏移量的巨集定義外,所有的運算都在我們的驅動入口常式 DriverEntry() 中完成,而且為了支持動態卸載,我註冊了
Unload() 回調,稍後你會看到 Unload() 的內部實現——大致就是卸載時取消對 KiServiceTable 的寫許可權映射。
————————————————————————————————————————————————————————————————————————————————
為了驗證定位 KiServiceTable 過程的準確性,我添加了下列列印輸出語句,註意,DbgPrint() 的輸出需要在被調試機器上以
DbgView.exe 查看;抑或直接輸出到調試機器上的 windbg.exe/kd.exe 屏幕上:
——————————————————————————————————————————————————————————————————————————————
結合上圖,在調試器中進行驗證——“dd”命令可以按雙字(四位元組)顯示給定虛擬記憶體地址處的內容;“dps”命令可以按照函
數符號顯示從給定記憶體地址開始的常式地址——它就是專為函數指針數組(例如 KiServiceTable)設計的,如下圖:
——————————————————————————————————————————————————————————
現在,KiServiceTable 可以經由全局變數 os_ki_service_table 以只讀形式訪問,在我們 hook 它之前,需要設法更改為可寫。
先來看看嘗試向只讀的 KiServiceTable 寫入時會發生什麼事情,如下圖所示,我通過 RtlFillMemory() 試圖向 KiServiceTable
持有的第一個四位元組(亦即系統服務 nt!NtAcceptConnectPort )填充 4 個 ASCII 字元“A”:
————————————————————————————————————————————————————————————
註意,RtlFillMemory() 的第一個參數是一個指針,指向要被填充的記憶體塊,後面二個參數分別是填充的長度與數據;由於我們的
變數 os_ki_service_table 是 DWORD 型,所以我把它強制轉型為匹配的指針,再作為實參傳入。。。。重新構建驅動,
放入以調試模式運行的虛擬機中載入,宿主機中發生的情況如下圖所示,假設我們編譯好的 rootkit 名稱為
UseMdlMappingSSDT.sys ,
圖中表明出現一個致命系統錯誤,代碼為 0x000000BE,圓括弧裡邊是攜帶錯誤信息的四個參數,在故障排查時會用到它們。
事實上,這就是一個 BugCheck,當錯誤檢查發生時,如果目標系統連接著宿主機上的調試器,就斷入調試器,否則目標系統
上將執行 KeBugCheckEx() 常式,後者會屏蔽掉所有處理器核上的中斷事件,然後將顯示器切換到低分辯率的 VGA 圖形模式下,
繪製一個藍色背景,然後向用戶顯示 “檢查結果” 對應的停機代碼。這就是“藍屏”的由來。
——————————————————————————————————————————————————————————
在此場景中,我們得到一個 0x000000BE 的停機代碼,將其作為關鍵字串搜索 MSDN 文檔,給出的描述如下圖:
————————————————————————————————————————————————————————
官方講解的很清楚:0x000000BE(ATTEMPTED_WRITE_TO_READONLY_MEMORY)停機代碼是由於驅動程式嘗試向一個只讀
的記憶體段寫入導致的;第一個參數是試圖寫入的虛擬地址,第二個參數是描述該虛擬地址所在虛擬頁-物理頁的 PTE(頁表項)
內容;後面兩個參數為保留未來擴展使用,所以被我截斷了。結合前面一張圖我們知道,嘗試寫入的虛擬地址為
0x83CAFF7C,描述映射它的物理頁的 PTE 內容是 0x03CAF121,後面兩個參數就目前而言可以忽略。
如下圖所示,0x83CAFF7C 就是 KiServiceTable 的起始地址;描述它的 PTE 經解碼後的標誌部分有一個“R”屬性,表示
只讀;BugCheck 時刻的棧回溯信息顯示,內核中通用的異常處理程式 MmAccessFault() 負責處理與記憶體訪問相關的錯誤,
它是一個前端解析常式,如果異常或錯誤能夠處理,它就分發至實際的處理函數,否則,它調用 KeBugCheck*() 系列函數,
該家族函數會根據調試器的存在與否作出決定——要麼調用 KiBugCheckDebugBreak() 斷入調試器;要麼執行如前文所述的操作
流程來繪製藍屏:
————————————————————————————————————————————————————————————
至此確定了 BugCheck 是由於在驅動中調用 RtlFillMemory() 寫入只讀的內核記憶體引發的。另一個更強大的調試器擴展命令
“!analyze -v”可以輸出詳細的信息,包括 BugCheck “現場”的指令地址和寄存器狀態,如下圖所示,導致 BugCheck 的
指令地址為 0x9ff990b4,該指令把 eax 寄存器的當前值(0x41414141,亦即我們調用 RtlFillMemory() 傳入的 4 個 ASCII 字
符“A”)寫入 ecx 寄存器持有的記憶體地址處,試圖把 nt!NtAcceptConnectPort() 的入口點地址替換成 0x41414141 ;另外它會
給出驅動源碼中對應的行號——也就是第 137 行的 RtlFillMemory() 調用:
——————————————————————————————————————————————————————————
如你所見,微軟 C/C++ 編譯器(cl.exe)把 RtlFillMemory() 內聯在它的調用者內部,換言之,儘管有公開的文檔描述它的
返回值,參數。。。。具體的實現還是由編譯器說了算——為了性能優化,RtlFillMemory() 直接實現為一條簡潔的數據移動
指令,相關的參數由寄存器傳遞,沒有因函數調用創建與銷毀棧幀帶來的額外開銷!
到目前為止,儘管我們通過一系列步驟從 _KTHREAD 定位到了系統服務指針表,但以常規手段卻無法 hook 其中的系統服務函
數,因為它是只讀的。
下一篇文章我將討論如何使用 MDL(Memory Descriptor List,記憶體描述符鏈表)來繞過這種限制,隨心所欲地讀寫
KiServiceTable!