翻译:https://redops.at/blog/edr-analysis-leveraging-fake-dlls-guard-pages-and-veh-for-enhanced-detection
在这篇博客文章中,我想记录并分享我在EDR(端点检测与响应)调试和逆向工程领域的最新发现和经验。最近,我接触到了一个端点检测与响应(EDR)系统,由于其检测行为引起了我的兴趣。这个系统超越了常见的检测机制,如内联API挂钩、Windows事件跟踪和内核回调。在这篇文章中,我想讨论一个与EDR相关的、相当非传统但非常有趣的检测机制。
0X00 前言
本文尝试通过调试和逆向工程分析一个特定EDR(端点检测与响应)的特定功能或检测机制。我的主要目标不是深入了解逆向工程的每一个细节,而是开发对EDR新实施的检测机制工作原理的深入理解,探索这一机制的功能,并质疑其可能实施的原因。
此外,我认为重要的是要理解,每个商业EDR最终都是一个黑盒子。人们可以尝试通过静态和动态分析等不同方法来了解EDR的工作方式,但特别是其内部工作方式和逻辑大部分仍然是一个黑盒子。可以提出关于EDR内部结构和逻辑结构的假设,然后分析这些假设以验证它们。然而,获取关于其工作方式的完整清晰度和明确声明往往是困难的。
0X01 检测机制
EDR系统(端点检测与响应)根据阶段使用不同的机制来检测恶意软件。在静态阶段,通常使用扫描器来检查文件的已知哈希值、签名和特定字节序列。如果攻击者成功绕过这种静态检测,许多EDR会采用沙箱技术。这涉及到在虚拟化环境中执行潜在可疑文件以分析其行为。此外,EDR实施了如用户模式挂钩(例如内联API挂钩或IAT挂钩)、反恶意软件扫描接口(AMSI)、Windows事件跟踪以及威胁情报和内核回调等方法,用于基于行为的检测。
在我之前的一些文章中,我已经多次提到了一些EDR使用的内联API挂钩的概念,这些EDR通过这种方式主动干预Windows API的代码执行和参数。有关详细信息,请参阅我的最后一篇博客文章《通过向量异常处理的系统调用》。
非传统检测方法
在这篇文章中,我想讨论一个与EDR相关的、相当非传统但非常有趣的检测机制,该机制基于对进程环境块(PEB)的修改、使用假DLL和保护页以及使用向量异常处理(VEH)的组合。
Fake DLL技术
在Windows中初始化进程时,需要的DLL会被加载到虚拟内存中。这是按照进程的依赖和需求的特定顺序完成的。对于像ntdll.dll
和kernel32.dll
这样的系统级DLL,操作系统在进程的虚拟内存中放置引用(指针)。这些指针指向DLL在共享内存中的实际物理位置。
DLL加载的顺序由Process Environment Block(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完全相同。另一个显著的特点是,模拟版本缺少模块描述。
尝试使用Process Hacker更仔细地检查这些所谓的仿制品不会产生任何结果。它们无法被分析,且相应文件的镜像在硬盘上找不到(错误信息:无法加载PE文件:未找到对象名称)。显然,这些仿制品是相应DLL的一种假冒版本,因此我们将其称为"Fake DLL"。使用Process Hacker仔细查看ntdll.dll
上下文中的Fake DLL时,发现它实际上是ntdll.dll
的手动映射版本,但名称以Leetspeak
形式更改。另一个有趣的特征在于仔细检查内存保护时显现:以RX(读-执行)分配的内存区域额外配备了一个保护页。我们记下这个细节,并稍后更详细地探讨它的含义。
但我们现在可以说的是,"Fake DLL"并不是一个可以在磁盘上找到的真正独立的DLL,而是ntdll.dll
的手动映射版本,只是手动重命名为类似ntdll.dll
的名称。
0X02 P3B蕴含的风险
为了更好地理解假DLL在待分析EDR系统上下文中的角色,我们将注意力转向一个安装了EDR的系统上一个活动进程(例如cmd.exe)的进程环境块(PEB)。
PEB是每个Windows进程中的一个关键存储结构,负责管理进程特定的数据,如程序基地址、堆、环境变量和命令行信息。它的结构包含许多字段和指针,特别值得注意的是Ldr结构,它负责管理加载的模块,特别是DLL。PEB对于每个进程都是唯一的,它在进程管理和DLL加载机制中扮演着关键角色。它为进程提供了必要的信息和资源,以便高效地执行和管理。
然而,全面讨论PEB将超出本文的范围。对于希望深入了解的人,我建议深入研究Windows内部机制。但我们的主要目标是了解EDR如何使用假DLL。为了调查假DLL何时被映射到内存中,我们使用WinDbg。在我们附加到一个活动的cmd.exe进程后,我们尝试访问PEB中包含的双向链表InLoadOrderModuleList
。这一步使我们能够分析模块在内存中的加载时间和顺序,包括识别Fake DLL。
第一步是使用WinDbg中的!peb命令
访问cmd.exe的PEB。如下图所示,InMemoryOrderModuleList
中的第二个位置是ntdll.dll
的假版本,第三个位置是kernel32.dll
的假版本。这证实了这些已经成功地被映射到进程的内存中。值得一提的是,InMemoryOrderModuleList
与InLoadOrderModuleList
之间的区别在于,InMemoryOrderModuleList
按照模块在进程的虚拟内存中的位置列出模块。而InLoadOrderModuleList
则指示DLL的加载顺序。
为了验证这一观察,我们想要访问Ldr结构中的InLoadOrderModuleList
,并检查位于第二和第三位置的模块。首先,需要确定Ldr的地址(在这个例子中为00007ffa61ebc4c0
)。然后,在WinDbg中使用命令dt nt!_PEB_LDR_DATA 00007ffa61ebc4c0
来访问PEB_LDR_DATA结构
。下图展示了对PEB_LDR_DATA结构
的访问。从基地址开始,偏移量为0x10
的位置是InLoadOrderModuleList
条目,它清楚地告诉我们,在进程初始化期间,假的ntdll.dll
是否真的被加载在第二位置,假的kernel32.dll
被加载在第三位置。
通过这种方法,我们能够深入了解EDR如何操纵进程加载的DLLs,以及它如何通过这种方式增强其检测能力。这种对假DLL的使用,尤其是在它们被映射到进程的内存中时,显示了EDR系统采取的一种非常特殊和复杂的方法来监视和分析潜在的恶意行为。通过深入了解这些机制,安全研究人员可以更好地理解EDR系统的工作原理,并可能发现新的方法来提高系统的安全性或发现潜在的漏洞。
在下一步中,我们通过InLoadOrderModuleList
(在本例中为)的起始地址访问0x000001d5eb302650
此列表中的第一个模块。这是通过WinDbg中的命令完成的dt nt!_LDR_DATA_TABLE_ENTRY 0x000001d5eb302650
。下图显示第一个模块cmd.exe是图像本身。此结果是预期的,因为执行进程的映像必须始终按模块顺序排在第一位。
要访问模块顺序中的第二个模块,我们再次在WinDbg中使用前面的命令,但将地址更新为的起始地址InLoadOrderLinks
,在本例中0x000001d5eb313d10
为运行此命令后的下图显示,模拟版本ntdll.dll
实际上已作为订单中的第二个模块加载。
为了还检查模块加载顺序中的第三个模块,我们在WinDbg中重复上一步,但将地址相应地更新为InLoadOrderLinks
第三个模块的起始地址(在本例中为0x000001d5eb317c20
)。下图证实了我们之前的发现:假的kernel32.dll
被加载为列表中的第三个模块。
为了完成我们的分析,我们在WinDbg中重复该步骤两次,以确定其他模块在加载顺序中的位置。结果图显示,按模块加载顺序,真实的ntdll.dll
为第四,真实的为第五。kernel32.dll
这些信息对于以后来说非常重要。
另外,需要注意的是,它还InLoadOrderModuleList
用于确定EDR的hooking DLL
被加载到哪里。在这种情况下,在kernelbase.dll
加载第六个模块之后,在进程初始化期间将Hooking DLL
作为第七个模块加载。
如果使用WinDbg在未安装EDR系统的端点或具有替代EDR系统的端点上执行相同的分析,则在模块InLoadOrderModuleList
加载顺序方面会出现明显不同的情况。分析结果表明,这些场景中不存在Fake DLL。还可以清楚地看到,ntdll.dll
像往常一样,它们被加载kernel32.dll
到模块列表中的第二和第三位。这种直接比较强调了最初检查的EDR系统的独特性,该系统通过使用Fake DLL和不同的模块加载顺序而与标准配置不同。
我们的分析结果以及与WinDbg的比较清楚地表明,进程环境块 (PEB),或更具体地说,InLoadOrderModuleList
进程(此处使用示例cmd.exe)在具有特定EDR系统的端点上进行了专门修改。
0X03 保护页
通过操纵PEB中的InLoadOrderModuleList
,EDR确保在用户模式下初始化进程时,假的ntdll.dll
和kernel32.dll
版本被加载在第二和第三位置,然后真正的ntdll.dll
和kernel32.dll
跟随在第四和第五位置。但EDR进行这种操纵的目的是什么呢?
为了回答这个问题,让我们再次查看ntdll.dll
和kernel32.dll
的损坏版本。正如我们已经确定的,这些是原始DLL的版本,它们被手动映射到内存中,并在内存中由一个作为RX(读-执行)提交的保护页补充。根据微软文档,保护页可以触发一个STATUS_GUARD_PAGE_VIOLATION
(0x80000001
)异常。
这些观察表明,使用假DLL和保护页操纵进程环境块(PEB)很可能是用来激活EDR注册的向量异常处理程序(VEH)。对EDR的x64 Hooking 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在触发后可以恢复相应Fake 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;
}
int main() {
// 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");
return 1;
}
// 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
return 1;
}
// 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
return 1;
}
// 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);
return 0;
}
同样有趣的是,Windows API AddVectoredExceptionHandler
与API LdrEnumerateLoadedModules
、ntdll.dll
以及术语PEBTrap一起出现在挂钩DLL中。这可能表明了PEB的修改、Fake DLLs、保护页和向量异常处理程序之间的交互。
为了更好地理解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在恶意软件执行的上下文中经常使用。
0X04 DLL Base vs. Original Base
从恶意软件开发者的角度来看,主要问题在于恶意软件依赖于从PEB或者ntdll.dll
或kernel32.dll
动态检索信息。具体的一个例子是使用shellcode加载器或直接或间接使用系统调用的shellcode,例如,不在代码中锚定系统服务号(SSNs)。相反,它尝试在运行时通过结合PEB遍历和导出地址表(EAT)解析在ntdll.dll
中动态获取这些SSN为了通过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() {
// 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;
}
int main() {
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
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() {
// 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;
}
int main() {
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
return 0;
}
在安装了EDR的终端上运行我们扩展的代码后,下图显示了一个揭示性的结果:使用偏移量0xF8
的OriginalBase
找到的内存地址对应于真实的ntdll.dll
的地址。这证实了使用偏移量0x30
(DllBase
)进行PEB遍历将会带我们进入EDR插入的假ntdll.dll
的内存区域。然而,如果我们选择偏移量0xF8
的OriginalBase
,我们将会进入真实的ntdll.dll
的内存区域。
0X05 向量异常处理
在我们更仔细地查看下一节中要分析的检测链的内部逻辑之前,我想更详细地探讨我们将要分析的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
,并在PEB中使用Windbg检查了CrossProcessFlags条目的值。可以清楚地看到,CrossProcessFlags
的十进制值为4(十六进制为0x00000004
),因此正在使用VEH,或者假定是EDR的VEH(但这将在稍后更详细地检查)。然而,在图的右侧比较中,我们看到同一个进程在没有安装EDR的虚拟机上。如预期,这里的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,并且还能证明调用原生函数RtlAddVectoredExceptionHandler
(注册VEH所需)是由EDR用户模式挂钩DLL发起的。
0X06
在我们总结和探讨可能的解决方法之前,让我们尝试更好地理解EDR如何检查在假DLL的上下文中是否触发了GUARD_PAGE标志。正如我们已经知道的,假DLL并不是真正的DLL,它们只是手动映射的版本,例如在ntdll.dll的上下文中。然而,EDR仍然需要一个内存中的部分来处理逻辑或检查在假DLL的上下文中何时触发了GUARD_PAGE。通过查看内存中的EDR模块,我们可以识别EDR用于内联挂钩部分的DLL或模块。在这个EDR的上下文中,这是EDR在用户模式下的进程内存中使用的唯一真正的DLL,除了假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
。
0X07 总结
在我们探讨潜在的规避技术之前,让我们简要总结一下对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的VEH时,EDR的检测机制被激活。一个可能的解决策略是使用偏移量0xF8的OriginalBase来确定真实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名称的循环。这可以防止除非PEB被EDR修改,否则不访问InLoadOrderModuleList
中的第四个模块。
// 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;
}
int main() {
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
return 0;
}
我们实验的结果,如下图所示,是在安装了正在调查的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>
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;
// 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
return NULL;
}
int main() {
// 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();
return 0;
}
下图展示了我们如何使用C代码避开EDR的假ntdll.dll,并成功确定真实ntdll.dll的基地址。
0X08 解读
我想以对EDR系统的简短解读来结束我的分析。与其他EDR解决方案直接比较,基于假DLL、保护页和向量异常处理的描述检测机制被视为一种相当非传统的方法,更可能在游戏黑客领域中找到。然而,它在实践中被证明是非常有效的。根据我自己的经验,我可以说,成功绕过的时间和精力——定义为恶意软件既未被阻止也未被检测到的情况——与其他EDR系统相比显著更高。这种方法的复杂性和连贯结构使其看起来像一个“陷阱”。据我目前的理解,如果在进程初始化期间通过PEB遍历使用DLLBase偏移量0x30尝试访问ntdll.dll,但实际上最终进入了假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以优化效率,而不会过度影响系统性能。
没有评论