最近一直在做EDR相关的工作,虽然略有了解EDR的机制,但是并未深究其完整的工作框架和可能的绕过机制,借工作空闲时间依靠智谱清言阅读一下《Evading EDR The Definitive Guide to Defeating Endpoint Detection Systems》一书。
在众多现代端点安全产品的组件中,最常部署的是负责函数挂钩的DLL。这些DLL向防御者提供了代码执行的关键信息,如传递给目标函数的参数及其返回结果。目前,供应商通常利用这些数据来增强其他更稳固的信息渠道。然而,函数挂钩依然是端点检测与响应系统(EDR)的核心部分。在本章中,我们将探讨EDR通常如何截取函数调用,以及作为攻击者,我们可以采取哪些策略来绕过或干扰这些机制。
本章重点关注Windows文件ntdll.dll中的函数挂钩,我们将在稍后介绍其功能,但现代EDR也会挂钩其他Windows函数。实现这些其他挂钩的过程与本章描述的工作流程非常相似。
How Function Hooking Works
要理解端点安全产品如何运用代码挂钩技术,首先需要掌握用户模式下代码与内核交互的方式。用户模式下的代码在执行时通常会借助Win32 API来实现主机上的某些功能,如请求其他进程的处理句柄。然而,许多操作无法完全在用户模式下通过Win32 API完成,例如内存和对象管理,这些操作属于内核的职责范畴。
在x64系统中,为了将执行流程转移到内核,会使用syscall指令。但Windows并非在每一个需要与内核交互的函数中都直接实现syscall指令,而是通过ntdll.dll中的函数来提供这些功能。用户模式的函数只需将必要的参数传递给这些导出的函数,后者则会将控制权转交给内核,并返回操作结果。例如,图2-1展示了当用户模式应用程序调用Win32 API函数kernel32!OpenProcess()时的执行流程。
为了有效检测恶意活动,安全产品供应商通常会挂钩特定的Windows API。例如,EDR检测远程进程注入的一种方法是通过挂钩那些用于打开其他进程句柄、分配内存区域、向该内存写入数据以及创建远程线程的函数。
在早期Windows版本中,供应商(以及恶意软件作者)经常将他们的挂钩放置在系统服务调度表(SSDT)上。SSDT是内核中的一个表,它包含了用于syscall调用的内核函数指针。安全产品通过覆盖这些函数指针,替换为自己的内核模块中的函数指针,用于记录函数调用信息,然后执行目标函数,并将返回值传回源应用程序。
然而,自2005年Windows XP推出以来,微软引入了名为内核补丁保护(KPP)的机制,旨在防止对SSDT及其他关键结构的修改。因此,在现代64位Windows版本上,这种技术变得不可行。这导致传统的挂钩必须在用户模式下进行。由于ntdll.dll中执行syscall的函数是在用户模式下观察API调用的最后机会,EDR通常会挂钩这些函数以监控其调用和执行。一些常见的挂钩函数在表格2-1中有详细说明。
通过拦截对这些API的调用,EDR能够监控传递给原始函数的参数,以及返回给调用API的代码的值。代理程序随后可以分析这些数据,以判断活动是否具有恶意性。例如,为了检测远程进程注入,一个代理可以监控以下行为:内存区域是否以读写执行权限分配,是否在新分配的内存中写入数据,以及是否使用指向该写入数据的指针创建了一个线程。
Implementing the Hooks with Microsoft Detours
尽管有许多库使实现函数挂钩变得简便,但大多数库在幕后都采用了相似的技术。这是因为,本质上,所有函数挂钩都涉及到修改无条件跳转(JMP)指令,以此将执行流从被挂钩的函数重定向到EDR开发人员指定的函数。
Microsoft Detours是其中一个最常用于实现函数挂钩的库。在技术层面,Detours通过用一个无条件JMP指令替换要挂钩函数的前几个指令,从而将执行流重定向到开发人员定义的函数,这个函数通常被称为detour。detour函数执行开发人员指定的操作,例如记录传递给目标函数的参数。之后,它将执行传递给另一个函数,通常称为trampoline,该函数执行目标函数并包含最初被覆盖的指令。目标函数执行完毕后,控制权返回到detour。detour可能会执行额外的处理,例如记录原始函数的返回值或输出,然后再将控制权返回到原始进程。
图2-2展示了正常进程的执行与添加了detour的进程之间的比较。实线箭头表示预期的执行流程,而虚线箭头则表示挂钩后的执行流程。
在此示例中,EDR选择挂钩ntdll!NtCreateFile()函数,这是一个用于创建新的I/O设备或打开现有设备的句柄的syscall。在正常操作中,这个syscall会直接转移到内核,由其内核模式对应函数继续执行操作。然而,当EDR的挂钩被部署后,执行流程会在注入的DLL中暂停。这个edr!HookedNtCreateFile()函数将代表ntdll!NtCreateFile()执行syscall,从而允许它收集传递给syscall的参数以及操作的结果。
在调试器中检查挂钩函数,例如WinDbg,可以清楚地展示挂钩函数与非挂钩函数之间的差异。列表2-1展示了在WinDbg中未挂钩的kernel32!Sleep()函数的样子。
该函数的反汇编展示了我们预期的执行流程。当调用者调用kernel32!Sleep()时,执行首先跳转到跳转桩kernel32!SleepStub(),然后通过长跳转(JMP)到kernel32!_imp_Sleep(),后者提供了调用者所期望的真实Sleep()功能。
在利用Detours进行DLL注入并挂钩该函数后,其外观和结构将发生显著变化,如列表2-2所示。
与直接跳转到kernel32!_imp_Sleep()不同,反汇编代码现在包含了一系列的JMP指令。第二个JMP指令将执行流程跳转到trampoline64!TimedSleep(),如列表2-3所示。
为了收集关于挂钩函数执行的度量数据,这个trampoline函数通过其内部的trampoline64!TrueSleep()包装函数调用合法的kernel32!Sleep()函数,以评估其睡眠的CPU时钟滴答数,并通过弹出消息显示这些数据。
虽然这是一个人为构造的例子,但它展示了每个EDR的函数挂钩DLL的核心功能:代理目标函数的执行并收集有关如何调用它的信息。在这个案例中,我们的EDR简单地测量了挂钩程序的睡眠时间。在真实的EDR应用中,与对手行为相关的重要函数,例如ntdll!NtWriteVirtualMemory()用于将代码复制到远程进程,也会以类似的方式进行代理,但挂钩可能会更加关注传递的参数和返回的值。
Injecting the DLL
一个挂钩函数的DLL在未加载到目标进程之前并不特别有用。一些库提供了通过API创建进程并注入DLL的能力,但这对于EDR来说并不实用,因为它们需要能够在用户随时创建的进程中注入它们的DLL。幸运的是,Windows提供了一些方法来实现这一点。
在Windows 8之前,许多供应商选择使用AppInit_Dlls机制将他们的DLL加载到每个交互式进程(那些导入user32.dll的进程)中。不幸的是,恶意软件作者经常滥用这种技术来实现持久化和信息收集,而且它还因引起系统性能问题而臭名昭著。微软不再推荐使用这种方法进行DLL注入,从Windows 8开始,在启用了安全启动的系统上完全阻止了这种方法。
将功能挂钩DLL注入进程的最常用技术是利用驱动程序,它可以利用内核级别的功能异步过程调用(KAPC)注入将DLL插入进程。当驱动程序接收到新进程创建的通知时,它将为进程分配一些内存用于APC例程和要注入的DLL名称。然后它会初始化一个新的APC对象,负责将DLL加载到进程中,并将其复制到进程的地址空间。最后,它会更改线程的APC状态中的一个标志,以强制执行APC。当进程恢复其执行时,APC例程将运行加载DLL
Detecting Function Hooks
在网络安全实践中,攻击者经常需要确定他们计划使用的函数是否已被挂钩。一旦识别出被挂钩的函数,他们就可以将它们列入清单,并限制或完全避免使用这些函数。这样攻击者可以绕过EDR(端点检测与响应系统)的函数挂钩DLL的检查,因为其检查功能将永远不会被调用。检测挂钩函数的过程通常很简单,特别是对于ntdll.dll导出的本地API函数。
ntdll.dll中的每个函数都包含一个syscall桩。构成这个桩的指令在列表2-4中显示。
你可以通过在WinDbg中反汇编ntdll.dll导出的函数来查看这个桩,如列表2-5所示。
在ntdll!NtAllocateVirtualMemory()的反汇编中,我们可以观察到syscall桩的基本组成部分。这个桩首先在R10寄存器中保留了RCX寄存器的值,然后将对应于NtAllocateVirtualMemory()的syscall编号(在这个版本的Windows中为0x18)移入EAX。接下来,MOV指令之后的TEST和条件跳转(JNE)指令是所有syscall桩中都存在的检查。这些检查在受限制的用户模式下使用,当内核模式代码启用了Hypervisor代码完整性,而用户模式代码未启用时。在这种情况下,可以安全地忽略这些检查。最后,执行syscall指令,将控制权转移到内核以处理内存分配。当函数完成后,控制权返回给ntdll!NtAllocateVirtualMemory(),后者仅简单地返回。
由于所有本地API的syscall桩结构都是相同的,任何对这些桩的修改都表明可能存在函数挂钩。例如,列表2-6展示了被篡改的ntdll!NtAllocateVirtualMemory()函数的syscall桩。
0:013> u ntdll!NtAllocateVirtualMemory
ntdll!NtAllocateVirtualMemory
00007fff`fe90c0b0 e95340baff jmp 00007fff`fe4b0108
00007fff`fe90c0b5 90 nop
00007fff`fe90c0b6 90 nop
00007fff`fe90c0b7 90 nop
00007fff`fe90c0b8 f694259893fe7f01 test byte ptr [SharedUserData+0x308],1
00007fff`fe90c0c0 7503 jne ntdll!NtAllocateVirtualMemory+0x15
00007fff`fe90c0c2 0f05 syscall
00007fff`fe90c0c4 c3 ret
00007fff`fe90c0c5 cd2e int 2Eh
00007fff`fe90c0c7 c3 ret
在这里,请注意,ntdll!NtAllocateVirtualMemory()的入口点处并没有syscall桩,而是存在一个无条件JMP指令。EDR通常使用这种类型的修改来重定向执行流到它们的挂钩DLL。
因此,为了检测EDR放置的挂钩,我们可以简单地检查当前加载到我们进程中的ntdll.dll副本中的函数,将它们的入口点指令与未修改的syscall桩的预期操作码进行比较。如果我们发现我们想要使用的函数上有挂钩,我们可以尝试使用下一节中描述的技术来规避它。
Evading Function Hooks
在端点安全软件中使用的所有传感器组件中,函数挂钩在规避方面是最受研究的。攻击者可以使用多种方法来规避函数拦截,这些方法通常可以归结为以下几种技术(还有更多其他技术):
- 直接执行未修改的syscall桩中的指令
- 重新映射ntdll.dll以获取未挂钩的函数指针或覆盖当前映射在进程中的挂钩ntdll.dll
- 阻止非Microsoft DLL在进程中加载,以防止EDR的函数挂钩DLL放置其detour
Making Direct Syscalls
迄今为止,最常被滥用的规避ntdll.dll函数挂钩的技术是直接执行syscall。如果我们自己执行syscall桩中的指令,我们可以模仿一个未修改的函数。要做到这一点,我们的代码必须包含所需函数的签名、包含正确syscall编号的桩以及目标函数的调用。这个调用使用签名和桩来传递所需的参数并以函数挂钩无法检测的方式执行目标函数。列表2-7包含了我们需要创建的第一个文件来实现这一技术。
在我们的项目中,第一个文件包含了ntdll!NtAllocateVirtualMemory()的重新实现。该文件中的唯一函数将填充EAX寄存器以执行syscall编号,然后执行syscall指令。这段汇编代码将保存在自己的.asm文件中,Visual Studio可以配置为使用Microsoft宏汇编器(MASM)编译它,并与项目中的其他代码一起编译。
尽管我们已经构建了自己的syscall桩,但我们仍然需要一种方法从我们的代码中调用它。列表2-8展示了我们如何做到这一点。
这个函数定义包含了所有必需的参数及其类型,以及返回类型。它应该在我们的头文件syscall.h中,并在我们的C源文件中包含,如下2-9所示。
这个文件中的wmain()函数调用NtAllocateVirtualMemory()以在当前进程中分配一个具有读写权限的0x1000字节缓冲区。这个函数不在微软向开发者提供的头文件中定义,所以我们必须在自己的头文件中定义它。当这个函数被调用时,调用的是我们项目中的汇编代码,而不是ntdll.dll,有效地模拟了未挂钩的ntdll!NtAllocateVirtualMemory()的行为,而不会运行EDR挂钩的风险。
这种技术的主要挑战之一是微软经常更改syscall号码,因此任何硬编码这些号码的工具可能只适用于特定版本的Windows。例如,在Windows 10构建1909上,ntdll!NtCreateThreadEx()的syscall号码是0xBD。在接下来的版本20H1上,它是0xC1。这意味着针对构建1909的工具不会在Windows的后续版本上工作。
为了帮助解决这个限制,许多开发人员依赖于外部资源来跟踪这些更改。例如,Google Project Zero的Mateusz Jurczyk维护了一个包含每个Windows版本中函数及其关联syscall号码的列表。在2019年12月,Jackson Thuraisamy发布了工具SysWhispers,它使攻击者能够动态地为他们的进攻工具包中的syscall生成函数签名和汇编代码。列表2-10显示了SysWhispers为Windows 10构建1903至20H2上的ntdll!NtCreateThreadEx()函数生成的汇编代码。
这段汇编代码从进程环境块1中提取构建号,然后使用该值将适当的syscall号码移动到EAX寄存器,在进行syscall之前。虽然这种方法可行,但它需要大量的努力,因为攻击者必须在每次微软发布新的Windows构建时更新他们的数据集中的syscall号码。
Dynamically Resolving Syscall Numbers
在2020年12月,一位名为@modexpblog的Twitter研究人员发表了一篇博客文章,题为《绕过用户模式挂钩和直接调用系统调用》。这篇文章介绍了一种新的函数挂钩规避技术:在运行时动态解析syscall号码,从而使攻击者无需为每个Windows构建硬编码值。
这种技术的工作流程如下,用于创建一个函数名称和syscall号码的字典:
- 获取当前进程映射的ntdll.dll的处理句柄。
- 枚举所有以Zw开头的导出函数,以识别系统调用。需要注意的是,以Nt(更常见)开头的函数(在用户模式下调用时工作方式相同)在这里似乎是随机的。
- 存储导出函数名称及其关联的相对虚拟地址。
- 根据相对虚拟地址对字典进行排序。
- 将函数的syscall号码定义为排序后字典中的索引。
使用这种技术,攻击者可以在运行时收集syscall号码,将它们插入到桩中的适当位置,然后像静态编码方法中通常那样调用目标函数。
Remapping ntdll.dll
另一种常见的规避用户模式函数挂钩的技术是向进程中加载ntdll.dll的新副本,并用新加载文件的正文覆盖现有挂钩版本,然后调用所需的函数。这种策略之所以有效,是因为新加载的ntdll.dll不包含之前加载副本中实现的挂钩,所以当它覆盖受污染的版本时,实际上清除了EDR放置的所有挂钩。列表2-11显示了这个技术的初级示例。为了简洁省略了一些行。
我们的代码首先获取当前加载的(挂钩的)ntdll.dll的基址。然后,我们从磁盘读取ntdll.dll的内容并将其映射到内存。此时,我们可以解析挂钩的ntdll.dll的PE头部,寻找.text节区的地址,该节区包含图像中的可执行代码。一旦我们找到它,我们更改该内存区域的权限,以便我们可以写入它,从“干净”文件复制.text节区的内容,并恢复内存保护。完成这一系列事件后,EDR最初放置的挂钩应该已经被移除,开发人员可以安全地调用ntdll.dll中的任何函数,而不用担心执行被重定向到EDR注入的DLL。
虽然从磁盘读取ntdll.dll似乎很简单,但它确实带来了一个潜在的权衡。这是因为将ntdll.dll加载到单个进程中多次是非典型行为。防御者可以使用Sysmon,这是一个免费系统监控实用程序,提供了与EDR相同的许多遥测收集功能。几乎每个非恶意进程都有一对一的进程GUID与加载的ntdll.dll的映射。当我查询大型企业环境中的这些属性时,大约有3700万进程在一个月内加载了ntdll.dll超过一次,占比仅约0.04%。
为了避免基于这种异常的检测,你可能会选择在一个暂停状态中生成一个新的进程,获取新进程中映射的未修改ntdll.dll的处理句柄,并将其复制到当前进程。然后,你可以像以前一样获取函数指针,或者替换现有的挂钩ntdll.dll,以有效地覆盖EDR放置的挂钩。列表2-12演示了这个技术。
这个最小示例首先打开对我们进程当前映射的ntdll.dll副本的一个处理句柄,获取其基址,并解析其PE头部。接下来,它创建了一个暂停的进程,并解析了这个进程的ntdll.dll副本PE头部,该副本还没有机会被EDR挂钩。这个函数的其余流程与前一个示例完全相同,当它完成后,挂钩的ntdll.dll应该已经恢复到干净状态。
就像所有事情一样,这里也存在权衡,因为我们的新暂停进程为检测提供了另一个机会,例如通过挂钩的ntdll!NtCreateProcessEx()、驱动程序或ETW提供程序。在我的经验中,很少看到一个程序为了合法原因创建一个临时暂停的进程。
Conclusion
函数挂钩是端点安全产品可以监控其他进程执行流的一种原始机制。虽然它为EDR提供了非常有用的信息,但它非常容易绕过,因为其常见实现的固有弱点。因此,大多数成熟的EDR现在将其视为辅助遥测源,并依赖更健壮的传感器。
标签:HOOKING,函数,syscall,ntdll,挂钩,dll,EDR,Evading From: https://www.cnblogs.com/jicey/p/18138880