一、核心用途
1.1 项目架构
SysWhispers4是一个Windows NT系统调用桩代码(stub)生成器,它的核心目标是:绕过AV/EDR 在ntdll.dll上设置的用户态钩子,直接或间接地发起系统调用。
项目架构
1 | SysWhispers4/ |
| 层级 | 解决的问题 | 代表技术 | 核心作用 |
|---|---|---|---|
| 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 | #include <Windows.h> |
在bitdefender环境下,用x64dbg运行编译的shellcode加载器,查看它的内存模块,多出了两个可疑的dll:atcuf64.dll和bdhkm64.dll

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

正常来说未被Hook的Nt或Zw函数的stub都是按照x64 syscall 约定要求,进入内核前把第一个参数从 rcx 复制到 r10,并把系统调用号放入 Rax。
1 | mov r10,rcx |
其jmp 7FF84E170600 ,表明ZwCreateThreadEx函数入口被改写成跳转了到其他地方。
1 | jmp 7FF84E170600 |
所以可以肯定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拦截。

换个思路,我们不再通过NtCreateThreadEx 创建新线程,而是使用纤程切换执行shellcode,避免触发“新线程执行可疑内存”这一类行为特征。
1 | #include <stdio.h> |
可以看到轻松绕过,这也侧面说明问题并不只在于“有没有调用 CreateThread”或“有没有经过被 Hook 的 ntdll stub”。即使绕开了用户态hook,安全产品仍然可以从更高层的行为链路进行判断,例如:
- 进程内出现可执行内存;
- 内存权限从RW/RX/RWX发生异常变化;
- shellcode在非正常模块区域执行;
- 调用栈、返回地址或执行区域异常;
- 行为序列符合典型 loader 特征。
换句话说,系统调用并不是万能的。它主要解决的是绕过 AV/EDR 在 ntdll.dll 上设置的 inline hook这一类问题,但并不能天然绕过行为分析、内存扫描、调用栈检测或内核侧遥测。

二、SSN 解析策略(怎么找到syscall编号)
2.1 FreshyCalls
原理:FreshyCalls不直接读取ntdll 函数入口中的 mov eax, SSN 指令,而是枚举 ntdll.dll 导出表中的 Nt* 系列函数,并按照函数导出地址从低到高排序。在常见Windows x64版本中,系统调用stub在 ntdll 中通常按照SSN递增顺序排列,因此排序后的数组下标可以被用来推算对应函数的SSN。
优点:实现简单,只枚举导出表,只使用导出名和导出地址,不读取函数入口字节。
①解析ntdll的 PE 导出表

这一步的目的就是:拿到ntdll中所有导出函数的名字和地址。
②收集所有 Nt* 导出

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


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

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

测试一下在有Bitdefender inline hook的情况下,是否还能正常获取SSN。可以看到下图我们计算出的NtAllocateVirtualMemory SSN = 24,而x64dbg调试出的NtAllocateVirtualMemory SSN = 0x18 = 24,没毛病。
计算出的NtCreateThreadEx SSN = 201 = 0xC9,而NtCreateThreadEx被Hook了,我们看它的下一个邻居
NtCreateThreadStateChange SSN = 0xCA,刚好是NtCreateThreadEx SSN的下一个编号。

2.2 Hell’s Gate
核心思路:从ntdll导出表找到Nt* 函数地址,直接读函数入口的机器码字节,扫描 4C 8B D1 B8()模式,提取紧随的 2 字节作为 SSN。
1 | 4C 8B D1 mov r10, rcx ; 保存第1个参数(rcx 是 Win64 调用约定的参数1) |
项目作者在注释说了,它有一个致命问题: EDR hook会覆盖函数入口字节(4C 8B D1 B8 → E9 XX XX XX XX),模式匹配失败,SSN就拿不到了。

NtWriteVirtualMemory被Hook的情况

未被Hook能正确获取SSN

2.3 Halo’s Gate
Halo’s Gate是在Hell’s Gate的基础上发展起来的一种变体,和Hell’s Gate一样,读函数入口字节提取SSN。但被hook时不放弃,而是搜索附近的干净邻居函数,用邻居的SSN推算自己的SSN。
Windows的系统调用号(SSN)在内存中是连续且严格按照导出函数地址顺序排列的。如果目标函数被Hook了,它通常只会影响该函数开头的几个字节,而它在内存中向上或向下相邻的系统调用函数大概率是干净的(未被 Hook)。
①遍历 ntdll 的导出表,过滤出所有 Nt* 函数,按导出地址排序

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

