绕过用户模式EDR钩子的技术入门

本文详细介绍了Windows系统调用机制、EDR用户模式钩子的工作原理,以及多种绕过技术,包括直接系统调用、间接系统调用和EDR去钩方法,适合恶意软件研究和安全技术爱好者阅读。

绕过用户模式EDR钩子的技术入门

时间回溯

最近我重新开始研究恶意软件,并翻阅了一些旧笔记以撰写文章。在对照旧博客文章交叉参考笔记时,我意识到自己从未真正发布过关于系统调用和用户模式钩子的大部分工作。由于我的下一篇文章要求读者熟悉这两个概念,我决定花时间整理并发布其余的研究成果。而且,谁不喜欢免费的额外博客文章呢?

尽管本文旨在独立成篇,但如果你感兴趣,可以在这里、这里、这里和这里找到我之前的文章。令人惊讶的是,尽管这些研究已经超过十年,但今天仍然完全适用。我想,变化越多,保持不变的东西就越多?

什么是系统调用

系统调用是从用户模式切换到内核模式的标准方式。它们是软件中断的现代、更快版本。系统调用接口极其复杂,但由于大部分内容与我们的工作无关,我将只给出一个高级摘要。在大多数情况下,你不需要深入了解其工作原理即可使用这些技术,但了解它是有帮助的。

在Windows上,内核有一个允许从用户模式调用的函数表。这些函数有时称为系统服务、本机函数或Nt函数。它们是以Nt或Zw开头的函数,位于ntoskrnl.exe中。系统服务表称为系统服务描述符表(SSDT)。

要从用户模式调用系统服务,必须执行系统调用,这是通过syscall指令完成的。应用程序通过将其ID存储到eax寄存器中来告诉内核它想要调用哪个系统服务。系统服务ID(通常称为系统服务编号、系统调用编号或简称SSN)是函数在SSDT中的条目索引。因此,将eax设置为0将调用SSDT中的第一个函数,1将调用第二个,2将调用第三个,依此类推……查找过程类似于:entry = nt!KiServiceTable + (SSN * 4)。

syscall指令导致CPU切换到内核模式并调用系统调用处理程序,该处理程序从eax寄存器获取SSN并调用相应的SSDT函数。

假设应用程序调用kernel32.dll中的OpenProcess()函数来打开进程句柄。kernel32!OpenProcess()的反汇编显示,该函数实际上只是设置对ntdll.dll中NtOpenProcess()的调用。现在,让我们看看NtOpenProcess()的逻辑。

在NtOpenProcess()内部,几乎没有任何代码。这是因为像所有以Nt或Zw开头的函数一样,NtOpenProcess()实际上位于内核中。ntdll(用户模式)版本的这些函数只是执行系统调用来调用其内核模式对应部分,这就是为什么它们通常被称为系统调用存根。

在我们的例子中,NtOpenProcess的SSN是0x26,但这个数字在不同Windows版本中会变化,所以不要期望它对你相同。从简化的高级视图来看,调用流大致如下:

简化x64系统调用流。以下是之前文章中更详细的x86系统调用流概述。

注意:从用户模式来看,函数的Nt和Zw版本是相同的。从内核模式来看,Zw函数采用略有不同的路径。这是因为Nt函数设计为从用户模式调用,因此对函数参数进行更广泛的验证。

EDR和用户模式钩子

自从微软在2005年引入内核补丁保护(又名PatchGuard)以来,许多对内核的修改现在被阻止了。以前,安全产品通过钩住SSDT来从内核内部监控用户模式调用。由于所有Nt/Zw函数都在内核中实现,所有用户模式调用都必须通过SSDT,因此受到SSDT钩子的影响。

PatchGuard使SSDT钩子成为禁区,因此许多EDR转向钩住ntdll。查看安全产品在PatchGuard之前和之后放置钩子的位置。由于SSDT存在于内核中,用户模式应用程序无法在不加载内核驱动程序的情况下干扰这些钩子。现在,钩子被放置在用户模式中,与应用程序一起。

那么,用户模式钩子是什么样子的?要钩住ntdll.dll中的函数,大多数EDR只是用jmp指令覆盖函数代码的前5个字节。jmp指令将代码执行重定向到EDR自己的DLL中的某些代码(该DLL自动加载到每个进程中)。当CPU被重定向到EDR的DLL后,EDR可以通过检查函数参数和返回地址来执行安全检查。一旦EDR完成,它可以通过执行被覆盖的指令来恢复ntdll调用,然后跳转到钩子(jmp指令)之后的ntdll位置。

