重建天堂之門:從 32 位元地獄一路打回天堂聖地(上)深度逆向工程 WOW64 設計
前言
這份研究是從去年底開始摸的,主因是一直對 Windows 如何實作 WOW64 兼容架構覺得有意思,並且坊間蠻多開源的天堂之門專案都死掉了 QQ 所以才打算自己完全重新逆向工程分析過一次。
逆向整個 WOW64 架構可以發現很多蠻有意思的問題,比方說 Intel 晶片指令集模式切換、記憶體模擬分配、WOW64 偽造的原生 32-bit 環境,例如 System32 在 32-bit 下會被重導向至 SysWOW64 到底在哪一層實作重導向的呢 ;)
此研究也運氣不錯分別被國內研討會 CYBERSEC 收錄為《重建天堂之門:從 32bit 地獄一路打回天堂聖地》與 HITB(Hack In The Box Security Conference)收錄為 《WoW Hell: Rebuilding Heavens Gate》若對線上演講內容有興趣可以參考 Youtube 上 HITB 的直撥串流 HITB2021AMS D1T2 - WoW Hell: Rebuilding Heavens Gate - ShengHao Ma 不過我英文很破能不看就不看啦
由於整份演講內容想提的細節太多了,所以本部落格文拆成上下兩集。當前本文是上集,會講述完整逆向工程整套微軟設計的 WOW64 架構怎麼設計的。而下集《重建天堂之門:從 32 位元地獄一路打回天堂聖地(下)攻擊篇:x96 Shellcode、天堂聖杯 & 天堂注入器》會著重在我今年發表出來的幾個有趣的攻擊點 :)
如果對天堂之門技術已經很熟的大佬,可以跳過本文看下一集了 <(_ _)>
*注意*
本份研究在 2021/06/11 是基於 Windows 10 Enterprise 最新版本逆向工程結果所紀錄,可能與讀者電腦中反編譯出來的結果有所出入是正常的。筆者實測了同樣是 Windows 10 但不同組建版本在組合語言層級皆有些許差異,不過各個函數設計用途是固定且規律的、不會因版本而有所差異。
而整份研究不限但至少涉及了以下已知文獻,蠻值得一看的
- 2011 - Mixing x86 with x64 code by ReWolf
- 2012 - Knockin’ on Heaven’s Gate by george_nicolaou
- 2012 - KERNEL: Creation of Thread Environment Block (TEB) by waleedassar
- 2018 - WOW64 internals by wbenny
- 2020 - WOW64 Subsystem Internals and Hooking Techniques by FireEye
- 2021 - (new) Rebuild The Heaven's Gate: from 32 bit Hell back to Heaven Wonderland
WOW64 架構
Windows 32 on Windows 64 (WOW64) 是內建於 64-bit Windows 中的一套翻譯機設計、用來解決並幫助 32-bit 執行程式的系統中斷(System Interrupt)能被正確的翻譯為 64-bit 系統中斷。
已裝載的 32-bit ntdll 模組之函數(如圖所示左側)便是託管於 WOW64 架構之 32-bit 執行程式呼叫的任何 Win32 API 最終呼叫到的系統函數;然而在原生 64-bit 環境的系統上是無法直接吃 32-bit system interrupts 的。因此 call edx 那行指令,實質會呼叫到 WOW64 翻譯機負責將單次的 system interrupt 翻譯為 64-bit system interrupt 並呼叫 64-bit ntdll 模組上對應的系統函數(如圖所示右側)
而為何原生 64-bit 系統為何會無法吃 32-bit 系統中斷?主因是 x86/x64 Calling Convention 的不同。比方說:
- 資料結構排列方式:顯而易見的 32-bit & 64-bit 的同一種資料結構所排出來的記憶體狀況很明顯地會不一樣。因此 WOW64 架構應將參數中所有 32-bit 資料結構之內容正確裝填在 64-bit 的同種資料結構
- 參數定址問題:在 32-bit NTAPI 下應該按順序將參數壓入堆疊、而 64-bit NTAPI 則應遵守將參數按順序放在 r8, r9, rcx, rdx, 接著才是壓入堆疊。因此 WOW64 應將 32-bit 參數按 x64 Calling Convention 方式排放在暫存器與堆疊上,才能使 64-bit 系統函數在預期的地方取得參數資料
而若以原生 32-bit 程式跑在原生 32-bit 系統的狀況,那麼呼叫到圖中所示左方的 32-bit NtResumeThread 函數、接著走進 call edx 便會直接走進 ntdll 的 KiFastCall 函數並直接踩上 syscall 或 int 2Eh 中斷跳返回系統執行對應的行為,而不會進到 WOW64 層進行翻譯系統中斷的行為。
RunSimulatedCode
剛剛暗示到了一個重點 WOW64 Process 實質上是將 32-bit 程式「託管於」原生 64-bit Process 中,代表了任何 WOW64 Process 一開始也是 64-bit Thread 在執行一系列初始化任務,接著「通過某個函數」將自身降為 32-bit 模式的 Process 狀態、才跳入 32-bit 程式的入口——而這個函數便是 64-bit 模組 wow64cpu.dll 導出的 RunSimulatedCode 函數。
這個函數作為降級為 32-bit 模式的入口函數,如圖所示為其函數入口開頭程式碼片段。可見其在 64-bit Thread 的暫存器留下三個大重點:
- 透過 gs:30h 將暫存器 r12 指向到當前的 64-bit TEB 結構的地址
- 將 r15 指向到 wow64cpu.dll 全局變數上的一張指針清單 TurboThunkDispatch
- 從 64-bit TEB 結構 + 1488h 提取出來的地址會是 32-bit Thread Context 結構,並將其保存於暫存器 r13中,而這個結構用作 32-bit Thread 的狀態紀錄快照
關於最後一點:由於 WOW64 Process 在運作階段會需要頻繁切換 32/64-bit 的運行模式(或標準的作業系統分時多工設計)因此相對於每個 Thread 都會有一塊獨立的記憶體用於備份當下執行到哪、暫存器狀態、堆疊狀況 等,而這塊記憶體的地址便會固定被保存在 64-bit TEB 結構 +1488h offset 處,而這一點很重要、在本文後續會將其用於攻擊使用。
TurboThunkDispatch
- 最後一個函數 CpupReturnFromSimulatedCode 便是 32 走回 64 位元的第一個 64-bit 入口函數。每當 32-bit 程式踩到了任何 32-bit 系統函數需要進行 system interrupt 時,便會踩入 wow64cpu.dll 導出的這個函數對當前 32-bit Thread 狀態進行備份、並跳入 TurboDispatchJumpAddressEnd 以 64-bit ntdll 的函數進行仿真當前收到的系統中斷
- 第一個函數 TurboDispatchJumpAddressEnd 其用於呼叫 wow64.dll 導出的翻譯機函數 Wow64SystemServiceEx 完成仿真系統中斷,並在仿真完成後從上一次備份的 Thread 狀態進行恢復、並跳返回上一次 32-bit 程式的 return address 繼續運行
NTAPI Trampoline
而以上圖為例可以見到 jmp 33:+6009h 單行指令使用的是 opcode 為 0xEA 的 far jump,因此一但此行指令成功執行跳到 +6009h 處時、區段暫存器 cs segment 值便在跳躍的同時被寫入為 0x33。
接續著 Intel 處理器就會識別到 cs segment 為 0x33 便將接下來的程式碼以 x64 指令集方式進行解析,因此可以見到 +6009h 處開始的程式碼可以置放上 64-bit 的程式碼而使用到了 qword 與 r15 等 64-bit 保留字。
而 cs segment 值不同將影響 Intel 以不同指令集進行解析:
- 0x23 - 當前狀態為 WOW64 架構中的 32-bit Thread 模式
- 0x33 - 當前狀態為原生 64-bit Thread 狀態(跑在原生 64-bit 系統中)
- 0x1B - 當前狀態為原生 32-bit Thread 狀態(跑在原生 32-bit 系統中)
而剛剛我們提到了暫存器 r15 固定指向到 TurboThunkDispatch 指針清單、在這份指針清單 +F8h 處正好指向了最後一個函數 CpupReturnFromSimulatedCode(32 → 64 位元的入口)因此接續著我們從此函數繼續解析。
CpupReturnFromSimulatedCode
我們剛剛提及了 CpupReturnFromSimulatedCode 會是 32-bit 跳返回 64-bit 的第一個入口函數,在這函數會將重要的 32-bit Thread 狀態做一份備份、接著就走進 TurboDispatchJumpAddressStart 函數。而整個 WOW64 Process 執行與仿真過程中、一個 Thread 至少會有兩個 stack 參與:
- 32-bit堆疊:原始 32-bit 本身會使用於保存 32-bit 參數使用的那個 stack... 嘿對就是用來 push/pop/call/ret 的那個
- 64-bit堆疊:而另一個 stack 則是僅在 WOW64 翻譯階段使用的 64-bit 堆疊,僅有在 Thread 切回 64-bit 時才會使用到這組堆疊
以上圖所示程式碼 xchg rsp, r14 指令,其將當前使用 32-bit 堆疊從暫存器 esp 改擺放至暫存器 r14、將 64-bit 堆疊從 r14 取回放到暫存器 rsp 中用作當前的主要堆疊,便完成了兩堆疊無污染的切換。
而我們剛剛提到了 r14 現在保存的是 32-bit 堆疊對吧?因此 mov r8d, [r14] 便能提取 32-bit 堆疊上紀錄的 return address、接續著便能將此地址保存入 r13 所指向的 Thread 快照紀錄 CONTEXT.EIP 中,到時候用作跳返回 32-bit 原始程式繼續執行使用。同理,r11 拿到了 r14+4 的地址即 32-bit 堆疊上保存當前系統函數之參數的位址,可以想像為 C/C++ 中的 va_start。
接著便是將 32-bit 運作必備的幾個重要參數諸如文字操作系列指令會影響到的暫存器 edi, esi、跟 stack frame 有關的 ebp、運算旗標紀錄(r8d)等也一並寫入 r13 所指向的 Thread 快照紀錄,這樣便算是完成了 32-bit 狀態的快照備份,可以安心跳到 TurboDispatchJumpAddressStart 函數中了 :)
而在 TurboDispatchJumpAddressStart 函數(如上圖所示)可以淺而易見的它是一個 switch case 的路由函數。
我們剛剛說 32-bit NTAPI 會在暫存器 eax 中保存系統函數識別碼對吧?這邊可以發現接下來會跳到 r15 保存的 TurboThunkDispatch 指針表上的函數,而 index(rcx)計算方式便是:系統函數識別碼 >> 16-bit
因此可以發現每個 uint32_t 系統函數識別碼其高 2 bytes 其實就是此系統函數在 TurboThunkDispatch 指針表的 index。而在實務測試中大部分會被使用到的 NTAPI 系統函數高 16-bit 的 index 值都是 0,因此接續著就會直接跳到 TurboDispatchJumpAddressEnd 函數接續著執行。
TurboDispatchJumpAddressEnd
- 第一個參數(rcx)為前面我們提過了暫存器 eax 值保存了當前要呼叫的 NTAPI 系統函數識別碼
- 第二個參數(rdx)為 r11 保存了 32-bit 參數的起點、亦即 C/C++ 中的 va_start()
接著呼叫入 Wow64SystemServiceEx 便會將我們的行為自動仿真並執行完成。而 x96 Calling Convention 中 eax/rax 暫存器紀錄了函數執行的返回值,因此一旦我們仿真完成、便把函數返回值保存入 r13 所指向的 Thread 快照記憶體中,接著準備跳返回 32-bit 原程式繼續執行。
在跳返回 32-bit 原程式的過程中,前面進入 WOW64 架構時呼叫過 CpupReturnFromSimulatedCode 將 Thread 狀態建了一份快照上 r13。因此在這階段僅須將上一次備份的 edi, esi, ebp, eax, esp, ... 等紀錄寫回當前暫存器值便完成了 Thread 狀態的恢復
然而當下程式碼仍是 64-bit Thread 模式,因此在整段程式碼最後以 jump far [r14] 跳返回 32-bit 原程式 return address 同時,也會將 cs segment 覆寫回 0x23、使 Intel 晶片能夠將接續著程式碼內容以 32-bit x86 指令集正確解析
那麼到這邊為止就是完整的 WOW64 Process 進出 WOW64 架構的完整翻譯與仿真過程,不過... 缺乏解釋一直被稱為翻譯機函數的 Wow64SystemServiceEx 的內部實作,讓我們接著說明
Wow64SystemServiceEx
前面提過在 NTAPI 函數內會將暫存器 rax 寫入上對應的系統函數識別碼。以圖中所示 32-bit NtOpenProcess 所對應的系統函數識別碼即是 26h。
而這個 32-bit 的數值在 WOW64 Subsystem Internals and Hooking Techniques by FireEye 文獻中也被提及其實是一個資料結構 WOW64_SYSTEM_SERVICE,代表系統函數識別碼之數值(實質為 16-bit 大小)其低 12-bit 為函數識別碼、而較高的 4-bit 則代表系統函數表辨識碼... 咦?這不就是一個二維矩陣的索引值嗎?那麼那張二維矩陣是?
在 IDA Pro 中能上下翻找一下帶有偵錯符號表的 wow64.dll 可以發現有一張全局指針表 sdwhnt32JumpTable、裡面有完整對應 32-bit NTAPI 的 whNt 開頭的回調函數(Callback Function)
比方說我們呼叫了 32-bit NtOpenProcess、NtResumeThread、NtOpenFile,那麼在 64-bit 的 wow64.dll 模組中也有對應的 whNtOpenProcess、whNtResumeThread、whNtOpenFile 這些回調函數,這些回調函數會負責呼叫 64-bit NTAPI 來真正這些對應 32-bit NTAPI 想要執行的功能
因此接續著在函數 Wow64SystemServiceEx 內部會去校驗前述的二維索引值是否超出二維指針表 sdwhnt32JumpTable 的大小以避免 Out-Of-Bounds 查表程式崩潰的狀況,若二維索引值是在合理範圍內,那麼就從 sdwhnt32JumpTable 指針表中提取對應的 whNt 開頭的回調函數、後續接著呼叫它即可。
上圖引用自 WOW64 Subsystem Internals and Hooking Techniques by FireEye 文獻。提及了在 WOW64 架構中的這個翻譯機設計內被微軟埋入了一個小暗門可以用來監控全機 WOW64 Process 呼叫了哪些 32-bit NTAPI,並且允許「在呼叫前修改參數」與「呼叫後監控函數返回的結果」
其實原理就是這道暗門設計在 Wow64SystemServiceEx 翻譯機函數中,於呼叫 whNt 開頭的回調函數之前,會先確認系統槽是否有 wow64log.dll,若有則優先調用暗門的函數「告知」當前正在呼叫哪個系統函數與其參數、並在呼叫完成後也會「再次通知」暗門函數同一函數執行完成的結果。
而若系統中當前並未有該暗門模組 wow64log.dll,那麼便會將 32-bit 參數陣列傳入給對應的 whNt 開頭的回調函數進行仿真該次的 system interrupt。那到這邊為止,應該都沒提及 whNt 回調函數內部怎設計的對吧?
剛剛提及了 32-bit 的參數陣列 va_start() 起點會被送進來 whNt 開頭的回調函數進行仿真為 64-bit 系統 interrput 對吧?那麼我們就以最簡單的 32-bit NtOpenProcess 做舉例。
可以見到負責仿真的 whNtOpenProcess 函數會負責將 32-bit 的四個參數從 32-bit 堆疊上提取出來、資料結構就用 64-bit 堆疊區域變數來重新填充一個出來,並按照 x64 Calling Convention 呼叫 64-bit NtOpenProcess 來把請求正確的打回 64-bit Windows Kernel(可以看到參數排列就是依 64-bit 的 rcx, rdx, r8, r9, etc)
以上便是完整的 WOW64 逆向工程解析,
那麼下一篇文會講著解釋天堂之門攻擊技巧與我寫的幾個有趣小攻擊技巧玩具 :)
留言
張貼留言