AI摘要:绕过EDR:通过矢量异常处理程序(VEH)生成合法调用堆栈,实现系统调用绕过EDR检测。

免责声明

以下研究只能用于道德目的。请负责任,不要将其用于任何非法的事情。这仅用于教育目的。

介绍

EDR 使用用户空间 Hook,这些 Hook 通常放置在 Windows 操作系统中的每个进程中,有时也位于 ntdll.dllkernel32.dll 中。他们通常通过以下两种方式之一实现其挂钩程序:

  1. 修补函数的前几个字节,以便使用重定向进行挂接(类似于 Microsoft Detours 库)。
  2. 覆盖使用该函数的 DLL 的 IAT 表中的函数地址

钩子不会放置在目标 DLL 中的每个函数中。在 ntdll.dll 中,大多数钩子都放置在 syscall 包装函数中。这些钩子通常用于将执行安全地重定向到 EDR 的 DLL,以检查参数以确定进程是否正在执行任何恶意操作。

规避这些钩子的一些常见旁路

  • 重新映射 ntdll.dll:从磁盘或缓存访问 ntdll 的新副本,并使用新副本(节或特定函数字节)重新映射挂钩版本。
  • 直接系统调用:使用相应的 SSN 和 syscall 操作码模拟 syscall 包装器在程序中执行的操作。
  • 间接系统调用:在程序中设置 syscall 参数,并使用 jmp 指令将执行重定向到 ntdll.dll 中的 syscall 操作码所在的地址。

还有更多绕过技术,例如阻止加载任何未签名的 DLL,通过监控 LdrLoadDll 阻止加载 EDR 的 DLL 等。

检测策略

  • 检测重映射 ntdll.dll:如果进程在其内存空间内包含两个 ntdll.dll 实例,则通常是可疑行为的明显迹象。
  • 检测直接系统调用:执行直接系统调用时,EDR 可以注册插桩回调,以检查用户空间代码从何处恢复。如果它返回到进程而不是返回到 ntdll.dll 地址空间,则清楚地表明发生了直接的系统调用。
  • 检测间接系统调用:由于此技术涉及跳转到 ntdll.dll 地址空间以执行 syscall 事件,因此之前的检测将失败。但是,线程调用堆栈分析会揭示存在异常行为,因为没有通过各种 Windows API 的合法调用,而这只是 ntdll.dll 的过程。

下面介绍的研究试图解决上述检测策略。

LayeredSyscall – 概述

一般的思路是在执行间接 syscall 之前生成一个合法的调用堆栈,同时将模式切换到内核模式,并且最多支持 12 个参数。此外,调用堆栈可以由用户选择,前提是其中一个堆栈帧满足预期 syscall 的参数数量的大小要求。实现的概念还允许用户不仅生成合法的调用堆栈,还在需要时生成用户选择的 Windows API 之间的间接 syscall。

矢量异常处理程序(VEH)

矢量异常处理程序(VEH)用于为我们提供对 CPU 上下文的控制,而无需发出任何警报。由于异常处理程序并未被广泛归因于恶意行为,因此它们为我们提供了对硬件断点的访问,这些断点将被滥用以充当钩子。

请注意,这里提到的调用堆栈生成不是由工具或用户构建的,而是由系统执行的,无需执行我们自己的展开操作或在内存中单独分配。这意味着,如果存在对一个 Windows API 的检测,则只需调用另一个 Windows API 即可更改调用堆栈。

VEH 处理程序 #1 – AddHwBp

我们在两个关键区域(syscall 操作码和 ret 操作码)注册设置硬件断点所需的第一个处理程序,这两个区域都在 ntdll.dll 中的 syscall 包装器中。

处理程序注册为 EXCEPTION_ACCESS_VIOLATION 异常处理程序,该处理程序由工具生成,就在对 syscall 进行实际调用之前。这可以通过多种方式执行,但我们将使用 null 指针的基本读取来生成异常。

但是,由于我们必须支持用户可以调用的任何 syscall,因此我们需要一种通用方法来设置断点。我们可以实现一个包装函数,该函数接受一个参数并继续触发异常。此外,处理程序可以通过访问寄存器 RCX 来检索 Nt* 函数的地址,该寄存器存储传递给包装函数的第一个参数。

触发 ACCESS_VIOLATION 异常后,我们执行内存扫描以找出 syscall 操作码和 ret 操作码(就在 syscall 操作码之后)存在的偏移量。我们可以通过检查操作码 0x0F0x05 是否彼此相邻来做到这一点。

