版权所有 (C) 2026 Adam Zypherion <[email protected]> — 依据 GPL-3.0 许可。
间接系统调用其实很简单,只要你见过一个。加载器遍历 ntdll,从 Nt* 序言中读取 SSN,再往后找两个字节 0F 05,自行设置 r10/rax/rdx/r8/r9,然后直接 jmp 到那两个字节。系统调用从 ntdll 内部执行。你在 kernel32 导出上挂的钩子永远不会被触发。你在 ntdll 导出上挂的钩子也永远不会被触发。SYSCALL 发生时 [RSP] 指向加载器的 RWX 页面,但从外部看,这个调用没有任何异常之处。
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
这些变体各有名称。Tartarus' Gate 和 RecycledGate 使用某个桩的 syscall 指令,但 SSN 却是另一个桩的,因此即使你记录了桩名称,你的钩子报告的信息也是假的。VEH 系统调用故意触发访问违规,并利用自身 VEH 重写上下文,使 RIP 落在 ntdll 的 syscall 指令上,且寄存器已预先设置好。Hell's Gate 根本不使用 ntdll。加载器将 0F 05 写入自己的 RWX 页面,并从那里运行。
内核态(KM)的解决方案是驱动,我可能会在以后抽时间做这个并发布一个项目,但短期内不会发生 :D
PAGE_GUARD 看起来应该有效。用 PAGE_GUARD 标记存放 syscall 字节的页面,在 VEH 中捕获 STATUS_GUARD_PAGE_VIOLATION,检查,重定向到私有蹦床,设置陷阱标志,单步跳出,重新武装页面。每次 Nt 调用触发一次陷阱,无论加载器如何到达那里。它适用于除 Hell's Gate 之外的所有方式。
问题在于操作系统并不配合。PAGE_GUARD 是一次性的——这意味着每次触发后标志位会被清除,你必须重新设置。你的处理程序会执行系统调用(NtProtect 重新设置保护,NtContinue 恢复执行),而这些系统调用有桩,这些桩就在你刚保护的页面上。我用构建在私有页面上的私有系统调用桩绕过了大部分问题(分配 RWX,写入 mov r10,rcx; mov eax,SSN; syscall; ret,锁定为 RX,从不触碰 ntdll),但这很脆弱。每个 Windows 小版本都会改变时间片。示例程序每生成一个线程,都会与重建保护标志的完整性工作线程产生一次竞态。在同一台机器上运行十次,演示成功率大约在 30% 到 50% 之间。
最终,我不再试图说服 Windows 让 PAGE_GUARD 按我想要的方式工作,而是尝试改写字节。
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
syscall 指令处的字节是 0F 05。仅第一个字节(0F)是包含 SYSCALL、CPUID、RDTSC 等双字节操作码家族的 prefix。它本身不可执行;CPU 需要第二个字节才能解码。如果你将第一个字节替换为 0xCC(INT3),这对组合就变成了 CC 05,CPU 会将其解码为 INT3,后跟一个永远不会到达的 stray 字节。任何到达该地址的代码路径都会触发 EXCEPTION_BREAKPOINT。
这便是整个机制。我们的 VEH 捕获断点,查找该地址属于哪个桩(我们在初始化时通过枚举 ntdll 的导出构建了映射表),对出现的调用方执行三项检查,然后设置 Context->Rip 到一个私有蹦床,该蹦床执行真正的系统调用并返回。该字节保持为 CC。下一个调用者会以相同方式命中它,因此无需来回翻转页面保护。
要明确的是:是的,这是通过字节覆盖实现的挂钩。只是它不在人们通常所说的“ntdll 挂钩”的那个字节上。经典 EDR 挂钩覆盖的是桩的 最初 几个字节,用 JMP <my_func> 取代:
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; 覆盖 "mov r10, rcx"
...
0F 05 syscall
C3 ret
这正是间接系统调用绕过的方式。加载器自行读取 SSN,直接跳转到 0F 05,序言(以及你的 jmp)永远不会执行,挂钩永远不会触发。我们所做的是覆盖 syscall 字节本身:
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; 未触碰
B8 18 00 00 00 mov eax, 18h ; 未触碰
CC 05 int3 / 05 ; 原来是 0F 05,我们将 0F 写成了 CC
C3 ret
现在,无论你如何到达该地址——通过序言、通过跳过序言的间接跳转、还是通过 VEH 系统调用上下文重写将 RIP 直接扔到那里——只要 CPU 执行那个字节,就会触发陷阱。与经典挂钩属于同一技术族,只是位置不同,覆盖范围完全不同。
蹦床如下所示:
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <stub SSN> mov eax, ssn
0F 05 syscall
C3 ret
这三项检查与 PAGE_GUARD 版本完全相同,因为它们本来就是正确的检查;它们只是需要一个可靠的地方来运行。
返回地址。[RSP] 是系统调用本应返回的位置。对于真正的调用,它在 ntdll、kernel32、kernelbase 或相关的运行时 DLL 内部。对于 Hell's Hall,它在加载器正在运行的任何 RWX 页面内部。我们在初始化时通过调用 GetModuleHandle 获取这些名称并读取其 PE 头的 .text 范围,构建了一个受信任返回目标的简短列表。
SSN。桩的序言在到达 syscall 字节之前已经执行,因此 eax 持有加载到其中的任何值。如果加载器进行了 Tartarus 交换,该值将不会匹配我们在枚举时从同一个桩读出的 SSN。我们会记录不匹配,并且蹦床在自身 syscall 之前写入正确的 SSN,因此实际执行的内核函数是字节所属的那个,而不是加载器想要的那个。该技术在同一步骤中被记录并中和。
栈回溯。从当前上下文调用 RtlVirtualUnwind,回溯五帧。每一帧的 RIP 都应该位于已知模块内,并且应该有一个 RUNTIME_FUNCTION 条目。即使 [RSP] 本身看起来受信任(加载器可以伪造,因为它可以大致预测其调用者在内存中的位置,并在那里伪造一个可信的返回地址),Shellcode 和 ROP gadgets 也无法通过此项检查。
如果任何检查失败,我们将一个小结构体推入无锁环,转储线程下次唤醒时打印它:
[!! hallwatch !!] indirect syscall (untrusted caller, wrong ssn for this stub)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (stub encodes 0x00000018)
thread : 26388
Hell's Gate 是 INT3 也无能为力的情况。我们从未修补加载器的 RWX 页面,因为我们根本不知道它的存在。
我们采用的方法是能工作的最简单的扫描器。每 250 毫秒(实际上频率太高,浪费 CPU 周期,但仅用于 POC),完整性工作线程使用 VirtualQuery 遍历地址空间,查找任何具有可执行页面保护的 MEM_COMMIT 区域。如果该区域位于已加载模块内,则跳过。如果位于我们自己的蹦床池内,也跳过。其余的就是外来可执行内存。我们最多扫描 64 KB 区域寻找 0F 05 字节对,按地址去重,每个唯一命中记录一次。我知道这很笨,也浪费 CPU 周期,但这只是一个 POC——这不应该贬低情况,但目前确实如此。
这可以捕获位于新 RWX 分配中的 Hell's Gate,也可以捕获 shadow ntdll(加载器执行 NtMapViewOfSection 获取 ntdll.dll 的私有副本到新地址的情况)。shadow 的情况很有趣。该映射不在我们的模块快照中,因此即使它们来自磁盘上完全合法的签名 DLL,其 syscall 指令也会显示为外来可执行字节。
另一种变体:攻击者将 0F 05 烘焙到自身二进制文件的 .text 中,因此 syscall 位于已加载模块内部,而非 RWX 页面。Foreign-RWX 会跳过已加载模块,因此这种过去能溜过去。所以我们还有另一个扫描器,遍历每个模块的可执行字节,跳过 ntdll、win32u 和 wow64cpu(这三个模块合法地拥有 syscall 指令),并且仅当后续三个字节内有 C3 时才报告 0F 05。ret 检查使其可用。单独的 0F 05 经常作为较大指令的一部分出现在编译器输出中,但永远不会紧邻 C3。真正的 syscall 桩总是如此。日志:
[hallwatch] scan: module-text syscall stub at 0x00007FF7FFC9227C in HellsHall-Demo.exe -- Hell's Gate inside loaded module .text
完整性工作线程还有其他两个小任务。它每 tick 读取每个已修补桩处的字节,如果该字节不是 CC,则写回 CC。这是最简单的反取消挂钩:样本从某处(新 ntdll 映射、磁盘上的 PE、已知正确的字节表)读取原始的 0F,写回覆盖我们的补丁。我们在下一 tick 注意到并再次补丁。
每第五十 tick(大约 12.5 秒),工作线程再次在链首安装我们的 VEH 并移除之前的句柄。样本可以遍历 LdrpVectorHandlerList 并注销我们;工作线程限制了这个窗口保持打开的时间。12.5 秒在受攻击时是很长的时间,在沙箱中运行恶意软件时则是很短的时间。我可能会在测量更频繁执行的成本后缩短它。
工作线程运行时设置了一个 TLS 标志,VEH 会检查它。工作线程自身的系统调用(用于修补的 NtProtect、用于扫描的 NtQuery)会像其他调用一样触发我们的断点,但该标志告诉 VEH 跳过检测逻辑,并静默地通过蹦床重定向。
临界区在首次有人调用时通过三状态比较交换(0 = 未初始化,2 = 忙,1 = 就绪)进行自我初始化。这很丑陋,但避免了在 DLL 内部使用静态构造函数,这在 Windows 上会涉及加载器锁等另一套问题。
值得注意的一个 ABI 问题。INT3 是一个陷阱,这意味着当我们的 VEH 被调用时,Context->Rip 指向的是 下一条 指令,而不是陷阱本身。如果我们修补了 0x7FF827660372 处的字节,Context->Rip 到达 VEH 时为 0x7FF827660373。Record->ExceptionAddress 指向陷阱,但我们必须将 Context->Rip 重置为 ExceptionAddress,然后才能重定向到蹦床,否则蹦床会晚一个字节开始,系统调用什么也不做。告诉你这个,是因为这是一个花了我大量时间才找到的 bug。
有四个导出函数。IscInitialize 武装检测器;DllMain 会自动调用它,但如果你想获取返回值,也可以从宿主进程调用。IscGetDetectionCount 返回一个单调递增的计数器。IscShutdown 等待正在进行的处理程序并恢复 0F 字节。IscFlush 同步清空环,如果你将检测器嵌入沙箱,需要在样本退出前获取事件,这个函数很有用。
最小集成只需 LoadLibrary。DllMain 处理初始化并从此启动两个后台线程。
Isc = Indirect Syscalls (间接系统调用)
我们尚未捕获的情况:
进行完整性检查的样本。
使用我们未修补的桩的样本。当前的允许列表约有 40 个名称,涵盖攻击性内存、进程、线程、节、令牌和文件原语。只要 DllMain 能及时完成,添加更多名称是增量式的。从加载器锁内修补所有 488 个桩就是另一回事了……
使用内核权限运行的样本。不是用户态问题。
我们最终选择了 INT3,但陷阱机制本身不是有趣的部分。它比 PAGE_GUARD 效果更好的原因在于,陷阱不需要与操作系统进行持续的协商。字节是 CC。它保持为 CC。操作系统对哪些字节存在于 ntdll 中没有意见。