現如今Windows x64已經很普遍了。基于核心驅動,監視程序建立,在程序主線程啟動之前修改其入口點以便執行一段自己的代碼是十分有用的。本文以Windows 7 x64為基礎,對這個問題進行了嘗試。
0x1 修改的時機
Windows x64提供了注冊回調的方法,PsSetCreateProcessNotifyRoutine/ PsSetCreateProcessNotifyRoutineEx,以使得安全産品開發者可以在程序建立時得到通知。在回調函數中得到建立程序的主線程對象是修改入口點的第一步。
PsSetCreateProcessNotifyRoutineEx回調函數的原型是:
VOID
CreateProcessNotifyEx(
__inout PEPROCESS Process,
__in HANDLE ProcessId,
__in_opt PPS_CREATE_NOTIFY_INFO CreateInfo
);
第一個參數是一個指向新建立的程序對象。通過 _EPROCESS 可以得到該程序目前唯一的一個線程:
0:kd> dt _EPROCESS ThreadListHead
ntdll!_EPROCESS
+0x300 ThreadListHead : _LIST_ENTRY
ThreadListHead的Flink指向主線程 _ETHREAD 結構中的 ThreadListEntry的位址:
0:kd> dt _ETHREAD ThreadListEntry
ntdll!_ETHREAD
+0x420 ThreadListEntry: _LIST_ENTRY
得到ThreadListEntry位址後即可反推出線程對象的位址,本例中即為
ethread = poi(eprocess+0x300) - 0x420
PsSetCreateProcessNotifyRoutine回調函數的原型是:
VOID
(*PCREATE_PROCESS_NOTIFY_ROUTINE)(
IN HANDLE ParentId,
IN HANDLE ProcessId,
IN BOOLEAN Create
);
第二個參數是新建立程序ID。可以采用PsLookupProcessByProcessId可以得到程序對象,進而再進一步采用上面的方面獲得新程序的主線程對象:
NTSTATUS PsLookupProcessByProcessId(
_In_ HANDLE ProcessId,
_Out_ PEPROCESS *Process
);
Windows還提供了通過PsSetCreateThreadNotifyRoutine注冊線程建立回調的方法。回調函數的原型是:
VOID
(*PCREATE_THREAD_NOTIFY_ROUTINE) (
IN HANDLE ProcessId,
IN HANDLE ThreadId,
IN BOOLEAN Create
);
同理也可以通過ThreadId得到線程對象,同時通過判斷線程對象ThreadListEntry所鍊起來的線程個數,可得知目前線程是否是某個程序的主線程。當然,通過測量程序PEB中的Ldr.Initialized成員也可以判斷目前是否正在建立新程序。
通常程序建立的流程是這樣的:
1. 建立Section
2. 建立Process
3. 建立挂起的Thread
4. 通知程序建立回調
5. 通知線程建立回調
6. Resume挂起的Thread
是以,在我們得到回調時就可以改變Thread的上下文(Context),随後線程在Resume時以新的Context運作,這樣就達到了修改程序(主線程)入口點的目的。
0x2 64位程序入口點的修改
當我們在回調函數中觀察主線程對象時,可以看到:
2:kd> dt _KTHREAD InitialStack fffffa801929c060
ntdll!_KTHREAD
+0x028 InitialStack : 0xfffff880`033e0c70 Void
而檢視其調用堆棧可見:
2:kd> !thread fffffa801929c060
……
……nt!KiStartUserThread
……nt!KiStartUserThreadReturn(TrapFrame @ fffff880`033e0ae0)
……ntdll!RtlUserThreadStart
針對InitialStack(fffff880`033e0c70)不難計算得出:
2:kd> [email protected]@(sizeof(_KTRAP_FRAME))
Evaluateexpression: -8246282810656 = fffff880`033e0ae0
這樣一來,原來線程初始核心堆棧的底部原來就是存儲TrapFrame的地方,通過下面的方面可驗證TrapFrame.Rip存儲的就是線程使用者态起始位址:
2:kd> dt _KTRAP_FRAME Rip fffff880`033e0ae0
ntdll!_KTRAP_FRAME
+0x168 Rip : 0x7704c500
2:kd> dt _ETHREAD StartAddress fffffa801929c060
ntdll!_ETHREAD
+0x388 StartAddress : 0x00000000`7704c500 Void
實踐證明,修改TrapFrame.Rip,可以改變主線程運作的起始位址。
0x3 Wow64入口點的修改
當把以上的方法應用到Wow64程序時,發現這種修改竟然失效了!
我首先懷疑是否Wow64的線程上下文不在TrapFrame裡了?苦思無果後,我在TrapFrame上下了一個記憶體通路斷點——看看是否有人修改了它:
3:kd> ba w 8 fffff880`03bbdae0+168
這下發現,确實有人修改了TrapFrame中的Rip。初始看到的Rip是:
2:kd> dt _KTRAP_FRAME Rip fffff880`03bbdae0
ntdll!_KTRAP_FRAME
+0x168 Rip : 0x7704c500
Rip被修改後變成了:
2:kd> dt _KTRAP_FRAME Rip fffff880`03bbdae0
ntdll!_KTRAP_FRAME
+0x168 Rip : 0x7704c320
列印函數調用堆棧:
nt!PspSetContext+0x69
nt!PspGetSetContextInternal+0x40d
nt!PspGetSetContextSpecialApc+0xa1
nt!PspSetContextThreadInternal+0xe5
nt!PspInitializeThunkContext+0x1b4(這個表達式調試器給的是錯的)
nt!PspUserThreadStartup+0xc3
nt!KiStartUserThread+0x16
nt!KiStartUserThreadReturn(TrapFrame @ fffff880`03bbdae0)
ntdll!LdrInitializeThunk
原來,系統将新線程在核心中的起始定為nt!KiStartUserThread,預設将使用者态起始定為ntdll!RtlUserThreadStart。而對于線程自從核心态開始運轉後,卻偷偷将使用者态起始定為ntdll!LdrInitializeThunk,以便做些初始化的工作。其使用者态關鍵函數的調用次序是:
ntdll!LdrInitializeThunk
ntdll!RtlUserThreadStart
PE!EntryPoint
(其中PE的入口點可以用僞寄存器@$exentry定位)
但到此為止,64位線程也應當是這樣處理的,但為什麼修改Rip可以改變64位線程卻改變不了Wow64線程昵?
一個合理的推理是64位線程Rip顯然被自身修改為ntdll!LdrInitializeThunk,這就意味着從ntdll!LdrInitializeThunk開始的使用者态代碼最終也要去執行我們修改的Rip。也就是說系統有一種機制在ntdll!LdrInitializeThunk之前記錄Rip,在之後又找回了它。ntdll!LdrInitializeThunk位于64位ntdll中。這令我恍然大悟Wow64線程是32位的,也許系統找回了Rip,但不是我們修改過的,而是某個32位的代碼位址!系統使用者态代碼在我面前耍了一回花槍。對于Wow64線程來說,使用者态關鍵函數的調用次序是:
ntdll!LdrInitializeThunk
ntdll32!LdrInitializeThunk
ntdll32!RtlUserThreadStart
PE!EntryPoint
下一個問題——怎麼驗證?我們正在64虛拟機中進行核心調試,使用者态代碼記憶體目前全都無法通路,而且奇怪的是象bp /t這樣的斷點竟然無法達到預期(Microsoft怎麼會有這樣的Bug?不過WinDbg的粗制濫造不是由來以久了嗎?)。好吧,現實逼迫我在虛拟機中再安裝個一WinDbg用于使用者态調試(夠變态)!而且32位WinDbg最早也隻能斷到32位代碼,不能停留在64位初始代碼上,是以我們需要x64版本。
虛拟機中的WinDbg中斷在ntdll!LdrInitializeThunk後的某個地方,這是友善我們加載符号用的。調試發現系統從ntdll!LdrInitializeThunk向下執行并未轉到ntdll!RtlUserThreadStart,而是轉到了ntdll32!LdrInitializeThunk。注意這已經是32位代碼了,這之前一定發生了什麼。調試跟蹤發現:
ntdll!LdrInitializeThunk
->wow64!Wow64LdrpInitialize
->wow64cpu!CpuGetContext
->ntdll!ZwQueryInformationThread
->wow64!RunCpuSimulation
ntdll!ZwQueryInformationThread獲得的上下文中就已經有了ntdll32!LdrInitializeThunk。而wow64!RunCpuSimulation隻不過是轉去執行ntdll32!LdrInitializeThunk。這個位址又是如何取得的呢?觀察NtQueryInformationThread的調用代碼:
and qword ptr [rsp+20h],0
mov r9d,2CCh
mov r8,r10
mov edx,1Dh
call qword ptr [wow64cpu!_imp_NtQueryInformationThread (00000000`73931010)]
對照NtQueryInformationThread的原型,我們得知,它的調用是這樣的:
NtQueryInformationThread(
ThreadHandle, // -2, 即 NtCurrentThread()
ThreadWow64Context, // 1Dh
Wow64_Context, // Wow64_Context.ContextFlags=0x1002f
sizeof(WOW64_CONTEXT), // 2CCh
0);
其中Wow64_Context為指向一個WOW64_CONTEXT結構的指針。取得的上下文中Wow64_Context.Eip被賦為ntdll32!LdrInitializeThunk(Wow64_Context.Eax指向PE檔案的入口點)。
找到了事情的重點,剩下就是針對核心中的NtQueryInformationThread去逆向。NtQueryInformationThread函數調用的關鍵路徑摘要如下:
NtQueryInformationThread
-> PspWow64GetContextThreadOnAmd64
-> PspWow64ReadOrWriteThreadCpuArea
-> PsGetThreadTeb
結果是,WOW64_CONTEXT位址儲存線上程對象的Teb的0x1488編移處(實際上這個值要再加上4),也就是所謂的線程局部存儲槽口處TlsSlots[WOW64_TLS_CPURESERVED]+4。在使用者态調試器中可以通過!wow64exts.info觀察到這個值。而這個值等于起始于Teb相同位址的_NT_TIB的StackBase+4。是以,我們在程序建立時,可以修改這個值,進而實作了修改Wow64程序入口點的目的。
0x4 總結
本文中說到的方法需要大量逆向Windows系統,這可不是一件簡單的工作,何況同時還要同易出錯的WinDbg鬥争。在調試Wow64時,我忘記了核心本身是64位的,而32位線程也必将起始于64位代碼這個事實。Wow64線程的入口點也正如我最初懷疑的一樣,不在TrapFrame當中。