Windows 中的系统调用是使用操作码 0x0F0x05 构建的。在 syscall 开始后两个字节,您可以找到 ret 操作码 0xC3

硬件断点使用寄存器 Dr0, Dr1, Dr2, Dr3Dr6, Dr7,其中 Dr0Dr1 用于修改其相应寄存器的必要标志。处理程序使用 ExceptionInfo->ContextRecord->Dr0Dr1syscallret 偏移处设置断点。如下面的代码所示,我们通过访问 Dr0Dr1 来启用它们。我们还设置了寄存器 Dr7 的最后一位和第二位,让处理器知道断点已启用。

如下图所示,抛出异常是因为我们尝试读取 null 指针地址。

引发异常后,处理程序将负责并放置断点。

syscall 入口地址处中断并使用我们的 VEH 处理程序设置断点。

syscall 操作码处放置断点。

请注意,一旦触发了异常,就有必要将寄存器 RIP 单步执行到传递生成异常的操作码所需的字节数。在本例中,它是 2 个字节。

跳过生成异常的指令。

在异常触发代码之后递增 RIP

之后,CPU 将继续执行异常的其余部分,这将作为我们的钩子执行。我们将在下面的第二个处理程序中看到此操作。

VEH 处理程序 #2 – HandlerHwBp

此处理程序包含三个主要部分:

  1. 保存上下文并启动用户选择的调用堆栈的生成
  2. 正确返回进程而不崩溃
  3. 找到正确的位置来重定向执行并通过执行间接 syscall 绕过钩子

第 #1 部分 – 处理 syscall 断点

硬件断点在由系统执行时,会生成一个异常代码,该代码将被检查以处理我们的断点。在控制流的第一个顺序中,我们使用 ExceptionInfo->ExceptionRecord->ExceptionAddress 成员检查异常是否是在 syscall 启动时生成的,该成员指向生成异常的地址。

我们继续保存生成异常时 CPU 的上下文。这允许我们查询存储的参数,根据 Microsoft 的调用约定,这些参数存储在 RCX, RDX, R8, R9 中,并且还允许我们使用寄存器 RSP 来查询其余的参数,这将在后面进一步解释。

将控制流更改为良性函数。

存储后,我们可以将 RIP 更改为指向我们的 demo 函数;在本例中,我们使用简单的 MessageBox()

RIP 更改为用户所需的功能。

RIP 更改为良性函数起始地址的调试器视图。

下面的 demo 函数负责生成我们需要的合法调用堆栈,用户可以根据需要进行更改。

MessageBox() 用作演示函数。

第 #2 部分 – 生成合法的调用堆栈

一般思路是将执行重定向到良性 Windows API 调用,然后生成合法调用堆栈并重定向以执行间接 syscall。尽管我们在 syscallret 指令上有钩子,但存在一个问题,我们需要知道在哪里停止执行以重定向以执行间接 syscall。

我们使用调试器使用的 Trap Flag(TF)来执行单步执行。还有其他方法可以完成这部分,比如使用 ACCESS_VIOLATION、page guard violation 等。要启用 trap 标志,我们可以使用 EFlags 寄存器。由于我们已经可以访问上下文,因此可以使用以下代码片段来启用它。

为了生成合法的调用堆栈,我们需要等待系统发生特定条件(即,调用必须到达 ntdll.dll 地址空间,因为大多数系统调用通常是从 ntdll.dll 内部重定向的)。这确保了调用堆栈在观察者眼中看起来尽可能合法,如果不是太热衷的话。

这可以通过多种方式进行检查,但为了简单起见,我们可以获取 ntdll.dll 的句柄并使用 GetModuleInformation() 获取 DLL 的基部和结尾。查询后,我们可以检查由于 trap 标志而生成的异常地址是否在其地址空间内。

存储 ntdll.dll 基址和结束地址的信息。

我们使用一个简单的结构来存储信息,该信息在工具开始时初始化。

DllInfo 结构定义。

如果满足条件,我们可以继续将执行重定向到预期的 syscall。这首先需要我们检索保存的上下文,该上下文从中断 syscall 操作码并设置 syscall 中获得。

Windows 中的 Syscall 按以下方式设置:

如何在 Windows 中实现 syscall。

系统调用在 Windows 中的外观。

