——————————————————————————————————————————————————————————————————————————————————————————— 本篇開始進入正題,因為涉及 MDL,所以相關的背景知識是必須的: nt!_MDL 代表一個“記憶體描述符鏈表”結構,它 ...
———————————————————————————————————————————————————————————————————————————————————————————
本篇開始進入正題,因為涉及 MDL,所以相關的背景知識是必須的:
nt!_MDL 代表一個“記憶體描述符鏈表”結構,它描述著用戶或內核模式虛擬記憶體(亦即緩衝區),其對應的那些物理頁被鎖定住,
無法換出。
因為一個虛擬的,地址上連續的用戶或內核緩衝區可能映射到多個不連續的物理頁,所以 nt!_MDL 定長(0x1c 位元組)的頭部後緊
跟數量可變的頁框號(Page Frame Numbers),MDL 描述的每個物理頁面都有一個頁框號,
於是這些頁框號引用的物理地址範圍就對應了一片特定的用戶或內核模式緩衝區。
通常虛擬和物理頁的大小為 4 KB,KiServiceTable 中的系統服務數量為 401 個,每函數的入口點占用 4 位元組,整張調用表大小
為 1.6 KB,通過 MDL 僅需要一張物理頁即可描述這個緩衝區;在這種情況下,該 MDL 後只有一個頁框號。
儘管 nt!_MDL 是半透明的結構,不過在內核調試器以及 WRK 源碼面前還是被脫的一絲不掛,如下圖為 WRK 源碼
的“ntosdef.h”頭文件中的定義,如你所見,稱為“鏈表”乃因它的首個欄位“Next”是一枚指針,指向後一個 nt!_MDL 結構。
對於我們 hook KiServiceTable 的場景而言,無需用到 Next 欄位;那什麼情況下會用到呢?
Windows 中某些類型的驅動程式,例如網路棧,它們支持 MDL 鏈,其內的多個 MDL 描述的那些緩衝區實際上是零散的,
假設棧中每個驅動都分配一個 MDL,其後跟著一些物理頁框號來描述它們各自用到的虛擬緩衝區,那麼這些緩衝區就通過
每個 _MDL 的 Next 欄位(指向下一個 MDL)鏈接起來。
————————————————————————————————————————————————————————————
對於描述用戶模式緩衝區的 MDL,其內的 Process 欄位指向所屬進程的 EPROCESS 結構,進程中的這塊虛擬地址空間被 MDL 鎖
住。
如果由 MDL 描述的緩衝區映射到內核虛擬地址空間中,_MDL 的 MappedSystemVa 欄位指向內核模式緩衝區的基地址。
僅當 _MDL 的 MdlFlags 欄位內設置了 MDL_MAPPED_TO_SYSTEM_VA 或 MDL_SOURCE_IS_NONPAGED_POOL 比特位,
MappedSystemVa 欄位才有效。
_MDL 的 Size 欄位含有 MDL 頭部加上其後的整個 PFN 數組總大小。
上圖還包含了 MdlFlags 欄位的所有標誌巨集定義,這個 2 位元組的欄位可以是任意巨集的組合,用於說明 MDL 的一些狀態與屬性。
MDL 的 StartVa 欄位和 ByteOffset 欄位共同定義了由該 MDL 鎖定的原始緩衝區的起始地址。
(原始緩衝區可能會映射到其它內核緩衝區或用戶緩衝區)
StartVa 指向虛擬頁的起始地址,ByteOffset 包含實際從 StartVa 開始的緩衝區偏移量
MDL 的 ByteCount 欄位描述由該 MDL 鎖定的緩衝區大小(以位元組為單位)
對於我們要 hook 的 KiServiceTable 而言, KiServiceTable 這片內核緩衝區所在的虛擬頁起點由 StartVa 欄位攜帶;
ByteOffset 欄位則攜帶 KiServiceTable 的頁內偏移量,ByteCount 欄位攜帶 KiServiceTable 這片內核緩衝區的大小。
如果你現在看得雲里霧裡,不用擔心,後面我們在調試時會把描述 KiServiceTable 的一個 nt!_MDL 結構實例拿出來分析,
到時候你就會恍然大悟這些欄位的設計思想了。
————————————————————————————————————————————————————————————
通過編程方式使用 MDL 繞過 KiServiceTable 的只讀屬性,需要藉助 Windows 執行體組件中的 I/O 管理器以及
記憶體管理器導出的一些函數,大致流程如下:
IoAllocateMdl() 分配一個 MDL 來描述 KiServiceTable -> MmProbeAndLockPages() 把該 MDL 描述的 KiServiceTable 所
屬物理頁鎖定在記憶體中,並賦予對這張頁面的讀寫訪問許可權(實際是將描述該頁面的 PTE 內容中的“R”標誌位修改成“W”)
->MmGetSystemAddressForMdlSafe() 將 KiServiceTable 映射到另一片內核虛擬地址區域(一般而言,位於 rootkit 被載入
到的內核地址範圍內)。
如此一來,KiServiceTable 的原始虛擬地址與新映射的虛擬地址都轉譯到相同的物理地址,而且描述新虛擬地址的 PTE 內容標記了
寫許可權比特位,這樣我們就能夠通過修改這個新的虛擬地址中的系統服務常式實現安全掛鉤 KiServiceTable,不會導致
BugCheck。
如下所示,我把上述涉及的所有操作都封裝到一個自定義的函數 MapMdl() 裡面。由於整個邏輯比較長,截圖分為多張說明:
MapMdl() 在我們的 rootkit 入口點——DriverEntry() 中被調用,而在 DriverEntry() 外部聲明幾個與 MDL 相關的全局變數,
它們被 MapMdl() 與 DriverEntry() 共用。
註意,os_ki_service_table 存儲著 KiServiceTable 的地址(參見前一篇定位 KiServiceTable 的代碼),
把它從 DWORD 轉換為泛型指針是為了符合 MapMdl() 中的 IoAllocateMdl() 調用時的形參要求;最後一個參數——表達式
0x191 * 4——就是整個 KiServiceTable 緩衝區的大小:假若 MapMdl() 成功返回,則全局變數 mapped_ki_service_table
持有 KiServiceTable 新映射到的內核虛擬地址;這些全局變數都是“自註釋”的,pfn_array_follow_mdl 持有的地址處內容
就是 MDL 描述的物理頁框號:
——————————————————————————————————————————————————————————————
MapMdl()第一部分邏輯如下圖所示,局部變數 mapped_addr 預期存放 KiServiceTable 新映射到的內核虛擬地址,並作為
MapMdl() 的返回值給 DriverEntry(),進一步初始化全局變數 mapped_ki_service_table。
註意,PVOID 可以賦給其它任意類型的指針,這是合法的。
IoAllocateMdl() 返回一枚指針,指向分配好的 MDL,該 MDL 描述 KiServiceTable 的物理記憶體佈局;這枚指針被用來初始化
作為實參傳入的全局變數 mdl_ptr(mdl_pointer 是形參)。
我添加的第一個軟體斷點就是為了研究 IoAllocateMdl() 分配的 MDL 其中 MappedSystemVa,StartVa,以及 MdlFlags 這些
欄位的內容——事實上,這些欄位值會在
IoAllocateMdl() -> MmProbeAndLockPages() ->MmGetSystemAddressForMdlSafe()
調用鏈的每一階段發生變化,所以我總共添加了三個斷點在相關的檢查區域,有助於我們在後面的調試過程中深入理解 nt!_MDL
的設計思想。
我把使用 Windows 執行體組件常式進行的操作放入一個 try-except 塊內,以便處理可能出現的異常,except 塊內的邏輯如下
圖,當違法訪問出現時,調用 IoFreeMdl() 釋放我們的 MDL 指針,然後 MapMdl() 返回 NULL,從而導致 DriverEntry() 列印出
錯信息。
————————————————————————————————————————————————————————————
關於 IoAllocateMdl() 的第二個參數,我們有必要進一步瞭解,所以我翻譯了 MSDN 文檔上的相關片段,如下:
IoAllocateMdl() 的第二個參數指定要通過分配的 MDL 描述的緩衝區的大小。如果這個長度小於 4KB,
那麼映射它的 MDL 就只描述了一個被鎖定的物理頁面;
如果長度是 4KB 的整數倍,那麼映射它的 MDL 就描述了相應數量的物理頁面(通過緊接 MDL 後面的 PFN 數組)
對於 Windows Server 2003,Windows XP,以及 Windows 2000,
此常式支持的最大緩衝區長度(以位元組為單位)是:
PAGE_SIZE * (65535 - sizeof(MDL)) / sizeof(ULONG_PTR) (約 67 MB)
對於 Windows Vista 和 Windows Server 2008,能夠傳入的最大緩衝區大小為:
(2 gigabytes - PAGE_SIZE)
對於 Windows 7 和 Windows Server 2008 R2,能夠傳入的最大緩衝區大小為:
(4 gigabytes - PAGE_SIZE)
執行此常式的 IRQL 要求為 <= DISPATCH_LEVEL
————————————————————————————————————————————————————————————
MapMdl()第二部分邏輯如下圖所示,它緊跟在第一個軟體斷點之後。我們檢查 MDL 中的 MDL_ALLOCATED_FIXED_SIZE 標誌是
否置位,該標誌因調用 IoAllocateMdl() 傳入第二個參數指示固定大小而置位;MmProbeAndLockPages() 的第三個參數是實現
寫訪問的關鍵所在,能否鎖定記憶體倒是其次,因為像 KiServiceTable 這種系統範圍的調用表,地位非常重要,如果被換出物理內
存,系統豈不就崩潰了,所以坦白講我們只是因為需要寫許可權才調用它的。
第二個斷點緊跟其後,這樣就可以在調試器中檢查 MmProbeAndLockPages() 是如何修改 MDL 中的標誌;也可以使用編程手段
檢查,如圖中的第二個 if 塊邏輯,事實上 MmProbeAndLockPages() 調用會向 MdlFlags 欄位內添加 MDL_WRITE_OPERATION
與 MDL_PAGES_LOCKED 標誌,這就是我們想要的結果!
最後我們調用 MmGetSystemAddressForMdlSafe() 把該 MDL 描述的原始虛擬地址映射到內核空間的另一處,新地址通常位於
驅動載入到的內核空間某處;局部變數 mapped_addr 持有這個新地址,最終用來返回並初始化全局變數
mapped_ki_service_table。
同理我們可以檢查 MmGetSystemAddressForMdlSafe() 修改了哪些 MDL 結構成員,對於理解 MDL 的工作機理非常關鍵。
————————————————————————————————————————————————————————————
MapMdl()第三部分邏輯如下圖所示,我們檢查 MmGetSystemAddressForMdlSafe()是否多添加了一個
MDL_MAPPED_TO_SYSTEM_VA 標誌,然後以 DBG_TRACE 巨集列印信息。
全局變數 backup_mdl_ptr 是我們在調用 IoAllocateMdl() 就做好備份的 MDL 指針,它與 mdl_ptr 指向同一個 nt!_MDL 結構。
接下來的邏輯有助於你理解 MDL 頭部後面的 PFN 數組:mdl_ptr 指向 nt!_MDL 結構頭部,把它加上 1 ,意味著把它持有的
記憶體地址加上 1 * sizeof(MDL) 個位元組,於是就定位到了 MDL 頭部後面的 PFN 數組起始地址——現在全局變數
pfn_array_follow_mdl(一枚 PPFN_NUMBER 型指針)持有這個地址;正如圖中倒數第三條 DbgPrint() 調用所言——
MDL 結構後偏移 xx (0x1b)地址處是一個 PFN 數組,用來存儲該 MDL 描述的虛擬緩衝區映射到的物理頁框號。
最後一條 DbgPrint() 調用通過解引 pfn_array_follow_mdl 來輸出該地址處存放的物理頁框號。
在 return mapped_addr; 語句的後面,則是 try-except 塊的異常捕獲邏輯,請參前面截圖。
————————————————————————————————————————————————————————————
現在,程式訪問可讀寫的 mapped_ki_service_table 與只讀的 os_ki_service_table 都轉譯到同一塊物理記憶體,
後者就是實際上存儲 KiServiceTable 的地方。接下來,我們用一枚函數指針保存 KiServiceTable 中某個原始的系統服務,
然後用我們的鉤子常式地址替換掉該位置處的原始系統服務,而鉤子常式內部僅僅是調用原始系統服務,實現安全轉發。
為了演示簡單起見,我選取 KiServiceTable 中 0x39(57)號常式,因為它的參數只有一個,方便我們的鉤子常式仿效同樣的
參數聲明——內核系統服務調度器(nt!KiFastCallEntry())並不知道它調用的目標系統服務已經被替換成我們的鉤子常式,
所以他會以既定方式使用鉤子常式的返回值和輸出參數,在這種情況下,只要我們的鉤子常式原型聲明與被掛鉤系統服務有
細微差別,都可能導致非預期的內核錯誤而藍屏,顯然,那些參數既多又複雜的系統服務不適合我用來演示。
此外,某些系統服務接收的參數類型的定義不在 wdm.h / ntddk.h 頭文件內,講明瞭這些數據類型不是給驅動開發人員使用的,
僅供內核組件使用,為了引入包含該定義的頭文件則會碰到複雜的頭文件嵌套包含問題,其麻煩程度絲毫不遜於 Linux 平臺上
的“二進位軟體包依賴性地獄”。
57 號系統服務常式亦即 nt!NtCompleteConnectPort(),有且僅有一個文檔化的參數,WRK 源碼中的相關定義如下圖:
————————————————————————————————————————————————————————————
所以我們的鉤子常式只要完全仿效它的返回值類型與形參類型即可,然後在內部調用指向原始常式的函數指針實施重定向。
通過 typedef 定義一個函數指針,其返回值類型與形參類型與 NtCompleteConnectPort() 一致,然後聲明一個該函數指針
實例。相關代碼如下圖:
————————————————————————————————————————————————————————————
全局變數 ori_sys_service_ptr 持有 NtCompleteConnectPort() 的入口點地址,前者是在我們的 rootkit 入口點
DriverEntry() 中初始化的;保存這枚指針後就可以用鉤子常式替換 NtCompleteConnectPort(),如下圖所示:
需要指出一點,儘管把指針名稱 mapped_ki_service_table 當作數組名稱來訪問 KiServiceTable 是被 C 語言核心規範允許的,
但是上圖那段代碼在編譯器會產生警告,如下:
1 1>warnings in directory d:\kmdsource_use_mdl_mapping_ssdt 2 1>d:\kmdsource_use_mdl_mapping_ssdt\usemdlmappingssdt.c(155) : warning C4047: '=' : 'OriginalSystemServicePtr' differs in levels of indirection from 'DWORD' 3 1>d:\kmdsource_use_mdl_mapping_ssdt\usemdlmappingssdt.c(157) : warning C4047: '=' : 'DWORD' differs in levels of indirection from 'NTSTATUS (__stdcall *)(HANDLE)'
ori_sys_service_ptr 是一枚 OriginalSystemServicePtr 型函數指針( NTSTATUS (__stdcall *)(HANDLE) ),而
mapped_ki_service_table 是普通指針,它的數組名稱表示法結合數組下標,實際上被視為一個存儲對應元素的 DWORD 變數,
兩者的間接定址級別不同。
就目前而言我們可以無視這兩條警告,因為含有這段代碼的 rootkit 源碼在編譯後確實能夠安全地 hook 目標系統服務函數,系統
正常運作不會有問題,類似的警告可以通過指定警告級別的編譯選項來過濾掉。
——————————————————————————————————————————————————————————————————————————————————
講到這裡你一定會嫌我既羅嗦又婆婆媽媽的,那麼來看下麵這一張簡明扼要的全局概覽,它解釋了 MDL 是如何把一片緩衝區
映射到另一處,並描述兩者相同的物理佈局,註意,圖中的組織結構是執行完 MmGetSystemAddressForMdlSafe() 後才會產生的。
註意,上圖中我沒有給出 PFN 數組中第一個成員攜帶的具體 20 位物理頁框號,原始和映射到的新內核緩衝區,以及實際 RAM
中的物理頁框號,而“byte within page”就是頁內特定偏移處開始的位元組序列,亦即系統服務常式入口點的實際物理地址!
這些“占位符”我會在第三部分的調試單元內給出,畢竟,驅動開發與調試是相輔相成的,只有理論沒有實踐怎麼行,只有源碼
沒有調試怎知真理,不然,任何人對於記憶體的需求就真的不會超過 640 K 了。。。。。
——————————————————————————————————————————————————————————————————————————————————————
to be continued