文章总结: 这篇文章分析了一种EDR系统的非常规检测机制,该系统通过修改PEB加载假DLL并设置保护页面,当恶意软件尝试访问假DLL时触发STATUS_GUARD_PAGE_VIOLATION异常,进而激活EDR的向量化异常处理程序来检测和阻止恶意行为。作者提出了几种可能的规避策略,包括使用OriginalBase偏移量、直接访问正确的模块、使用WindowsAPI或通过PEB迭代和字符串比较来识别真正的DLL。
综合评分: 90
文章分类: 漏洞分析,逆向分析,二进制安全,安全工具,实战经验
EDR 分析:利用假 DLL、保护页面和向量化异常处理增强检测能力
Daniel Feichter
securitainment
2025年12月18日 17:18
中国香港
在这篇博客文章中,我想记录并分享我在 EDR (Endpoint Detection and Response,端点检测与响应) 调试和逆向工程领域的最新发现和经验。我最近接触到一个端点检测与响应 (EDR) 系统,其检测行为引起了我的兴趣。该系统超越了常见的检测机制,如内联 API 挂钩、Windows 事件跟踪和内核回调。本文将讨论一种相当非常规但极具价值的检测机制,该机制与 EDR 结合使用。
免责声明
未提及任何产品或制造商名称。出于保密原因,我已对所有使用的图像进行匿名化处理。本文的目的纯粹是学术性的;此处分享的信息仅用于研究目的,在任何情况下都不应用于不道德或非法活动。
我还想强调,我不是逆向工程师,但我对该领域很着迷,希望了解更多并记录我的进展。我也不对我的评论的准确性或完整性做出任何声明。
简介
本文试图通过调试和逆向工程来分析特定 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) 中结构 PEB_LDR_DATA内的双向链表 InLoadOrderModuleList确定。ntdll.dll和 kernel32.dll是任何 Windows 进程的基本关键组件,始终被加载。
当分析配备待分析 EDR 的系统上的活动进程 (例如 cmd.exe) 时,会出现一个特殊的观察结果。我们使用 Process Hacker 工具更详细地分析活动进程 cmd.exe,特别关注模块选项卡以检查在 cmd.exe中加载的模块。乍一看,似乎 kernel32.dll和 ntdll.dll已被加载两次。然而,仔细观察发现,这些看似重复的 DLL 的拼写不同,因为每个的一个版本都是用 Leetspeak 编写的。使用 Process Hacker 对这些重复的 DLL 版本进行更仔细的检查表明,假 DLL 的文件大小与原始 DLL 的文件大小完全相同。还值得注意的是,缺少模块描述。
使用 Process Hacker 调查这些疑似仿冒品的尝试没有产生任何结果。它们无法被分析,并且在硬盘驱动器上找不到这些文件的相应映像 (错误消息:无法加载 PE 文件:找不到对象名称)。显然,这些仿制品是相应 DLL 的某种假版本,这就是为什么我们称它们为 “假 DLL”。在 Process Hacker 中更仔细地查看 ntdll.dll上下文中的假 DLL 表明,它实际上是 ntdll.dll的手动映射版本,尽管使用了 Leetspeak 中的不同名称。仔细查看内存保护揭示了另一个有趣的特性:分配为 RX(读执行) 的内存还配备了保护页面 (RX+G)。我们将注意这个细节,稍后仔细研究。
我们现在可以说的是,”假 DLL” 不是可以在磁盘上找到的真正独立的 DLL,而是 ntdll.dll的手动映射版本,已手动重命名为类似于 ntdll.dll的名称。
我可以检查您的 PEB 吗?
为了更好地理解这些假 DLL 在所分析的 EDR 系统上下文中的作用,我们将注意力转向安装了 EDR 的系统上活动进程的进程环境块 (PEB) – 例如 cmd.exe。
PEB 是任何 Windows 进程中的关键内存结构,负责管理特定于进程的数据,例如程序基址、堆、环境变量和命令行信息。在其结构中包含许多字段和指针,Ldr结构特别值得注意,因为它负责管理加载的模块,尤其是 DLL。PEB 对每个进程都是唯一的,在进程管理和 DLL 加载机制中起着关键作用,为进程提供有效执行和管理所需的必要信息和资源。
然而,对 PEB 的全面讨论超出了本文的范围。为了更深入的理解,我建议感兴趣的读者深入研究 Windows 内部。但是,我们的主要目标是了解更多关于 EDR 使用假 DLL 的信息。
为了准确调查假 DLL 何时映射到内存中,我们使用 WinDbg。附加到活动的 cmd.exe进程后,我们尝试访问进程环境块 (PEB) 中的双向链表 InLoadOrderModuleList。此步骤允许我们分析内存中模块的加载时间和顺序,包括识别假 DLL。
第一步是在 WinDbg 中使用 !peb命令访问 cmd.exe的 PEB。如下图所示,InMemoryOrderModuleList中的第二个位置是 ntdll.dll的假版本,第三个位置是 kernel32.dll的假版本,这证实了它们已成功映射到进程的内存中。需要注意的是,InMemoryOrderModuleList和 InLoadOrderModuleList之间的区别在于:InMemoryOrderModuleList按照模块在进程虚拟内存中的顺序列出模块,而 InLoadOrderModuleList指定 DLL 的加载顺序。
为了验证这一观察结果,我们访问 Ldr结构中的InLoadOrderModuleList,并检查第二和第三位置列出的模块。为此,我们首先需要找到Ldr的地址 (在本例中为00007ffa61ebc4c0)。然后使用命令dt nt!_PEB_LDR_DATA 00007ffa61ebc4c0在 WinDbg 中访问结构PEB_LDR_DATA。下图显示了如何访问PEB_LDR_DATA结构。从偏移量为0x10的基址开始,有条目InLoadOrderModuleList,它为我们提供了明确的信息,说明假ntdll.dll是否在位置 2 实际加载,假kernel32.dll是否在进程初始化期间在位置 3 加载。
下一步是使用 InLoadOrderModuleList的起始地址 (在本例中为 0x000001d5eb302650) 访问此列表中的第一个模块。这是通过在 WinDbg 中使用命令 dt nt!LDRDATATABLEENTRY 0x000001d5eb302650完成的。下图显示第一个模块是 cmd.exe本身的映像。这是可以预期的,因为执行进程的映像必须始终在模块序列中排在第一位。
要访问顺序中的第二个模块,我们再次在 WinDbg 中使用先前的命令,但将地址更新为 InLoadOrderLinks的起始地址,在本例中为 0x000001d5eb313d10。运行此命令后的以下屏幕截图显示,假版本的 ntdll.dll`实际上作为顺序中的第二个模块加载。
要检查模块加载顺序中的第三个模块,我们在 WinDbg 中重复前面的步骤,但相应地将地址更新为第三个模块的 InLoadOrderLinks的起始地址,在本例中为 0x000001d5eb317c20。下图证实了我们之前的发现:假 kernel32.dll`作为列表中的第三个模块加载。
为了完成我们的分析,我们在 WinDbg 中重复此步骤两次,以确定加载顺序中其他模块的位置。结果图显示,真正的 ntdll.dll排在第四位,真正的 kernel32.dll在模块加载顺序中排在第五位。此信息稍后将很重要。
请注意,InLoadOrderModuleList还用于确定 EDR 挂钩 DLL 的加载位置。在这种情况下,挂钩 DLL 在进程初始化期间作为第七个模块加载,在 kernelbase.dll作为第六个模块加载之后。
陷阱在哪里?
如果在没有安装 EDR 系统的端点或具有替代 EDR 系统的端点上使用 WinDbg 执行相同的分析,则关于 InLoadOrderModuleList中模块加载顺序会出现明显不同的情况。分析结果表明,在这些情况下没有假 DLL。同样清楚的是,ntdll.dll像往常一样在模块列表中的第二位加载,kernel32.dll在第三位加载。这种直接比较强调了最初研究的 EDR 系统的独特性,该系统通过使用假 DLL 和不同的模块加载顺序而不同于标准配置。
我们使用 WinDbg 的分析结果和比较清楚地表明,进程的进程环境块 (PEB),或更具体地说进程的 InLoadOrderModuleList– 这里以 cmd.exe为例 – 在具有特定 EDR 系统的端点上被专门修改。
保护页面
通过操纵 PEB 中的 InLoadOrderModuleList,EDR 确保当在用户模式下初始化进程时,ntdll.dll和 kernel32.dll的假版本在第二和第三位加载,然后真正的 ntdll.dll和 kernel32.dll在第四和第五位跟随。但是,这种 EDR 操纵的目的是什么?
为了回答这个问题,让我们再看看 ntdll.dll和 kernel32.dll的伪造版本。正如我们已经确定的,这些不是独立的实体,而是原始 DLL (例如 ntdll.dll) 的手动映射版本。这些假 DLL 的一个关键方面是,它们提交为 RX(读执行) 的内存区域配备了内存中的保护页面。根据 Microsoft 文档,保护页面可以触发 STATUS_GUARD_PAGE_VIOLATION (0x80000001)异常。
这些观察结果表明,使用假 DLL 和保护页面操纵进程环境块 (PEB) 很可能用于激活 EDR 注册的向量化异常处理程序 (VEH)。仔细查看 EDR 的 x64 挂钩 DLL 支持这一理论:实现 VEH 所需的 Windows API AddVectoredExceptionHandler和 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 violation
LONG CALLBACK GuardPageExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) {
// Check if the exception is a guard page violation
if (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 exception
return EXCEPTION_CONTINUE_EXECUTION;
}
// If it's not a guard page violation, continue searching for other handlers
return 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 failed\n");
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 page\n");
VirtualFree(pMemory, 0, MEM_RELEASE); // Clean up if setting the guard page fails
return1;
}
// 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 Handler\n");
VirtualFree(pMemory, 0, MEM_RELEASE); // Clean up if handler registration fails
return1;
}
// 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 AddVectoredExceptionHandler与挂钩 DLL 中的 API LdrEnumerateLoadedModules、ntdll.dll和术语 PEBTrap一起出现,这可能表明 PEB 修改、假 DLL、保护页面和向量化异常处理程序之间的交互。
为了更好地理解在何种条件下由 STATUS_GUARD_PAGE_VIOLATION (0x80000001)激活 EDR 的向量化异常处理程序 (VEH)(由于假 ntdll.dll中的保护页面被触发),仔细查看挂钩 DLL 提供了有趣的见解。似乎在此 DLL 中存在一个特殊的比较操作,该操作检查在调用原生 API 后是否由于保护页面在假 ntdll.dll中被触发而发生相应的异常代码 0x80000001。具体来说,如果抛出值为 0x80000001的异常,则 EDR 的向量化异常处理程序将变为活动状态 (并且如果必要可能会终止进程)。但是,如果在调用相应的原生 API (例如 NtProtectVirtualMemory) 之后没有跟随值为 0x80000001的异常,则允许原生 API 的进一步执行。但是,这目前是一个假设,而不是明确的陈述。有趣的是,挂钩 DLL 中对大约 25 个原生 API 进行了此比较,包括 NtAllocateVirtualMemory、NtWriteVirtualMemory、NtProtectVirtualMemory等,这些通常在恶意软件执行的上下文中使用。
DLL 基址 vs. 原始基址
从恶意软件开发人员的角度来看,主要问题是当恶意软件依赖于从 PEB 或从 ntdll.dll或 kernel32.dll动态检索信息时。这方面的一个具体例子是使用 shellcode 加载器或直接或间接使用系统调用的 shellcode,例如,而不将系统服务号 (SSN) 硬编码在代码中,而是尝试使用 PEB 遍历和导出地址表 (EAT) 解析的组合在运行时在 ntdll.dll中动态获取这些 SSN。
要通过 PEB-Walk 访问 ntdll.dll的基址,通常使用 DLLBase的偏移量 0x30。但是,在我们分析的 EDR 上下文中,这会导致您最终进入 ntdll.dll假版本的内存,从而通过页面保护挂钩触发 EDR 的向量化异常处理程序。
为了验证此陈述的准确性,我们使用以下 C 代码规划一个实验。我们的目标是使用 PEB 遍历并通过偏移量 0x30``DllBase访问 ntdll.dll的基址以确定并输出其内存地址。然后,我们将此地址与 cmd.exe内存中真假 ntdll.dll的内存地址进行比较。
#include<windows.h>
#include<stdio.h>
UINT_PTR NtdllDllBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_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 Base
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0x30);
return baseAddress;
}
intmain() {
UINT_PTR ntdllBase = NtdllDllBase();
printf("Base address (offset 0x30) of the loaded ntdll.dll: %p\n", (void*)ntdllBase);
printf("Press any key to exit...");
getchar(); // wait for keypress
return0;
}
下图显示,使用 0x30``DllBase偏移量找到的基址与内存中的真正 ntdll.dll不匹配。但是,如果您检查 EDR 系统实现的假 ntdll.dll,您会看到内存地址匹配。这意味着在 PEB 遍历期间,当使用 0x30``DllBase偏移量时,访问的内存区域不是真正的 ntdll.dll的内存区域,而是 EDR 系统用于页面保护挂钩的假 ntdll.dll的内存区域。
在我们实验的下一步中,我们调整 C 代码,不仅通过偏移量 0x30(DllBase) 访问和输出 ntdll.dll的基址,而且还通过偏移量 0xF8(OriginalBase) 确定和输出同一 DLL 的基址。OriginalBase提供了访问 InLoadOrderModuleList中模块基址的替代方法。通过这种方式扩展我们的代码,我们可以将找到的两个地址与内存中真假 ntdll.dll的地址进行比较。
#include<windows.h>
#include<stdio.h>
UINT_PTR NtdllDllBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_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
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0x30);
return baseAddress;
}
UINT_PTR NtdllOriginalDLLBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_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 Base
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0xF8);
return baseAddress;
}
intmain() {
UINT_PTR ntdllBase = NtdllDllBase();
UINT_PTR ntdllOriginalBase = NtdllOriginalDLLBase();
printf("Base address (offset 0x30) of the loaded ntdll.dll: %p\n", (void*)ntdllBase);
printf("Original base address (offset 0xF8) of the loaded ntdll.dll: %p\n", (void*)NtdllOriginalDLLBase());
printf("Press any key to exit...");
getchar(); // wait for keypress
return0;
}
在安装了 EDR 的端点上运行我们的扩展代码后,下图显示了一个启发性的结果:使用 OriginalBase偏移量 0xF8找到的内存地址对应于真正 ntdll.dll的地址。这证实了使用偏移量 0x30(DllBase) 的 PEB 遍历会将我们带到 EDR 插入的假 ntdll.dll的内存区域。但是,如果我们为 OriginalBase选择偏移量 0xF8,我们将到达真正 ntdll.dll的内存区域。
一个关键问题仍未得到解答:为什么当我们为 OriginalBase使用偏移量 0xF8时,我们会得到合法 ntdll.dll的内存地址,而当我们为 DllBase使用偏移量 0x30时,我们会得到假 ntdll.dll的内存地址?
这是因为 EDR 使用指向假 ntdll.dll内存区域的地址覆盖 DllBase的内存地址或指针。这可以在安装了 EDR 的虚拟机中检查。如果您使用 WinDbg 查看 EDR 正在操纵的 ntdll.dll的结构,您会看到 DllBase指针被替换为指向 EDR 的假 ntdll.dll的内存地址。另一方面,OriginalBase的内存地址仍然指向正确、合法的 ntdll.dll。下图说明了这一点。
向量化异常处理
在我们仔细研究我们将在下一节中分析的检测链的内部逻辑之前,我想更详细地了解我们将要分析的 EDR 在某些进程的上下文中使用向量化异常处理的理论如何得到证实。我们已经看到 EDR 从 kernel32.dll导入 AddVectoredExceptionHandler和 RemoveVectoredExceptionHandler函数。但是,为了证明进程正在使用向量化异常处理程序或正在由注册的向量化异常处理程序监视,让我们更仔细地查看进程环境块 (PEB)。使用 Windbg 进行调试,我们想查看 PEB 中 CrossProcessFlags(64 位为偏移量 0x50) 的值。根据 Olllie Whitehouse 的以下文章和 Geoff Chapell 的文档,CrossProcessFlags条目可以告诉我们进程是否正在使用 VEH。换句话说,如果 CrossProcessFlags条目的十进制值为 4,则进程正在使用 VEH。另一方面,如果 CrossProcessFlags条目的十进制值为 0,则进程未使用 VEH。
这可以在下图中清楚地看到:在安装了要分析的 EDR 的 VM 上启动了 notepad.exe,并使用 PEB 中的 Windbg 检查了 CrossProcessFlags条目的值。您可以清楚地看到 CrossProcessFlags的十进制值为 4 (十六进制为 0x00000004),因此正在使用 VEH,或者可能是 EDR 的 VEH (但稍后将对此进行更详细的检查)。但是,在图右侧的比较中,我们看到在未安装 EDR 的 VM 上的相同进程。正如预期的那样,这里的 CrossProcessFlags条目为 0,即 notepad.exe进程未使用 VEH。
因此,检查 PEB 中的 CrossProcessFlags条目告诉我们进程是否正在使用向量化异常处理,但了解哪个模块负责注册 VEH 也很有趣。换句话说,我们想证明 VEH 是由 EDR 注册的。为了提供这一证据,我们使用调试器 (如 x64dbg) 加载映像 notepad.exe,并在原生 API RtlAddVectoredExceptionHandler上设置断点。当断点被触发时,我们查看调用堆栈并检查从哪个地址或模块调用 RtlAddVectoredExceptionHandler函数。换句话说,如果 VEH 的注册是由 EDR 完成的,则预期调用堆栈上的 RtlAddVectoredExceptionHandler调用之前的地址是与 EDR 关联的地址。
下图强调了这一期望;可以看到,在 RtlAddVectoredExceptionHandler的上下文中触发断点后,之前在 EDR 的用户模式挂钩 DLL 的上下文中调用了堆栈帧。这表明 RtlAddVectoredExceptionHandler的函数调用是由 EDR 用户模式挂钩 DLL 发出的。
这些调查使我们能够使用 PEB 中的 CrossProcessFlags条目检查进程是否正在使用 VEH,还能够证明注册 VEH 所需的对原生函数 RtlAddVectoredExceptionHandler的调用是由 EDR 用户模式挂钩 DLL 发出的。
EDR DLL – 内部逻辑
在我们进入总结和可能的解决方法之前,让我们尝试更好地理解 EDR 如何检查是否在其中一个假 DLL 的上下文中触发了 GUARD_PAGE标志。正如我们已经知道的,假 DLL 不是真正的 DLL,它们只是手动映射的版本,例如在 ntdll.dll的上下文中。但是,EDR 仍然需要内存中的部分来处理逻辑或检查何时在其中一个假 DLL 的上下文中触发了 GUARD_PAGE。通过查看内存中的 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 模式搜索中使用它后,我们能够在钩子 DLL 的内存中观察到针对80000001h的几个比较操作。我认为下图是一个合理的指标,表明 EDR 的逻辑检查是否在其中一个假 DLL 的上下文中触发了STATUS_GUARD_PAGE_VIOLATION``0x80000001,该逻辑放置在 EDR 的挂钩 DLL 内部,然后根据场景在挂钩 DLL 内部采取进一步的步骤。
上图显示,基于比较操作 cmp eax, 80000001h,如果寄存器 eax不等于值 80000001h,则调用函数 7FFE77F5AE51。否则,如果 eax 的值为 80000001h,则调用函数 7FFE77F56230。换句话说,如果在其中一个假 DLL 的内存中命中 GUARD_PAGE标志,则调用函数 7FFE77F56230。
总结
在我们研究潜在的规避技术之前,让我们简要总结一下对 EDR 系统的分析。我们的调查显示,EDR 通过部署假 DLL 对进程环境块 (PEB) 中的 InLoadOrderModuleList执行有针对性的修改。值得注意的是,这些假 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 遍历的上下文中动态查询系统服务号 (SSN) 以执行直接或间接系统调用。
偏移量 OriginalBase
正如我们在分析中发现的,当在 PEB 遍历期间使用偏移量 0x30(DllBase) 时,会激活 EDR 的检测机制,触发保护页面并最终触发 EDR 的 VEH。一种可能的解决方法策略可能是使用 OriginalBase的偏移量 0xF8来确定真实 DLL (例如 ntdll.dll) 的基址,而不是访问假 DLL。但是,根据 Windows 的版本,这可能会导致问题,因为 OriginalBase的偏移量可能不同。对此尚未进行更详细的研究。
UINT_PTR NtdllOriginalDLLBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_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 Base
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0xF8);
return baseAddress;
}
通过 Flink 访问正确的模块
另一种策略可能是使用 PEB 遍历专门访问双向链表 InLoadOrderModuleList中的第四个模块,在这种情况下对应于正确的 ntdll.dll,因此可以绕过 EDR 的假 dll。但是,为了在实践中可靠运行 (例如,在红队准备期间,您不知道目标环境中运行哪个 EDR),可能需要实施额外的检查,例如比较 DLL 名称的循环。例如,这可以防止访问 InLoadOrderModuleList中的第四个模块,除非 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: %p\n", (void*)NtdllGetModul());
printf("Press any key to exit...");
getchar(); // wait for keypress
return0;
}
我们实验的结果,如下图所示,通过在安装了正在调查的 EDR 的端点上使用 Windows API GetModuleHandleA,我们实际上最终进入了真正 ntdll.dll的内存区域,而不是假 ntdll.dll的内存区域。这表明使用 GetModuleHandleA访问模块或 DLL 的基址提供了一种绕过 EDR 实现的假 DLL 以及页面保护挂钩的方法。
但是,不建议使用此策略,因为 Windows API GetModuleHandleA、GetProcAddress、LoadLibrary等通常由 EDR 使用内联 API 挂钩进行监视。因此应该始终避免这些 API,例如在 shellcode 加载器或 shellcode 本身中。
PEB 迭代和字符串比较
我想指出最后一种规避可能性。为了优化正确 ntdll.dll基址的确定并确保不会意外获得假 ntdll.dll的基址,可以使用以下 C 代码。此方法使用 PEB中 InLoadOrderModuleList内的迭代和字符串比较的组合来识别正确的 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 registry
PEB* get_peb() {
#ifdef _WIN64
return (PEB*)__readgsqword(0x60);
#else
return (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 modules
do {
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 conversion
snprintf(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 again
returnNULL;
}
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 found\n");
}
else {
printf("Base address from real ntdll.dll based on string or dll name comparison: %p\n", ntdll_base);
}
printf("Press any key to continue\n");
(void)getchar();
return0;
}
下图显示了我们如何使用 C 代码避免 EDR 的假 ntdll.dll并成功确定真正 ntdll.dll的基址。
解释
我想以简短的解释来结束对 EDR 系统的分析。与其他 EDR 解决方案的直接比较表明,所描述的检测机制 – 基于假 DLL、保护页面和向量化异常处理 – 被描述为一种相当非常规的方法,更有可能在游戏黑客领域找到。但是,它在实践中被证明非常有效。根据我自己的经验,我可以说,与其他 EDR 系统相比,成功绕过所需的时间和精力 (定义为恶意软件既不被阻止也不被检测的情况) 明显更高。这种方法的复杂性和连贯结构使它看起来像一个 “陷阱”。根据我目前的理解,假 ntdll.dll中的保护页面仅在尝试在进程初始化期间使用 PEB 遍历通过DLLBase偏移量0x30访问ntdll.dll时才会触发,但实际上最终进入假ntdll.dll的内存中。但是,如果应用程序或恶意软件使用诸如GetModuleHandleA或LoadLibrary之类的 API 来获取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 检测机制有所了解,感谢您的阅读。直到下一篇文章。
Happy Hacking!
Daniel Feichter @VirtualAllocEx
参考资料
- https://mark.rxmsolutions.com/…
- https://ph3n1x.com/posts/parse…
- http://www.rohitab.com/discuss…
- https://imphash.medium.com/win…
- https://www.geoffchappell.com/….
EDR Analysis: Leveraging Fake DLLs, Guard Pages, and VEH for Enhanced Detection
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:securitainment Daniel Feichter《EDR 分析:利用假 DLL、保护页面和向量化异常处理增强检测能力》