我们需要检索保存的上下文,但在此之前,我们需要将当前堆栈指针 RSP 保存到 temp 变量中,以便可以检索它。由于用保存的堆栈指针覆盖堆栈指针会完全改变调用堆栈,这将违背我们的目的,因此我们需要在复制后立即保存和恢复当前堆栈指针。

存储堆栈指针以便以后恢复它。

这可以防止调用堆栈发生变化,同时具有来自预期 syscall 的参数的初始状态。

EDR 钩子通常以 jmp 指令的形式放置在 Nt* syscall 开始地址后面的几个指令。

EDR 通常如何挂接到函数中。

因此,如果我们在处理程序中模拟 syscall 功能,然后将 RIP 更改为 syscall 操作码地址,我们可以有效地绕过 EDR 钩子,而无需接触它。

在异常处理程序中模拟 syscall。

我们可以在将 RIP 更改为 syscall 操作码之前继续模拟 syscall。

模拟 syscall。

在异常处理程序中模拟 syscall 的 Debugger 视图。

这种矢量 syscall 方法之前记录在这里:通过矢量 Syscall 绕过 AV/EDR 钩子。这将避免使用内联汇编代码或使用 winapi 访问上下文。

但有一个问题。在系统内调用的一些函数支持小于 4 的参数计数,但如果我们想支持几乎所有的系统调用,那么我们至少需要支持最多 12 个。

第 #2.5 部分 – 支持 >4 参数

在使用 Windows API 生成调用堆栈时,我们还需要考虑每个 Windows API 分配的堆栈大小。这对我们来说至关重要,因为 Windows 调用约定将大于 4 的参数存储在堆栈空间中。

Windows 调用约定的工作原理如下:

  • 将前 4 个参数存储在寄存器 RCX, RDX, R8, R9 中。
  • 为返回地址分配 8 个字节。
  • 再分配 4 x 8 字节,用于保存前 4 个参数。
  • 为变量和其他内容分配。

如何在 Windows 中设置堆栈。

有关进一步参考,请查看以下内容:Windows x64 调用约定:堆栈帧。

因此,这意味着我们首先需要找到一个合适的函数,该函数将支持最多 12 个参数的堆栈大小,我们可以将其视为大于 0x58 字节。一旦我们设法找到合适的函数,我们需要等待该函数执行对其他函数的调用指令。此 call 指令将在接触内部函数时相交。这是为了确保我们不仅分配了足够的堆栈空间,而且还有一个合法的返回地址可以运行回去。为此,我们可以再次使用我们的内存扫描方法,尽管我们将解决一些注意事项。

在某些函数帧中,我们没有足够的堆栈空间来存储 4 个以上的参数,而不会损坏堆栈。

堆栈空间不足。

如果函数不合适,则调用 stack。

大多数函数帧使用 sub rsp, #size 指令在函数的开头分配堆栈。

找到合适的函数帧以支持足够的堆栈分配。

检查适当的堆栈大小。

我们可以通过检查操作码 0xEC8348 来找到与此指令的匹配项,并且在大多数情况下,提取最高字节将导致堆栈的大小。

找到合适的大小,在本例中为 0x58 或更大。

一个主要的警告是,有时函数帧可能比预期的要小,在这种情况下,很容易到达帧的末尾,这通常是一个 ret 指令。因此,如果我们在找到堆栈大小之前找到 ret 操作码,我们将需要中断循环。这可以通过添加以下代码片段来检查:

如果函数框架较短,则退出。

我们使用全局标志 IsSubRsp,来了解我们是否执行了第一步,这引导我们进入第二步:等待 call 指令在我们想要的同一函数框架内发生。

检查函数帧是否包含 call 指令。

同样,这可以通过根据 call 指令 0xE8 的操作码检查异常地址来完成。

找到合适的函数帧,因为它在。

找到合适的函数框架。

另一个警告是确保函数帧不会退出,这意味着我们将计数器重置回 0,让它知道我们还没有找到合适的函数。

假设我们找到了正确的函数帧,它既包含适当的堆栈大小,又继续执行调用指令,我们可以继续将保存的上下文中的其余参数存储到我们刚刚找到的堆栈帧中。它从 5 x 8 字节之后开始 RSP

将所有参数存储在堆栈中。

因此,这允许一个干净的堆栈,而不会由于缺少堆栈空间而覆盖返回值来损坏堆栈。维护调用堆栈完整性。

