有過Windows下調試和反調試經歷的同事可能都遇到過無法調試程序的經歷,這其中所涉及的反調試手段非常多,有直接檢測當前程序運行環境是否有調試器進程的,原理大抵是遍歷進程,比較進程名字;騰訊的一些遊戲就是這麼幹的;或者遍歷窗口,看看窗口名字或者窗口類名字是否是某些調試器的;也有間接檢測的,比如判斷PEB的調試欄位,GFLAGS的標誌位;與堆相關的調試開關;還有一些諸如調試埠是否為空;還有一些檢測當前進程加載的模塊是否以D結尾的;等等諸如此類的各種檢測手段;那麼有沒有一種系統原生的「保護方式」,來確保指定的線程無法被調試,或者說實現調試逃逸?答案當然是肯定的。這便是此文需要講述的內容。1、反調試常見的手段;
2、內核調試;
3、IDA逆向分析及常規技巧;
4、x64架構下,gs寄存器的用途;
5、異常分發的關鍵流程及API;有時候因為某些原因,必須要調試一些程序,然而這些程序也不是傻白甜,都有一定程度的反調試措施,做破解用的比較多的調試器恐怕要數OD了,國人也為其編寫了很多插件,用以實現自動過反調試,如下圖所示,掌握常規的反調試技術對於做破解,安全的人來說,其好處是顯而易見的;今天就來講一下另一種系統原生的線程調試逃逸技術,筆者第一次接觸這個技術是當時做一個項目,需要逆向某軟體,找到它的某些關鍵數據的來源時,用Windbg Attach上去之後,發現對某個線程下的斷點,斷不下來,而其他線程的斷點都是沒有問題的,後來研究了下,發現了這麼個技術,撰寫此文,與君分享。
2.1源碼如下typedef NTSTATUS (NTAPI *NTSETINFORMATIONTHREAD)(IN HANDLE ThreadHandle,IN DWORD ThreadInformationClass,IN PVOID ThreadInformation,IN ULONG ThreadInformationLength);bool TestThreadHideFromDebugger();int ExceptionFilter(PEXCEPTION_POINTERS pExceptionPointer);
int main(){ if(!TestThreadHideFromDebugger()) return -1;
while(1) { printf("main thread id:%u\n",GetCurrentThreadId()); Sleep(500); }}
DWORD WINAPI MyThreadFun(LPVOID lpThreadParameter){ __try { while(1) { printf("work thread id:%u\n",GetCurrentThreadId()); Sleep(500); } } __except (ExceptionFilter(GetExceptionInformation())) { printf("Others\n"); }
return 0;}
bool TestThreadHideFromDebugger(){ DWORD dwTid = 0;
HANDLE hThread = CreateThread(NULL,0,MyThreadFun,NULL,CREATE_SUSPENDED,&dwTid); if(!hThread) { printf("Error:%u\n",GetLastError()); return false; }
HMODULE hModule = GetModuleHandle(TEXT("ntdll.dll")); NTSETINFORMATIONTHREAD NtSetInformationThread = (NTSETINFORMATIONTHREAD)GetProcAddress(hModule, "NtSetInformationThread"); NTSTATUS status = NtSetInformationThread(hThread, 0x11, 0, 0); if(status != 0) { printf("Error:%u\n",GetLastError()); return false; }
ResumeThread(hThread); return true;}
int ExceptionFilter(PEXCEPTION_POINTERS pExceptionPointer){ if(pExceptionPointer->ExceptionRecord->ExceptionCode == EXCEPTION_BREAKPOINT) { PVOID ExceptionAddress = pExceptionPointer->ExceptionRecord->ExceptionAddress; printf("Addr:%X\n",ExceptionAddress);
DWORD dwOldProtect = 0; VirtualProtect(ExceptionAddress,10,PAGE_EXECUTE_READWRITE,&dwOldProtect); *(PBYTE)ExceptionAddress = 0x8B;
MessageBox(NULL,NULL,NULL,NULL); return EXCEPTION_CONTINUE_EXECUTION; } else { return EXCEPTION_CONTINUE_SEARCH; }}1、TestThreadHideFromDebugger()這個函數內以掛起的方式創建了一個線程, 創建完後,調用NtSetInformationThread()來修改線程的「ThreadHideFromDebugger」屬性,使得線程能夠逃出調試器;修改完成後,再恢復線程執行;2、線程函數MyThreadFun()內同主線程,一個死循環不停的列印當前的線程ID;值得注意的是,工作線程必須套一層try except,不然進程會直接掛掉;原因也是很清晰的;當觸發int 3斷點時,CPU將該異常報告給OS,當然報告的方式是通過執行IDT表中相應的異常處理例程了,而後OS內核接管該異常;進過一系列的異常分發,最終到了決定是先交給調試器還是直接交給進程時,內核的異常分發引擎會判斷當前線程的ThreadHideFromDebugger屬性是否置位了,如果置位的話,正如我們demo中所示的這樣,那內核的異常分發引擎直接將次異常拋給進程中對應的線程,CPU的模式從Ring0即切換回Ring3即從內核態切換回用戶態;此時用戶態的異常分發函數KiUserExceptionDispatcher()接手繼續處理,處理的方式也很簡單,三步走,第一步便是遍歷VEH,沒人處理的話,那就進行第二步,接著遍歷SEH,也同樣沒人處理,那就到了UEH了,無他,系統默認的處理方式便是拉起WerFault.exe這個進程,然後掛掉整個進程;其實還有最後一個第三步,那就是VCH,不過這個VCH比較特殊,依賴於「別人」;關於這個異常分發,從CPU到內核再到用戶態,以及雙擊調試時,內核調試引擎的調試數據包與Windbg之間的交互後邊會專門撰文講解,這裡大家就先簡單了解下就好;回到正題,加了這層try except就是為了在SEH遍歷的過程中,攔截住這個異常,並且進行一些修改,去掉Windbg丟下的斷點,這也正是很多軟體開發商特別是遊戲開發商做保護時用到的技術手段;簡單的分析下,在SEH中我們調用了 VirtualProtect()修改內存屬性,改為可讀可寫可執行,下邊緊接著就是將異常發生的地址處的數據改為0x8B,為什麼是0x8B下邊demo演示時會有講解;然後返回EXCEPTION_CONTINUE_EXECUTION,告知內核調試引擎,繼續剛剛觸發異常的地方執行;3、主線程中就是一個簡單的死循環,不停的列印線程ID,與工作線程中的形成對比,我們做實驗時,分別在工作線程和主線程的printf處下斷點,看看效果;2.2 演示過程及效果見下圖講解先把TestThreadHideFromDebugger()中設置線程調試逃逸的代碼注釋掉,看看工作線程是否能夠命中斷點斷下來:
是OK的,下邊我們就模擬遊戲的做法,來實際感受下這個技術的利用手段;實驗2:
先如圖1所示,在這兩個地方下斷點,當圖1的斷點命中時,看一下工作線程中printf處斷點所對應的地址和字節碼,這兩個信息我們後邊有用;再如圖2所示,先屏蔽掉修改內存屬性的代碼,看看執行效果;如下圖圖3和圖4所示:如預期,我們在工作線程的printf處下了斷點,當程序執行到此處時,調試器並沒有接管到該斷點異常,相反我們的異常處理try except接管到了,原因就是我上邊講的;在異常處理中,我們列印出了異常觸發即斷點指令的代碼地址即0x011A1AB8,圖3和圖4是吻合的,並且我們在異常處理中也彈出了消息框,按照圖2的方式,我們沒有修改0x011A1AB8處的代碼,按理說這裡還是int 3,那麼我們點完確定按鈕,消息框關閉後,CPU又會返回到該出繼續執行int 3,這樣又會繼續到我們的異常處理程序中,消息框又會再次彈出,如此重複,大家可嘗試看看效果;將圖2中注釋掉的代碼恢復回來,如下圖所示:
此時,消息框只會彈一次,因為後續CPU再回去繼續執行時,代碼已經不是int3所對應的0xCC了,而是被我們復原了,那CPU再次執行時,當然不會再次報告異常;這便是很多軟體中反調試用到的技術手段的全部內容,當然遊戲裡邊反調試用這個方法的更多,特別是韓國的遊戲保護;3.1、用戶態逆向分析NtSetInformationThread()的內部動作如上圖所示,NtSetInformationThread()內部啥也沒幹,直接進了內核,只不過進內核時,會判斷下用那種方式進,當前架構下有很多種進內核的方式,比如調用門,中斷門,陷阱門,。。。Windows系統早先用的是陷阱門,索引號為0x2E,即IDT表中的0x2E號;後來intel為了提升模式切換的性能,搞了個快速調用,即syscall指令;這個有興趣的可以去了解下;有一個比較重要的是,0x7FFE0308這個是直接寫死的額,看著是全局變量,那這個全局變量指向的數據結構是什麼呢?如下,這塊內存是Ring3和Ring0共享的,Ring3隻讀權限,Ring0可讀可寫;兩個虛擬地址空間映射到同一個物理頁上,很簡單,搞一下頁表即可;這個頁剩下的地址空間可以做很多事情,大家可以嘗試想想;0:012> dt _KUSER_SHARED_DATAntdll!_KUSER_SHARED_DATA +0x000 TickCountLowDeprecated : Uint4B +0x004 TickCountMultiplier : Uint4B +0x008 InterruptTime : _KSYSTEM_TIME +0x014 SystemTime : _KSYSTEM_TIME +0x020 TimeZoneBias : _KSYSTEM_TIME +0x02c ImageNumberLow : Uint2B +0x02e ImageNumberHigh : Uint2B +0x030 NtSystemRoot : [260] Wchar +0x238 MaxStackTraceDepth : Uint4B +0x23c CryptoExponent : Uint4B +0x240 TimeZoneId : Uint4B +0x244 LargePageMinimum : Uint4B +0x248 AitSamplingValue : Uint4B +0x24c AppCompatFlag : Uint4B +0x250 RNGSeedVersion : Uint8B +0x258 GlobalValidationRunlevel : Uint4B +0x25c TimeZoneBiasStamp : Int4B +0x260 NtBuildNumber : Uint4B +0x264 NtProductType : _NT_PRODUCT_TYPE +0x268 ProductTypeIsValid : UChar +0x269 Reserved0 : [1] UChar +0x26a NativeProcessorArchitecture : Uint2B +0x26c NtMajorVersion : Uint4B +0x270 NtMinorVersion : Uint4B +0x274 ProcessorFeatures : [64] UChar +0x2b4 Reserved1 : Uint4B +0x2b8 Reserved3 : Uint4B +0x2bc TimeSlip : Uint4B +0x2c0 AlternativeArchitecture : _ALTERNATIVE_ARCHITECTURE_TYPE +0x2c4 BootId : Uint4B +0x2c8 SystemExpirationDate : _LARGE_INTEGER +0x2d0 SuiteMask : Uint4B +0x2d4 KdDebuggerEnabled : UChar +0x2d5 MitigationPolicies : UChar +0x2d5 NXSupportPolicy : Pos 0, 2 Bits +0x2d5 SEHValidationPolicy : Pos 2, 2 Bits +0x2d5 CurDirDevicesSkippedForDlls : Pos 4, 2 Bits +0x2d5 Reserved : Pos 6, 2 Bits +0x2d6 Reserved6 : [2] UChar +0x2d8 ActiveConsoleId : Uint4B +0x2dc DismountCount : Uint4B +0x2e0 ComPlusPackage : Uint4B +0x2e4 LastSystemRITEventTickCount : Uint4B +0x2e8 NumberOfPhysicalPages : Uint4B +0x2ec SafeBootMode : UChar +0x2ed VirtualizationFlags : UChar +0x2ee Reserved12 : [2] UChar +0x2f0 SharedDataFlags : Uint4B +0x2f0 DbgErrorPortPresent : Pos 0, 1 Bit +0x2f0 DbgElevationEnabled : Pos 1, 1 Bit +0x2f0 DbgVirtEnabled : Pos 2, 1 Bit +0x2f0 DbgInstallerDetectEnabled : Pos 3, 1 Bit +0x2f0 DbgLkgEnabled : Pos 4, 1 Bit +0x2f0 DbgDynProcessorEnabled : Pos 5, 1 Bit +0x2f0 DbgConsoleBrokerEnabled : Pos 6, 1 Bit +0x2f0 DbgSecureBootEnabled : Pos 7, 1 Bit +0x2f0 DbgMultiSessionSku : Pos 8, 1 Bit +0x2f0 DbgMultiUsersInSessionSku : Pos 9, 1 Bit +0x2f0 DbgStateSeparationEnabled : Pos 10, 1 Bit +0x2f0 SpareBits : Pos 11, 21 Bits +0x2f4 DataFlagsPad : [1] Uint4B +0x2f8 TestRetInstruction : Uint8B +0x300 QpcFrequency : Int8B +0x308 SystemCall : Uint4B +0x30c SystemCallPad0 : Uint4B +0x310 SystemCallPad : [2] Uint8B +0x320 TickCount : _KSYSTEM_TIME +0x320 TickCountQuad : Uint8B +0x320 ReservedTickCountOverlay : [3] Uint4B +0x32c TickCountPad : [1] Uint4B +0x330 Cookie : Uint4B +0x334 CookiePad : [1] Uint4B +0x338 ConsoleSessionForegroundProcessId : Int8B +0x340 TimeUpdateLock : Uint8B +0x348 BaselineSystemTimeQpc : Uint8B +0x350 BaselineInterruptTimeQpc : Uint8B +0x358 QpcSystemTimeIncrement : Uint8B +0x360 QpcInterruptTimeIncrement : Uint8B +0x368 QpcSystemTimeIncrementShift : UChar +0x369 QpcInterruptTimeIncrementShift : UChar +0x36a UnparkedProcessorCount : Uint2B +0x36c EnclaveFeatureMask : [4] Uint4B +0x37c TelemetryCoverageRound : Uint4B +0x380 UserModeGlobalLogger : [16] Uint2B +0x3a0 ImageFileExecutionOptions : Uint4B +0x3a4 LangGenerationCount : Uint4B +0x3a8 Reserved4 : Uint8B +0x3b0 InterruptTimeBias : Uint8B +0x3b8 QpcBias : Uint8B +0x3c0 ActiveProcessorCount : Uint4B +0x3c4 ActiveGroupCount : UChar +0x3c5 Reserved9 : UChar +0x3c6 QpcData : Uint2B +0x3c6 QpcBypassEnabled : UChar +0x3c7 QpcShift : UChar +0x3c8 TimeZoneBiasEffectiveStart : _LARGE_INTEGER +0x3d0 TimeZoneBiasEffectiveEnd : _LARGE_INTEGER +0x3d8 XState : _XSTATE_CONFIGURATION比較有意思的一些欄位如TimeZoneId、KdDebuggerEnabled、DbgSecureBootEnabled、SystemCall、ImageFileExecutionOptions等等3.2、內核態逆向分析NtSetInformationThread()的內部動作說明下,我這裡分析的是基於Win10 16299版本的ntoskrnl.exe這份文件;這個函數比較大,從IDA給出來的數據看,有一千多行,分幾次截圖,給出關鍵的代碼數據進行分析;上邊的代碼簡單解釋下,代碼中根據ThreadInformationClass對其做了簡單的歸類,根據我們傳入的ThreadHideFromDebugger對應的數據是0x11,會執行go LABEL_5;到了這個標籤這,會順序執行,最終來到第245行,按著這個if比較往下走,最終會來到下圖所示的部分:由上圖所示,先一個if判斷,判斷傳入的參數ThreadInformationLength是否為0,如果不是則直接返回錯誤,且錯誤碼為0xC0000004,在全面的demo中,知道該參數傳入的確實為0;緊接著下邊調用ObpReferenceObjectByHandleWithTag()根據現場的句柄找到線程對象;通過第三個參數返回,即Thread;緊接著調用了InterlockedOr(),這個API是實現原子或操作的;但IDA給出的偽代碼不太好,欄位解析的有問題,我們看下反彙編代碼,如下:下邊我們需要看下ETHREAD的0x6D0偏移處的欄位是什麼,直接用Windbg雙機調試看下是最快的如下:原來是設置的這個地方的HideFromDebugger位置,將其位置1;OK;NtSetInformationThread()的分析還差最後一步;即返回,如下圖:3.3、OS在異常分發時,又是如何利用此數據的想要知道如何使用的,最簡單的辦法就是在剛在這個位置,針對特定的線程下內存訪問斷點,直接命中就能通過棧回溯來找到;這個就留給大家自己去完成了;我們下邊直接分析;張銀奎老師那本《軟體調試》書上有專門針對異常分析的具體講解,核心的API有這麼一個DbgkForwardException();下面我們來逆向分析下這個API;但是IDA反彙編的不好,為什麼這麼說呢?內核裡幾乎很少會用到TEB的東西,因為TEB裡有的數據ETHREAD中都有,此外,這裡訪問TEB時也沒加_try _except,所以這裡反彙編出來的偽代碼很不好,我們直接看反彙編代碼更為清晰,彙編代碼如下:有一些輔助知識,需要說明下,在x64架構的內核裡,gs寄存器保存的是KPCR的基地址,KPCR是內核裡,作業系統為每個CPU核維護的一份數據結構;該結構如下:KPRCB作為KPCR的拓展欄位,裡邊記錄的數據更多,很多欄位也都是非常常用的;其中有三個非常重要的欄位:
+0x008 CurrentThread : Ptr64 _KTHREAD:指向當前CPU正在執行的代碼所屬的線程的線程對象,即當前被調度到的線程對象;+0x010 NextThread : Ptr64 _KTHREAD:指向下一次調度時,需要調度的線程對象;+0x018 IdleThread : Ptr64 _KTHREAD:指向當沒有線程可調度時,可被調度的線程對象,這個線程一般做一些清理動作,比如清理內存,整理內存的那幾個鍊表等等;優先級非常低;mov rax, gs:188hmov ecx, [rax+6D0h]test cl, 4jnz short loc_14044BDE0這個指令就是獲取的CurrentThread,然後取的偏移0x6D0處的數據;而這個0x6D0在上邊也已經就分析過了,恰為_KTHREAD.HideFromDebugger;喔喔,異常分發函數DbgkForwardException()在分發異常時,查看了這個數據;如果這裡置位了的話,那麼邏輯就如下圖所示了:直接返回了,也就意味了不發送給調試器了,這就是線程調試逃逸的全部原理了;在本文中,自己編碼實現了線程調試逃逸,學會運用這裡技術手段;詳細講解了實現的技術細節,特別的講解了為什麼需要加一層try except;而後我們由通過逆向手段來詳細分析了OS是如何實現這一技術的,並且詳細分析了在CPU觸發報告異常到OS接管異常進行異常分發時是如何利用此數據達到對調試器隱藏線程異常的;特別的還拓展介紹了gs寄存器在x64架構下,內核是如何使用的;涉及到了KPCR,KPRCB等等關鍵數據結構;至此,整個技術點全部剖析完畢。