在上面的例子中,NtWriteFile被钩住。绿色指令是NtWriteFile的原始指令。NtWriteFile的前3条指令已被EDR的钩子覆盖(一个jmp,将执行重定向到edr.dll中名为NtWriteFile的函数)。每当EDR想要调用真实的NtWriteFile时,它执行3条被覆盖的指令,然后跳转到被钩函数的第4条指令以完成系统调用。

尽管EDR钩子可能因供应商而异,但原理相同,并且都共享相同的弱点:它们位于用户模式。由于钩子和EDR的DLL都必须放置在每个进程的地址空间内,恶意进程可以篡改它们。

绕过EDR钩子

有多种方法可以绕过EDR钩子,因此我将只介绍主要的方法。

EDR去钩

由于被钩住的ntdll位于我们自己的进程内存中,我们可以使用VirtualProtect()使内存可写,然后用原始函数代码覆盖EDR的jmp指令。为了替换钩子,我们当然需要知道原始汇编指令是什么。最常见的方法是从磁盘读取ntdll.dll文件,然后将内存版本与磁盘版本进行比较。这假设EDR不会检测或阻止手动从磁盘读取ntdll.dll。

这种方法的主要缺点是EDR可以定期检查ntdll的内存以查看其钩子是否已被移除。如果EDR检测到其钩子已被移除,它可能会重新写入它们,或者更糟,终止进程并触发检测事件。虽然钩子可能需要放置在用户模式,但检查它们可以从内核模式完成,因此我们无法做太多事情来阻止它。

手动映射DLL

与其从磁盘读取干净的ntdll副本以使我们能够去钩原始ntdll,我们可以将干净副本加载到进程内存中,并使用它而不是原始版本。由于LoadLibrary()和LdrLoadDll()等函数不允许系统两次加载相同的DLL,我们必须手动加载它。

手动映射DLL的代码可能很广泛,并且容易出错或被检测。DLL通常还调用其他DLL,因此我们将被限制仅使用手动加载的ntdll中的函数,或者加载每个所需DLL的第二个副本,并修补它们仅使用其他手动加载的DLL,这可能会变得相当混乱。如果防病毒软件进行内存扫描并看到每个DLL的多个副本加载到内存中,也有很大的检测机会。

直接系统调用

如前所述,用户模式Nt/Zw函数实际上除了执行系统调用之外什么都不做。因此,我们不需要映射整个新的ntdll副本来进行一些系统调用。相反,我们可以将系统调用逻辑直接实现到我们自己的代码中。

我们需要做的就是将想要调用的函数的SSN移动到eax寄存器中,然后执行syscall指令。就像这样简单:

1
2
3
4
5
6
__asm {
  mov r10, rcx
  mov eax, 0x123
  syscall
  ret
}

不幸的是,由于EDR的钩子通常覆盖设置eax寄存器的指令,我们不能简单地从被钩函数中提取它。但是……有几种方法可以找出它是什么。

读取干净的ntdll副本

你可能已经对这个想法感到厌烦,但我们可以从磁盘读取干净的ntdll副本并从那里提取SSN。由于SSN总是放入eax寄存器,我们需要做的就是扫描想要调用的函数以查找mov eax, imm32指令。但是,如果我们想要一种不只是从磁盘读取ntdll的变体的方法呢?好吧,别害怕!

基于函数顺序计算系统调用编号

系统调用ID是索引,因此是顺序的。如果我们想要调用的函数的SSN是0x18,那么直接在其前面的函数可能是0x17,直接在其后面的函数是0x19。由于EDR不会钩住每个Nt函数,我们可以简单地从最近的非钩住函数获取SSN,然后通过添加或减去它与目标函数之间的函数数量来计算我们想要的函数。

NtAllocateVirtualMemory被EDR钩住,但其前后的函数没有被钩住。前面的函数是系统调用编号0x17,后面的函数是0x19。我们可以很容易地假设我们想要的SSN是0x18。这种方法有一个缺陷:我们不能100%保证系统调用编号将永远保持顺序,或者DLL不会跳过一些。

硬编码

所有方法中最简单的是硬编码系统调用编号。虽然它们因版本而异,但在过去并没有太大变化。检测操作系统版本并加载正确的SSN集并不是太多工作。事实上,j00ru已经友好地发布了每个Windows版本的每个系统调用编号列表。这种方法的唯一缺点是如果系统调用编号发生变化,代码可能无法自动在新Windows版本上工作。

直接系统调用的问题

直接系统调用已经成为绕过用户模式钩子的首选方法超过十年。我实际上在2012年首次尝试了这种方法。不幸的是,已经做了很多工作来尝试防止这种绕过。最常见的检测是通过让EDR的内核模式驱动程序检查调用堆栈。

尽管EDR不能再钩住内核中的许多地方,但它可以使用操作系统提供的监控功能,例如:

  • ETW事件
  • 内核回调
  • 过滤器驱动程序

