Syscall绕过EDR

文摘   2024-10-17 15:10   陕西  

欢迎加入我的知识星球,目前正在更新免杀相关的东西,129/永久,每100人加29。

加好友备注星球!!!

一些资源的截图:

本文基于如下文章:
https://whiteknightlabs.com/2024/07/31/layredsyscall-abusing-veh-to-bypass-edrs/

调用堆栈

一般操作系统或安全软件在执行系统调用的时候,会检查调用堆栈是否是正常的,调用堆栈是记录函数调用路径的数据结构,他会告诉操作系统一个函数从哪里调用的,EDR会检查这个调用堆栈结构,如果发现不合法或者不合理,则可能认为是一个恶意的行为。

那么我们在想什么是正常的调用堆栈,什么是不正常的调用堆栈呢?

正常的调用堆栈

一般合法的调用堆栈,会遵循以下的调用路径的。

比如我们去通过ReadFile函数去读取文件的内容,ReadFile函数会调用Ntdll.dll中的NtReadFile函数,这个函数是系统调用的封装函数。

NtReadFile通过syscall指令向操作系统内核发出系统调用,将控制权转移给内核态,最终实现文件读取的操作。

relaysec.exe -> kernel32.dll!ReadFile -> ntdll.dll!NtReadFile -> ntoskrnl.exe!syscall -> 内核文件读取处理

不正常的调用堆栈

在绕过EDR的情况下,攻击者一般不会直接去调用ReadFile函数或NtReadFile,而是直接通过汇编调用syscall指令,从而跳过Windows提供的API层,这会让调用堆栈变得可疑,因为它不符合常见的调用路径。

program.exe -> ntoskrnl.exe!syscall -> 内核文件读取处理

为什么EDR会检测调用堆栈?

  1. 调用堆栈可以显示函数是如何被调用的,如果攻击者直接调用系统指令,比如syscall,跳过正常的API层(比如ReadFile),堆栈中会缺少某些标准的调用路径,EDR会认为这是一个可疑的操作。

  2. EDR通常会在常用的系统API(比如NtReadFile)上插入钩子来检查传递给这些函数的参数,如果攻击者手动修改寄存器或直接调用syscall来绕过这些API层,那么调用堆栈的异常会让EDR感知到有绕过的行为。

  3. 意软件通常会尝试避开正常的系统调用路径来执行其操作,EDR通过监控调用堆栈可以识别这些攻击模式。例如,攻击者可能会通过内联汇编直接发出系统调用,而不是使用操作系统提供的API,堆栈分析可以帮助发现这种异常行为。

EDR如何检测调用堆栈?

  1. EDR会在关键的函数上设置钩子,例如Windows API(如Nt* 系列的函数)。当调用这些函数时,EDR会查看调用堆栈,检查调用的源地址(是来自合法的应用程序,还是通过恶意行为直接调用)。

  2. EDR会遍历堆栈,验证调用链是否符合常见的模式,例如,从用户态API到ntdll.dll,再到内核态。如果跳过了某些常见步骤,EDR可能会标记异常。

  3. 合法调用堆栈通常会遵循一致的模式(如从 kernel32.dll -> ntdll.dll -> 内核态),EDR可以通过对比常见的调用路径来检查堆栈是否异常或伪造。

所以我们在绕过的时候,需要去构建一个合法的调用堆栈,让EDR看起来这个调用堆栈数据结构是正常的。

生成合法的调用堆栈

操作系统和安全软件在执行系统调用时,会检查调用堆栈是否正常。调用堆栈是记录函数调用路径的数据结构,它告诉系统一个函数是从哪里调用的。EDR 会检查这个调用堆栈,如果发现它不合法或不合理,就可能认为是可疑行为。因此,攻击者需要构建一个“看起来合法”的调用堆栈。

绕过技术的关键点是:

  • 你并不需要手动构建调用堆栈:调用堆栈可以由操作系统自动生成。

  • 你可以选择让调用堆栈框架支持最多 12 个参数,因为 Windows 的系统调用可能需要多达 12 个参数。生成的调用堆栈将会满足系统调用的要求,并且不会触发EDR的检测。

