利用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()有两个目的:
- 初始化进程(如果尚未初始化)
- 初始化线程
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_GetProcAddressForCaller和ntdll!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非常方便,原因有二:
- 它可以通过设置
ntdll!AvrfpAPILookupCallbacksEnabled独立于AppVerifier接口启用,因此不需要AppVerifier提供程序。 ntdll!AvrfpAPILookupCallbacksEnabled和ntdll!AvrfpAPILookupCallbackRoutine在内存中很容易定位,尤其是在Windows 10上。
介绍EDR预加载器
为了演示,我决定构建一个概念验证,利用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。
|
|
步骤2:设置回调以调用我们的恶意代码
设置回调最简单的方法是,以挂起状态启动我们自己进程的第二个副本。
由于ntdll在每个进程中的地址都相同,我们只需要在自己进程中定位回调指针。
一旦我们的进程以挂起状态启动,我们就可以直接使用WriteProcessMemory()来设置指针。
我们也可以将此技术用于进程空洞化、shellcode注入等,因为它允许我们在不创建/劫持线程或排队APC的情况下执行代码。但对于这个PoC,我们将保持简单。
注意:由于许多ntdll指针是加密的,我们不能简单地将指针设置为我们目标地址。我们必须先加密它。 幸运的是,密钥是相同的值,并且存储在所有进程中的相同位置。
|
|
现在我们可以使用WriteProcessMemory()写入指针并将AvrfpAPILookupCallbacksEnabled设置为1:
|
|
步骤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的入口点地址。
|
|
禁用APC分发器
当APC排队到一个线程时,它们由ntdll!KiUserApcDispatcher()处理,该函数运行APC然后调用ntdll!NtContinue()将线程返回到其原始上下文。
通过钩住KiUserApcDispatcher并用我们自己的函数替换它(该函数只是循环调用NtContinue()),任何APC都不能再排队到我们的进程中(包括来自EDR内核驱动程序的APC)。
|
|
代理LdrLoadDll调用
通过在ntdll!LdrLoadDll()上放置钩子,我们可以监控哪些DLL正在被加载。
如果任何EDR尝试使用LdrLoadDll加载其DLL,我们可以卸载或禁用它。
理想情况下,我们可能想要钩住ntdll!LdrpLoadDll(),它级别更低,并且直接被一些EDR调用,但为简单起见,我们将只使用LdrLoadDll。
|
|
最后思考
虽然这个PoC仅针对Windows 10 64位设计,但该技术至少在Windows 7及更早的系统上应该是可行的(我还没有检查XP或Vista)。 然而,在低于Windows 10的系统上,找到正确的偏移量更加困难。对于更稳健的方法,我建议使用反汇编器。 无论如何,这是一个相当有趣的周末项目,希望有人能从中学习到一些东西。
如果你喜欢我的工作,请在LinkedIn和Mastodon上关注我,了解更多。 你可以在以下位置找到完整的源代码:github.com/MalwareTech/EDR-Preloader