间接系统调用与C2定制
原创 pandazhengzheng 2026-08-30 22:00 广东

目录
问题背景:ntdll 的 inline hook
系统调用的底层机制
Direct Syscalls(直接系统调用)
Indirect Syscalls(间接系统调用)
SSN 解析技术族
Indirect Syscalls 的实现详解
Indirect Syscalls 的检测与规避
C2 通信的检测面
Havoc 框架架构
C2 定制关键技术
Sleep Mask 与内存规避
完整用户态免杀链
小结
1. 问题背景:ntdll 的 inline hook
1.1 hook 的位置
EDR 在 ntdll.dll 的每个关键 Nt*/Zw* 函数序言安装 inline hook,将控制权转到 EDR 的处理例程。典型 hook 形式(x64):
原始 NtXxx 序言:
mov r10, rcx
mov eax, <SSN>
syscall
ret
被 hook 后:
jmp <edr_handler> ; 或 mov rax, addr; jmp rax
<原始字节备份在 trampoline>1.2 为什么前几节未解决
第二节(APC/Fiber)解决"执行载体",但 shellcode 内部仍调用
ntdll;第三节(Module Stomping/Call Stack Spoofing)解决"内存与栈特征",但 shellcode 调用
ntdll时控制权仍经过被 hook 的序言。
只要 shellcode 调用 ntdll!NtXxx,inline hook 就触发。绕过 hook 的唯一方式是不经过 ntdll 的 stub 发起系统调用——这就是 syscall 类技术的核心。
1.3 系统调用的本质
ntdll!NtXxx 只是系统调用的用户态包装:设置 SSN 到 EAX、设置 r10、执行 syscall 指令进入内核。**syscall 指令本身才是系统调用的入口**,ntdll 的 stub 只是方便调用。若攻击者自己 emit mov r10, rcx; mov eax, SSN; syscall; ret,即可不经过 ntdll 发起系统调用。
2. 系统调用的底层机制
2.1 syscall 指令的语义
x64 syscall 指令完成:
保存用户态 RIP 到 RCX(
RCX = next instruction after syscall);保存用户态 RFLAGS 到 R11;
清除 RFLAGS 中的 IF(关中断);
加载内核态 CS/SS(
MSR.LSTAR→ RIP,进入内核 syscall 入口);内核根据 EAX(SSN)查
SSDT/SSDTShadow派发到对应内核例程。
2.2 SSN(System Service Number)
每个系统服务在 SSDT 中有一个序号(SSN)。ntdll!NtXxx 的 stub 中 mov eax, <SSN> 即设置该序号。SSN 因 Windows 版本与补丁而异,不能硬编码。
2.3 ntdll stub 的标准形式
x64 下,几乎所有 NtXxx stub 形式统一:
mov r10, rcx ; Windows 内核 syscall 约定:参数1通过 r10 传递(因为 syscall 会破坏 rcx)
mov eax, <SSN> ; 系统服务号
syscall ; 进入内核
ret ; 返回(部分函数后有 ret 0/ret 4,但 Nt* 通常是 ret)少数函数(如 NtCreate* 等需保存更多寄存器)可能有额外 prologue,但 syscall 前必有 mov r10, rcx; mov eax, SSN。
3. Direct Syscalls(直接系统调用)
3.1 原理
Direct Syscalls 在 shellcode 内部直接 emit mov r10, rcx; mov eax, SSN; syscall; ret 序列,不经过 ntdll 的 stub,从而不触发其上的 inline hook。
3.2 实现
shellcode 内嵌汇编(或 C 内联汇编):
NTSTATUS MyNtAllocateVirtualMemory(HANDLE ProcessHandle, PVOID *BaseAddress, ...){
// 设置参数(x64 调用约定:rcx=ProcessHandle, rdx=BaseAddress, ...)
// 然后执行 syscall
__asm__ volatile(
"mov r10, rcx\n"
"mov eax, %0\n"// SSN
"syscall\n"
:
: "i" (SSN_NtAllocateVirtualMemory)
: "rax", "r10", "rcx", "r11", "memory"
);
}或用动态构造的 shellcode stub:
direct_syscall:
mov r10, rcx
mov eax, [ssn] ; 动态填入 SSN
syscall
ret3.3 致命缺陷:调用栈暴露
当 syscall 指令执行进入内核后,**syscall 的返回地址(即 syscall 指令的下一条指令地址)落在 shellcode 所在的私有内存**。EDR 在内核态(ETW-TI、回调)或 hook 处理时回溯调用栈,会发现:
syscall返回地址在MEM_PRIVATE(shellcode 内部);合法系统调用的返回地址应在
ntdll的 stub 内(syscall指令的下一条是ret,地址在ntdll)。
这是极强的异常信号:合法进程的系统调用返回地址几乎必然在 ntdll,而 Direct Syscalls 的返回地址在私有内存,一目了然。
3.4 Direct Syscalls 的适用与局限
绕过用户态 hook:✓ 完全绕过(不经过 stub);
调用栈暴露:✗ 返回地址在私有内存;
ETW-TI 仍感知:✗ 内核埋点仍记录系统调用(但 ETW-TI 不直接检查返回地址,主要靠后续栈回溯);
适用场景:仅当 EDR 不做调用栈检查时有效;现代 EDR 普遍检查,故 Direct Syscalls 已不够。
4. Indirect Syscalls(间接系统调用)
4.1 核心思路
Indirect Syscalls 在 Direct Syscalls 基础上修复"返回地址暴露"问题:让 syscall 指令本身从一个合法的、未被 hook 的 ntdll 区域执行,使得 syscall 的返回地址落在 ntdll 内。
4.2 关键观察
ntdll 中每个 NtXxx stub 末尾都是 syscall; ret。即便其序言被 hook 覆写,stub 末尾的 syscall; ret 通常未被覆写(hook 一般只覆写序言前几字节,跳转到 handler,handler 内部调用 trampoline 执行原始序言 + 原始 syscall)。
因此,ntdll 中存在大量 syscall; ret gadget——可以是任意 NtXxx 的末尾,不限于目标函数。
4.3 实现思路
解析 ntdll:找到目标
NtXxx的 SSN;寻找一个
syscall; retgadget:在ntdll中扫描任意一个未被 hook 覆盖的syscall指令;设置寄存器:
r10 = rcx,eax = 目标 SSN;跳转到该 gadget:
jmp <ntdll 中的 syscall; ret>。
此时:
syscall指令在ntdll内执行,返回地址落在ntdll内(gadget 的ret后一条指令地址);未经过被 hook 的 stub 入口,inline hook 不触发;
系统调用号通过
eax传入,内核正确派发到目标服务。
4.4 与 Direct Syscalls 的对比
维度 | Direct Syscalls | Indirect Syscalls |
|---|---|---|
绕过用户态 hook | ✓ | ✓ |
syscall返回地址 | 私有内存(暴露) | ntdll 内(合法) |
调用栈最近一层 | 异常 | 合法 |
实现复杂度 | 低 | 中 |
依赖 ntdll 完整性 | 否(自己 emit) | 是(需读 ntdll 找 gadget) |