直接系统调用与间接系统调用技术详解
1. 核心概念与背景
在Windows系统中,用户模式(User Mode)的应用程序通过系统调用(System Call)接口来请求内核模式(Kernel Mode)的服务,例如分配内存、创建线程等。这些调用通常通过ntdll.dll中的函数(如NtAllocateVirtualMemory)实现,这些函数内部包含特定的syscall指令用于切换到内核。
为了监控和防御恶意行为,终端检测与响应(EDR)等安全产品广泛采用了用户模式API Hook技术,特别是在ntdll.dll中。其常见实现方式是内联Hook(Inline Hook),即在API函数的起始处插入一条jmp指令,将执行流重定向到EDR的监控模块(如hooking.dll)。EDR在此处对参数等进行审查:若判断为合法,则跳回ntdll原函数继续执行syscall进入内核;若判断为恶意,则终止进程。
为了绕过这种Hook,攻击者发展出了直接系统调用(Direct Syscall) 和间接系统调用(Indirect Syscall) 技术。
2. 直接系统调用 (Direct Syscall)
2.1 基本原理
直接系统调用的核心思想是:不调用ntdll.dll中被Hook的函数,而是由攻击者在自己的程序中重新实现系统调用存根(Syscall Stub),即自己编写包含syscall指令的汇编代码。这样,执行syscall指令的代码位于程序自身的内存空间,而非ntdll.dll的空间,从而避开了EDR的Hook。
2.2 关键技术细节
-
获取系统调用号(SSN, System Call Number):
- 每个
Nt*函数在ntdll.dll中都有一个对应的系统调用号,存储在EAX寄存器中用于执行syscall。 - 虽然函数代码可能被Hook,但其系统调用号通常仍存储在函数体内的特定偏移处(例如,在函数开始后的第4或第5个字节)。
- 通过
GetProcAddress获取ntdll.dll中目标函数的地址,然后从该地址的固定偏移处读取SSN。 - 示例代码:
HANDLE hNtdll = GetModuleHandleA("ntdll.dll"); UINT_PTR pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtAllocateVirtualMemory"); DWORD wNtAllocateVirtualMemory = ((unsigned char*)(pNtAllocateVirtualMemory + 4))[0]; // 从特定偏移读取SSN
- 每个
-
实现汇编存根(Assembly Stub):
- 为每个需要使用的
Nt*函数编写一个汇编过程。 - 存根代码主要完成两件事:
- 将参数从RCX寄存器移动到R10寄存器(因为
syscall指令使用R10而非RCX)。 - 将之前获取的SSN移动到EAX寄存器。
- 执行
syscall指令。 - 执行
ret返回。
- 将参数从RCX寄存器移动到R10寄存器(因为
- 示例汇编代码(MASM语法):
.CODE EXTERN wNtAllocateVirtualMemory:DWORD ; 声明外部变量,存储SSN NtAllocateVirtualMemory PROC mov r10, rcx ; 参数从rcx移至r10 mov eax, wNtAllocateVirtualMemory ; SSN移至eax syscall ; 执行系统调用 ret ; 返回 NtAllocateVirtualMemory ENDP END
- 为每个需要使用的
-
动态获取SSN的进阶方法:
- 上述简单读取SSN的方式可能不稳定或易被检测。
- 更高级的技术包括:
- Hell's Gate: 解析
ntdll.dll的PE结构,从导出表中查找函数并提取SSN,避免使用可能被Hook的GetProcAddress。 - Halo's Gate: Hell's Gate的进化版,解决了系统调用号排序可能随机化的问题。
- FreshyCalls / Syswhispers2/3: 类似原理的自动化工具,用于生成直接系统调用的头文件和汇编代码。
- Hell's Gate: 解析
2.3 直接系统调用的局限性(IoC)
虽然绕过了基于Hook的检测,但直接系统调用引入了新的可疑指标(IoC),EDR可以据此进行检测:
syscall指令的来源:syscall指令在程序自身的内存空间中执行,而不是在ntdll.dll的预期内存区域中。- 调用栈异常:执行流从程序空间跳转到内核,返回地址也在程序空间,这与正常模式(
ntdll.dll-> 内核 ->ntdll.dll)不同。
3. 间接系统调用 (Indirect Syscall)
3.1 基本原理
间接系统调用是直接系统调用的进化,旨在消除上述IoC。其核心思想是:只在自己程序中实现部分存根,而关键的syscall指令和ret返回指令仍然在ntdll.dll的原始内存地址中执行。
3.2 关键技术细节
-
获取SSN和
syscall指令地址:- 与直接系统调用相同,需要先从
ntdll.dll中读取目标函数的SSN。 - 关键新增步骤:定位
ntdll.dll中目标函数内部的syscall指令的确切地址。- 通常,
syscall指令在函数体内有一个相对固定的偏移(例如,示例中为+0x12)。 - 示例代码:
UINT_PTR pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtAllocateVirtualMemory"); DWORD wNtAllocateVirtualMemory = ((unsigned char*)(pNtAllocateVirtualMemory + 4))[0]; // 获取SSN UINT_PTR syscallAddrInNtdll = pNtAllocateVirtualMemory + 0x12; // 计算ntdll中syscall指令的地址
- 通常,
- 与直接系统调用相同,需要先从
-
修改汇编存根:
- 在自己的汇编存根中,不再使用
syscall指令,而是使用无条件跳转指令(jmp) 跳转到ntdll.dll中刚才找到的syscall指令地址。 - 这样,
syscall指令本身是在ntdll.dll的合法内存空间中执行的。 - 示例汇编代码修改:
.CODE EXTERN wNtAllocateVirtualMemory:DWORD EXTERN syscallAddrNtAllocateVirtualMemory:QWORD ; 声明外部变量,存储ntdll中的syscall地址 NtAllocateVirtualMemory PROC mov r10, rcx mov eax, wNtAllocateVirtualMemory jmp syscallAddrNtAllocateVirtualMemory ; 跳转到ntdll中的syscall指令,而非自己调用 NtAllocateVirtualMemory ENDP END - 注意:此存根中没有
ret指令。执行流程是:jmp到ntdll-> 执行syscall进入内核 -> 内核返回至ntdll->ntdll中的ret指令将返回地址弹出,返回到最初调用我们自定义NtAllocateVirtualMemory函数的程序地址。这使得返回地址也在程序空间,但仍是一个潜在IoC。
- 在自己的汇编存根中,不再使用
3.3 优势与剩余的IoC
- 优势:
- 消除了直接系统调用最明显的IoC:
syscall指令不在ntdll.dll中。现在syscall指令在ntdll.dll的合法内存区域执行,对EDR来说看起来更“正常”。 ret指令也在ntdll.dll中执行。
- 消除了直接系统调用最明显的IoC:
- 剩余的/新的IoC:
- 调用栈/返回地址异常:虽然
syscall和ret在ntdll中,但ntdll中的ret指令返回到的地址是程序自身的内存空间,而不是类似kernel32.dll或user32.dll等更高级API的地址。高级的EDR通过分析调用栈(Call Stack)可以发现这种不寻常的返回路径(从ntdll直接返回到一个未知的程序内存区域)。 - 跳转行为:从程序空间
jmp到ntdll.dll中间某条指令的行为也可能被视为异常。
- 调用栈/返回地址异常:虽然
3.4 间接系统调用的局限性与应对
- ETW(Event Tracing for Windows):如果EDR启用了ETW等深度监控技术,可以捕获整个调用链的详细信息,包括返回地址。单纯的间接系统调用可能不足以绕过这类检测。
- 调用栈欺骗(Call Stack Spoofing):为了应对调用栈分析,需要配合调用栈欺骗技术。即在发起系统调用前,手动伪造堆栈上的返回地址,使其指向一个合法的系统模块(如
kernelbase.dll),让调用栈看起来更像是一次合法的API调用链。 - SSN随机化:系统调用号可能随Windows版本更新而变化,因此需要 robust 的方法(如Halo's Gate)来动态可靠地获取正确的SSN。
4. 总结对比
| 特性 | 标准API调用 (被Hook) | 直接系统调用 (Direct Syscall) | 间接系统调用 (Indirect Syscall) |
|---|---|---|---|
syscall指令位置 |
ntdll.dll |
程序自身内存空间 | ntdll.dll |
ret指令位置 |
ntdll.dll |
程序自身内存空间 | ntdll.dll |
| 返回地址路径 | ntdll -> 合法调用者 (e.g., kernel32) |
程序 -> 程序 | ntdll -> 程序 |
| 主要绕过目标 | - | 绕过ntdll的Hook |
绕过Hook并隐藏syscall来源 |
| 主要IoC | - | syscall来源、返回路径 |
返回路径(需配合调用栈欺骗) |
| 技术复杂度 | - | 中 | 中-高 |
5. 实践建议与思考
- 动态获取是关键:静态硬编码SSN和
syscall地址会导致兼容性极差,必须使用动态解析ntdll.dllPE结构的方法(Hell's/Halo's Gate)。 - 组合技术:没有银弹。间接系统调用常需与调用栈欺骗、直接内存操作(避免使用可疑API)、ETW绕过等技术结合使用,才能形成有效的规避链。
- 检测视角:从防御者角度看,监控
syscall指令的执行上下文(是否在ntdll镜像内)和分析调用栈的合理性(从内核返回后是否跳转到了非预期的模块地址)是检测此类规避手法的有效手段。 - 持续演变:这是一个猫鼠游戏。微软和EDR厂商也在不断引入新的缓解机制(如内核模式调用保护),而攻击技术也随之持续进化。