③如果当前函数被 Hook(未匹配到特征码),就搜索目标函数的上下干净邻居的SSN
- delta(距离):控制搜索的步长
- dir(方向):
dir = -1代表向低地址(索引减小)方向找。dir = 1代表向高地址(索引增大)方向找。
内层的dir循环每次步进2(dir += 2),所以dir的取值序列严格为:-1,然后是1。当外层delta增长时,实际检查的数组索引ni顺序如下:
delta = 1, dir = -1\rightarrowni = myIdx - 1(紧邻的上一个)delta = 1, dir = 1\rightarrowni = myIdx + 1(紧邻的下一个)delta = 2, dir = -1\rightarrowni = myIdx - 2(上两个)delta = 2, dir = 1\rightarrowni = myIdx + 2(下两个)
在上下邻居中找找标准的Windows x64 Syscall Stub汇编特征码
1 | mov r10, rcx ; 4C 8B D1 |
找到之后,用统一公式计算目标函数的SSN:ssn = neighborSsn - (delta * dir),完美融合了向上和向下两种完全相反的搜索逻辑。
具体算法我不想多说了,问ai吧。

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

2.4 Tartarus’ Gate
它在Halo’s Gate的基础上引入了更严苛的挂钩判定函数(IsHooked),并且将邻居搜索半径扩大到了16。
在Halo’s Gate中,判断一个函数是否被挂钩的标准是:只要没匹配到 4C 8B D1 B8 特征,程序就默认它被挂钩了,直接去盲找邻居。
而Tartarus’ Gate更加严谨:用一个辅助函数 IsHooked 来主动判断Nt函数的开头几个字节是否被Hook,这个辅助函数是Tartarus’ Gate 的精华,它罗列了主流安全软件最常用的几种Hook指令:
pFn[0] == 0xE9u(Near JMP):直接强行跳转到EDR自身的检测模块pFn[0] == 0xFFu && pFn[1] == 0x25u(Far/Absolute JMP):如果EDR自己的内存模块离ntdll的距离超过了E9跳转的2GB限制,它就必须借用RIP相对寻址pFn[0] == 0xE8u(CALL):有些EDR会在这里放一个CALL指令进入检测函数,检测完后再通过ret返回或直接在函数内部还原上下文。pFn[0] == 0xCCu(INT 3):在敏感函数头放一个0xCC,一旦触发,就通过注册的向量化异常处理(VEH)捕捉异常,在异常回调里审查调用栈。pFn[0] == 0xEBu(Short JMP):由于ntdll许多空白字节空间,EDR 先通过一个短跳转 (EB) 跳到附近几字节外的空闲区,再在空闲区拼接一个大跳转。
代码流程与Halo’s Gate大致一样,就是
- 通过 PEB 定位 ntdll,遍历 EAT 收集所有 Nt* 导出
- 按 VA 排序(插入排序),排序索引近似 SSN 顺序
- 对每个目标函数:
- 先用 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 的实现

主流程代码

运行效果

说实话,与其主动猜测AV/EDR的Hook指令,不如检查前4字节是不是 4C 8B D1 B8,且没有合理的Syscall头部特征,就一律判定为Hooked。
2.5 SyscallsFromDisk
既然内存中的ntdll可能被hook,那就直接从磁盘上读一份干净的原始ntdll.dll。
ntdll.dll的数据来源有两个途径
\KnownDlls\ntdll.dll:Windows启动时会把系统DLL预映射到\KnownDlls\这个Section Object里,用 NtOpenSection + NtMapViewOfSection直接映射干净副本,连磁盘都不用读。但不是所有系统都有这个section。磁盘直接读(上面方法失效后的fallback方案,SysWhispers4未实现这个途径):CreateFileW(“C:\Windows\System32\ntdll.dll”) 打开磁盘文件,CreateFileMappingW + MapViewOfFile映射到内存,磁盘上的ntdll.dll永远是干净的

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

运行效果

2.6 RecycledGate
在前面介绍了FreshyCalls方案:只靠排序获取SSN,但是它需要手动排除一些非系统调用的Nt函数
而HellsGate只靠读字节获取SSN,不过当系统调用是stub被AV/EDR hooked了,那么就找不到 4C 8B D1 B8 的特征字节序列了。
RecycledGate把两种方法交叉验证:Nt函数排序后给一个候选SSN,特征字节序列匹配后给一个SSN,不一致时根据stub是否被hook决定信任哪个。
大致流程就是:
- 枚举所有Nt导出
- 按地址排序
- FreshyCalls获取SSN
- Hell’s Gate获取SSN,进行交叉验证,如果一致,则获取的SSN可信度最高。
- 不一致怎么办:不是常见Hook,就相信HellsGate方案的SSN,如果被Hook了,就相信FreshyCalls方案的SSN
枚举所有Nt导出

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