在 Windows 系统中,许多系统调用都是通过 ntdll.dll 提供的封装函数调用的,例如 NtCreateUserProcess。安全软件会监控这些函数,并插入钩子来检查传入的参数、调用的源地址等。

直接系统调用会触发这些钩子,因为它是从受监控的API发出的。间接系统调用则通过另一层间接跳转来避免被检测到:

  • 间接调用的意思是:你设置好系统调用的参数,不直接调用 ntdll.dll 中的函数,而是使用跳转指令直接跳到系统调用的机器码部分,避开了 EDR 插入的钩子。

  • 系统的调用堆栈依然保持合法状态,这样安全软件看到的调用流程看似正常,但实际上它绕过了钩子。

向量异常处理程序(VEH) 是 Windows 中处理异常的一种机制。它允许程序在发生异常时,通过注册的异常处理程序来捕获和处理这些异常。通常,这种行为不会被安全软件视为恶意,因为 VEH 是正常的异常处理机制。

绕过方式中,VEH 被用来:

  • 捕获异常并修改 CPU 上下文:当程序执行到特定位置(例如非法的指令地址或断点),会触发异常。通过 VEH,你可以捕获这些异常,并更改程序的执行路径,例如更改 RIP 寄存器(指令指针寄存器),从而重新定向执行流到其他地方(比如系统调用的机器码部分)。

  • 不引发安全警报:VEH 的另一个优势是,它不像其他异常处理手段那样容易被认为是恶意行为。硬件断点可以通过 VEH 被滥用为“钩子”,这使得绕过更加隐蔽。

VEH异常处理

如果我们想去Hook Nt*系列的函数,我们需要在这些系统调用的包装器中设置硬件断点,在执行这些系统调用之前,我们需要某种方式来触发异常,以便让异常处理程序来进行处理。

我们可以通过访问空指针来生成一个访问违规异常(EXCEPTION_ACCESS_VIOLATION)。当程序访问到无效的地址时,系统就会自动触发异常,启动你注册的异常处理函数。

这里定义了一个宏。

#define TRIGGER_ACCESS_VIOLATION_EXCEPTION int *a = 0; int b = *a;

这里定义了一个名字叫做TRIGGER_ACCESS_VIOLATION_EXCEPTION的宏,这个宏的作用是故意引发访问违规异常,这里首先定义了一个指向空指针的变量(int *a = 0;),然后再去引用这个空指针,引用这个空指针会导致程序的崩溃,因为它视图去访问无效的内存地址。

函数部分如下:

void _SetHwBp(ULONG_PTR FuncAddress) {    TRIGGER_ACCESS_VIOLATION_EXCEPTION}

这个函数接收一个参数,也就是目标函数的地址,在函数体内调用了触发访问违规异常的宏。

当我们获取到需要操作的函数地址之后,比如Nt系列的函数地址,我们会在该地址附近的内存区域进行扫描,寻找特定的指令。

在x86-64架构上,syscall指令对应的操作码通常是0x0F,0x05。ret指令是函数调用结束时返回的指令,他的操作码一般是0xC3。

我们可以通过内存扫描来找到0x0F 0x05这两个操作码相邻的地方,也就是syscall指令的地方。

如下代码:

这里会进行for循环从SyscallEntryAddr系统调用的入口地址开始的前25个字节,这里的SyscallEntryAddr我们可以理解为某个系统调用函数的起始地址,比如说NtVirtualAllocMemory函数的地址为0x12345678,那么它的前25个字节,其实就是0x12345678,0x12345679,0x1234567A,......依次类推 一直检查到0x12345691截止。

紧接着其实就是判断,SyscallEntryAddr + i是否等于0x0F,syscall指令的第一个字节就是0x0F。

再接着判断SyscallEntryAddr + i + 1是否等于0x05,syscall指令的第二个字节就是0x05。

如果找到了这两个字节,这意味着就找到了syscall指令了,那么将其偏移地址复制给OPCODE_SYSCALL_OFF,表示syscall指令的起始地址。

OPCODE_SYSCALL_RET_OFF = i + 2; i+2表示syscall指令之后的ret操作码的偏移量,这是因为syscall指令占用两个字节,所以操作码的位置在syscall指令之后的第三个字节。

