本環境是蛇矛實驗室基于"火天網演攻防演訓靶場"進行搭建,通過火天網演中的環境構建模塊,可以靈活的對目標網絡進行設計和配置,并且可以快速進行場景搭建和復現驗證工作。
背景概述
在安全研發的過程中,難免會遇到在用戶模式對抗 AV/EDR 的掛鉤,應對的方法有很多,比如可以使用直接系統調用,還可以將殺軟的所掛的鉤子解除,還有一種就是尋找具有相同功能未被掛鉤的 API。本文將講述直接系統調用的原理,并對一些相關的項目進行分析。
API 的調用過程分析
首先,我們編寫一段測試程序(x64)并使用相關工具對其調用流程進行分析。
#include?#include? #include? int?main() { ????PROCESS_INFORMATION pi{}; ????STARTUPINFO si{ sizeof(si) }; ????CreateProcess(nullptr, nullptr, nullptr, nullptr, 0, 0, 0, nullptr, &si, &pi); ????system("pause"); ????return?0; }
使用 Process Monitor 工具進行過濾監測,可以看到 CreateProcess API 調用流程。
從上圖可以看到,當我們在程序中調用 CreateProcess 之后,實際上是調用了用戶層下 kernel32.dll 這個 DLL 中的 CreateProcessW ,從這個調用堆棧可以看出,在用戶模式下最終會調用 ntdll.dll 中的 NtCreateUserProcess 函數,并通過這個函數進入內核。
使用 IDA 對這個函數進行查看。
通過 IDA 中查看這個函數的實現可以看到,NtCreateUserProcess 函數的主體,函數以 rcx 寄存器作為參數,然后將其值復制到 r10 寄存器中,由于在函數主體中后續沒有用到 r10 寄存器,所以這句沒有實際的作用,接下來,將 0C8h 賦值給 eax 寄存器,其中 0C8h 為系統調用號,在 Windows 中,系統調用通常使用特定的調用號來標識,在這個例子中,調用號 0C8h 就表示 NtCreateUserProcess 系統調用,隨后執行一條測試指令,檢查 ds:7FFE0308h 位置處的字節值是否為 1,這條指令是用來判斷 CPU 是否支持快速調用(即是否支持 syscall 指令),如果支持,則會使用 syscall 指令來執行系統調用,否則會通過中斷調用(int 2eh) 指令來執行系統調用進入內核。
使用 Native API
Native API 是一種用于訪問 Windows 操作系統內部功能的 API。它位于高于應用程序級別和內核級別之間,可以讓開發人員訪問操作系統的一些底層功能。上文通過對用戶層的 API 的調用流程進行分析,可以發現,用戶層的大多數 API 調用最終都會轉到 ntdll.dll 中去執行,并通過相對應的 Native API 中轉最終進入內核層執行,通過 ntoskrnl.exe 實現具體的功能。
熟悉 Windows 編程的人應該知道,Native API 一般是不直接對外公開的,它通常作為操作系統內部的一個組件,并且只能由操作系統內部的組件或應用程序調用,所以在使用一些未文檔的化的 Native API 時,需要通過網上公開的資料或者通過逆向取獲取其函數原型和相關參數。
下面使給出一段使用 Native API 的代碼(x64),注意 NtCreateThreadEx 32 位和 64 位函數原型不同,具體差別可自行上網查閱或者逆向。
#include?#include? // 定義 NtCreateThreadEx 函數指針 using?NtCreateThreadExT = NTSTATUS(NTAPI*)( ????OUT PHANDLE ThreadHandle, ????IN ACCESS_MASK DesiredAccess, ????IN LPVOID ObjectAttributes OPTIONAL, ????IN HANDLE ProcessHandle, ????IN PVOID StartRoutine, ????IN PVOID Argument OPTIONAL, ????IN ULONG CreateFlags, ????IN SIZE_T ZeroBits, ????IN SIZE_T StackSize, ????IN SIZE_T MaximumStackSize, ????IN LPVOID AttributeList OPTIONAL); EXTERN_C DWORD WINAPI ThreadProc(LPVOID param) { ????std::cout?<< GetCurrentThreadId() << std::endl; ????return?0; } int?main() { ????// 獲取 NtCreateThreadEx 函數的地址 ????auto?NtCreateThreadEx = (NtCreateThreadExT)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtCreateThreadEx"); ????if?(NtCreateThreadEx == NULL) ????{ ????????std::cout?<< "get NtCreateThreadEx func address failed!"?<< std::endl; ????????return?-1; ????} ????HANDLE thread = NULL; ????NTSTATUS status = NtCreateThreadEx( ????????&thread, // 輸出線程句柄 ????????GENERIC_EXECUTE, // 指定線程的訪問權限 ????????NULL, // 指定線程安全描述符 ????????GetCurrentProcess(), // 指定線程所在的進程句柄 ????????ThreadProc, // 指定線程函數 ????????NULL, // 指定線程函數的參數 ????????FALSE, // 指定是否在創建時掛起線程 ????????0, // 指定堆棧中保留的字節數 ????????0, // 指定堆棧中分配的字節數 ????????0, // 指定堆棧中總共保留的字節數 ????????NULL?????????????????// 指定附加的參數 ????); ????if?(status != 0) ????{ ????????printf("thread create failed: %d ", status); ????????return?-1; ????} ????std::cout?<< "thread created successfully "?<< std::endl; ????// 等待線程結束 ????WaitForSingleObject(thread, INFINITE); ????// 關閉線程句柄 ????CloseHandle(thread); ????system("pause"); ????return?0; }
上述代碼先定義了 NtCreateThreadEx 類型的函數指針,并通過 GetModuleHandleA + NtCreateThreadEx 來獲取 NtCreateThreadEx 的函數地址,之后通過使用 NtCreateThreadEx 創建一個線程,在創建的線程回調函數中,輸出了創建線程的 ID 號,最終待線程執行完畢后,關閉了線程句柄。
執行的結果如下:
在本節演示程序中,我們通過從已加載的 ntdll.dll 內存中獲取NtCreateThreadEx 的函數地址,這能有效的繞過 AV/EDR 關于 Kernel32、KernelBase 的掛鉤,但是卻不能繞過 AV/EDR 對 ntdll的掛鉤。目前來說,大多數 AV/EDR 在用戶模式下都是對 ntdll 中的相關 API 進行掛鉤了,所以接下來還需要了解直接系統調用,也就是通常說的重寫 R3 下的 API。
直接系統調用
前文中對 CreateProcess 的調用流程進行了分析介紹了如何使用 Native API ,下面將解釋一下系統調用,在理解系統調用之前,首先需要了解一下現代操作系統的基礎結構,用戶模式(空間)和內核模式(空間)。在 Windows 中,操作系統提供了一個內核空間,用于處理所有的底層系統事務,并提供一些系統服務,而應用程序運行在用戶空間,并且不能直接訪問內核空間的內容。當用戶空間的應用程序需要訪問內核空間的系統服務時,就需要通過系統調用來實現。
系統調用是一種特殊的函數,允許應用程序訪問內核空間。應用程序通過調用系統調用函數,將參數傳遞給內核空間,內核空間處理該請求并返回結果。
在 Windows 中,系統調用是通過向特定的內核空間地址發送特殊的中斷來實現的。具體實現細節取決于 Windows 版本。例如,在 Windows 10 中,系統調用可以通過快速系統調用 (syscall) 指令或者傳統的中斷方式來實現。
綜上,系統調用是一種能夠讓用戶空間應用程序訪問內核空間系統服務的機制。通過系統調用,用戶空間應用程序可以獲得更多的系統功能,例如讀寫磁盤、訪問網絡等。在 Windows 中,系統調用的實現方式為直接系統調用,它是通過執行特殊的匯編指令來直接調用內核空間的系統服務的。對于直接系統調用,與其它系統調用不同的是,它需要在編譯時預知調用的系統服務。換句話說,在編譯時就需要確定具體的系統服務的調用號,并在匯編代碼中顯式地使用它。例如在 NtCreateThreadEx 函數的匯編代碼中,我們可以看到如下代碼(win11):
在這段代碼中,mov 指令將 0C7h 的值存儲到了 eax 寄存器中,這個值就是調用號。然后 syscall 指令會使用 eax 寄存器中的值作為系統服務的調用號,從而調用內核空間的系統服務。
總之,系統調用是用戶空間應用程序訪問內核空間系統服務的機制,而直接系統調用是 Windows 中系統調用的實現方式。
現如今,AV/EDR 在用戶層下通常通過掛鉤 Native API 用以監控程序的執行流程,進而來判斷執行的代碼是否是惡意的。為了繞過這些安全產品在用戶層的 API 掛鉤,可以采用直接系統調用,因為直接系統調用是指直接通過匯編指令調用內核空間的系統服務,而不會經過用戶層的 API 函數,所以能夠繞過一些在用戶層的 API 掛鉤的安全產品。
那如何實現直接系統調用呢?這里使用 Visual Studio 進行直接系統調用的測試。
編寫的測試代碼:
首先新建匯編文件(.asm),并在其中編寫需要進行直接系統調用的匯編代碼。
.code NtCreateThreadEx proc ????mov r10, rcx ????mov eax, 0C7h ????syscall ????ret NtCreateThreadEx endp end
如果想要 .asm 文件參與編譯,需要設置一下項目屬性,在 Build Customizations 中勾選 masm 的支持。
之后在 asm 文件 上右鍵設置其屬性 --- General --- Item Type --- Mircrosoft Macro Assembler
設置完畢后,開始編寫主程序的調用代碼。
#include?#include? EXTERN_C NTSTATUS NtCreateThreadEx( ????OUT PHANDLE ThreadHandle, ????IN ACCESS_MASK DesiredAccess, ????IN LPVOID ObjectAttributes OPTIONAL, ????IN HANDLE ProcessHandle, ????IN PVOID StartRoutine, ????IN PVOID Argument OPTIONAL, ????IN ULONG CreateFlags, ????IN SIZE_T ZeroBits, ????IN SIZE_T StackSize, ????IN SIZE_T MaximumStackSize, ????IN LPVOID AttributeList OPTIONAL); DWORD WINAPI ThreadProc(LPVOID prarm) { ????std::cout?<< "thead id:"?<< GetCurrentThreadId() << std::endl; ????return?0; } int?main() { ????HANDLE hproc = GetCurrentProcess(); ????HANDLE hthread = nullptr; ????// hthread = CreateThread(nullptr, 0, ThreadProc, nullptr, 0, nullptr); ????NtCreateThreadEx(&hthread, GENERIC_EXECUTE, nullptr, hproc, ThreadProc, nullptr, FALSE, 0, 0, 0, nullptr); ????WaitForSingleObject(hthread, INFINITE); ????CloseHandle(hthread); ????system("pause"); ????return?0; }
在匯編文件中下斷點,調試運行可以發現,程序能夠走到我們自己編寫的直接系統調用中,并沒有走原始的 Native API。
執行完畢后,程序運行的結果如下。
使用重寫的 NtCreateThreadEx 創建的線程代碼被正常執行了。
如果將上述代碼中 NtCreateThreadEx 函數換成 CreateThread ,通過 Process Monitor 工具對該程序 Thread Create 操作進行監控查看其調用堆棧。
可以看到,最終還是會轉到 NtCreateThreadEx 函數。
查看 NtCreateThreadEx 創建線程下的調用堆棧。
在這個調用堆棧中,并沒有發現 NtCreateThreadEx 被調用,這是因為此時調用的是程序中實現的 NtCreateThreadEx。
上述代碼通過對 NtCreateThreadEx 進行直接系統調用,從而繞過了 AV/EDR 在用戶模式下對 Native API (這里是 NtCreateThreadEx )的掛鉤。
但需要說明的是在不同的 Windows 操作系統版本之間,系統調用號可能會有所不同。
例如上述代碼是在 win11 中實現的,自己查看 NtCreateThreadEx 的調用號為 0C7h,當將其放到其他操作系統中,可能存在不同,比如在 win10 中測試發現,NtCreateThreadEx 的調用號為 0C1h。
下圖為 win11(版本號22621.819) 中 NtCreateThreadEx 的調用號。
下圖為 win10(版本號19043.1110) 中 NtCreateThreadEx 的調用號。
不同操作系統間調用號的不同詳情可參考:
https://j00ru.vexillium.org/syscalls/nt/32/
https://j00ru.vexillium.org/syscalls/nt/64/
通過上面自己編寫個 API 的直接系統調用可以看到,這個過程還是比較繁瑣,需要區分不同操作系統之間的 API 的調用號的不同,并且還需要去獲取 API 的函數原型。于是網上出現了各種方便用戶使用系統調用的優秀項目,從本質來說,要想使用直接系統調用,就是想辦法獲取相關 Native API 的 stubs,我根據其實現方式的不同進行了分類:
動態 SSN 號獲取
二次加載 ntdll 獲取 stubs(Dual-load ntdll)
讀取內存中的 KnownDlls
從磁盤讀取 ntdll
下面介紹幾個比較優秀的項目,并對其進行簡單分析。
SysWhispers
SysWhispers 項目可以通過 python 腳本自動生成 x64 版本系統調用 stubs。
項目地址:https://github.com/jthuraisamy/SysWhispers
使用介紹:根據項目說明文檔進行測試,該項目最終會生成兩個文件 xxx.h 和 xxx.asm ,將其拷貝到項目中即可使用,具體在項目中使用這些函數參見上文 API 的調用過程。
這里以 NtCreateFile 作為演示。
py?.syswhispers.py?--functions NtCreateFile -o?syscalls
運行之后發現,同級目錄下發現:
查看頭文件,發現其中聲明了調用相關 Native API 使用到的數據結構。
查看其生成 asm 文件內容,可以發現其中是具體 API 的直接系統調用代碼 stubs,生成的匯編代碼首先會判斷系統版本進而去選擇內置的函數系統調用號,之后在進行系統調用。
這段匯編代碼通過 PEB 檢查系統的主要版本、次要版本和構建號,之后根據這些信息獲取正確的系統調用,如下圖所示。
Syswhispers2
在使用 Syswhispers 過程中可以發現,該項目需要提前知道相關函數系統版本調用號進行編寫,一般未將所有系統版本情況包含進去,就會調用失敗,不具有通用性,所以原作者對其進行改進,形成了 Syswhispers2 。
項目地址:https://github.com/jthuraisamy/SysWhispers2
作者在項目介紹文檔中指出了與 Syswhispers 項目的不同,其中最大的區別是不需要指定要支持哪個版本的 Windows 了。
Syswhispers2 項目的用法與 Syswhispers 一致,以生成 NtCreateUserProcess 的系統 stubs 來說明。
py .syswhispers.py --functions NtCreateThreadEx -o syscalls -a x64 -l masm
下面對生成的關鍵代碼進行分析。
生成的 syscallsstubs.std.x64.asm 中的代碼如下圖所示。
上述的代碼片段的主要功能是通過內置預定義的 hash 值來獲取系統調用號,進而進行相關函數的系統調用。
其中關鍵的函數為 SW2_GetSyscallNumber ,該函數在 syscalls.c 文件中被定義與實現。
該函數中又調用了另一個關鍵函數 SW2_PopulateSyscallList ,該函數將 Zw 開頭 Native API 名稱的 hash 值按照升序排序保存到 SW2_SyscallList.Entries 這個全局數組中。其中獲取 Zw 開頭的 API 是通過 peb 的到 ntdll 基址后,遍歷其導出表得到的,排序算法使用的冒泡排序。
通過 peb 獲取已加載的 ntdll 在內存中的基址。
遍歷內存中 ntdll 的導出表,獲取 Zw 開頭的函數名稱,存儲到全局數組 SW2_SyscallList.Entries 中。
對 SW2_SyscallList.Entries 這個全局數組進行冒泡升序排序。
生成的文件中還有一個匯編文件(syscallsstubs.rnd.x64.asm),其代碼片段如下。
上述代碼片段與 syscallsstubs.std.x64.asm 中代碼不同點在于,該段匯編代碼隱藏了 syscall 指令的出現,Syswhispers2 項目使用 SW2_GetRandomSyscallAddress 函數生成了隨機的 syscall 指令地址,用于防止 syscall 指令在匯編代碼片段中出現。
在使用這個文件時需要注意,需要 #define RANDSYSCALL 聲明宏,以開啟 SW2_GetRandomSyscallAddress 。
其中 SW2_GetRandomSyscallAddress 函數在 syscalls.c 文件中定義與實現。
該段代碼通過特征碼定位的形式來獲取隨機一個 Native API 的 syscall 指令地址,之后通過 call qword ptr [syscallAddress] 替換代碼中的 syscall 指令,從而繞過了 AV/EDR 對 syscall 指令的標記。
SysWhispers3
SysWhispers3 項目在項目說明文檔中解釋了與 Syswhispers2 項目的不同之處。
下面對 SysWhispers3 項目生成的文件進行簡單分析。
主要分析生成的匯編代碼文件和 syscalls.c 文件。
分析使用的生成腳本命令
py .syswhispers.py --functions NtCreateThreadEx -o syscalls -a x64 -c msvc -m jumper_randomized
對生成的代碼文件分析,一共生成了 3 個文件。
生成的 syscalls-asm.x64.asm 文件如下。
該段匯編代碼主要是隱藏了 syscall 指令的出現,并隨機獲取其他 API stubs 中的 syscall 指令,之后通過使用 jmp syscall 地址對其進行轉換,從而繞過了部分 AV/EDR 對 syscall 指令的標記。
其中函數的調用地址是 SW3_GetRandomSyscallAddress 函數實現的,該函數在 syscalls.c 文件中定義并實現。
SW3_GetRandomSyscallAddress 函數的主要作用是得到一個隨機的 Native API 的 ????syscall 指令地址。剩下的幾個函數與 Syswhispers2 項目中大體類似,便不在這里分析了。
HellsGate
項目地址:https://github.com/am0nsec/HellsGate
HellsGate 主要思路是通過 PEB 獲取已加載的 ntdll 在內存中的基地址,隨后通過解析其導出表,來定位 API 地址,之后通過特征碼來獲取函數的系統調用號,進而實現系統調用。
下面分析一些關鍵代碼。
這段代碼片段就是 HellsGate 用來定位系統調用號的特征碼。
mov r10,rcx // 0x4c 0x8b 0xd1 mov eax,// 0xb8 xx xx 0x00 0x00
雖然 HellsGate 通過特征碼能夠準確定位獲取函數的系統調用號,但也存在一定的局限性,使用它的前提是內存中的 ntdll 必須是“干凈”的 ntdll ,也就是不能被 AV/EDR 掛鉤,一旦被掛鉤,比如 mov r10,rcx 被掛鉤了,那么其字節碼就變了,就找不到想要的函數系統調用號了。
Halo’s Gate
后續也出現了一些改進方案,比如 Halo’s Gate,詳情可參考:https://blog.sektor7.net/#!res/2021/halosgate.md ,基本思路就是由于相鄰的系統調用有一定規律性(如下圖所示),所以只要定位相鄰的系統調用,就可以推導出想要函數的系統調用號。
HellsGatePoC
從 HellsGate 之門項目可以看到,它的局限性在于需要有一塊“干凈”的 ntdll ,不然,它無法獲取到想要的系統調用號。所以,又一個思路誕生了,該項目的主要思路是從磁盤中中獲取干凈的 ntdll,并將其映射到內存中,進而獲取到系統調用號,從而使用系統調用。
項目地址:https://github.com/N4kedTurtle/HellsGatePoC
更多的項目
當然有關使用直接系統調用繞過用戶層掛鉤的項目遠遠不止這些,由于篇幅有限,本文并沒有將其全部都介紹一遍,下面我推薦2個我認為優秀的項目。
https://github.com/JustasMasiulis/inline_syscall
https://github.com/crummie5/FreshyCalls
有興趣的讀者可以去了解下。
經過前文的分析,可以知道使用直接系統調用大致就是獲取 Native API 的 stubs 或者動態的獲取系統調用號去構造 stubs,雖然使用直接系統調用能夠有效的避免 AV/EDR 在用戶模式下的掛鉤,但是去獲取這個 stubs 或者調用號的過程還是會被 AV/EDR 監控的,并且在 Window Vista 之后,在內核模式中,安全廠商的研發人員可以利用微軟提供的現成的內核通知回調很輕松的監控用戶模式下程序的各種動作,所以在進行防御規避的過程中,需要不斷去發掘出新的思路方法,或者將已知的各種規避技術相互結合來進行欺騙和繞過。
并且隨著 Windows 版本的提升,微軟也已經開始也有趨勢去處理調用號的問題,從下圖(x86)可以發現,系統調用號并不一定是穩定遞增的,所以從這個方面想,那種基于排序動態獲取系統調用號的方法是否在未來還可用?以及是否有對應的緩解措施去應對這些變化,是作為一個安全研究員需要去不斷探索的事。
參考文獻
https://j00ru.vexillium.org/syscalls/nt/32/
https://j00ru.vexillium.org/syscalls/nt/64/
https://www.mdsec.co.uk/2020/12/bypassing-user-mode-hooks-and-direct-invocation-of-system-calls-for-red-teams/
https://blog.sektor7.net/#!res/2021/halosgate.md
https://teamhydra.blog/2020/09/18/implementing-direct-syscalls-using-hells-gate/
編輯:黃飛
?
評論
查看更多