直接与间接Syscall及堆栈欺骗
直接 / 间接 Syscall 与堆栈欺骗
本文整理对 Direct Syscall、Indirect Syscall,以及 SysWhispers4 式栈顶欺骗的学习与复现:从高层 API 一路下沉到 syscall,再处理返回地址在调用栈上的异常。
一、从高层 API 到直接 Syscall
要做的事始终没变:申请内存、写入 shellcode、创建线程执行。变化的是调用链下沉到哪一层。
1. High Level:走 kernel32
最直接的做法,就是调用高层 API:
VirtualAlloc → memcpy → CreateThread → WaitForSingleObject → CloseHandle → VirtualFree


2. Medium Level:改走 ntdll
下一步不再依赖 kernel32 导出,而是用 GetModuleHandle 拿到 ntdll,再用 GetProcAddress 按名字解析函数地址:

先声明对应 Nt* 函数签名,把解析到的地址转成可调用函数,再把原先的 kernel32 API 全部换成 ntdll 版本:




3. Low Level:自己写 stub,直接 syscall
获取 SSN
要从底层发起系统调用,得先拿到每个函数对应的 SSN。以 NtAllocateVirtualMemory 为例,在 IDA 里搜索 ntdll 可以看到 stub 的形态:


自定义 syscall 汇编
public 是 MASM 关键字,用来把标签暴露给外部;C++ 侧配合 extern "C",链接器才能找到它。
普通 x64 调用里,第一个参数在 rcx;而 syscall 要求第一个参数放在 r10。因此 stub 里要先做一次搬运:
1 | mov r10, rcx |


接着用自己写好的 syscall 版函数,替换原先走导入表的 API:


再用 dumpbin 看导入表,相关 Nt* 已不再依赖常规导入:

二、动态获取 SSN
SSN 不宜硬编码。不同 Windows 版本上编号会变,更稳妥的做法是运行时从 ntdll 解析。
1. SysWhispers2:按名字找函数,再读 SSN
这条路径实现最简单,但也依赖 GetModuleHandleA / GetProcAddress。
以动态获取 NtAllocateVirtualMemory 为例:
1 | HMODULE hNtdll = GetModuleHandleA("ntdll.dll"); |

定位到导出函数入口后,addr + 4 处就是 mov eax, SSN 里的立即数:


2. Hell’s Gate:绕开 GetProcAddress,扫描 stub 取 SSN
(1)找 ntdll 基址
先从 PEB 入手:
1 | PEB_T* peb = (PEB_T*)__readgsqword(0x60); |


再经 LDR 找到 InMemoryOrderModuleList 的表头:
1 | LIST_ENTRY* head = &peb->Ldr->InMemoryOrderModuleList; |


链表第二个节点就是 ntdll。Windows 加载器映射模块的大致顺序是:
1 | 1. EXE 自身 |


在该节点中取出 DllBase:

(2)手动解析导出表
从可选头取出导出表 RVA,加上基址得到导出表地址;再遍历名称表,匹配目标函数并返回其地址:




(3)扫描函数体找 SSN
Hell’s Gate 不从入口按固定偏移读 SSN,就是为了避免入口附近被 hook 后,偏移失效、读到错误数据。
Phase A:向前遍历,找 syscall


Phase B:从 syscall 往回找 mov eax, imm32


Phase C:验证附近存在 mov r10, rcx(4C 8B D1)
从 B8 位置继续往回扫:

三条都匹配,才确认这是合法 stub,并返回 SSN。
三、Indirect Syscall
直接 syscall 是在自己的 exe 里执行 syscall。正常调用路径会先进入 ntdll stub,再由 ntdll 里的 syscall 进入内核;直接 syscall 则完全绕过了 ntdll。
以 NtAllocateVirtualMemory 为例,自写 stub 大致是:把第一个参数从 rcx 挪到 r10,把 SSN 填进 eax,然后直接 syscall:

动态调试可以看到,真正执行 syscall 的地址落在 exe 自己的 .text 段,而不是 ntdll。


这种调用源异常,很容易被 EDR 的 call stack / 返回地址检查盯上。间接 syscall 的思路是:参数和 SSN 仍然自己准备,但最终跳到 ntdll 地址空间里的 syscall 去执行,让进入内核的那条指令看起来来自合法模块。
因此除了 SSN,还需要拿到 ntdll 里的 syscall 地址。

实现上,在遍历函数体、解析 SSN 的同时扫描指令字节,找到 0F 05(即 syscall)并记下地址即可:

完整路径在调试里也很清楚:先从自己的代码跳进 ntdll 的 syscall,再真正陷入内核;返回时同样从 ntdll 回来。



