间接系统调用与C2定制

· 2026-08-30 22:00 · 5 阅读

原创 pandazhengzheng 2026-08-30 22:00 广东

目录

  1. 问题背景:ntdll 的 inline hook

  2. 系统调用的底层机制

  3. Direct Syscalls(直接系统调用)

  4. Indirect Syscalls(间接系统调用)

  5. SSN 解析技术族

  6. Indirect Syscalls 的实现详解

  7. Indirect Syscalls 的检测与规避

  8. C2 通信的检测面

  9. Havoc 框架架构

  10. C2 定制关键技术

  11. Sleep Mask 与内存规避

  12. 完整用户态免杀链

  13. 小结


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 指令完成:

  1. 保存用户态 RIP 到 RCX(RCX = next instruction after syscall);

  2. 保存用户态 RFLAGS 到 R11;

  3. 清除 RFLAGS 中的 IF(关中断);

  4. 加载内核态 CS/SS(MSR.LSTAR → RIP,进入内核 syscall 入口);

  5. 内核根据 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
    ret

3.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 实现思路

  1. 解析 ntdll:找到目标 NtXxx 的 SSN;

  2. 寻找一个 syscall; ret gadget:在 ntdll 中扫描任意一个未被 hook 覆盖的 syscall 指令;

  3. 设置寄存器r10 = rcxeax = 目标 SSN

  4. 跳转到该 gadgetjmp <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)


跳转微信打开