用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可信度高

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

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

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 | jmp 7FF84E170600 |
代码实现细节可以参考项目源码,毕竟SysWhispers4是开源项目,下面就通过动态调试验证3个目标
- DR0是否被设置成目标Nt函数的syscall指令的地址
- 是否触发异常,且CPU执行流程跳转到VEH处理函数
- 即使目标系统调用stub被Hook的情况下,EAX/RAX存储的还是SSN
验证1:DR0是否被设置成目标Nt函数的syscall指令的地址。
其实也不用特意下断点,我的目标是 NtAllocateVirtualMemory,如果DR0正确设置成stub的syscall指令的地址,x64dbg会停在这里。可以观察到 DR0 = 00007FF96FFB06C2,而 NtAllocateVirtualMemory 的syscall指令地址 = 00007FF96FFB06C2。

验证2:是否触发异常,且CPU执行流程跳转到VEH处理函数
首先在VehHandler下一个断点

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

验证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。

运行效果

三、调用方式(syscall 指令怎么执行)
| 方法 | syscall 在哪 | RIP 位置 | 抗静态扫描 | 抗动态检测 | 实现复杂度 |
|---|---|---|---|---|---|
| Embedded | 本进程 .text |
非 ntdll |
弱 | 弱 | 低 |
| Indirect | ntdll 固定地址 |
ntdll |
中 | 中 | 中 |
| Randomized | ntdll 随机地址 |
ntdll |
中 | 强 | 高 |
| Egg | 本进程运行时替换 | 非 ntdll |
最强 | 弱 | 高 |
3.1 Embedded
原理: syscall指令写在自己的代码里,直接执行。例如下面的汇编代码
1 | NtAllocateVirtualMemory PROC |
检测特征: RIP不在ntdll内,EDR通过RIP位置就能判断这不是正常调用。

3.2 Indirect
原理: SSN还是在自己代码里加载,但不执行syscall,而是jmp到ntdll里的 syscall;ret 的gadget。例如下面的汇编代码
1 | NtAllocateVirtualMemory PROC |
检测特征: RIP在ntdll内,看起来像正常调用。但每次调用跳到同一个固定地址,行为模式可被统计。

3.3 Randomized
原理: 在ntdll的可执行段中扫描所有0F 05 C3(syscall;ret),收集到 64 个的池子里。每次调用时用rdtsc(CPU时间戳计数器)做随机源,从池中随机选一个。例如下面的汇编代码
1 | NtAllocateVirtualMemory PROC |
检测特征: 每次调用的RIP地址不同,行为模式随机化,难以通过单一地址或调用频率检测。

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

四、总结
SysWhispers4的核心价值在于:通过动态获取SSN,并选择不同的syscall调用方式,绕过AV/EDR在 ntdll.dll 上设置的用户态inline hook。
但系统调用并不是万能的。它只能减少某些API hook带来的检测风险,并不能天然绕过行为分析、内存扫描、调用栈检测或内核侧监控。
从测试结果也可以看到,即使使用直接或间接系统调用创建线程执行shellcode,仍然可能被Bitdefender拦截。这说明安全产品关注的不只是调用了哪个API,而是完整的行为链路,例如可执行内存、异常执行区域、线程创建、调用栈和内存权限变化等。
因此,SysWhispers4 更适合作为理解Windows syscall、用户态Hook 和 EDR 检测机制的研究工具。它解决的是如何绕过 ntdll inline hook,而不是如何无条件绕过 EDR。
这篇文章也是本月的最后一篇。下个月计划继续写两篇文章,可能会先发在先知社区,然后再同步到个人博客。
第一篇文章会围绕 Convert2Shellcode 项目的完整重构方案展开,重点包括:
- 运行上下文构造
- 用户参数传递
- Rust程序不能直接转换为 shellcode 的问题(TLS Data)
- 调用DLL指定导出函数
- 清除旧PE数据
- 抹除映射后的PE特征
- x86 EXE转shellcode的支持
Convert2Shellcode与Iris Beacon睡眠混淆冲突的解决思路
第二篇文章会重点讨论Iris Beacon的功能重构设计,这里暂时不过多展开。
最后一个月,继续压榨一下自己,争取再给各位师傅带来两篇文章。回头看,从3月份开始每个月至少写一篇,居然也一路坚持到了现在,确实有点泪目 >.<