介绍
EDR 使用用户空间钩子,这些钩子通常放置在Windows 操作系统中,ntdll.dll有时也位于每个进程中。它们通常以以下两种方式之一实现钩子程序:kernel32.dll
-
使用重定向修补要挂钩的函数的前几个字节(类似于 Microsoft Detours 库)
-
覆盖使用该函数的 dll 的 IAT 表中的函数地址
钩子并非放置在目标 dll 中的每个函数中。在 中ntdll.dll,大多数钩子都放置在Nt*系统调用包装函数中。这些钩子通常用于将执行安全地重定向到 EDR 的 dll,以检查参数以确定进程是否正在执行任何恶意操作。
一些常见的绕过这些钩子的方法如下:
-
重新映射 ntdll.dll:从磁盘或缓存访问 ntdll 的新副本KnownDll ,并使用新副本(部分或特定函数字节)重新映射挂钩版本。
-
直接系统调用:Nt*使用相应的 SSN 和系统调用操作码模拟系统调用包装器在您的程序中执行的操作。
-
间接系统调用:在程序中设置系统调用参数,并使用指令将执行重定向系统调用操作码所在的jmp地址ntdll.dll
还有更多的绕过技术,比如阻止任何未签名的dll被加载,通过监控LdrLoadDll阻止EDR的dll被加载等等。
另一方面,可以采用一些检测策略来检测并可能防止上述逃避技术:
-
检测重映射 ntdll.dll
-
如果一个进程在其内存空间中包含两个 ntdll.dll 实例,这通常是可疑行为的明显迹象。
-
检测直接系统调用
-
当执行直接系统调用时,EDR 可以注册一个检测回调来检查用户空间代码从哪里恢复。如果它返回到进程而不是返回到 ntdll.dll 地址空间,那么这清楚地表明发生了直接系统调用。
-
检测间接系统调用
-
由于该技术涉及跳转到 ntdll.dll 地址空间来执行系统调用事件,因此先前的检测将失败。但是,线程调用堆栈分析会发现存在异常行为,因为没有通过各种 Windows API 进行合法调用,而只是对 ntdll.dll 的过程。
下面提出的研究试图解决上述检测策略。
LayeredSyscall – 控制流概述
总体思路是在执行间接系统调用之前生成合法的调用堆栈,同时将模式切换到内核态,并支持最多 12 个参数。此外,调用堆栈可以由用户选择,前提是其中一个堆栈框架满足预期系统调用的参数数量的大小要求 Nt*。如果需要,实施的概念还可以允许用户不仅生成合法的调用堆栈,还可以生成用户选择的 Windows API 之间的间接系统调用。
向量异常处理程序 (VEH) 用于让我们控制 CPU 上下文,而无需发出任何警报。由于异常处理程序并未被广泛归类为恶意行为,因此它们为我们提供了对硬件断点的访问权限,这些断点将被滥用为钩子。
需要注意的是,这里提到的调用堆栈生成不是由工具或用户构建的,而是由系统执行的,无需执行我们自己的展开操作或在内存中单独分配。这意味着如果存在检测,只需调用另一个 Windows API 即可更改调用堆栈。
VEH 处理器 #1 –AddHwBp
syscall 我们在两个关键区域(操作码和操作码)注册了设置硬件断点所需的第一个处理程序ret ,它们都在Nt*系统调用包装器内ntdll.dll。
EXCEPTION_ACCESS_VIOLATION在实际调用 syscall 之前,处理程序被注册到由工具生成的 handle 中。这可以通过多种方式执行,但我们将使用对空指针的基本读取来生成异常。
但是,由于我们必须支持用户可以调用的任何系统调用,因此我们需要一种通用方法来设置断点。我们可以实现一个包装函数,该函数接受一个参数并继续触发异常。此外,处理程序可以Nt*通过访问寄存器来检索函数的地址RCX,该寄存器存储传递给包装函数的第一个参数。
触发ACCESS_VIOLATION异常
检索到后,我们执行内存扫描以找出系统调用操作码和ret操作码(紧接着系统调用操作码)所在的偏移量。我们可以通过检查操作码 0x0F和0x05彼此相邻来做到这一点,如下面的代码所示。
通过扫描内存查找系统调用操作码
如以下屏幕截图所示,Windows 中的系统调用是使用操作码 0x0F 和 0x05 构建的。在系统调用开始后两个字节,您可以找到 ret 操作码 0xC3。
系统调用操作码 – 0xF 和 0x5;ret 操作码 – 0xC3
硬件断点是使用寄存器Dr0, Dr1, Dr2, 和设置的,Dr3其中Dr6 和Dr7 用于修改其相应寄存器的必要标志。处理程序使用Dr0 和在和偏移处Dr1 设置断点。如下面的代码所示,我们通过访问或来启用它们。我们还设置了寄存器的最后一位和第二位,让处理器知道断点已启用。syscall ret ExceptionInfo->ContextRecord->Dr0Dr1Dr7
ACCESS_VIOLATION 的 AddHwBp() 异常处理程序
正如您在下图中看到的,由于我们试图读取空指针地址,因此引发异常。
异常触发代码的反汇编
一旦抛出异常,处理程序将接管并设置断点。
将断点置于系统调用操作码处
请注意,一旦触发异常,就需要将寄存器步进RIP 到传递生成异常的操作码所需的字节数。在本例中,它是 2 个字节。
增加 RIP 以超过异常触发代码
此后,CPU 将继续处理异常的其余部分,这将作为我们的钩子执行。我们将在下面的第二个处理程序中看到这一点。
VEH 处理器 #2 –HandlerHwBp
该处理程序包含三个主要部分:
-
保存上下文并启动用户选择的调用堆栈的生成
-
正确返回进程而不崩溃
-
通过执行间接系统调用来找到重定向执行的正确位置并绕过钩子
第 1 部分 - 处理 Syscall 断点
硬件断点在由系统执行时会生成异常代码, EXCEPTION_SINGLE_STEP检查该代码以处理我们的断点。在控制流的第一顺序中,我们Nt*使用成员检查异常是否在系统调用开始时生成ExceptionInfo->ExceptionRecord->ExceptionAddress,该成员指向生成异常的地址。
检查系统调用操作码处的硬件断点
我们继续保存异常发生时的 CPU 上下文。这使我们能够查询存储的参数(根据 Microsoft 的调用约定,这些参数存储在 和 中),RCX, RDX, R8还R9, 使我们能够使用寄存器RSP 查询其余参数(稍后将进一步解释)。
将控制流转移到良性函数
一旦存储,我们可以将RIP 指向我们的演示函数;在本例中,我们使用一个简单的MessageBox()。
将 RIP 更改为良性函数起始地址的调试器视图
下面的演示函数负责生成我们需要的合法调用堆栈,用户可以根据需要进行更改。
MessageBox() 用作演示函数
第 2 部分 - 生成合法的调用堆栈
总体思路是将执行重定向到良性的 Windows API 调用,然后生成合法的调用堆栈并重定向以执行间接系统调用。虽然我们在syscall andret 指令处有钩子,但还是存在一个问题,我们需要知道在哪里停止执行以重定向以执行间接系统调用。
我们使用调试器用来执行单步执行的陷阱标志 (TF)。还有其他方法可以完成此部分,例如使用ACCESS_VIOLATION、页面保护违规等。要启用陷阱标志,我们可以使用寄存器EFlags。由于我们已经可以访问上下文,因此我们可以使用以下代码片段启用它。
启用跟踪标志来处理指令跟踪
为了生成合法的调用堆栈,我们需要等待系统满足某个条件(即调用必须到达的地址空间,因为ntdll.dll大多数Nt*系统调用通常从 ntdll.dll 内部重定向)。这确保调用堆栈在观察者眼中尽可能合法,即使观察者不是太敏锐。
有很多种方法可以检查这一点,但为了简单起见,我们可以获取句柄ntdll.dll,并使用它GetModuleInformation()来获取 dll 的基址和结尾。查询后,我们可以检查由于陷阱标志而生成的异常地址是否在其地址空间内。
存储ntdll.dll基地址和结束地址的信息
我们使用一个简单的结构来存储信息,该结构在工具启动时初始化。
DllInfo 结构定义
如果条件满足,我们可以继续将执行重定向到预期的系统调用。这首先需要我们检索从系统调用操作码中断并设置系统调用时保存的上下文。
Windows 中的系统调用设置方式如下:
Windows 中的系统调用
我们需要检索已保存的上下文,但在此之前,我们需要将当前堆栈指针保存RSP到临时变量中,以便可以检索它。由于用已保存的堆栈指针覆盖堆栈指针会完全改变调用堆栈,这会违背我们的目的,因此我们需要在复制后立即保存并恢复当前堆栈指针。
存储堆栈指针以便稍后恢复
这可以防止调用堆栈发生变化,同时保持来自预期系统调用的参数的初始状态。
EDR 钩子通常以指令的形式放置在系统调用起始地址 jmp的开始处或后面几条指令处 Nt*。
EDR 通常如何与函数挂钩
因此,如果我们在处理程序中模拟系统调用功能,然后将 RIP 更改为系统调用操作码地址,我们就可以有效地绕过 EDR 挂钩,而无需触碰它。
在我们的异常处理程序中模拟系统调用
RIP在将其更改为系统调用操作码之前,我们可以继续模拟系统调用。
异常处理程序中模拟系统调用的调试器视图
这种矢量系统调用方法之前曾在此处记录过:通过矢量系统调用绕过 AV/EDR 挂钩。这将避免使用内联汇编代码,或使用 winapis 访问上下文。
但有一个问题。系统支持参数中调用的一些函数数量少于 4,但如果我们想要支持几乎所有的系统调用,那么我们至少需要支持 12 个。
第 2.5 部分 - 支持 >4 个论点
在使用 Windows API 生成调用堆栈时,我们还需要考虑每个 Windows API 分配的堆栈大小。这对我们来说至关重要,因为 Windows 调用约定将大于 4 个的参数存储在堆栈空间中。
Windows 调用约定的工作原理如下,
-
将前 4 个参数存储在寄存器中,RCX, RDX, R8, and R9
-
为返回地址分配 8 个字节
-
分配另外 4 x 8 字节,用于保存前 4 个参数
-
分配变量和其他内容
Windows 中如何设置堆栈
欲了解更多参考信息,请参阅以下内容:Windows x64 调用约定:堆栈框架
因此,这意味着我们需要首先找到一个合适的函数,该函数将支持最多 12 个参数的堆栈大小,我们可以认为这大于0x58 字节。一旦我们设法找到一个合适的函数,我们就需要等待该函数执行对其他函数的调用指令。此调用指令将在接触内部函数时相交。这是为了确保我们不仅分配了足够的堆栈空间,而且还有一个合法的返回地址来运行。为此,我们可以再次使用我们的内存扫描方法,但有一些我们将要解决的注意事项。
如下面的屏幕截图所示,某些函数框架中没有足够的堆栈空间来存储超过 4 个参数而不会破坏堆栈。
如果不合适的函数则调用堆栈
大多数函数框架使用指令在函数开头分配堆栈sub rsp, #size。
检查适当的堆栈大小
我们可以通过检查操作码来找到与该指令匹配的字符,0xEC8348并且在大多数情况下提取最高字节将得到堆栈的大小。
找到正确的大小,在本例中为 0x58 或更大
一个主要的警告是,有时函数框架可能比预期的要小,在这种情况下,很容易到达框架的末尾,这通常是一条ret 指令。因此,如果我们在找到堆栈大小之前找到操作码,我们将需要中断循环ret 。这可以通过添加以下代码片段来检查:
函数框架短时退出
我们使用一个全局标志IsSubRsp来查明我们是否执行了第一步,这将引导我们进入第二步:等待一条call 指令在我们想要的相同功能框架内发生。
检查函数框架是否包含调用指令
同样,这可以通过检查异常地址与调用指令的操作码 0xE8 来完成。
找到合适的函数框架
另一个注意事项是确保函数框架不存在,这意味着我们将计数器重置为 0,以让它知道我们尚未找到合适的函数。
假设我们找到了正确的函数框架,它既包含适当的堆栈大小,又可以继续执行调用指令,我们可以继续将保存的上下文中的其余参数存储到我们刚刚找到的堆栈框架中。它从5 x 8start 之后的字节开始RSP。
将所有参数存储在堆栈中
因此,这可以实现干净的堆栈,而不会因堆栈空间不足而通过覆盖返回值来破坏堆栈。调用堆栈的完整性得以保持。
找到合适的堆栈
因此,这意味着我们的约束变为:
-
调用必须到达ntdll.dll地址空间
-
该调用必须支持适当的堆栈大小
-
该调用必须支持在其内部调用另一个函数
第 3 部分 - 处理 ret 断点
一旦设置了堆栈并执行了系统调用,它将继续命中ret 我们已经放置硬件断点的操作码。最后一步是确保我们可以安全地返回到原始调用函数,而不是返回到我们用来生成调用堆栈的用户选择的 Windows API 函数,尽管这也可以做到,我们将在后面讨论。
由于堆栈框架当前指向被调用的 Windows API 的合法调用堆栈,因此一旦ret 执行,它将立即返回正常执行。相反,我们可以将其指向保存的上下文,RSP, 这将使ret 地址从堆栈中弹出并返回到调用系统调用的函数Nt*,从而绕过合法 Windows API 调用的任何进一步执行。
回到我们最初的包装函数
我们还清除了设置的硬件断点的寄存器,以便可以将它们重用于多个系统调用。
恢复堆栈的调试器视图
公开函数包装器
我们在工具中提供了一个头文件,需要将其包含在内才能使用系统调用的包装函数。这是受到rad9800Nt*所做工作的启发,您可以在此处查看,TamperingSyscals
通过解析SysWhispers3的原型,我们可以为我们喜欢的系统调用生成头文件。
调用原始 Nt* 系统调用的包装函数
由于系统调用的 SSN 对于每个版本的 Windows 来说都在不断变化,因此我们还需要支持针对当前在系统上运行的 Windows 版本动态获取 SSN。因此,我们在此处包含了MDSecGetSsnByName()提供的功能,使用异常目录解析系统服务编号。有多种方法可以检索 SSN,例如 Halo 的门、Syswhispers 工具等。
用法
下面是一段示例代码,展示了如何使用函数包装器。我们ntdll.dll在工具的头文件中包含了常用的系统调用函数。
LayeredSyscall 与 NtCreateUserProcess 系统调用的结合使用
结果
调用堆栈分析
在我们的工具执行之前,间接系统调用将生成调用堆栈。这清楚地表明存在可疑行为,因为在到达 ntdll.dll 之前没有合法的函数调用。
正在发生的间接系统调用的线程调用堆栈
现在,一旦我们的工具运行,我们就可以看到系统调用发生时生成的调用堆栈。
使用 LayeredSyscall 的合法线程调用堆栈
针对 EDR 进行测试
我们还选择通过针对现有 EDR 进行测试来展示此工具的有效性。Sophos Intercept X 被选为我们的测试环境。
至于我们想要测试的恶意方法,我们采用了古老的 Process Hollowing 技术。由于它是一种被广泛检测到的技术,因此查看使用我们的技术的前后版本是一个不错的选择。
我们独创的工艺挖空方法,立刻就被EDR检测到了。
Sophos Intercept X (EDR) 检测典型的进程注入
现在,让我们使用我们的工具来包装所有系统调用函数并再次运行测试。
Sophos Intercept X (EDR) 无法检测 LayeredSyscall 包装的进程注入
如上图所示,可执行文件成功注入了样本有效MessageBox 负载,且 EDR 未发出任何警报。(显示的警报来自上一次测试)。
结论
这项研究和该工具旨在从不同角度看待如何配备间接系统调用或其他方法(例如睡眠混淆),这些方法可能需要合法的堆栈才能不被发现地工作。由于在程序中构建堆栈通常会在开发不周的情况下被破坏,因此该工具允许操作系统轻松生成必要的调用堆栈,此外任何 Windows API 都可能被使用。此外,这并不是说这种绕过方法适用于所有 EDR,因为它需要对许多其他 EDR 和检测技术进行更彻底的测试才能称之为全局绕过。
工具链接:https://github.com/WKL-Sec/LayeredSyscall
潜在检测
到目前为止,针对此技术的检测需要检查特定程序中是否存在恶意注册的异常处理程序。其他检测方法还包括通过对 Windows API 生成的已知调用堆栈实施启发式方法来标记异常堆栈行为。
参考
-
https://malwaretech.com/2023/12/silly-edr-bypasses-and-where-to-find-them.html
-
https://github.com/rad9800/TamperingSyscalls
-
https://github.com/Dec0ne/HWSyscalls
-
https://labs.withsecure.com/publications/spoofing-call-stacks-to-confuse-edrs
-
https://www.codereversing.com/archives/594#:~:text=Defining the exception handler&text=On Windows%2C when a hardware,Dr0 register has been hit
-
https://www.mdsec.co.uk/2022/04/resolving-system-service-numbers-using-the-exception-directory/
-
https://github.com/klezVirus/SysWhispers3/blob/master/data/prototypes.json
-
https://www.x86matthew.com/view_post?id=writeprocessmemory_apc
-
https://github.com/Xacone/BestEdrOfTheMarket
原文始发于微信公众号(Ots安全):LayeredSyscall——滥用 VEH 绕过 EDR
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论