四、更稳健的 SSN 解析:应对 Hook
Hell’s Gate 一类做法,是直接从 stub 里读 mov eax, SSN。如果 EDR 把入口 hook 掉了,这段字节就读不到,SSN 解析也会失败。后面几种方法,都是在解决这个问题。
1. Halo’s Gate:用邻居推算
Windows 给 syscall 编号时有个规律:SSN 与 ntdll 里 stub 的地址顺序基本一致——地址越小,SSN 越小。把 Nt* stub 按地址升序排好后,表里相邻的两项,也是相邻的 syscall 号。某个函数被 hook、读不出 SSN 时,就可以借助邻居的 SSN 和下标差反推出来。

需要注意的是,导出表遍历顺序本身并不严格按地址升序,个别函数会乱序。因此不能直接拿导出表下标当 SSN,必须自己按地址排一次。

(1)升序建立 Nt* 函数表
每个函数的数据结构里记录 name、addr、ssn、hooked:

遍历 ntdll 导出表,筛出 Nt 开头的函数:

再判断入口是否被 hook。常见特征包括:入口是 E9 / EB / FF 25 这类跳转,或者前三个字节不是正常的 mov r10, rcx(4C 8B D1)。

把能解析到的信息写入函数表:未被 hook 的可以直接从 stub 读出 SSN,被 hook 的先打上标记。最后按地址升序排序。

(2)对被 hook 的目标找邻居推 SSN
对被 hook 的目标,向上、向下找最近一个已经有 SSN 的邻居。按地址排序后,数组下标差就等于 SSN 差:
1 | 上面邻居:目标SSN = 邻居SSN + (目标下标 - 邻居下标) |
两边都找一遍,选距离更近的那个邻居来推算:

2. Tartarus’ Gate:补上“假正常入口”
Halo’s Gate 主要看入口有没有被改。但有些 EDR 的 hook 会保留前 3 个字节的 mov r10, rcx,只从第 4 个字节开始改成跳转。入口看起来还像正常 stub,Halo’s Gate 就会误判成“没被 hook”,接着去读已经被改掉的 SSN,结果自然是错的。
Tartarus’ Gate 在此基础上多判一步:第 4 个字节是不是 E9(相对跳转)。如果是,同样按被 hook 处理,走邻居搜索分支。
1 | // Halo's Gate 只检查了这里: |
3. FreshyCalls:干脆不读 stub 立即数
Halo’s Gate / Tartarus’ Gate 的思路是“尽量从导出函数里读 SSN,读不到再靠邻居推”。FreshyCalls 换了个更干脆的做法:既然 SSN 和地址顺序一致,那就干脆不读 stub 里的立即数,直接按地址排序后自己编号。
遍历 ntdll 导出表,用 C++ 的 map 存所有 Nt / Zw 开头函数的地址和名字。地址作为键时,map 会自动按地址升序排好:

排完后从 0 开始依次赋 SSN。这样拿到的编号只依赖 stub 在内存里的布局顺序,不再依赖入口那几个字节有没有被改,也就摆脱了是否被 hook 的影响。

调试对比可以看到,推出来的 SSN 和 ntdll 原始 stub 里的编号一致。


五、堆栈欺骗
这部分对照 SysWhispers4 的 --stack-spoof 思路复现:在间接 syscall 之外,再伪造一层返回地址,让栈回溯时看起来更像从合法模块返回。
1. 为什么还要堆栈欺骗
间接 syscall 解决的是:syscall 指令本身来自 ntdll,而不是自己的 .text。
但 EDR 还会做 call stack / 返回地址检查。syscall 陷入内核时,如果栈上的返回地址直接指向自己的 exe,仍然会被当成异常调用链。
- 间接 syscall:管的是 谁在执行
syscall - 堆栈欺骗:管的是 syscall 结束后如何返回
两者需要叠在一起用。
2. 先看正常 API 的调用栈
以 VirtualAllocEx 为例,完整路径大致是:
1 | exe → kernel32(jmp) → kernelbase → kernelbase → ntdll(syscall) |
三种路径的对比(正常 / 间接 syscall / 堆栈欺骗):

call qword ptr ds:[<&VirtualAllocEx>]

call 先到 kernel32;kernel32 里只有一个 jmp,本身不会再压新的返回地址。


返回地址压入调用栈:

jmp 进 kernelbase 后,再发起下一个 call。


call <kernelbase.VirtualAllocExNuma>
跳到 kernelbase 另一处,这里再 call 到 ntdll。


调用栈:

call qword ptr ds:[<NtAllocateVirtualMemory>]
在 ntdll 里发起 syscall。


