直接 / 间接 Syscall 与堆栈欺骗

本文整理对 Direct Syscall、Indirect Syscall,以及 SysWhispers4 式栈顶欺骗的学习与复现:从高层 API 一路下沉到 syscall,再处理返回地址在调用栈上的异常。


一、从高层 API 到直接 Syscall

要做的事始终没变:申请内存、写入 shellcode、创建线程执行。变化的是调用链下沉到哪一层。

1. High Level:走 kernel32

最直接的做法,就是调用高层 API:

VirtualAllocmemcpyCreateThreadWaitForSingleObjectCloseHandleVirtualFree

image-20260728170904403

image-20260728172006863

2. Medium Level:改走 ntdll

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

image-20260728172403497

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

image-20260728173202280

image-20260728173700896

image-20260728184600651

image-20260728172144444

3. Low Level:自己写 stub,直接 syscall

获取 SSN

要从底层发起系统调用,得先拿到每个函数对应的 SSN。以 NtAllocateVirtualMemory 为例,在 IDA 里搜索 ntdll 可以看到 stub 的形态:

image-20260729095403723

image-20260729100022043

自定义 syscall 汇编

public 是 MASM 关键字,用来把标签暴露给外部;C++ 侧配合 extern "C",链接器才能找到它。

普通 x64 调用里,第一个参数在 rcx;而 syscall 要求第一个参数放在 r10。因此 stub 里要先做一次搬运:

1
mov r10, rcx

image-20260728190725014

image-20260728194159657

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

image-20260729093257624

image-20260729093348358

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

image-20260729102734754


二、动态获取 SSN

SSN 不宜硬编码。不同 Windows 版本上编号会变,更稳妥的做法是运行时从 ntdll 解析。

1. SysWhispers2:按名字找函数,再读 SSN

这条路径实现最简单,但也依赖 GetModuleHandleA / GetProcAddress

以动态获取 NtAllocateVirtualMemory 为例:

1
2
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
BYTE* addr = (BYTE*)GetProcAddress(hNtdll, funcName);

image-20260729112546489

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

image-20260729115937475

image-20260729123938710

2. Hell’s Gate:绕开 GetProcAddress,扫描 stub 取 SSN

(1)找 ntdll 基址

先从 PEB 入手:

1
PEB_T* peb = (PEB_T*)__readgsqword(0x60);

image-20260729145059364

image-20260729145030837

再经 LDR 找到 InMemoryOrderModuleList 的表头:

1
LIST_ENTRY* head = &peb->Ldr->InMemoryOrderModuleList;

image-20260729145450959

image-20260729145334774

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

1
2
3
4
5
1. EXE 自身
2. ntdll.dll
3. kernel32.dll
4. kernelbase.dll
5. 后续按依赖加载

image-20260729151251079

image-20260729150945583

在该节点中取出 DllBase

image-20260729152446437

(2)手动解析导出表

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

image-20260729153800440

image-20260729153119129

image-20260729153733111

image-20260729154555947

(3)扫描函数体找 SSN

Hell’s Gate 不从入口按固定偏移读 SSN,就是为了避免入口附近被 hook 后,偏移失效、读到错误数据。

Phase A:向前遍历,找 syscall

image-20260729160129882

image-20260729155807812

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

image-20260729160912690

image-20260729160401988

Phase C:验证附近存在 mov r10, rcx4C 8B D1

B8 位置继续往回扫:

image-20260729161129750

三条都匹配,才确认这是合法 stub,并返回 SSN。


三、Indirect Syscall

直接 syscall 是在自己的 exe 里执行 syscall。正常调用路径会先进入 ntdll stub,再由 ntdll 里的 syscall 进入内核;直接 syscall 则完全绕过了 ntdll。

NtAllocateVirtualMemory 为例,自写 stub 大致是:把第一个参数从 rcx 挪到 r10,把 SSN 填进 eax,然后直接 syscall

image-20260731105935677

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

image-20260731104902929

image-20260731105015673

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

因此除了 SSN,还需要拿到 ntdll 里的 syscall 地址。

image-20260731105738776

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

image-20260731110108522

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

image-20260731110815190

image-20260731110903146

image-20260731110956116


四、更稳健的 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 和下标差反推出来。

image-20260803143623133

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

image-20260803145428234

(1)升序建立 Nt* 函数表

每个函数的数据结构里记录 nameaddrssnhooked

image-20260803144914496

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

image-20260803145030747

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

image-20260803160534504

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

image-20260803145124640

(2)对被 hook 的目标找邻居推 SSN

对被 hook 的目标,向上、向下找最近一个已经有 SSN 的邻居。按地址排序后,数组下标差就等于 SSN 差:

1
2
上面邻居:目标SSN = 邻居SSN + (目标下标 - 邻居下标)
下面邻居:目标SSN = 邻居SSN - (邻居下标 - 目标下标)

两边都找一遍,选距离更近的那个邻居来推算:

