利用虚假 DLL、保护页和 VEH 实现增强EDR检测
免责声明
本文不会提及任何产品或制造商的名称。出于保密原因,我对所有使用的图像进行了匿名处理。本文的目的纯粹是学术性的;这里分享的信息仅用于研究目的,
简介
本文试图通过调试和逆向工程分析特定 EDR 的特定功能或检测机制。我的主要目标不是深入研究逆向工程过程的细节。相反,我对深入了解 EDR 中新实现的检测机制的工作原理、探索该机制的功能以及质疑其可能实现的原因很感兴趣。 此外,我认为理解任何商业 EDR 最终都是一个黑匣子是很重要的。你可以通过各种方法,如静态和动态分析,尝试了解 EDR 的工作原理,但内部工作原理和逻辑在很大程度上仍然是一个黑匣子。可以对 EDR 的内部工作原理和逻辑结构提出假设,然后对这些假设进行分析以验证它们。然而,通常很难对其功能做出完全清晰和明确的陈述。
检测机制
端点检测与响应(EDR)系统在不同阶段使用不同的机制来检测恶意软件。在静态阶段,通常使用扫描器分析文件是否存在已知哈希值、签名和特定字节序列。如果攻击者设法绕过这种静态检测,许多 EDR 会采用沙箱技术。这涉及在虚拟化环境中运行潜在可疑文件以分析其行为。此外,EDR 实现了诸如用户模式挂钩(例如内联 API 挂钩或导入地址表挂钩)、反恶意软件扫描接口(AMSI)、Windows 事件跟踪以及基于行为的检测的威胁情报和内核回调等方法。
不寻常的检测
然而,在本文中,我想讨论一种与 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 的拼写不同,因为每个 DLL 的一个版本是用 Leetspeak 写的。使用 Process Hacker 对这些重复的 DLL 版本进行更仔细的检查表明,虚假版本的文件大小与原始 DLL 的文件大小完全相同。还值得注意的是,模块描述缺失。
尝试使用 Process Hacker 调查这些疑似仿冒品不会产生任何结果。它们无法进行分析,并且在硬盘上找不到这些文件的相应映像(错误消息:无法加载 PE 文件:找不到对象名称)。显然,这些仿制品是相应 DLL 的某种虚假版本,这就是为什么我们称它们为“虚假 DLL”。使用 Process Hacker 更仔细地查看ntdll.dll
上下文中的虚假 DLL,会发现它实际上是ntdll.dll
的手动映射版本,尽管名称不同。更仔细地查看内存保护会揭示另一个有趣的特征:分配为RX
(可读可执行)的内存也提供了一个保护页(RX+G
)。我们将注意到这个细节,并稍后更仔细地查看它是关于什么的。 我们已经可以说的是,“虚假 DLL”不是可以在磁盘上找到的真正独立的 DLL,而是ntdll.dll
的手动映射版本,已被手动重命名为与ntdll.dll
相似的名称。
检查PEB
为了更好地理解这个虚假 DLL 在被分析的 EDR 系统上下文中的作用,我们将注意力转向安装了 EDR 的系统上的活动进程(例如cmd.exe
)的进程环境块(PEB)。 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
是否实际上在第二位加载,虚假的kernel32.dll
是否在第三位加载。
下一步是使用InLoadOrderModuleList
的起始地址(在这种情况下是0x000001d5
eb302650)在 WinDbg 中访问此列表中的第一个模块。这是通过在 WinDbg 中使用命令
dt nt!_LDR_DATA_TABLE_ENTRY 0x000001d5eb302650
完成的。下图显示第一个模块是cmd.exe
本身的映像。这是预期的,因为执行进程的映像必须始终在模块序列中排在第一位。
为了按顺序访问模块中的第二个模块,我们再次在 WinDbg 中使用上一个命令,但将地址更新为InLoadOrderLinks
的起始地址,在这种情况下是0x000001d5
eb313d10。运行此命令后的以下屏幕截图显示,虚假版本的
ntdll.dll实际上作为顺序中的第二个模块加载。 ![](https://raw.githubusercontent.com/Hipepper/allPictures/main/202410/LDR_DATA_TABLE_ENTRY_SECOND_MODULE.png) 为了检查模块加载顺序中的第三个模块,我们在 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 的比较清楚地表明,在具有特定 EDR 系统的端点上,进程环境块(PEB),或者更具体地说,进程的InLoadOrderModuleList
(这里以cmd.exe
为例)被专门修改。
保护页
通过在 PEB 中操作InLoadOrderModuleList
,EDR 确保在用户模式下初始化进程时,虚假版本的ntdll.dll
和kernel32.dll
在第二位和第三位加载,然后真正的ntdll.dll
和kernel32.dll
在第四位和第五位加载。但是 EDR 进行这种操作的目的是什么呢? 为了回答这个问题,让我们再看一下被损坏的ntdll.dll
和kernel32.dll
版本。正如我们已经确定的那样,这些是原始 DLL 的版本,已被手动映射到内存中,并在内存中补充了一个保护页,该保护页以RX
(可读可执行)提交。根据 Microsoft 文档,保护页可以触发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>
// 向量异常处理程序函数
// 当发生异常(如保护页违规)时,此函数将被调用
LONG CALLBACK GuardPageExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) {
// 检查异常是否为保护页违规
if (pExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_GUARD_PAGE_VIOLATION) {
printf("保护页访问被检测到!n");
// 在这里你可以添加逻辑来记录违规、分析访问模式,
// 或根据你的 EDR 的要求采取任何其他适当的行动。
// 可选:如果需要连续监视,可以在此处恢复保护页。
// 在处理异常后继续执行
return EXCEPTION_CONTINUE_EXECUTION;
}
// 如果不是保护页违规,继续搜索其他处理程序
return EXCEPTION_CONTINUE_SEARCH;
}
int main() {
// 设置要监视的敏感内存区域
// 这可以代表你想要保护的关键内存部分
SYSTEM_INFO si;
GetSystemInfo(&si); // 获取系统信息,包括页面大小
LPVOID pMemory = VirtualAlloc(NULL, si.dwPageSize, MEM_COMMIT, PAGE_READWRITE);
if (pMemory == NULL) {
printf("内存分配失败n");
return 1;
}
// 使用保护页保护敏感内存
// 对该页的任何访问都将触发保护页违规
DWORD oldProtect;
if (!VirtualProtect(pMemory, si.dwPageSize, PAGE_GUARD | PAGE_READWRITE, &oldProtect)) {
printf("设置保护页失败n");
VirtualFree(pMemory, 0, MEM_RELEASE); // 如果设置保护页失败,则清理
return 1;
}
// 注册向量异常处理程序
// 此处理程序将在发生异常(包括保护页违规)时被调用
PVOID handler = AddVectoredExceptionHandler(1, GuardPageExceptionHandler);
if (handler == NULL) {
printf("添加向量异常处理程序失败n");
VirtualFree(pMemory, 0, MEM_RELEASE); // 如果处理程序注册失败则清理
return 1;
}
// 你的应用程序逻辑在这里
// 这是你将实现 EDR 的其余功能的地方
//...
// 在退出应用程序之前进行清理
// 这包括注销异常处理程序和释放分配的内存
RemoveVectoredExceptionHandler (handler);
VirtualFree (pMemory, 0, MEM_RELEASE);
return 0;
}
还值得注意的是,Windows APIAddVectoredExceptionHandler
与 APILdrEnumerateLoadedModules
、ntdll.dll
和术语PEBTrap
一起出现在挂钩 DLL 中。这可能是 PEB 修改、虚假 DLL、保护页和向量异常处理程序之间相互作用的一个指示。
为了更好地理解 EDR 的向量异常处理程序(VEH)何时通过STATUS_GUARD_PAGE_VIOLATION (0x80000001)
激活,对挂钩 DLL 的仔细观察提供了有趣的见解。似乎在此 DLL 中有一个特殊的比较操作,该操作检查在虚假ntdll.dll
中由于保护页被触发而调用本机 API 后是否发生代码为0x80000001
的相应异常。具体来说,如果抛出值为0x80000001
的异常,则 EDR 的向量异常处理程序变为活动状态(并且可能在必要时终止进程)。但是,如果在调用相应的本机 API(例如NtProtectVirtualMemory
)后没有值为0x80000001
的异常,则允许本机 API 的进一步执行。然而,这目前是一个假设,而不是一个确定的陈述。但是,值得注意的是,在挂钩 DLL 中对大约 25 个本机 API(包括NtAllocateVirtualMemory
、NtWriteVirtualMemory
、NtProtectVirtualMemory
等,这些 API 通常在恶意软件执行的上下文中使用)进行了这种比较。
DLL 基地址与原始基地址
从恶意软件开发人员的角度来看,主要问题是当恶意软件依赖于从 PEB 或ntdll.dll
或kernel32.dll
动态检索信息时。一个具体的例子是使用 shellcode 加载器或直接或间接使用系统调用的 shellcode,例如,不在代码中锚定系统服务编号(SSNs
)。相反,它尝试在运行时在ntdll.dll
中使用 PEB 遍历和导出地址表(EAT)解析动态获取这些SSNs
。 为了通过 PEB 遍历访问ntdll.dll
的基地址,通常使用偏移量0x30
的DLLBase
。然而,在我们的 EDR 上下文中,这会导致你最终进入虚假版本的ntdll.dll
的内存中,从而通过页保护挂钩触发 EDR 的向量异常处理程序。 为了验证此声明的准确性,我们计划使用以下 C 代码进行实验。我们的目标是使用 PEB 遍历并通过偏移量0x30
的DllBase
访问ntdll.dll
的基地址,以确定并输出其内存地址。然后,我们想将此地址与cmd.exe
内存中的真实和虚假ntdll.dll
的内存地址进行比较。
#include <windows.h>
#include <stdio.h>
UINT_PTR NtdllDllBase () {
// 从 GS 寄存器读取 PEB 偏移量
UINT_PTR pebAddress = __readgsqword (0x60);
// 访问 PEB 中的 PEB_LDR_DATA 字段
UINT_PTR ldr = (UINT_PTR)(pebAddress + 0x18);
// 访问 InInitializationOrderModuleList 中的第一个条目
UINT_PTR inInitOrderModuleList = (UINT_PTR)(ldr + 0x10);
// 遍历到列表中的第二个模块(通常是 ntdll.dll)
UINT_PTR secondModule = (UINT_PTR)(inInitOrderModuleList);
// 取消注释以下行以前进到第三个模块
//secondModule = (UINT_PTR)(secondModule);
// 通过使用 DLL Base 访问模块的基地址
UINT_PTR baseAddress = (UINT_PTR)(secondModule + 0x30);
return baseAddress;
}
int main() {
UINT_PTR ntdllBase = NtdllDllBase();
printf ("加载的 ntdll.dll 的基地址(偏移量 0x30):% pn", (void*) ntdllBase);
printf ("按任意键退出...");
getchar (); // 等待按键
return 0;
}
下图显示,使用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 () {
// 从 GS 寄存器读取 PEB 偏移量
UINT_PTR pebAddress = __readgsqword (0x60);
// 访问 PEB 中的 PEB_LDR_DATA 字段
UINT_PTR ldr = (UINT_PTR)(pebAddress + 0x18);
// 访问 InInitializationOrderModuleList 中的第一个条目
UINT_PTR inInitOrderModuleList = (UINT_PTR)(ldr + 0x10);
// 遍历到列表中的第二个模块(通常是 ntdll.dll)
UINT_PTR secondModule = (UINT_PTR)(inInitOrderModuleList);
// 取消注释以下行以前进到第三个模块
//secondModule = (UINT_PTR)(secondModule);
// 通过使用 DLL Base 访问模块的基地址
UINT_PTR baseAddress = (UINT_PTR)(secondModule + 0x30);
return baseAddress;
}
UINT_PTR NtdllOriginalDLLBase () {
// 从 GS 寄存器读取 PEB 偏移量
UINT_PTR pebAddress = __readgsqword (0x60);
// 访问 PEB 中的 PEB_LDR_DATA 字段
UINT_PTR ldr = (UINT_PTR)(pebAddress + 0x18);
// 访问 InInitializationOrderModuleList 中的第一个条目
UINT_PTR inInitOrderModuleList = (UINT_PTR)(ldr + 0x10);
// 遍历到列表中的第二个模块(通常是 ntdll.dll)
UINT_PTR secondModule = (UINT_PTR)(inInitOrderModuleList);
// 取消注释以下行以前进到第三个模块
//secondModule = (UINT_PTR)(secondModule);
// 通过使用 Original Base 访问模块的基地址
UINT_PTR baseAddress = (UINT_PTR)(secondModule + 0xF8);
return baseAddress;
}
int main() {
UINT_PTR ntdllBase = NtdllDllBase();
UINT_PTR ntdllOriginalBase = NtdllOriginalDLLBase();
printf ("加载的 ntdll.dll 的基地址(偏移量 0x30):% pn", (void*) ntdllBase);
printf ("加载的 ntdll.dll 的原始基地址(偏移量 0xF8):% pn", (void*) NtdllOriginalDLLBase ());
printf ("按任意键退出...");
getchar (); // 等待按键
return 0;
}
在安装了 EDR 的端点上运行我们扩展后的代码后,下图显示了一个揭示性的结果:使用0xF8
的偏移量用于OriginalBase
找到的内存地址对应于真实ntdll.dll
的地址。这确认了使用偏移量0x30
(DllBase
)的 PEB 遍历将带我们到 EDR 插入的虚假ntdll.dll
的内存区域。但是,如果我们选择0xF8
的偏移量用于OriginalBase
,我们将到达真实ntdll.dll
的内存区域。
一个关键问题仍然没有答案:为什么当我们使用0xF8
的偏移量用于OriginalBase
时我们得到合法ntdll.dll
的内存地址,而当我们使用0x30
的偏移量用于DllBase
时我们得到虚假ntdll.dll
的内存地址? 这是因为 EDR 用指向虚假ntdll.dll
的内存区域的地址覆盖了DllBase
的内存地址或指针。这可以在安装了 EDR 的虚拟机中进行检查。如果你使用 WinDbg 查看 EDR 正在操作的ntdll.dll
的结构,你会看到DllBase
指针被替换为指向来自 EDR 的虚假ntdll.dll
的内存地址。另一方面,OriginalBase
的内存地址仍然指向正确的、合法的ntdll.dll
。下图说明了这一点。
向量异常处理 VEH
在我们更仔细地查看下一节中要分析的检测链的内部逻辑之前,我想更详细地了解我们要分析的 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 的虚拟机上启动了一个notepad.exe
,并使用 Windbg 在 PEB 中检查CrossProcessFlags
条目的值。你可以清楚地看到CrossProcessFlags
具有十进制值 4(十六进制为0x00000004
),因此正在使用 VEH,或者大概是 EDR 的 VEH(但这将在后面更详细地检查)。但是,在图的右侧比较中,我们在没有安装 EDR 的虚拟机上看到相同的进程。正如预期的那样,这里的CrossProcessFlags
条目为 0,即notepad.exe
进程没有使用 VEH。
因此,检查 PEB 中的CrossProcessFlags
条目可以告诉我们一个进程是否正在使用向量异常处理,但是了解哪个模块负责注册 VEH 也会很有趣。换句话说,我们想证明 VEH 是由 EDR 注册的。为了提供此证明,我们使用调试器(如 x64dbg)加载notepad.exe
映像,并在本机 APIRtlAddVectoredExceptionHandler
上设置断点。当断点被触发时,我们查看调用栈并检查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
的几个比较操作。我认为下面的插图是一个合理的指示,即检查虚假 DLL 之一的上下文中是否触发了STATUS_GUARD_PAGE_VIOLATION
的0x80000001
的 EDR 的逻辑被放置在 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 遍历的上下文中一些可能的规避策略(在这种情况下,规避被定义为未被阻止和检测到的活动),例如,用于动态查询系统服务编号(SSNs
)以执行直接或间接的系统调用。
偏移量 OriginalBase
正如我们在分析中发现的那样,当在 PEB 遍历期间使用偏移量0x30
(DllBase
)时,EDR 的检测机制被激活,保护页被触发,最终 EDR 的 VEH 被触发。一种可能的解决方法是使用偏移量0xF8
的OriginalBase
来确定真实 DLL(例如ntdll.dll
)的基地址,而不是访问虚假 DLL。然而,这可能会根据 Windows 的版本而引起问题,因为OriginalBase
的偏移量可能不同。对此尚未进行更详细的研究。
UINT_PTR NtdllOriginalDLLBase() {
// 从 GS 寄存器读取 PEB 偏移量
UINT_PTR pebAddress = __readgsqword(0x60);
// 访问 PEB 中的 PEB_LDR_DATA 字段
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// 访问 InInitializationOrderModuleList 中的第一个条目
UINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);
// 遍历到列表中的第二个模块(通常是 ntdll.dll)
UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);
// 取消注释以下行以前进到第三个模块
// secondModule = *(UINT_PTR*)(secondModule);
// 通过使用 Original Base 访问模块的基地址
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0xF8);
return baseAddress;
}
通过 Flink 访问正确模块
另一种策略可以是使用 PEB 遍历专门访问双向链表InLoadOrderModuleList
中的第四个模块,在这种情况下,它对应于正确的ntdll.dll
,因此可以绕过 EDR 的虚假 DLL。然而,为了在实践中可靠运行(例如,在红队准备期间,你不知道目标环境中正在运行哪个 EDR),可能需要实施额外的检查,例如,一个比较 DLL 名称的循环。这可以防止访问InLoadOrderModuleList
中的第四个模块,除非 PEB 已被 EDR 修改。
// 获取在 PEB 中具有默认 InLoadOrderModuleList 的机器上 ntdll.dll 的基地址
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
// 获取在分析的 EDR 上下文中具有修改后的 InLoadOrderModuleList 的 PEB 的机器上 ntdll.dll 的基地址
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;
}
int main() {
UINT_PTR ntdllGetModul = NtdllGetModul();
printf("通过 GetModuleHandleA 从 ntdll 获得的基地址:%pn", (void*)NtdllGetModul);
printf("按任意键退出...");
getchar(); // 等待按键
return 0;
}
我们的实验结果如下图所示,通过在正在调查的 EDR 的端点上使用 Windows APIGetModuleHandleA
,我们实际上到达真实ntdll.dll
的内存区域,而不是虚假ntdll.dll
的内存区域。这表明使用GetModuleHandleA
访问模块或 DLL 的基地址提供了一种绕过 EDR 实现的虚假 DLL 以及页保护挂钩的方法。
然而,这种策略并不真正推荐,因为 Windows APIGetModuleHandleA
、GetProcAddress
、LoadLibrary
等通常由 EDR 使用内联 API 挂钩进行监视。因此,这些 API 应始终避免使用,例如在 shellcode 加载器或 shellcode 本身中。
PEB 迭代和字符串比较
我想指出最后一种规避可能性。为了优化正确ntdll.dll
的基地址的确定并确保你不会意外地获得虚假ntdll.dll
的基地址,可以使用以下 C 代码。这种方法在 PEB 的InLoadOrderModuleList
中结合使用迭代和字符串比较来识别正确的ntdll.dll
。具体来说,代码遍历加载的模块列表,将模块名称与“ntdll.dll
”精确比较,并在完全匹配时提取正确模块的基地址。这种方法是区分真实ntdll.dll
与潜在虚假版本并正确确定其基地址的精确且相当可靠的解决方案。 代码复制
// 资源:
// Hiding in Plain Sight: Unlinking Malicious DLLs from the PEB
#include <stdio.h>
#include <windows.h>
#include <psapi.h>
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, * PPEB_LDR_DATA;
typedef struct _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;
typedef struct _PEB {
BYTE Reserved1[2];
BYTE BeingDebugged;
BYTE Reserved2[1];
PVOID Reserved3[2];
PPEB_LDR_DATA Ldr;
} PEB, * PPEB;
typedef struct _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;
// 通过读取 FS 或 GS 注册表返回指向 PEB 的指针
PEB* get_peb() {
#ifdef _WIN64
return (PEB*)__readgsqword(0x60);
#else
return (PEB*)__readfsdword(0x30);
#endif
}
// 通过比较 DLL 的名称与“ntdll.dll”字符串并在名称包含“ntdll.dll”时返回 DLL 的基地址来获取真实 ntdll.dll 的基地址
PVOID get_ntdll_base_via_name_comparison() {
PEB* peb = get_peb(); // 获取指向 PEB 的指针
LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList; // 获取加载模块列表中的第一个条目
do {
current = current->Flink; // 移动到下一个条目
MY_LDR_DATA_TABLE_ENTRY* entry = CONTAINING_RECORD(current, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
char dllName[256]; // 缓冲区,用于存储 DLL 的名称
// 假设 FullDllName 是一个 UNICODE_STRING,转换为 char*可能需要更多操作,考虑进行适当的转换
snprintf(dllName, sizeof(dllName), "%wZ", entry->FullDllName);
if (strstr(dllName, "ntdll.dll")) { // 检查 dllName 是否包含“ntdll.dll”
return entry->DllBase; // 返回 ntdll.dll 的基地址
}
} while (current!= &peb->Ldr->InMemoryOrderModuleList); // 循环直到再次到达第一个条目
return NULL;
}
int main() {
// 通过比较 DLL 的名称与“ntdll.dll”字符串获取 ntdll.dll 的基地址
PVOID ntdll_base = get_ntdll_base_via_name_comparison();
if (ntdll_base == NULL) {
printf("未找到 ntdll.dlln");
}
else {
printf("基于字符串或 DLL 名称比较的真实 ntdll.dll 的基地址:%pn", ntdll_base);
}
printf("按任意键继续n");
(void)getchar();
return 0;
}
下图显示了我们如何使用 C 代码避免 EDR 的虚假ntdll.dll
并成功确定真实ntdll.dll
的基地址。
解释
我想用一个简短的解释来结束我对 EDR 系统的分析。与其他 EDR 解决方案直接比较,所描述的基于虚假 DLL、保护页和向量异常处理的检测机制被描述为一种相当不寻常的方法,更可能在游戏黑客领域中找到。然而,它在实践中已被证明是非常有效的。根据我自己的经验,我可以说,与其他 EDR 系统相比,成功绕过(定义为恶意软件既未被阻止也未被检测到的情况)所需的时间和精力要高得多。这种方法的复杂性和连贯结构使其看起来像一个“陷阱”。就我目前的理解,只有在使用 PEB 遍历在进程初始化期间通过DLLBase
偏移量0x30
尝试访问ntdll.dll
但实际上最终进入虚假ntdll.dll
的内存时,虚假ntdll.dll
中的保护页才会被触发。但是,如果应用程序或恶意软件使用 API 如GetModuleHandleA
或LoadLibrary
来获取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/...
-
• https://ph3n1x.com/posts/parse...
-
• http://www.rohitab.com/discuss...
-
• https://imphash.medium.com/win...
-
• https://www.geoffchappell.com/....
原文始发于微信公众号(TIPFactory情报工厂):利用虚假 DLL、保护页和 VEH 实现增强EDR检测
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论