繞過用戶模式EDR Hook原理及思路
1.什么是系統調用
系統調用是從用戶模式過渡到內核模式的標準方式。它們是現代版的軟件中斷,速度更快。
系統調用接口極其復雜,但由于大部分內容與我們的工作無關,我只想做一個較高層次的總結。在大多數情況下,你并不需要深入了解它是如何工作的,就可以使用這些技術,但了解一下還是有幫助的。
在 Windows 中,內核有一張允許從用戶模式調用的函數表。這些函數有時被稱為系統服務、本地函數或 Nt 函數。它們是以 Nt 或 Zw 開頭的函數,位于 ntoskrnl.exe 中。系統服務表稱為系統服務描述符表,簡稱 SSDT。
要從用戶模式調用系統服務,必須執行系統調用,通過 syscall 指令完成。應用程序將系統服務 ID 保存在 eax 寄存器中,以此告訴內核要調用哪個系統服務。系統服務 ID(通常稱為系統服務號、系統調用號或簡稱 SSN)是該函數在 SSDT 中的索引項。因此,將 eax 設置為 0 將調用 SSDT 中的第一個函數,1 將調用第二個函數,2 將調用第三個函數,依此類推...
查詢結果如下:entry = nt!KiServiceTable+(SSN * 4)。
syscall 指令會使 CPU 切換到內核模式并調用系統調用處理程序,該程序會從 eax 寄存器中獲取 SSN 并調用相應的 SSDT 函數。
假設一個應用程序調用 kernel32.dll 中的 OpenProcess() 函數來打開一個進程的句柄。
圖片
正如你所看到的,該函數的真正作用是調用位于 ntdll.dll 中的 NtOpenProcess()。現在,讓我們來看看 NtOpenProcess() 的邏輯。
圖片
在 NtOpenProcess() 中,幾乎沒有任何代碼。這是因為與所有以 Nt 或 Zw 開頭的函數一樣,NtOpenProcess() 實際上位于內核中。這些函數的 ntdll(用戶模式)版本只是執行系統調用來調用其內核模式對應函數,這就是為什么它們經常被稱為系統調用存根。
在我們的例子中,NtOpenProcess 的 SSN 是 0x26,但這個數字會隨著 Windows 版本的變化而變化,所以不要指望它對你來說也是一樣的。從簡化的高層視圖來看,調用流程大致如下:
圖片
下面是關于 x86 系統調用流程的更詳細概述:
圖片
注意:在用戶模式下,函數的 Nt 和 Zw 版本完全相同。在內核模式下,Zw 函數的運行路徑略有不同。這是因為 Nt 函數是為從用戶模式調用而設計的,因此要對函數參數進行更廣泛的驗證。
2.EDR和用戶模式鉤子
自 2005 年微軟推出內核補丁保護(又稱 PatchGuard)以來,許多對內核的修改現在都被阻止了。以前,安全產品通過掛鉤 SSDT 從內核內部監控用戶模式調用。由于所有 Nt/Zw 功能都是在內核中實現的,因此所有用戶模式調用都必須通過 SSDT,并因此受到 SSDT 掛鉤的影響。補丁防護使 SSDT 鉤子成為禁區,因此許多 EDR 鉤子轉向掛鉤 ntdll。
圖片
由于 SSDT 存在于內核中,因此用戶模式應用程序無法在不加載內核驅動程序的情況下干擾這些鉤子。現在,鉤子被放置在用戶模式下,與應用程序并存。
那么,用戶模式鉤子是什么樣的呢?
圖片
要掛接 ntdll.dll 中的函數,大多數 EDR 只需用 jmp 指令覆蓋函數代碼的前 5 個字節。jmp 指令會將代碼執行重定向到 EDR 自身 DLL(會自動加載到每個進程中)中的某些代碼。CPU 被重定向到 EDR 的 DLL 后,EDR 可以通過檢查函數參數和返回地址來執行安全檢查。一旦 EDR 完成檢查,它就可以通過執行覆蓋指令恢復 ntdll 調用,然后跳轉到鉤子(jmp 指令)之后的 ntdll 位置。
圖片
在上例中,NtWriteFile 被掛鉤。綠色指令是 NtWriteFile 的原始指令。NtWriteFile 的前 3 條指令已被 EDR 的鉤子(將執行重定向到 edr.dll 中名為 NtWriteFile 的函數的 jmp)覆蓋。每當 EDR 想要調用真正的 NtWriteFile 時,它會執行 3 條被覆蓋的指令,然后跳轉到掛鉤函數的第 4 條指令,完成系統調用。
雖然不同廠商的 EDR 掛鉤可能略有不同,但原理仍然相同,而且都有一個共同的弱點:它們都位于用戶模式下。由于鉤子和 EDR 的 DLL 都必須放在每個進程的地址空間內,因此惡意進程可以篡改它們。
3.繞過EDR鉤子
繞過 EDR 鉤子的方法有很多,我只介紹主要的幾種。
卸載EDR鉤子
由于掛鉤的 ntdll 位于我們自己進程的內存中,因此我們可以使用 VirtualProtect() 使內存可寫,然后用原始函數代碼覆蓋 EDR 的 jmp 指令。為了替換鉤子,我們當然需要知道原來的匯編指令是什么。最常見的方法是從磁盤讀取 ntdll.dll 文件,然后將內存版本與磁盤版本進行比較。前提是 EDR 不會檢測或阻止從磁盤手動讀取 ntdll.dll。
這種方法的主要缺點是,EDR 可以定期檢查 ntdll 的內存,查看其鉤子是否已被刪除。如果 EDR 檢測到其鉤子已被移除,它可能會將鉤子寫回,更有甚者會終止進程并觸發檢測事件。雖然鉤子可能需要放在用戶模式下,但檢查鉤子可以在內核模式下進行,因此我們也沒有什么辦法來防止這種情況發生。
手動映射DLL
與其從磁盤中讀取 ntdll 的純凈拷貝來解鎖原始 ntdll,我們還不如直接將純凈拷貝加載到進程內存中,然后使用它來代替原始 ntdll。由于 LoadLibrary() 和 LdrLoadDll() 等函數不允許系統兩次加載同一個 DLL,所以我們必須手動加載。手動映射 DLL 的代碼可能會很繁雜,而且容易出錯或被檢測到。
DLL 通常也會調用其他 DLL,因此我們要么只能使用手動加載的 ntdll 中的函數,要么為我們需要的每個 DLL 加載第二個副本,并修補它們,使其只能使用其他手動加載的 DLL,這可能會變得非常混亂。如果殺毒軟件在進行內存掃描時,發現每個 DLL 都有多個副本加載到內存中,那么也很有可能被發現。
直接系統調用
正如前面討論的那樣,用戶模式下的Nt/Zw函數實際上除了執行系統調用之外并不執行其他任何操作。因此,我們實際上不需要映射整個新的ntdll副本來執行一些系統調用。相反,我們可以直接將系統調用邏輯實現到我們自己的代碼中。我們只需將要調用的函數的SSN(函數號)移動到eax寄存器中,然后執行syscall指令。
__asm {
mov r10, rcx
mov eax, 0x123
syscall
ret
}
不幸的是,由于EDR的鉤子通常會覆蓋設置eax寄存器的指令,我們不能簡單地從被掛鉤的函數中提取它。但是...有一些方法我們可以找出它是什么。
從ntdll讀取一個干凈的拷貝
你可能已經對這個想法感到厭倦了,但我們可以從磁盤上讀取一個干凈的ntdll副本,然后從中提取SSN。由于SSN始終被放入eax寄存器,我們只需掃描我們想要調用的函數以找到"mov eax, imm32"指令即可。但是,如果我們想要一種不僅僅是從磁盤讀取ntdll的變體呢?別擔心!
根據函數順序計算系統調用號
系統調用ID是索引,因此是順序的。如果我們想要調用的函數的SSN是0x18,那么直接在它之前的可能是0x17,直接在它之后的可能是0x19。由于EDR并不掛鉤每個Nt函數,我們可以簡單地從最近的未被掛鉤的函數中獲取SSN,然后通過添加或減去在它和我們目標函數之間有多少個函數來計算我們想要的函數的SSN。
圖片
這種方法確實有一個缺陷:我們無法百分之百地保證系統調用號將永遠保持連續,或者DLL不會跳過一些。
硬編碼
最簡單的方法就是直接硬編碼系統調用號。雖然它們在不同版本之間會有所改變,但在過去它們的變化并不是很大。檢測操作系統版本并加載正確的SSN集并不是太難的工作。事實上,j00ru友好地發布了每個Windows版本的每個系統調用號的列表。這種方法唯一的缺點是,如果系統調用號發生變化,代碼可能在新的Windows版本上無法自動運行。
直接系統調用的問題
在過去的十多年里,直接系統調用一直是繞過用戶模式鉤子的首選方法。實際上,我自己在2012年初次嘗試了這種方法。不幸的是,為了防止這種繞過方式,已經進行了很多工作。最常見的檢測方法是讓EDR的內核模式驅動程序檢查調用堆棧。
盡管EDR不能再在內核中掛鉤很多地方,但它可以利用操作系統提供的監視功能,比如:
- ETW事件
- 內核回調
- 過濾驅動程序
如果我們執行手動系統調用,而在調用的內核函數經過以上任何一種情況時,EDR可以利用機會檢查我們線程的調用堆棧。通過展開調用堆棧并檢查返回地址,EDR可以看到導致此系統調用的整個函數調用鏈。
如果我們執行對kernel32!VirtualAlloc()的正常調用,調用堆棧可能如下所示:
圖片
在這種情況下,對VirtualAlloc()的調用是由ManualSyscall!main+0x53啟動的。按照調用的順序,調用堆棧的相關部分如下:
- ManualSyscall!main+0x53
- KERNELBASE!VirtualAlloc+0x48
- ntdll!NtAllocateVirtualMemory+0x14
- nt!KiSystemServiceCopyEnd+0x25
這告訴我們(或者EDR)可執行文件(ManualSyscall.exe)調用了VirtualAlloc(),這個函數調用了NtAllocateVirtualMemory(),然后執行了一個系統調用以切換到內核模式。
現在讓我們看看進行直接系統調用時的調用堆棧:
圖片
調用堆棧的相關部分按順序如下:
- ManualSyscall!direct_syscall+0xa
- nt!KiSystemServiceCopyEnd+0x25
在這里,很明顯內核轉換是由ManualSyscall.exe內部的代碼觸發的,而不是ntdll。但是,這有什么問題嗎?
嗯,在像Linux這樣的系統上,應用程序直接發起系統調用是完全正常的。但請記住我提到過Windows版本之間系統調用號會發生變化嗎?結果,編寫依賴于直接系統調用的Windows軟件是非常不切實際的。由于ntdll已經為您實現了每個系統調用,幾乎沒有理由進行手動系統調用。除非你正在編寫繞過EDR鉤子的惡意軟件。你是在寫用于繞過EDR鉤子的惡意軟件嗎?
由于直接系統調用是惡意活動的強有力指標,更復雜的EDR系統將記錄源自于ntdll之外的系統調用的檢測情況。說實話,你仍然可以在很多時候逃脫檢測,但這有什么樂趣呢?
4.間接系統調用
大多數EDR在Nt函數的開頭寫入它們的鉤子,覆蓋SSN但保留系統調用指令不變。這使我們能夠利用ntdll已經提供的系統調用指令,而不是引入我們自己的。我們只需自己設置r10和eax寄存器,然后跳轉到被掛鉤的ntdll函數內的系統調用指令(位于EDR鉤子之后)。
圖片
注意:我們并不嚴格需要test或jnz指令,它們只是為了向后兼容。一些古老的CPU不支持syscall指令,而是使用int 0x2e。test指令檢查系統調用是否啟用,如果沒有啟用,則回退到軟中斷。如果我們希望支持這些系統,我們可以自己執行檢查,然后根據需要跳轉到int 0x2e指令(也位于Nt函數內)。
就像直接系統調用一樣,我們仍然需要系統調用號放入eax寄存器,但我們可以使用在直接系統調用部分詳細介紹的所有相同技術。
通過這種方式設置系統調用將給我們一個類似以下的調用堆棧:
圖片
正如你所看到的,調用堆棧現在看起來好像是來自ntdll!NtAllocateVirtualMemory()而不是我們的可執行文件,因為從技術上講確實是這樣的。
我們可能會遇到的一個問題是,如果EDR鉤子或覆蓋了Nt調用中的syscall指令的部分。我從未見過這種情況發生,但理論上可能會發生。在這種情況下,我們可以跳轉到另一個未被掛鉤的Nt函數內的syscall指令。這仍然可以繞過僅驗證調用名稱是否來自ntdll的EDR,但對于檢查內核函數是否與來自ntdll的函數相匹配的這種檢查通常都會失敗。
更大的問題是,如果EDR檢查的不僅僅是第一個返回地址。不僅僅是系統調用的來源,還有執行系統調用的函數是誰調用的。如果我們正在從位于動態分配內存中的某個shellcode進行間接系統調用,那么EDR將會察覺到。來自于有效PE節(exe或DLL內存)之外的調用是相當可疑的。
此外,由于函數被EDR掛鉤,EDR的鉤子預期會出現在調用堆棧中。實際上,我并不確定哪些EDR,如果有的話,會檢查這一點。但是,正如你在這里看到的,從調用堆棧中很明顯我們繞過了EDR的鉤子。
圖片
理想情況下,我們希望偽造的不僅僅是系統調用的返回地址。對此的一個有趣解決方案是調用堆棧欺騙,我可能會在另一篇文章中詳細介紹。使用調用堆棧欺騙,可以偽造整個調用堆棧,但要保持調用堆棧穩定不崩潰可能會遇到一些挑戰。