for (int i = 0; i < 25; i++) {    // find syscall ret opcode offset    if (*(BYTE*)(SyscallEntryAddr + i) == 0x0F && *(BYTE*)(SyscallEntryAddr + i + 1) == 0x05) {        OPCODE_SYSCALL_OFF = i;        OPCODE_SYSCALL_RET_OFF = i + 2;        break;    }}

如下代码:

#include <stdio.h>#include <Windows.h>#include <winternl.h> // 包含 NTSTATUS 和其他 NT API 类型
typedef NTSTATUS(NTAPI* NtAllocateVirtualMemoryPtr)( HANDLE ProcessHandle, PVOID* BaseAddress, ULONG_PTR ZeroBits, PSIZE_T RegionSize, ULONG AllocationType, ULONG Protect );
int main(){ // 获取 ntdll.dll 模块句柄 HMODULE ntdll = GetModuleHandleA("ntdll.dll"); if (!ntdll) { printf("Failed to get ntdll.dll handle\n"); return 1; }
// 获取 NtAllocateVirtualMemory 函数地址 NtAllocateVirtualMemoryPtr SyscallEntryAddr = (NtAllocateVirtualMemoryPtr)GetProcAddress(ntdll, "NtAllocateVirtualMemory"); if (!SyscallEntryAddr) { printf("Failed to get NtAllocateVirtualMemory address\n"); return 1; }
// 获取函数地址的原始字节 BYTE* funcAddr = (BYTE*)SyscallEntryAddr;
// 查找 syscall ret 操作码偏移 for (int i = 0; i < 25; i++) { if (*(funcAddr + i) == 0x0F && *(funcAddr + i + 1) == 0x05) { printf("Found syscall ret opcode at offset %d\n", i); break; } }
return 0;}

硬件断点一般是由DR0-DR3这四个寄存器来进行设置的,这个四个寄存器中去设置断点的地址,而DR6 DR7寄存器是用于修改对应寄存器所需的标志,DR6寄存器是用于报告触发的端口,而DR7寄存器是用于启用或禁用断点,并设置条件。

我们来看一下异常处理函数代码的代码:

LONG WINAPI AddHwBp(    struct _EXCEPTION_POINTERS* ExceptionInfo) {    // 检查异常代码是否为 EXCEPTION_ACCESS_VIOLATION(访问冲突异常) 这个是在之前空指针哪里实现的    if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION) {                // 获取导致异常的系统调用入口地址,即异常时的 RCX 寄存器值        SyscallEntryAddr = ExceptionInfo->ContextRecord->Rcx;
// 遍历从当前系统调用入口开始的 25 个字节,查找 syscall 和 ret 的操作码 for (int i = 0; i < 25; i++) { // 检查是否遇到 0x0F 0x05(syscall 指令的机器码) if (*(BYTE*)(SyscallEntryAddr + i) == 0x0F && *(BYTE*)(SyscallEntryAddr + i + 1) == 0x05) { // 记录 syscall 操作码的偏移量 OPCODE_SYSCALL_OFF = i; // 记录 ret 指令的偏移量(假定 ret 紧跟 syscall 指令后) OPCODE_SYSCALL_RET_OFF = i + 2; break; } }
// 设置硬件断点(hwbp),以监视 syscall 操作码 // 将 Dr0 寄存器指向 syscall 操作码地址 ExceptionInfo->ContextRecord->Dr0 = (SyscallEntryAddr); // 启用 Dr0 断点(将 Dr7 的第 0 位设置为 1,表示启用) ExceptionInfo->ContextRecord->Dr7 = ExceptionInfo->ContextRecord->Dr7 | (1 << 0);
// 设置硬件断点(hwbp),以监视 ret 操作码 // 将 Dr1 寄存器指向 ret 操作码地址 ExceptionInfo->ContextRecord->Dr1 = (SyscallEntryAddr + OPCODE_SYSCALL_RET_OFF); // 启用 Dr1 断点(将 Dr7 的第 2 位设置为 1,表示启用) ExceptionInfo->ContextRecord->Dr7 = ExceptionInfo->ContextRecord->Dr7 | (1 << 2);
// 将 RIP 寄存器向前移动,跳过导致异常的指令 ExceptionInfo->ContextRecord->Rip += OPCODE_SZ_ACC_VIO;
// 告诉系统继续执行程序(异常已经处理) return EXCEPTION_CONTINUE_EXECUTION; }
// 如果异常不符合条件(不是 EXCEPTION_ACCESS_VIOLATION),则继续搜索其他异常处理程序 return EXCEPTION_CONTINUE_SEARCH;}

接下来我们需要去保存上下文并生成用户选择的调用堆栈,当系统触发硬件断点时,程序就会暂停,这时 VEH 处理程序会保存当前的 CPU 状态(上下文),并生成一个“看起来合法”的调用堆栈,让安全软件认为这是一个正常的系统调用。

当异常处理完成之后,程序需要继续执行,VEH处理程序会调整程序的执行流,让它能够正常返回,而不是导致程序崩溃。

当程序执行到一个硬件断点时(例如在 Nt* 系统调用的起始地址),CPU 生成一个 EXCEPTION_SINGLE_STEP 异常。VEH(向量异常处理程序)捕获到这个异常,并检查异常的生成位置。这个位置通过 ExceptionInfo->ExceptionRecord->ExceptionAddress 来表示,指向当前程序执行到的指令地址。VEH 处理程序需要判断这个异常地址是否与 Nt* 系统调用的起始地址一致。如果一致,说明硬件断点确实是在我们期望的地方触发了。接下来,处理程序就可以执行其余的操作,比如修改调用堆栈,或是执行间接系统调用,以绕过安全软件的钩子。

所以首先判断异常是否是硬件断点触发的。

当异常触发时,我们会保存异常触发时CPU的上下文,这样做的目的是为了可以查询存储的参数,一般参数都是在RCX,RDX,R8,R9存储前四个参数,后面的参数由堆栈进行传递,可以通过RSP寄存器来查询剩余的参数。

首先禁用Dr0寄存器以及DR7寄存器。

使用memcpy_s函数将当前CPU的上下文从ExceptionInfo->ContextRecord(结构体用来保存当前CPU的状态)拷贝到 SavedContext,这一步是为了保存当前的CPU状态。

修改RIP的值指向demofunction函数的地址,也就是说程序接下来会执行demofunction的地址。

接下来就是生成合法的调用堆栈了。

我们可以将其执行流重定向到正常的Windows API调用中,然后生成合法的调用堆栈并重定向以执行间接系统调用。

也就是说我们重定向到自定义的函数之后,在自定义函数中去调用messageboxa函数来生成正常的调用堆栈,然后设置陷阱标志,再去执行下一条指令的时候又会触发断点异常,我再去捕捉到之后,重定向到间接系统调用。

为了避免被EDR检测到,我们需要生成一个合法的调用堆栈,调用堆栈指的是函数调用的路径,安全工具会监控这个路径来判断程序行为的合理性,就比如说我们想要在Windows上去创建一个文件,我们一般都会使用更高级别的API来创建,比如CreateFile函数,而CreateFile函数位于kernel32.dll中,CreateFile函数会调用更底层的API函数,例如NtCreateFile函数,这个函数位于Ntdll中,最后调用内核模式的处理程序来实际的创建文件,调用栈会依次经过kernel32.dll和ntdll.dll,这样是一个合法的调用堆栈。

如果调用链中出现直接访问内核模式而跳过ntdll.dll的情况,可能会引发安全警报。

通过获取ntdll的信息,我们可以检查程序在执行期间触发的异常是否发生在该DLL的有效地址空间内。

这里其实将dll的基地址和结束地址都存储在了DllInfo这个结构中。

我们来整体简单的看一下代码:

前面main函数这一部分,我们只需要关注这里调用了一个名为wrpNtCreateUserProcess函数。

我们跟进这个函数,在这个函数中首先会通过调用RetrieveSyscallAddress函数来获取到NtCreateUserProcess函数的基地址,这里其实本质上是通过GetProcAddress函数来获取的。

紧接着调用GetSSnByName函数来获取到该NtCreateUserProcess函数的系统调用号。

这里其实是参考如下链接来实现的。

https://www.mdsec.co.uk/2022/04/resolving-system-service-numbers-using-the-exception-directory/

再着调用SetHwBp函数来触发空指针异常,将其NtCreateUserProcess函数的基地址传递给了第一个参数,将其flag以及ssn系统调用号传递给了第二个和第三个参数,我们都知道参数都是保存在RCX,RDX,R8,R9寄存器中的,如果有更多的参数需要牵扯到RSP寄存器,这个我们先不去谈论。

在_SetHwBp函数这里会执行TRIGGER_ACCESS_VIOLOATION_EXCEPTION宏,这个宏会触发一个空指针异常。

我们会发现一个问题,就是触发异常的地址其实是在_SetHwBp函数中的,当触发异常之后,就需要异常处理函数来进行处理,我们开始的时候注册了两个异常处理程序。异常处理函数分别是AddHwBp和HandlerHwBp函数。

那么触发空指针异常之后,我们首先来到AddHwBp函数这里进行处理。

首先会判断是否是空指针或写入地址错误引发的异常,那么明显肯定是的。

进入IF判断之后,首先会从RCX寄存器中去获取到NtCreateUserProcess函数的基地址。

因为我们触发异常的时候是在_SetHwBp函数中的,而__SetHwBp函数的第一个参数其实就是NtCreateUserProcess函数的地址。

那么根据我们上面所说,RCX寄存器中存储着第一个参数的值,所以这里我们可以将其取出来。

取出来之后,就是循环前25个字节,来获取到syscall指令和ret指令的偏移。

获取到syscall和ret指令的偏移之后,在NtCreateUserProcess函数的基地址这里设置硬件断点,并在ret指令偏移的地址这里设置硬件断点并且启用。

然后将Rip的值跳过4个字节。

那么当我们去调用NtCreateUserProcess函数的时候就会触发硬件断点异常,导致来到异常处理函数这里。

来到第二个异常处理函数HandlerHwBp这里,在这里首先判断是否是硬件断点所引发的异常。然后判断引发异常的地址是否是NtCreateUserProcess函数的地址。

如果成功的话进入IF判断,在IF判断中首先移除硬件断点。

然后保存异常发生时的CPU上下文,以便我们后续查询相关的参数,比如RCX,RDX,R8,R9等等。

紧接着将Rip寄存器的值指向合法的函数。

然后设置单步调试标记,在EFlags寄存器中设置单步调试标记,这意味着CPU将在每执行一条指令后触发一个单步调试异常(EXCEPTION_SINGLE_STEP)。

这里所说的每一条指令指的是,比如mov指令,call指令,ret指令等等。

最后return返回EXCEPTION_CONTINUE_EXECUTION告诉操作系统继续执行程序。

那么也就是说我们最后其实执行了return EXCEPTION_CONTINUE_EXECUTION,那么这里我们可以看到用到jmp指令,所以会返回到异常处理程序开头的部分。

但是如果我们将其注释掉的话,还记得我们将RIP指向了正常的函数吗?他就会去调用正常的函数。

当来到第二次判断这里的时候,这里肯定是不成立的,因为我们触发异常的地址变了,肯定就不是NtCreateUserProcess函数的地址了。

所以接着往下走,这里会判断异常的地址是否是NtCreateUserProcess函数的ret指令的地址。显然肯定也不是的。

再来到下面的IF判断,这里判断Rip地址是否在ntdll.dll的范围内,这里肯定不在的,因为Rip的值我们已经指定为了自定义的正常函数。

最后return EXCEPTION_CONTINUE_EXECUTION,表示异常处理程序已经处理了这个异常,现在程序可以从发生异常的地方继续执行,这里其实执行到了RIP所指向的地址。

期待和您的下次相遇!!!

Relay学安全
这是一个纯分享技术的公众号,只想做安全圈的一股清流,不会发任何广告,不会接受任何广告,只会分享纯技术文章,欢迎各行各业的小伙伴关注。让我们一起提升技术。
 最新文章