愚蠢的EDR绕过方法及其发现途径
Marcus Hutchins
最近我在测试一些EDR检测间接系统调用的能力时,想到了一个古怪的绕过方法。如果你还不熟悉直接和间接系统调用,建议先阅读这篇文章。
直接和间接系统调用的一个缺点是,从调用栈可以明显看出你绕过了EDR的用户模式钩子。以下是直接、间接和常规调用的示例调用栈:
- 直接系统调用的调用栈
- 间接系统调用的调用栈
- 常规挂钩Nt函数调用的调用栈
从最后一张图可以看出,当通过挂钩函数进行调用时,EDR钩子的返回地址会出现在调用栈中(在我的例子中是hmpalert)。
这是一个有趣的困境:我们不想调用挂钩函数,因为那可能触发检测;但如果我们完全绕过钩子,也可能触发检测。这时我有了一个有趣的想法:如果我确实调用挂钩函数,但以EDR无法正确检查调用参数的方式进行。
我立刻有了几个想法。
TOCTOU
“检查时间到使用时间”(TOCTOU)是一种常用于软件利用的技术。当对对象执行安全检查,但在检查和使用之间无法防止修改该对象时,就会出现漏洞。
以以下代码为例:
|
|
在上面,src_size是一个指向整数的指针。如果指定的大小大于目标缓冲区,函数就会失败。由于src_size是一个指针,程序将变量的地址传递给函数,而不是其值。在函数执行期间,程序完全有可能修改src_size指向的值。
如果攻击者能够完美计时,在if(*src_size >= 1024)之后、memcpy()调用之前改变src_size的值,他们仍然可以触发缓冲区溢出。该值只需要在if语句完成后小于1024,然后可以设置为大于dest_buffer的值。
注意:上面的例子高度简化,在现实世界中,编译器会优化此代码,只读取一次*src_size的值。
我最初的想法是利用类似的竞争条件来对抗EDR的钩子。使用良性参数调用挂钩函数,然后在调用过程中快速将其替换为恶意参数。如果我们可以计时更改,使其在EDR完成检查参数之后、系统调用指令之前发生,我们就可以在不实际绕过钩子的情况下绕过它。
在试图找出是否有什么方法可以避免过早修改参数并触发检测事件时,我有了另一个更好的想法。
想法2:硬件断点
这个想法更简单。选择一个我想调用的被EDR挂钩的ntdll函数,然后在系统调用指令上设置一个硬件断点。
硬件断点允许我们告诉CPU在某个地址被读取、写入或执行时触发异常。因此,通过在系统调用指令上设置执行断点,我们可以在EDR完成检查之后、系统调用发生之前拦截执行。
这基本上允许我们挂钩EDR的钩子,并将任何合法调用转换为自定义系统调用。
我们将能够做的是:使用不会触发检测的良性参数调用挂钩函数,然后在EDR已经检查调用之后用恶意参数替换参数。如果我们愿意,甚至可以更改系统调用号以调用与EDR认为我们正在进行的系统调用不同的系统调用。
硬件断点将在EDR检查完我们的假参数之后、系统调用指令转换到内核模式之前触发。当内核返回到用户模式时,它将返回到系统调用之后的指令,我们可以在那里放置第二个断点。
第二个断点处理程序可以更改参数 back,以防止修改被EDR可能进行的任何调用后检查捕获。在许多情况下,如果调用失败,EDR不会进行调用后检查,因此我们也可以将EAX寄存器更改为STATUS_NOT_FOUND、STATUS_INVALID_PARAMETER,或者向TDSS rootkit致敬:STATUS_TOO_MANY_SECRETS。
挂钩的NtWriteFile函数的代码流示例。
调用流将如下所示:
- 使用良性参数调用挂钩的Nt函数
- EDR检查良性参数
- EDR将控制权传递回挂钩的Nt函数以执行系统调用
- 我们的第一个断点被触发,我们将参数切换为恶意参数
- 我们继续执行,因此系统调用被触发
- 内核使用我们的真实参数,然后返回到Nt函数
- 我们的第二个断点被触发,我们将参数切换回去
- EDR执行任何调用后检查,只看到良性参数
理想情况下,最好的目标是使用CPU寄存器或内存指针作为参数的函数。如果我们开始修改堆栈变量,这可能会在调用栈展开期间显示出来。
寻找合适的目标
为了测试我的想法,我想出了一个会立即触发检测事件的函数调用。这实际上比我想象的要难得多。许多我确信会触发检测的操作并没有。
最后,我决定使用我的旧进程注入代码。该代码的工作原理有点像进程镂空。它创建一个处于挂起状态的新进程,将自身注入到挂起的进程中,然后使用SetThreadContext()将主线程的入口点更改为恶意代码的入口点。
我选择的目标是Sophos Intercept X,因为它宣传检测进程镂空攻击。如果我们对用户模式钩子进行逆向工程,我们可以确切地看到进程镂空是如何被检测到的。
EDR的NtSetContextThread钩子处理程序片段。
每当创建新线程时,其指令指针设置为RtlUserThreadStart()。RtlUserThreadStart的第一个参数是线程的入口点,该入口点将在函数完成初始化新线程后调用。在一个全新的进程中,只有一个线程,即主线程,它负责调用可执行文件的入口点。
在进程镂空期间,可执行文件的代码被取消映射并替换为恶意代码。由于旧代码和新代码不太可能具有完全相同的入口点地址,因此通常需要修改线程的起始地址。通过更改RtlUserThreadStart()的第一个参数(RCX寄存器),我们更改线程的入口点,从而更改进程的入口点。
Sophos的检测只是检查代码是否试图使用NtSetContextThread()来更改新线程的RCX寄存器,这是可疑行为。由于我们可以在创建新线程时指定任何入口点,因此没有理由在创建后更改它。这样做的唯一原因是线程是由其他东西创建的,比如PE加载器。
使用硬件断点绕过检查
实际上,我可以想到很多方法来绕过这个检查,但我只对试验CPU异常感兴趣。对于我们的第一个例子,我们只需在NtSetContextThread()的系统调用和retn指令上设置断点。
下面是我编写的一些示例代码,用于查找这些指令。
|
|
不幸的是,调试寄存器是特权寄存器,这意味着我们不能直接从用户模式设置它们。为了设置硬件断点,我们需要使用NtSetContextThread(),这有点讽刺。我们基本上将使用NtSetContextThread来绕过NtSetContextThread上的钩子。
为了设置我们的硬件断点,我们需要将DR0和DR1设置为我们想要中断的地址,然后DR7告诉CPU我们想要什么类型的断点。
|
|
在断点处理程序内部,我们只需更改RCX和RDX寄存器,它们包含NtSetContextThread()的参数1和参数2。在调用之前,我们可以将真实值存储在全局变量中,使用一些假值调用NtSetContextThread,然后让我们的异常处理程序用真实值替换假值。由于系统调用存根将第一个参数从RCX移动到R10,为了安全起见,我们将设置两者。
|
|
我们只能读取/写入挂起线程上的上下文,因此我们将创建一个新的挂起线程来调用NtSetContextThread()。我们将使用NtSetContextThread(NULL, NULL)作为我们的假参数。
|
|
结果
首先,让我们看看正常调用NtSetContextThread()时会发生什么。
现在,再次,但使用我们特殊的断点方法:
成功!代码能够将自己注入到记事本并显示一个消息框。但是,我实际上想更进一步。必须调用NtSetContextThread来设置我们的硬件断点并不理想。EDR可以使用其NtSetContextThread钩子来查看我们是否试图设置会干扰EDR的断点。
那么,常规的旧异常呢?
想法3:故意异常
Instead of hardware breakpoints, we’re going to try and cause a CPU exception. Regular exceptions can be handled in the exact same way as breakpoint exceptions, but we don’t need to call NtSetContextThread() to set them up.
We already know the EDR inspects the context structure whenever we call NtSetContextThread(), so let’s use that to our advantage. Most software checks if an address is NULL before trying to read it, but what if it’s neither NULL nor a valid address? What happens if we set the context address to 0x1337?
Let’s try the following:
|
|
然后我们运行它…
哎呀,EDR的钩子试图读取无效内存并使进程崩溃。现在我们有了一个简单的方法来触发异常,而无需任何硬件断点。
棘手的是异常发生在EDR的处理程序内部,而不是直接发生在系统调用之前,因此很难用真实参数替换假参数。我们还需要正确处理异常,以免进程崩溃。
从崩溃转储和我们之前的反汇编中,我们已经知道EDR正试图将context->Rcx字段读入RDX寄存器。异常在此伪代码的第1行触发。
我们可以使用反汇编器来制作更通用的绕过方法,但由于这只是一个PoC,我们将硬编码到