如果我们执行手动系统调用,并且沿途的某个地方我们调用的内核函数触发了上述任何一项,EDR可以趁机检查我们线程的调用堆栈。通过展开调用堆栈并检查返回地址,EDR可以看到导致此系统调用的整个函数调用链。

如果我们正常调用,比如kernel32!VirtualAlloc(),调用堆栈可能如下所示:ManualSyscall!main+0x53、KERNELBASE!VirtualAlloc+0x48、ntdll!NtAllocateVirtualMemory+0x14、nt!KiSystemServiceCopyEnd+0x25。这告诉我们(或EDR)可执行文件(ManualSyscall.exe)调用了VirtualAlloc(),它调用了NtAllocateVirtualMemory(),然后执行系统调用以切换到内核模式。

现在让我们看看当我们进行直接系统调用时的调用堆栈:ManualSyscall!direct_syscall+0xa、nt!KiSystemServiceCopyEnd+0x25。这里很清楚,内核转换是由ManualSyscall.exe内部的代码触发的,而不是ntdll。但是,这有什么问题呢?

在像linux这样的系统上,应用程序直接启动系统调用是完全正常的。但请记住,我提到系统调用ID在Windows版本之间会变化?因此,编写依赖直接系统调用的Windows软件非常不切实际。由于ntdll已经为你实现了每个系统调用,几乎没有理由进行手动系统调用。除非,当然,你正在编写恶意软件以绕过EDR钩子。你是在编写恶意软件以绕过EDR钩子吗?

因为直接系统调用是恶意活动的强烈指标,更复杂的EDR将记录源自ntdll外部的系统调用的检测。说实话,你仍然可以在很多时候逃脱检测,但那有什么乐趣呢?

间接系统调用

大多数EDR在Nt函数的开头写入它们的钩子,覆盖SSN但保留syscall指令 intact。这使我们能够利用ntdll已经提供的syscall指令,而不是自带。我们可以自己设置r10和eax寄存器,然后跳转到被钩ntdll函数中的syscall指令(在EDR的钩子之后)。

注意:我们严格不需要test或jnz指令,这些只是为了向后兼容。一些古老的CPU不支持syscall指令并使用int 0x2e代替。test指令检查是否启用了系统调用,如果没有,则回退到软件中断。如果我们希望支持这些系统,我们可以自己执行检查,然后在需要时跳转到int 0x2e指令(也位于Nt函数内)。

就像直接系统调用一样,我们仍然需要将系统调用编号放入eax,但我们可以使用直接系统调用部分中详细描述的所有相同技术。以这种方式设置系统调用将给我们一个如下所示的调用堆栈:如你所见,调用堆栈现在看起来好像调用来自ntdll!NtAllocateVirtualMemory()而不是我们的可执行文件,因为技术上确实如此。

我们可能遇到的一个问题是如果EDR钩住或覆盖Nt调用的syscall指令部分。我从未见过这种情况发生,但理论上可能。在这种情况下,我们可以跳转到不同、非钩住的Nt函数中的syscall指令。这仍然会绕过仅验证来自ntdll的调用名称的EDR,但会失败任何验证调用的内核函数与系统调用来自的ntdll函数匹配的检查。

更大的问题是,如果EDR检查的不仅仅是第一个返回地址。不仅仅是系统调用来自哪里,而是谁调用了执行系统调用的函数。如果我们从位于动态分配内存中的某些shellcode进行间接系统调用,那么EDR将会看到这一点。来自有效PE部分(exe或DLL内存)外部的调用相当可疑。

此外,由于函数被EDR钩住,EDR的钩子预计会出现在调用堆栈中。我实际上不确定哪些EDR(如果有)检查这一点。但是,如你所见,从调用堆栈中很清楚我们绕过了EDR钩子。

理想情况下,我们想要伪造的不仅仅是系统调用返回地址。一个有趣的解决方案是调用堆栈欺骗,我可能将在单独的文章中介绍。通过调用堆栈欺骗,可以伪造整个调用堆栈,但实现起来可能具有挑战性,而不会崩溃调用线程。你可以在这里阅读更多关于调用堆栈欺骗的信息:

  • Spoofing Call Stacks To Confuse EDRs - WithSecure
  • Long Live Custom Call Stacks - DarkVortex

到此为止

所以你现在了解了用户模式EDR钩子的基础知识以及一些常见的绕过技术。这些知识对我下一篇文章很重要,该文章将更深入地探讨EDR钩子的工作原理并详细说明一些替代的绕过方法。 第二部分:https://malwaretech.com/2023/12/silly-edr-bypasses-and-where-to-find-them.html

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计