简介
本文尝试通过调试和逆向工程来分析特定 EDR 的特定功能或检测机制。我的主要目标并非深入研究逆向工程过程的细节,而是深入理解 EDR 中新实现的检测机制的工作原理,探索该机制的功能,并探究其实现的可能原因。
此外,我认为重要的是要理解,任何商用 EDR 最终都是一个黑匣子。您可以尝试通过各种方法(例如静态和动态分析)来深入了解 EDR 的工作原理,但其内部工作原理和逻辑在很大程度上仍然是一个黑匣子。可以对 EDR 的内部工作原理和逻辑结构提出假设,然后进行分析以验证这些假设。然而,通常很难对其功能做出完全清晰、明确的陈述。
检测机制
端点检测与响应 (EDR) 系统根据不同的阶段使用不同的机制来检测恶意软件。在静态阶段,扫描器通常用于分析文件中已知的哈希值、签名和特定字节序列。如果攻击者设法绕过这种静态检测,许多 EDR 会诉诸沙盒技术。这需要在虚拟化环境中运行潜在的可疑文件来分析其行为。此外,EDR 还实现了诸如用户模式挂钩(例如内联 API 挂钩或 IAT 挂钩)、反恶意软件扫描接口 (AMSI)、Windows 事件跟踪以及用于基于行为的检测的威胁情报和内核回调等方法。
在我之前的一些文章中,我提到了内联 API 挂钩的概念,一些 EDR 会利用这一概念主动干扰 Windows API 代码和参数的执行。有关更多信息,请参阅我最近的博客文章《通过向量异常处理进行系统调用》。
非常规检测
然而,在本文中,我想讨论一种与 EDR 结合使用的非常规但非常有趣的检测机制,该机制基于进程环境块 (PEB) 修改、伪 DLL 和保护页面的使用以及矢量异常处理的组合。
伪造的DLL
在 Windows 上初始化进程时,所需的 DLL 会被加载到虚拟内存中。加载顺序取决于每个进程的依赖关系和需求。对于系统级 DLL(例如ntdll.dll和kernel32.dll),操作系统会在进程的虚拟内存中存储引用(指针)。这些指针指向 DLL 在共享内存中的实际物理位置。
DLL 的加载顺序由进程环境块 (PEB)InLoadOrderModuleList结构内的双向链表决定。和是任何 Windows 进程的必要关键组件,并且始终会被加载。PEB_LDR_DATAntdll.dllkernel32.dll
cmd.exe在分析配备了待分析 EDR 的系统上的活动进程(例如)时,我们发现了一个特殊的现象。我们使用 Process Hacker 工具对活动进程进行cmd.exe更详细的分析,尤其关注“模块”选项卡,检查 中加载的模块cmd.exe。乍一看,kernel32.dll和ntdll.dll似乎被加载了两次。然而,仔细观察就会发现,这些看似重复的 DLL 的拼写是不同的,因为每个 DLL 都有一个版本是用李特语 (Leetspeak) 编写的。使用 Process Hacker 仔细检查这些重复的 DLL 版本后,发现伪造的文件大小与原始 DLL 完全相同。同时,我们还注意到缺少模块描述。
尝试使用 Process Hacker 调查这些疑似仿冒品,但毫无进展。它们无法被分析,也无法在硬盘上找到这些文件的对应映像(错误消息:无法加载 PE 文件:未找到对象名称)。显然,这些仿制品是相应 DLL 的某种伪造版本,因此我们将其称为“伪 DLL”。ntdll.dll使用 Process Hacker 仔细检查伪造 DLL 后发现,它实际上是 的手动映射版本ntdll.dll,尽管在 Leetspeek 中使用了不同的名称。仔细检查内存保护机制,可以发现另一个有趣的特性:分配为(读取-执行)的内存RX也带有一个保护页(RX+G)。我们将记录这个细节,并在稍后深入探讨它的含义。
我们已经可以说的是,“Fake DLL”不是可以在光盘上找到的真正的独立 DLL,而是手动映射的版本,ntdll.dll已被手动重命名为类似于的名称ntdll.dll。
我可以检查一下您的 P3B 吗?
为了更好地理解这些伪 DLL 在所分析的 EDR 系统环境中的作用,我们将注意力转向活动进程的进程环境块 (PEB) - 例如cmd.exe- 在安装了 EDR 的系统上。
PEB 是 Windows 进程中至关重要的内存结构,负责管理进程特定的数据,例如程序基址、堆、环境变量和命令行信息。其结构包含众多字段和指针,Ldr尤其值得关注的是,它负责管理已加载的模块,尤其是 DLL。每个进程的 PEB 都是唯一的,在进程管理和 DLL 加载机制中发挥着关键作用。它为进程提供高效执行和管理所需的信息和资源。
然而,对 PEB 的完整讨论超出了本文的范围。为了更深入地了解,我建议感兴趣的人深入研究 Windows Internals。不过,我们的主要目标是进一步了解 EDR 如何使用伪造 DLL。
为了精确调查伪造 DLL 何时映射到内存中,我们使用了 WinDbg。连接到活动cmd.exe进程后,我们尝试访问进程环境块 (PEB) 内的双向链表InLoadOrderModuleList。此步骤使我们能够分析内存中模块的加载时间和顺序,包括识别伪造 DLL。
第一步是使用!pebWinDbg 中的命令访问 的 PEB cmd.exe。如下图所示, 中的第二个位置InMemoryOrderModuleList是 的伪造版本ntdll.dll,第三个位置是 的伪造版本。这证实了它们已成功映射到进程内存中。需要注意的是和kernel32.dll之间的区别在于按照模块在进程虚拟内存中的顺序列出它们。而 则指定了 DLL 的加载顺序。 InMemoryOrderModuleList
InLoadOrderModuleList
InMemoryOrderModuleList
InLoadOrderModuleList
为了验证这一观察结果,我们需要访问结构InLoadOrderModuleList体中的Ldr,并检查第二位和第三位列出的模块。为此,我们首先需要找到 的地址Ldr(在本例中为)。然后使用 WinDbg 命令访问00007ffa61ebc4c0该结构体。下图展示了如何访问该结构体。从基址偏移量为 开始,有一个条目,它为我们提供了清晰的信息,表明伪代码是否在进程启动期间实际加载到位置 2 和位置 3。PEB_LDR_DATA
dt nt!_PEB_LDR_DATA 00007ffa61ebc4c0
PEB_LDR_DATA
0x10
InLoadOrderModuleList
ntdll.dll
kernel32.dll
下一步是使用起始地址InLoadOrderModuleList(在本例中为0x000001d5`eb302650)访问此列表中的第一个模块。这可以通过 WinDbg 中的命令完成dt nt!_LDR_DATA_TABLE_ENTRY 0x000001d5`eb302650。下图显示第一个模块是其自身的映像cmd.exe。这是可以预料的,因为执行进程的映像必须始终位于模块序列的第一个位置。
为了访问订单中的第二个模块,我们再次使用 WinDbg 中的上一个命令,但将地址更新为 的起始地址InLoadOrderLinks,在本例中为0x000001d5`eb313d10。运行此命令后的以下屏幕截图显示 的伪造版本ntdll.dll实际上已作为订单中的第二个模块加载。
为了检查模块加载顺序中的第三个模块,我们在 WinDbg 中重复上一步,但将地址相应地更新为InLoadOrderLinks第三个模块的起始地址,在本例中为0x000001d5`eb317c20。下图证实了我们之前的发现:伪模块kernel32.dll被加载为列表中的第三个模块。
为了完成分析,我们在 WinDbg 中重复此步骤两次,以确定其他模块在加载顺序中的位置。结果图显示,real 模块在模块加载顺序中ntdll.dll位于第四位,real 模块kernel32.dll位于第五位。这些信息稍后会很重要。
需要注意的是InLoadOrderModuleList, 也用于确定 EDR 挂钩 DLL 的加载位置。在本例中,挂钩 DLL 在进程初始化期间作为第七个模块加载,在kernelbase.dll被加载为第六个模块之后。
问题出在哪里?
如果使用 WinDbg 在未安装 EDR 系统的终端和安装了备用 EDR 系统的终端上执行相同的分析,则会发现模块加载顺序存在明显差异InLoadOrderModuleList。分析结果表明,在这些情况下不存在伪造的 DLL。此外,还清楚地表明ntdll.dll仍然像往常一样在模块列表中排名第二和kernel32.dll第三。这种直接的比较突显了最初研究的 EDR 系统的独特性,它与标准配置的不同之处在于使用了伪造的 DLL 并采用了不同的模块加载顺序。
我们的分析结果和与 WinDbg 的比较清楚地表明,进程环境块 (PEB),或者更具体地说是InLoadOrderModuleList进程的 - 这里使用的示例cmd.exe- 在具有特定 EDR 系统的端点上进行了专门修改。
保护页
通过操纵InLoadOrderModuleListPEB 中的 ,EDR 确保在用户模式下初始化进程时,伪造的ntdll.dll和kernel32.dll会加载到第二和第三位,而真正的ntdll.dll和kernel32.dll则会加载到第四和第五位。但是 EDR 进行这种操纵的目的是什么呢?
为了回答这个问题,让我们再次看看ntdll.dll和的损坏版本kernel32.dll。正如我们已经确定的,这些是原始 DLL 的版本,已被手动映射到内存中,并在内存中补充了一个以 (read-execute) 形式提交的保护页RX。根据微软文档,该保护页可能会触发STATUS_GUARD_PAGE_VIOLATION (0x80000001)异常。
这些观察结果表明,使用伪造的 DLL 和保护页操纵进程环境块 (PEB) 很可能是为了激活 EDR 注册的向量异常处理程序 (VEH)。仔细研究 EDR 的 x64 挂钩 DLL 可以支持这一理论:实现 VEH 所需的 Windows APIAddVectoredExceptionHandler和RemoveVectoredExceptionHandler是从 导入的kernel32.dll。如果 EDR 确实注册了 VEH,则意味着当通过保护页抛出异常时,EDR 将通过 VEH 控制程序流,而不是将异常传递给结构化异常处理程序 (SEH)。
从另一个角度来看,在 EDR 的伪造 DLL 中触发保护页后,异常STATUS_GUARD_PAGE_VIOLATION (0x80000001)必须由向量异常处理程序或结构化异常处理程序处理。考虑到 EDR 所需的工作量,将异常传递给 SEH 似乎不太可能,而将其传递给专门注册的 VEH 似乎更合理、更合理。这意味着 EDR 可以在异常触发后主动干预其余程序流,从而在必要时阻止恶意软件。最终,这会导致应用程序流的重定向,这可以描述为挂钩。更具体地说,正如本文所述,是保护页挂钩或页面保护挂钩。
以下代码展示了如何在 C 语言中结合向量异常处理实现页面保护挂钩。需要强调的是,此代码仅为示例和基本框架,不包含 EDR 响应异常的具体逻辑。然而,可以设想,EDR 在触发异常后,会恢复相应伪造 DLL 中的保护页面,以便能够继续使用该保护页面进行监视。
#include<windows.h>#include<stdio.h>// Vectored Exception Handler function// This function is called when an exception occurs, such as a guard page violationLONG CALLBACK GuardPageExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo){// Check if the exception is a guard page violationif (pExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_GUARD_PAGE_VIOLATION) {printf("Guard Page Access Detected!n");// Here you can add logic to log the violation, analyze the access pattern,// or take any other appropriate action based on your EDR's requirements.// Optional: Restore the guard page here if you want continuous monitoring// Continue execution after handling the exceptionreturn EXCEPTION_CONTINUE_EXECUTION; }// If it's not a guard page violation, continue searching for other handlersreturn EXCEPTION_CONTINUE_SEARCH;}intmain(){// Set up a sensitive area of memory to monitor// This could represent a critical section of memory you want to protect SYSTEM_INFO si; GetSystemInfo(&si); // Get system information, including page size LPVOID pMemory = VirtualAlloc(NULL, si.dwPageSize, MEM_COMMIT, PAGE_READWRITE);if (pMemory == NULL) {printf("Memory allocation failedn");return1; }// Protect the sensitive memory with a guard page// Any access to this page will trigger the guard page violation DWORD oldProtect;if (!VirtualProtect(pMemory, si.dwPageSize, PAGE_GUARD | PAGE_READWRITE, &oldProtect)) {printf("Failed to set guard pagen"); VirtualFree(pMemory, 0, MEM_RELEASE); // Clean up if setting the guard page failsreturn1; }// Register the Vectored Exception Handler// This handler will be invoked for exceptions, including guard page violations PVOID handler = AddVectoredExceptionHandler(1, GuardPageExceptionHandler);if (handler == NULL) {printf("Failed to add Vectored Exception Handlern"); VirtualFree(pMemory, 0, MEM_RELEASE); // Clean up if handler registration failsreturn1; }// Your application logic goes here// This is where you would implement the rest of your EDR's functionality// ...// Clean up before exiting the application// This includes unregistering the exception handler and freeing allocated memory RemoveVectoredExceptionHandler(handler); VirtualFree(pMemory, 0, MEM_RELEASE);return0;}
值得注意的是,Windows API与 API 、以及术语AddVectoredExceptionHandler一起出现在挂钩 DLL 中。这可能表明 PEB 的修改、伪造的 DLL、保护页和向量异常处理程序之间存在交互。LdrEnumerateLoadedModules
ntdll.dll
PEBTrap
为了更好地理解 EDR 的向量异常处理程序 (VEH) 被激活的条件STATUS_GUARD_PAGE_VIOLATION (0x80000001),仔细研究挂钩 DLL 会发现一些有趣的见解。似乎此 DLL 中有一个特殊的比较操作,用于检查0x80000001在调用本机 API 之后,由于伪造的保护页被触发,是否发生了带有代码的相应异常ntdll.dll。具体来说,如果引发了值的异常0x80000001,则 EDR 的向量异常处理程序将变为活动状态(并且可能在必要时终止该进程)。但是,如果在0x80000001调用相应的本机 API 之后没有发生带有值的异常,例如NtProtectVirtualMemory,则允许进一步执行本机 API。然而,这目前只是一个假设,而不是一个明确的声明。但值得注意的是,在挂钩 DLL 中对大约 25 个本机 API 进行了这种比较,包括、NtAllocateVirtualMemory等,这些 API 经常用于恶意软件执行的上下文中。NtWriteVirtualMemory
NtProtectVirtualMemory
DLL 基类与原始基类
从恶意软件开发者的角度来看,主要问题在于恶意软件依赖于从 PEB 或 或 动态检索信息ntdll.dll。kernel32.dll一个具体的例子是使用 Shellcode 加载器或直接或间接使用系统调用的 Shellcode,例如,没有在代码中锚定系统服务编号 ( )。相反,它会尝试在运行时结合使用 PEB 遍历和导出地址表 (EAT) 解析来动态SSNs获取这些信息。SSNs
ntdll.dll
ntdll.dll要通过 PEB-Walk访问 的基址,通常需要使用 的偏移0x30量DLLBase。然而,在我们的 EDR 上下文中,这会导致你最终进入 的伪造版本的内存ntdll.dll,从而通过页面保护钩子触发 EDR 的矢量异常处理程序。
为了验证此说法的准确性,我们计划使用以下 C 代码进行实验。我们的目标是遍历 PEB 并访问ntdll.dllvia 偏移的基址,以确定并输出其内存地址。然后,我们希望将此地址与内存中0x30 DllBase真实和伪造文件的内存地址进行比较。ntdll.dll
cmd.exe
#include <windows.h>#include <stdio.h>UINT_PTR NtdllDllBase() {// Read the PEB Offset from the GS RegisterUINT_PTR pebAddress = __readgsqword(0x60);// Access the PEB_LDR_DATA field within PEBUINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);// Access the first entry in the InInitializationOrderModuleListUINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);// Traverse to the second module in the list (typically ntdll.dll)UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);// Uncomment the following line to advance to the third module// secondModule = *(UINT_PTR*)(secondModule);// Access the base address of the module by using DLL BaseUINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0x30);return baseAddress;}int main() {UINT_PTR ntdllBase = NtdllDllBase(); printf("Base address (offset 0x30) of the loaded ntdll.dll: %pn", (void*)ntdllBase); printf("Press any key to exit..."); getchar(); // wait for keypressreturn0;}
下图显示,使用偏移量找到的基址与内存中的0x30 DllBase实际地址不匹配ntdll.dll。但是,如果检查ntdll.dllEDR 系统实现的伪代码,则会发现内存地址匹配。这意味着,在 PEB 遍历过程中,使用偏移0x30 DllBase量访问的内存区域并非实际地址的内存区域ntdll.dll,而是ntdll.dllEDR 系统用于页面保护挂钩 (page guard hooking) 的伪代码的内存区域。
在实验的下一步中,我们希望调整 C 代码,使其不仅能够ntdll.dll通过偏移量0x30( DllBase) 访问和输出 的基地址,还能通过偏移量0xF8( OriginalBase) 确定和输出同一 DLL 的基地址。这 OriginalBase提供了一种访问 中模块基地址的替代方法InLoadOrderModuleList。通过以这种方式扩展代码,我们可以将找到的两个地址与内存中真实和伪造的地址进行比较ntdll.dll。
#include <windows.h>#include <stdio.h>UINT_PTR NtdllDllBase() {// Read the PEB Offset from the GS RegisterUINT_PTR pebAddress = __readgsqword(0x60);// Access the PEB_LDR_DATA field within PEBUINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);// Access the first entry in the InInitializationOrderModuleListUINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);// Traverse to the second module in the list (typically ntdll.dll)UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);// Uncomment the following line to advance to the third module// secondModule = *(UINT_PTR*)(secondModule);// Access the base address of the moduleUINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0x30);return baseAddress;}UINT_PTR NtdllOriginalDLLBase() {// Read the PEB Offset from the GS RegisterUINT_PTR pebAddress = __readgsqword(0x60);// Access the PEB_LDR_DATA field within PEBUINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);// Access the first entry in the InInitializationOrderModuleListUINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);// Traverse to the second module in the list (typically ntdll.dll)UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);// Uncomment the following line to advance to the third module// secondModule = *(UINT_PTR*)(secondModule);// Access the base address of the module by using Original BaseUINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0xF8);return baseAddress;}int main() {UINT_PTR ntdllBase = NtdllDllBase();UINT_PTR ntdllOriginalBase = NtdllOriginalDLLBase(); printf("Base address (offset 0x30) of the loaded ntdll.dll: %pn", (void*)ntdllBase); printf("Original base address (offset 0xF8) of the loaded ntdll.dll: %pn", (void*)NtdllOriginalDLLBase()); printf("Press any key to exit..."); getchar(); // wait for keypressreturn0;}
在安装了 EDR 的终端上运行我们的扩展代码后,下图显示了一个显而易见的结果:使用偏移量 找到的内存地址与真实 的地址相对应0xF8。这证实了使用偏移量( )的 PEB 遍历将带我们到达EDR 插入的伪造对象的内存区域。但是,如果我们选择 的偏移量,我们将到达真实对象的内存区域。 OriginalBase
ntdll.dll
0x30
DllBase
ntdll.dll
0xF8
OriginalBase
ntdll.dll
ntdll.dll一个关键问题仍然没有得到解答:为什么当我们使用偏移量时,0xF8我们得到的是合法的内存地址,而当我们使用偏移量时,我们得到的是OriginalBase假的内存地址?ntdll.dll0x30DllBase
这是因为 EDR 会DllBase用指向伪造内存区域的地址覆盖 的内存地址或指针ntdll.dll。这可以在安装了 EDR 的虚拟机中检查。如果您ntdll.dll使用 WinDbg 查看 EDR 操作的 结构,您会发现DllBase指针已被 EDR 指向伪造内存区域的内存地址替换。另一方面,ntdll.dll的内存地址仍然指向正确的、合法的。下图说明了这一点。OriginalBase
ntdll.dll
向量异常处理
在深入研究下一节中我们将要分析的检测链的内部逻辑之前,我想更详细地解释一下,我们将要分析的 EDR 在某些进程上下文中使用向量异常处理 (VEH) 的理论是如何被证实的。我们已经看到,EDR 从 导入了AddVectoredExceptionHandler和RemoveVectoredExceptionHandler函数kernel32.dll。但是,为了证明某个进程正在使用向量异常处理程序,或者正在被已注册的向量异常处理程序监视,让我们仔细研究一下进程环境块 (PEB)。使用 Windbg 进行调试,我们需要查看PEB 中CrossProcessFlags(0x5064 位偏移量) 的值。根据Olllie Whitehouse 的以下文章和Geoff Chapell 的文档CrossProcessFlags,该条目可以告诉我们某个进程是否正在使用 VEH。换句话说,如果该条CrossProcessFlags目的十进制值为 4,则该进程正在使用 VEH。另一方面,如果该条CrossProcessFlags目的十进制值为 0,则该进程未使用 VEH。
下图清晰地展现了这一点:notepad.exe在一台安装了待分析EDR的虚拟机上启动了一个进程,并CrossProcessFlags使用Windbg在PEB中检查了该条目的值。可以清楚地看到,它CrossProcessFlags的十进制值为4(十六进制0x00000004),因此它正在使用VEH,或者可能是EDR的VEH(稍后会更详细地讨论这一点)。然而,在图中右侧的比较中,我们看到的是同一个进程在未安装EDR的虚拟机上运行。正如预期的那样,CrossProcessFlags这里的条目为0,也就是说,该notepad.exe进程没有使用VEH。
因此,检查CrossProcessFlagsPEB 中的条目可以告诉我们进程是否正在使用向量异常处理 (VEH),但了解哪个模块负责注册 VEH 也很重要。换句话说,我们想要证明 VEH 是由 EDR 注册的。为了提供此证明,我们notepad.exe使用 x64dbg 等调试器加载映像,并在本机 API 上设置断点RtlAddVectoredExceptionHandler。触发断点后,我们会查看调用堆栈并检查RtlAddVectoredExceptionHandler函数是从哪个地址或模块调用的。换句话说,如果 VEH 的注册是由 EDR 完成的,那么调用 之前调用堆栈上的地址RtlAddVectoredExceptionHandler应该是与 EDR 关联的地址。
下图强调了这种预期;可以看出,在 的上下文中触发断点后RtlAddVectoredExceptionHandler,堆栈框架先前在 EDR 的用户模式挂钩 DLL 的上下文中被调用。这表明 的函数调用RtlAddVectoredExceptionHandler是由 EDR 用户模式挂钩 DLL 进行的。
通过这些调查,我们能够检查进程是否正在使用 PEB 中的条目来使用 VEH ,并且能够证明注册 VEH 所需的CrossProcessFlags本机函数的调用是由 EDR 用户模式挂钩 DLL 进行的。RtlAddVectoredExceptionHandler
EDR DLL - 内部逻辑
在了解概要和可能的解决方法之前,让我们先尝试更好地理解 EDR 是如何检查GUARD_PAGE某个伪 DLL 上下文中是否触发了该标志的。众所周知,伪 DLL 并非真正的 DLL,它们只是手动映射的版本,例如在 的上下文中ntdll.dll。然而,EDR 仍然需要一个内存部分来处理逻辑,或者检查GUARD_PAGE在某个伪 DLL 上下文中何时触发了 。通过查看内存中的 EDR 模块,我们可以识别用于内联挂钩部分的 EDR DLL 或模块。在此 EDR 的上下文中,除了伪 DLL 之外,这是 EDR 在用户模式下进程内存中使用的唯一真实 DLL,因此我们需要仔细研究来自 EDR 的挂钩 DLL,看看能否在我们研究伪 DLL 等的过程中找到一些重要的联系。
我想找出 EDR 内部的逻辑部分在哪里,以检查异常是否与相关异常代码相关STATUS_GUARD_PAGE_VIOLATION,或者更具体地说,与相关异常代码相关0x80000001。因此,我对 x64dbg 有了一个简单的想法。我们打开任何应用程序(如notepad.exe或)cmd.exe,使用 x64dbg 连接到它,第一步在内存映射中搜索挂钩 DLL 的基址。在第二步中,我们创建一个搜索模式,可用于尝试识别针对值的比较操作0x80000001。换句话说,我们在挂钩 DLL 内部查找针对上下文中的异常代码的比较操作STATUS_GUARD_PAGE_VIOLATION。因此,我们要搜索模式cmp eax, 80000001h,基于小端序,我们必须将模式转换为3D 01 00 00 80。这是我们的搜索模式,在 x64dbg 模式搜索中使用它之后,我们能够 80000001h在挂钩 DLL 的内存中观察到几个比较操作。我认为下图是一个合理的指示,表明来自 EDR 的逻辑(用于检查是否已STATUS_GUARD_PAGE_VIOLATION 0x80000001在某个假 DLL 的上下文中触发)被放置在来自 EDR 的挂钩 DLL 中,然后根据场景在挂钩 DLL 中采取进一步的步骤。
如上图所示,根据比较操作cmp eax, 80000001h,如果寄存器的eax值不等于 80000001h,则调用函数7FFE77F5AE51。否则,如果eax的值等于80000001h,7FFE77F56230则调用函数。换句话说,如果GUARD_PAGE某个伪造DLL在内存中的标志位被命中,7FFE77F56230则调用函数。
概括
在研究潜在的规避技术之前,我们先简要总结一下对 EDR 系统的分析。我们的调查显示,EDRInLoadOrderModuleList通过部署伪造的 DLL 对进程环境块 (PEB) 内的 进行了有针对性的修改。值得注意的是,这些伪造的 DLL 并非独立实体,而是原始 DLL 的手动映射版本,例如ntdll.dll。这些伪造 DLL 的一个关键特性是,它们RX(读取-执行)已提交的内存区域配备了保护页。当访问此内存区域(无论是读取还是执行)时,保护页都会抛出STATUS_GUARD_PAGE_VIOLATION (0x80000001)异常,这种机制也称为页面保护挂钩。然后,此异常会激活 EDR 的向量异常处理程序 (VEH),从而使 EDR 能够主动影响应用程序的执行流程。我们还能够识别出 EDR 中挂钩 DLL 中使用的比较操作,以检查是否STATUS_GUARD_PAGE_VIOLATION (0x80000001)已抛出异常。然而,EDR 的 VEH 启动后具体采取的措施现阶段仍不清楚,需要对 EDR 系统进行进一步检查。
总之,该技术通过控制(潜在恶意)应用程序的执行流程,为 EDR 提供了监控和潜在缓解恶意活动的能力。
可能的规避策略
最后,我们在 PEB 遍历的上下文中考虑一些可能的规避策略(在这种情况下,规避被定义为未被阻止和检测到的活动),例如动态查询系统服务编号(SSNs)以执行直接或间接的系统调用。
偏移原始基准
我们在分析中发现,当在 PEB 遍历过程中使用偏移量0x30( ),触发保护页,并最终触发 EDR 的 VEH 时,EDR 的检测机制就会被激活。一种可能的解决方法是使用 的偏移量来确定真实 DLL(例如 )的基址,而不是访问伪造的 DLL。然而,这可能会导致问题,具体取决于 Windows 版本,因为 的偏移量可能有所不同。目前尚未对此进行更详细的研究。DllBase
0xF8
OriginalBase
ntdll.dll
OriginalBase
UINT_PTR NtdllOriginalDLLBase() {// Read the PEB Offset from the GS RegisterUINT_PTR pebAddress = __readgsqword(0x60);// Access the PEB_LDR_DATA field within PEBUINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);// Access the first entry in the InInitializationOrderModuleListUINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);// Traverse to the second module in the list (typically ntdll.dll)UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);// Uncomment the following line to advance to the third module// secondModule = *(UINT_PTR*)(secondModule);// Access the base address of the module by using Original BaseUINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0xF8);return baseAddress;}
通过 Flink 访问正确的模块
另一种策略是使用 PEB 遍历来专门访问双向链表中的第四个模块InLoadOrderModuleList,在本例中,该模块对应正确的 DLL ntdll.dll,因此可以绕过 EDR 的伪造 DLL。然而,为了在实践中可靠运行(例如,在红队演练的准备阶段,您不知道目标环境中正在运行哪个 EDR),可能需要实施额外的检查,例如,循环比较 DLL 名称。例如,这可以防止InLoadOrderModuleList访问 DLL 中的第四个模块,除非 PEB 已被 EDR 修改。
// Get base address from ntdll.dll on machine with default InLoadOrderModuleList in PEB PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);// Get base address from ntdll.dll on machine with modified InLoadOrderModuleList in PEB in context of the analysed EDR PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink->Flink->Flink - 0x10);
Windows API
另一个可能的解决方法是使用 Windows API 函数GetModuleHandleA来确定 的基址ntdll.dll。以下代码展示了如何GetModuleHandleA使用 来确定此基址。在包含待分析 EDR 的系统上运行该代码后,可以检查正在访问的是真实内存区域ntdll.dll还是伪造内存区域ntdll.dll。通过这种方式,可以分析 EDR 对 PEB 的具体修改在多大程度上影响了 的使用GetModuleHandleA。
#include<windows.h>#include<stdio.h>UINT_PTR NtdllGetModul(){ UINT_PTR baseAddress = GetModuleHandleA("ntdll.dll");return baseAddress; }intmain(){ UINT_PTR ntdllGetModul = NtdllGetModul();printf("Base address from ntdll via GetModuleHandleA: %pn", (void*)NtdllGetModul());printf("Press any key to exit..."); getchar(); // wait for keypressreturn0;}
如下图所示,我们的实验结果显示,通过GetModuleHandleA在被调查的 EDR 端点上使用 Windows API,我们最终进入的是真实的内存区域ntdll.dll,而不是伪造的内存区域ntdll.dll。这表明,使用 访问模块或 DLL 的基址GetModuleHandleA可以绕过由 EDR 实现的伪造 DLL,从而绕过页面保护钩子。
然而,这种策略并不推荐,因为 Windows APIGetModuleHandleA等通常被 EDR 使用内联 API 挂钩进行监控。GetProcAddress因此LoadLibrary,应始终避免使用这些 API,例如在 Shellcode 加载器或 Shellcode 本身中。
PEB 迭代和字符串比较
我想指出最后一种规避可能性。为了优化对正确基址的确定ntdll.dll,并确保您不会意外获取伪造基址ntdll.dll,可以使用以下 C 代码。此方法结合使用迭代和字符串比较InLoadOrderModuleList来PEB识别正确的ntdll.dll。具体来说,代码会遍历已加载模块的列表,将模块名称与“ ntdll.dll”精确比较,如果完全匹配,则提取正确模块的基址。此方法是一种精确且相当可靠的解决方案,可以区分真实ntdll.dll版本和潜在的伪造版本,并正确确定其基址。
// Resources: // Hiding in Plain Sight: Unlinking Malicious DLLs from the PEB #include<stdio.h>#include<windows.h>#include<psapi.h>typedefstruct _UNICODE_STRING { USHORT Length; USHORT MaximumLength; PWSTR Buffer;} UNICODE_STRING, * PUNICODE_STRING;typedefstruct _PEB_LDR_DATA { BYTE Reserved1[8]; PVOID Reserved2[3]; LIST_ENTRY InMemoryOrderModuleList;} PEB_LDR_DATA, * PPEB_LDR_DATA;typedefstruct _LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName;} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;typedefstruct _PEB { BYTE Reserved1[2]; BYTE BeingDebugged; BYTE Reserved2[1]; PVOID Reserved3[2]; PPEB_LDR_DATA Ldr;} PEB, * PPEB;typedefstruct _MY_LDR_DATA_TABLE_ENTRY{ LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING ignored; ULONG Flags; SHORT LoadCount; SHORT TlsIndex; LIST_ENTRY HashTableEntry; ULONG TimeDateStamp;} MY_LDR_DATA_TABLE_ENTRY;// Returns a pointer to the PEB by reading the FS or GS registryPEB* get_peb(){#ifdef _WIN64return (PEB*)__readgsqword(0x60);#elsereturn (PEB*)__readfsdword(0x30);#endif}// Get the base address of reall ntdll.dll by comparing the name of the DLL with "ntdll.dll" string and returning the base address of the DLL if the name contains "ntdll.dll" PVOID get_ntdll_base_via_name_comparison(){ PEB* peb = get_peb(); // Get a pointer to the PEB LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList; // Get the first entry in the list of loaded modulesdo { current = current->Flink; // Move to the next entry MY_LDR_DATA_TABLE_ENTRY* entry = CONTAINING_RECORD(current, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);char dllName[256]; // Buffer to store the name of the DLL// Assuming FullDllName is a UNICODE_STRING, conversion to char* may require more than snprintf, consider proper conversionsnprintf(dllName, sizeof(dllName), "%wZ", entry->FullDllName);if (strstr(dllName, "ntdll.dll")) { // Check if dllName contains "ntdll.dll"return entry->DllBase; // Return the base address of ntdll.dll } } while (current != &peb->Ldr->InMemoryOrderModuleList); // Loop until we reach the first entry againreturnNULL;}intmain(){// Get the base address of ntdll.dll by comparing the name of the DLL with "ntdll.dll" string PVOID ntdll_base = get_ntdll_base_via_name_comparison();if (ntdll_base == NULL) {printf("ntdll.dll not foundn"); }else {printf("Base address from real ntdll.dll based on string or dll name comparison: %pn", ntdll_base); }printf("Press any key to continuen"); (void)getchar();return0;}
下图展示了我们如何使用C代码来避开EDR的伪造ntdll.dll并成功确定真实的基地址ntdll.dll。
解释
我想用一个简短的解释来结束我对 EDR 系统的分析。与其他 EDR 解决方案相比,本文所述的基于伪造 DLL、保护页和向量异常处理的检测机制被认为是一种相当非传统的方法,更可能出现在游戏黑客领域。然而,它在实践中已被证明非常有效。根据我的经验,成功绕过(定义为恶意软件既未被阻止也未被检测到的情况)所需的时间和精力明显高于其他 EDR 系统。这种方法的复杂性和连贯性使其看起来像一个“陷阱”。据我目前理解,ntdll.dll只有在进程初始化期间使用 PEB 遍历尝试ntdll.dll通过DLLBase偏移量进行访问时,伪造文件中的保护页才会被触发0x30,但实际上最终会进入伪造文件的内存ntdll.dll。但是,如果应用程序或恶意软件使用诸如GetModuleHandleA或 之类的 APILoadLibrary来获取 的句柄ntdll.dll,则伪造文件ntdll.dll、保护页和 VEH 机制将不起作用。然而,从恶意软件的角度来看,在这种情况下,EDR 进行内联 API 挂钩的可能性相对较高,因为GetModuleHandleA和LoadLibrary通常都带有内联挂钩。
虽然修改进程环境块 (PEB) 的过程、使用伪 DLL 以及页面保护挂钩和向量异常处理的过程相对容易理解,但在移交给 EDR 的 VEH 之后究竟会发生什么仍未得到解答。一个可能但未经证实的假设是,受影响的线程或进程要么被直接终止,要么被移交给 EDR 的挂钩 DLL,由后者决定是否终止该进程。然而,需要强调的是,这些只是现阶段的推测,并不能作为确凿的证据。
研究所述 EDR 机制对系统性能的影响也将具有指导意义,尤其是与其他不采用此特定方法的 EDR 系统相比。考虑到页面保护挂钩和向量异常处理的过程,可以预期这种方法对系统性能的影响会更大。与内联 API 挂钩类似,EDR 可以限制对特定 API 的访问,以最大限度地降低系统负载。IDA 中已识别出大约 25 个原生 API 的比较操作,这一观察结果支持了这一假设。这表明,EDR 专注于选定的关键 API,以优化效率,避免过度影响系统性能。
希望本文能让您稍微了解一下这种相当非传统的 EDR 检测机制,感谢您的阅读。下篇文章见。
参考
https://mark.rxmsolutions.com/hook-via-vectored-exception-handling/
https://ph3n1x.com/posts/parse-ntdll-and-peb/
http://www.rohitab.com/discuss/topic/41855-tutorial-the-different-ways-of-hooking/
https://imphash.medium.com/windows-process-internals-a-few-concepts-to-know-before-jumping-on-memory-forensics-part-2-4f45022fb1f8
https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/ntpsapi_x/peb_ldr_data.htm#:~:text=The%20PEB_LDR_DATA%20structure%20is%20the,structures%20in%20a%20different%20order
原文始发于微信公众号(Ots安全):EDR 分析:利用伪造 DLL、保护页和 VEH 增强检测
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论