image-20260803162443764

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
2
3
4
5
6
7
8
9
10
// Halo's Gate 只检查了这里:
if (stub[0] == 0xE9) {
goto neighbor_search;
}

// Tartarus' Gate 额外加一个:
if (stub[3] == 0xE9) { // 第 4 字节(0-indexed 是 stub[3])
// 前 3 字节看着正常,但第 4 字节被 hook 了
goto neighbor_search;
}

3. FreshyCalls:干脆不读 stub 立即数

Halo’s Gate / Tartarus’ Gate 的思路是“尽量从导出函数里读 SSN,读不到再靠邻居推”。FreshyCalls 换了个更干脆的做法:既然 SSN 和地址顺序一致,那就干脆不读 stub 里的立即数,直接按地址排序后自己编号。

遍历 ntdll 导出表,用 C++ 的 map 存所有 Nt / Zw 开头函数的地址和名字。地址作为键时,map 会自动按地址升序排好:

image-20260804145657718

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

image-20260804150006449

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

image-20260804161505041

image-20260804161736547


五、堆栈欺骗

这部分对照 SysWhispers4--stack-spoof 思路复现:在间接 syscall 之外,再伪造一层返回地址,让栈回溯时看起来更像从合法模块返回。

1. 为什么还要堆栈欺骗

间接 syscall 解决的是:syscall 指令本身来自 ntdll,而不是自己的 .text

但 EDR 还会做 call stack / 返回地址检查。syscall 陷入内核时,如果栈上的返回地址直接指向自己的 exe,仍然会被当成异常调用链。

  • 间接 syscall:管的是 谁在执行 syscall
  • 堆栈欺骗:管的是 syscall 结束后如何返回

两者需要叠在一起用。

2. 先看正常 API 的调用栈

VirtualAllocEx 为例,完整路径大致是:

1
2
exe → kernel32(jmp) → kernelbase → kernelbase → ntdll(syscall)
返回:ntdll → kernelbase → kernelbase → exe

三种路径的对比(正常 / 间接 syscall / 堆栈欺骗):

call_flow_compare

  1. call qword ptr ds:[<&VirtualAllocEx>]

image-20260806094715280

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

image-20260806095105671

image-20260806130443931

返回地址压入调用栈:

image-20260806094827968

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

image-20260806130552771

image-20260806130707113

  1. call <kernelbase.VirtualAllocExNuma>

跳到 kernelbase 另一处,这里再 call 到 ntdll。

image-20260806100008240

image-20260806131154539

调用栈:

image-20260806095840317

  1. call qword ptr ds:[<NtAllocateVirtualMemory>]

在 ntdll 里发起 syscall。

image-20260806100302326

image-20260806131530996

调用堆栈:

image-20260806100120704

  1. syscall 后逐层返回

第一个是 ntdll 的 ret,回到 kernelbase:

image-20260806100827205

image-20260806101033181

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

image-20260806101559213

image-20260806101630567

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

image-20260806101752682

image-20260806101802323

3. 间接 syscall 时,栈顶为什么不对

间接 syscall 在 exe 里 call 自己的 stub,栈上会压入 exe 的返回地址:

image-20260806105838035

image-20260806105822389

随后用 jmp 跳到 ntdll 的 syscalljmpcall 不同,不会再压返回地址:

image-20260806110239148

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

image-20260806110559711

image-20260806110620467

对比前面的正常路径:正常时这一层应该是 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 C3syscall; ret),取出后面的 ret 地址:

image-20260805093518806

相关栈布局

x64 调用约定里,调用者要在调用前预留 0x20 影子空间;前 4 个参数走 rcx, rdx, r8, r9,更多参数走栈。另外还要保证 16 字节对齐,所以常见写法是 sub rsp, 28h

1
2
3
4
sub rsp, 28h
; ...前4个参数放进 rcx, rdx, r8, r9
call function
add rsp, 28h ; 调用完恢复

三种栈布局对比(普通调用帧 / 间接 syscall / 欺骗后):

stack_layout_compare

ret 的行为是:弹出栈顶 8 字节到 RIP,再 RSP += 8。所以内核返回后会先落到伪造的 ntdll ret,再由它弹回 exe。对只看栈顶 / 第一层返回地址的检查来说,这一层就从 exe 变成了 ntdll。

多压 8 字节后,栈参数整体上移。syscall 会从 [rsp+28h] 起读第 5 个及以后的参数,因此要把原来的栈参数再向下复制一格:

stack_spoof_detail

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

image-20260805125108686

调试:伪装后的返回过程

exe 先 call,压入真实返回地址:

image-20260806111610374

image-20260806111635591

再压入伪造的 ntdll ret

image-20260806111934279

image-20260806112111642

syscall 结束后,返回顺序变成:

1
ntdll(syscall 后的 ret) → ntdll(伪造的 ret gadget) → exe

image-20260806124940022

这里会出现“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”和“返回时栈上长什么样”都尽量像合法模块。