调用堆栈:

- syscall 后逐层返回
第一个是 ntdll 的 ret,回到 kernelbase:


第二个是 kernelbase 的 ret,回到 kernelbase 另一处:


第三个再从 kernelbase 回到 exe 里 call 的下一条:


3. 间接 syscall 时,栈顶为什么不对
间接 syscall 在 exe 里 call 自己的 stub,栈上会压入 exe 的返回地址:


随后用 jmp 跳到 ntdll 的 syscall。jmp 和 call 不同,不会再压返回地址:

于是内核返回、执行完 stub 里真正的 ret 时,栈顶直接就是 exe:


对比前面的正常路径:正常时这一层应该是 kernelbase;间接 syscall 不做欺骗时,这一层变成了自己的 exe。这正是堆栈欺骗要修的点。
4. SysWhispers4 的做法:只伪装栈顶
SysWhispers4 的 --stack-spoof 核心思路很直接:在发起 syscall 前,把栈上可见的返回地址换成一个指向 ntdll 的合成地址,把真实返回地址藏到更下面一层。文档里把它描述成 synthetic return address,用来对付只看近端调用帧的 stack-walking。
这里复现的也是这一层,并不是完整复刻 VirtualAllocEx 那条 kernel32 → kernelbase → ntdll 调用链。
实现上,除了 SSN 和 ntdll 里的 syscall 地址,还要再找一个 ntdll 里的 ret 当跳板。可以扫 ntdll 的 .text,匹配 0F 05 C3(syscall; ret),取出后面的 ret 地址:

相关栈布局
x64 调用约定里,调用者要在调用前预留 0x20 影子空间;前 4 个参数走 rcx, rdx, r8, r9,更多参数走栈。另外还要保证 16 字节对齐,所以常见写法是 sub rsp, 28h。
1 | sub rsp, 28h |
三种栈布局对比(普通调用帧 / 间接 syscall / 欺骗后):

ret 的行为是:弹出栈顶 8 字节到 RIP,再 RSP += 8。所以内核返回后会先落到伪造的 ntdll ret,再由它弹回 exe。对只看栈顶 / 第一层返回地址的检查来说,这一层就从 exe 变成了 ntdll。
多压 8 字节后,栈参数整体上移。syscall 会从 [rsp+28h] 起读第 5 个及以后的参数,因此要把原来的栈参数再向下复制一格:

以 NtCreateThreadEx 为例,一共 11 个参数,需要把第 5 到第 11 共 7 个参数下移,并在 rsp 填入 ntdll 的 ret 地址。

调试:伪装后的返回过程
exe 先 call,压入真实返回地址:


再压入伪造的 ntdll ret:


syscall 结束后,返回顺序变成:
1 | ntdll(syscall 后的 ret) → ntdll(伪造的 ret gadget) → exe |

这里会出现“ntdll → 另一个 ntdll → exe”,是因为伪造地址选的是裸 ret 跳板,不是真实的 kernelbase 调用帧。它能把栈顶伪装成来自 ntdll,但不会长成前面分析的那条完整 VirtualAllocEx 返回链。
5. 局限
SysWhispers4 文档也写得很清楚:这种 --stack-spoof 只隐藏 immediate caller(栈顶这一层)。如果 EDR 继续往下走,真实的 exe 帧还在;并且如果伪造地址前面并不是合法的 call 返回点,深度检查仍可能识破。
文档里提到的更进一步方向包括:
- 多层栈伪造(multi-layer spoofing):不只改栈顶,而是构造更完整的假调用帧
- 选用合法 call site:伪造地址落在某条
call指令之后,经得起“返回地址是否对应真实调用点”的校验 - gadget / 更完整的 call stack spoofing:让栈看起来更接近正常的
kernelbase → ntdll路径
这些已经超出本次复现范围,可以作为后续继续往下学的方向。
六、小结
| 技术 | 解决什么问题 |
|---|---|
| Direct Syscall | 绕过 user-mode hook 的 ntdll / kernel32 导出函数 |
| 动态取 SSN(Hell’s Gate 等) | 避免硬编码,适配不同系统版本 |
| Halo / Tartarus / FreshyCalls | 在 stub 被 hook 时仍能可靠拿到 SSN |
| Indirect Syscall | 让真正执行 syscall 的指令落在 ntdll |
| 栈顶欺骗(SysWhispers4) | 让近端返回地址看起来来自 ntdll,而不是 exe |
整条线可以概括成:先把调用路径下沉到内核入口附近,再让“谁在执行 syscall”和“返回时栈上长什么样”都尽量像合法模块。