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

本文详细介绍了如何通过EDR预加载技术在Windows进程初始化早期执行恶意代码,从而绕过用户模式EDR钩子。文章探讨了进程初始化流程、APC队列机制、TLS回调限制,并提出了基于AppVerifier回调的创新绕过方法,包括DLL入口点篡改和APC分发器禁用等技术。

利用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 injection"。

排队APC的问题在于它们长期以来一直被用于代码注入,因此ntdll!NtQueueApcThread()被大多数EDR钩住和监视。将APC排队到挂起的进程中非常可疑且已有充分文档记录。EDR还可能钩住你的APC、重新排序APC队列或采取其他任何措施以确保其DLL首先运行。

TLS回调

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

寻找新方法

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

AppVerifier和ShimEngine接口

很久以前,微软创建了一个名为AppVerifier的工具,用于应用程序验证。它设计用于在运行时监视应用程序的错误、兼容性问题等。AppVerifier的许多功能是通过在ntdll中添加大量新回调来实现的。

在逆向工程AppVerifier层时,我实际上找到了两组有用的回调(AppVerifier和ShimEngine)。引起我注意的两个指针是ntdll!g_pfnSE_GetProcAddressForCaller和ntdll!AvrfpAPILookupCallbackRoutine,分别属于ShimEngine和AppVerifier层。

这两个指针在ntdll!LdrGetProcedureAddressForCaller()的末尾被调用,该函数是GetProcAddress()内部用于解析导出函数地址的函数。这些回调是完美的,因为当LdrpInitializeProcess()加载kernelbase.dll时,保证会调用LdrGetProcedureAddress()。而且任何尝试使用GetProcAddress()/LdrGetProcedureAddress()解析导出的操作都会调用它,包括EDR,这有很多有趣的可能性。

更好的是,这些指针存在于进程初始化之前可写的内存部分中。

选择要钩住的回调

虽然有很多好的选择,但我决定选择AvrfpAPILookupCallbackRoutine,它似乎在Windows 8.1中引入。虽然我可以使用旧的回调以兼容早期Windows版本,但这需要更多的工作,而且我想保持我的PoC简单。

AppVerifer接口的其余部分要求安装"Verifier Provider",这需要大量的内存操作。ShimEngine稍容易一些,但设置g_ShimsEnabled为TRUE会启用所有回调,而不仅仅是我们想要的那个,因此我们必须注册每个回调,否则应用程序将崩溃。

较新的AvrfpAPILookupCallbackRoutine有两个优点:

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

引入EDR-Preloader

出于演示目的,我决定构建一个概念验证,利用AvrfpAPILookupCallbackRoutine回调在EDR DLL之前加载,然后阻止其加载。目前,我只在两个主要EDR上测试过,但理论上应该可以通过一些调整对抗任何EDR代码注入。

你可以在文章底部找到完整的源代码。

步骤1:定位AppVerifier回调指针

为了设置回调,我们需要设置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。

 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;  //我们想要的指针在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时。

警告:当我们的回调触发时,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入口点
    // 待办:停止恶意软件

    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) {
    
    //待办:DLL创建要允许或不允许的DLL列表
    
    return OriginalLdrLoadDll(search_path, dll_characteristics, dll_name, base_address);
}

最终 thoughts

虽然这个PoC仅设计用于Windows 10 64位,但该技术应该至少在Windows 7及更早的系统上可行(我尚未检查XP或Vista)。然而,在Windows 10以下找到正确的偏移量更加困难。对于更健壮的方法,我建议使用反汇编器。

无论如何,这是一个相当有趣的周末项目,希望有人能从中学到一些东西。如果你喜欢我的工作,请在LinkedIn和Mastodon上关注我以获取更多信息。

你可以在以下位置找到完整的源代码:github.com/MalwareTech/EDR-Preloader

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