找到合适的堆栈帧。

找到合适的堆栈。

因此,这意味着我们的 constraints 更改为:

  • 调用必须到达 ntdll.dll 地址空间。
  • 调用必须支持适当的堆栈大小。
  • 调用必须支持在其自身内部调用另一个函数。

第 #3 部分 – 处理 ret 断点

设置堆栈并执行 syscall 后,它将继续命中我们已经放置硬件断点的 ret 操作码。最后一步是确保我们可以安全地返回到原始调用函数,而不是返回到我们用于生成调用堆栈的用户选择的 Windows API 函数,尽管这也可以完成,我们将在后面讨论。

由于堆栈帧当前指向来自已调用的 Windows API 的合法调用堆栈,因此一旦执行 ret,它将立即返回正常执行。相反,我们可以将其指向保存的上下文,这将使地址从堆栈中弹出并返回到调用 Nt* syscall 的函数,而无需为合法的 Windows API 调用进一步执行。

返回到我们原来的包装函数。

我们还从我们设置的硬件断点中清除寄存器,以便我们可以将它们重新用于多个 syscall。

通过在 ret 断点处修改 RSP 来更改堆栈以指向我们的原始函数。

恢复堆栈的 Debugger 视图。

公开函数包装器

我们在工具中提供了一个头文件,需要包含该文件才能使用 syscall 的包装函数。这是受到 rad9800 所做的工作的启发,您可以在此处查看 TamperingSyscalls

通过解析 SysWhispers3 的原型,我们可以为我们喜欢的 syscall 生成头文件。

用于调用原始 Nt* 系统调用的包装函数。

由于每个 Windows 版本的系统调用的 SSN 不断变化,因此我们还需要支持动态获取当前在系统上运行的 Windows 版本的 SSN。因此,我们在此处包含 MDSec 提供的 Resolving System Service Numbers using the Exception Directory。有多种方法可以检索 SSN,例如 Halo 的门、Syswhispers 工具等。GetSsnByName()

用法

下面是一段示例代码,用于演示如何使用函数包装器。我们在工具的头文件中包含了常用的 ntdll.dll syscall 函数。

LayeredSyscallNtCreateUserProcess syscall 一起使用。

结果

调用堆栈分析

在执行我们的工具之前,间接 syscall 将生成调用堆栈。这清楚地表明了可疑行为,因为在它到达 ntdll.dll 之前,没有合法的函数调用会通过。

正在发生的间接系统调用的线程调用堆栈。

现在,一旦我们的工具运行,我们就可以看到 syscall 发生时生成的调用堆栈。

使用 LayeredSyscall 的合法线程调用堆栈。

针对 EDR 进行测试

我们还选择通过对现有 EDR 进行测试来展示该工具的有效性。Sophos Intercept X 被选为我们的测试环境。

至于我们想要测试的恶意方法,我们采用了古老的进程挖空技术。由于它是一种被广泛检测的技术,因此使用我们的技术查看之前和之后的版本将是一个不错的选择。

我们原来的工艺镂空方法,立即被 EDR 检测到。

Sophos EDR 检测典型工艺注入。

Sophos Intercept X(EDR)检测典型工艺注入。

现在,让我们使用我们的工具包装所有系统调用函数并再次运行测试。

Sophos Intercept X(EDR)未检测到 LayeredSyscall 包装的进程注入。

可执行文件成功注入了示例负载,也没有来自 EDR 的警报。(显示的警报来自上一个测试)。MessageBox

结论

这项研究和该工具旨在以不同的方式看待如何配备间接系统调用或其他方法,例如睡眠混淆,这可能需要合法堆栈才能在不被发现的情况下工作。由于如果不仔细开发,在程序中构建我们的堆栈通常会损坏,因此该工具允许操作系统轻松生成必要的调用堆栈,从而增加了可能使用任何 Windows API 的事实。此外,这并不是说旁路方法适用于每个 EDR,因为它需要对许多其他 EDR 和检测技术进行更彻底的测试,才能将其称为全局旁路。

可能的检测

截至目前,针对此技术的检测将需要检查特定程序中是否存在恶意注册的异常处理程序。其他检测还可能包括通过对 Windows API 生成的已知调用堆栈实施启发式来标记异常堆栈行为。

引用

最后修改:2025 年 03 月 25 日
如果觉得我的文章对你有用,请随意赞赏