Module 7: Halo's Gate & TartarusGate Extensions
When the gate is hooked, ask the neighbors.
Beyond the Original
The original Hell's Gate fails if the target stub is hooked -- the SSN bytes are overwritten by the JMP patch. Halo's Gate (by Sektor7) and TartarusGate (by trickster0) solve this by searching neighboring syscall stubs to calculate the target SSN. This module covers the hook detection improvements, the neighbor search algorithm, and how these extensions handle various EDR hooking styles.
The Problem: Hooked Stubs
When an EDR hooks NtAllocateVirtualMemory, it overwrites the first 5+ bytes of the stub with a JMP instruction. The original Hell's Gate checks for the pattern 4c 8b d1 b8 and returns FALSE if it does not match. But the SSN information is not truly lost -- it can be recovered from the layout of ntdll's syscall region.
ASM; A hooked stub: first 5 bytes replaced by JMP
NtAllocateVirtualMemory: ; SSN = 0x18 (but we can't read it directly)
e9 xx xx xx xx jmp EDR_Hook ; EDR's JMP overwrites mov r10,rcx + b8
00 00 00 (remaining bytes of original mov eax, 0x18)
...
; BUT neighboring stubs are often still clean:
NtAccessCheckByType: ; SSN = 0x17 (one below, NOT hooked)
4c 8b d1 mov r10, rcx
b8 17 00 00 00 mov eax, 0x17 ; Clean! We can read this SSN
...
NtWorkerFactoryWorkerReady: ; SSN = 0x19 (one above, NOT hooked)
4c 8b d1 mov r10, rcx
b8 19 00 00 00 mov eax, 0x19 ; Clean! We can read this SSN
...
The Key Insight
Syscall stubs in ntdll.dll are laid out contiguously in memory, each approximately 32 bytes apart (the exact size depends on the Windows version). Since SSNs are assigned sequentially, if you find a clean neighbor stub N positions away, you can calculate the target SSN as neighbor_SSN +/- N. EDRs typically only hook a subset of functions (20-50 out of ~470), so clean neighbors are almost always available within a few positions.
Halo's Gate: Neighbor Search
Halo's Gate (introduced by Sektor7) extends Hell's Gate with a bidirectional search. When a stub is hooked, it walks up and down through adjacent stubs looking for one that is clean:
C// Halo's Gate neighbor search algorithm
// 'pFunctionAddress' points to the hooked stub
// Each stub is approximately 0x20 (32) bytes apart in memory
BOOL HalosGateResolve(PVOID pFunctionAddress, PWORD pSSN) {
BYTE* stub = (BYTE*)pFunctionAddress;
// First, try the direct read (original Hell's Gate check)
if (stub[0] == 0x4c && stub[1] == 0x8b && stub[2] == 0xd1 && stub[3] == 0xb8) {
*pSSN = *(WORD*)(stub + 4);
return TRUE; // Stub is clean
}
// Stub is hooked -- search neighbors
// Search both UP (higher addresses, higher SSNs)
// and DOWN (lower addresses, lower SSNs)
for (WORD delta = 1; delta < 500; delta++) {
// Search DOWN (neighbor with SSN = target - delta)
BYTE* neighborDown = stub - (delta * 0x20); // 0x20 = stub size
if (neighborDown[0] == 0x4c &&
neighborDown[1] == 0x8b &&
neighborDown[2] == 0xd1 &&
neighborDown[3] == 0xb8) {
// Found clean neighbor below
WORD neighborSSN = *(WORD*)(neighborDown + 4);
*pSSN = neighborSSN + delta; // Target is 'delta' positions above
return TRUE;
}
// Search UP (neighbor with SSN = target + delta)
BYTE* neighborUp = stub + (delta * 0x20);
if (neighborUp[0] == 0x4c &&
neighborUp[1] == 0x8b &&
neighborUp[2] == 0xd1 &&
neighborUp[3] == 0xb8) {
// Found clean neighbor above
WORD neighborSSN = *(WORD*)(neighborUp + 4);
*pSSN = neighborSSN - delta; // Target is 'delta' positions below
return TRUE;
}
}
return FALSE; // All nearby stubs are hooked (very unlikely)
}
Halo's Gate Neighbor Search
4c 8b d1 b8 16 00 ← SSN 0x16 (clean)4c 8b d1 b8 17 00 ← SSN 0x17 (clean)e9 xx xx xx xx ← HOOKED (SSN unknown)4c 8b d1 b8 19 00 ← SSN 0x19 (clean)e9 xx xx xx xx ← HOOKEDIn the diagram above, the target stub (SSN 0x18) is hooked. Halo's Gate finds the clean neighbor at delta=1 below (SSN 0x17) and calculates: 0x17 + 1 = 0x18. Alternatively, it could use the neighbor at delta=1 above (SSN 0x19) and calculate 0x19 - 1 = 0x18.
TartarusGate: Improved Hook Detection
TartarusGate (by trickster0) improves the hook detection logic. The original Hell's Gate and Halo's Gate only check byte 0 for 0xE9 (a JMP hook). But some EDRs place their JMP hook at byte 3 (offset 0x03), right after the 3-byte mov r10, rcx instruction. This produces the byte pattern 4c 8b d1 e9 -- the stub starts correctly but has a JMP where the mov eax, SSN opcode (0xB8) should be. TartarusGate adds a check for this pattern:
C// TartarusGate hook detection
// Checks byte 0 AND byte 3 for JMP hooks
BOOL IsStubHooked(BYTE* stub) {
// Check 1: Classic JMP hook (first byte is E9)
// EDR overwrites from byte 0: e9 xx xx xx xx ...
if (stub[0] == 0xe9)
return TRUE;
// Check 2: JMP hook at byte 3 (after mov r10, rcx)
// Some EDRs preserve the 3-byte "mov r10, rcx" but place
// a JMP where "mov eax, SSN" should be:
// 4c 8b d1 e9 xx xx xx xx (hooked)
// vs expected clean pattern:
// 4c 8b d1 b8 XX XX 00 00 (clean)
// Halo's Gate only checks byte 0 for 0xe9, missing this hook style
if (stub[3] == 0xe9)
return TRUE;
return FALSE; // Stub appears clean
}
TartarusGate's Byte 3 Check
The key improvement in TartarusGate is checking byte 3 for 0xE9 (JMP). In a clean stub, byte 3 is always 0xB8 (the opcode for mov eax, imm32). Some EDRs hook by preserving the initial mov r10, rcx (bytes 0-2: 4c 8b d1) but placing a JMP at byte 3 to redirect execution before the SSN is loaded. Hell's Gate and Halo's Gate only check byte 0 for 0xE9, so they see the correct 0x4C at byte 0 and assume the stub is clean -- but the SSN at bytes 4-5 is actually part of the JMP displacement, giving a bogus value. TartarusGate catches this by also checking byte 3.
Comparison of Gate Techniques
| Technique | Author | Hook Detection | SSN Recovery | Handles |
|---|---|---|---|---|
| Hell's Gate | am0nsec / RtlMateusz | Checks 4c 8b d1 b8 prefix | Direct read only | Clean stubs only; fails on hooked |
| Halo's Gate | Sektor7 | Same as Hell's Gate | Neighbor search (up/down by 0x20) | Hooked stubs via neighbor calculation |
| TartarusGate | trickster0 | Extended: also checks byte 3 for 0xE9 (JMP after mov r10, rcx) | Neighbor search + improved detection | Classic JMP hooks at byte 0 and JMP hooks at byte 3 |
Edge Cases and Pitfalls
Stub Size Variability
The neighbor search assumes a fixed stub size (typically 0x20 = 32 bytes). However, the actual stub size can vary slightly between Windows versions. On some builds, stubs include additional padding or a different test/jnz sequence. A more robust implementation would use the EAT to find neighboring function addresses directly, rather than assuming a fixed byte offset. This ensures correct neighbor identification regardless of stub size.
SysWhispers Variants
While not gate techniques per se, the SysWhispers family uses different approaches to the same SSN resolution problem and is worth understanding for context:
C// SysWhispers2: Sorts Zw* exports by address to derive SSN
// Since stubs are laid out in SSN order in memory,
// the Nth export (sorted by address) has SSN = N
// Step 1: Collect all Zw* export addresses from the EAT
typedef struct { PVOID addr; DWORD64 hash; } SYSCALL_ENTRY;
SYSCALL_ENTRY entries[MAX_ENTRIES];
DWORD count = 0;
for (DWORD i = 0; i < pExportDir->NumberOfNames; i++) {
PCHAR name = (PCHAR)(base + pdwAddrOfNames[i]);
if (name[0] == 'Z' && name[1] == 'w') {
entries[count].addr = (PVOID)(base + pdwAddrOfFunctions[pwOrdinals[i]]);
entries[count].hash = djb2((PBYTE)name);
count++;
}
}
// Step 2: Sort by address (ascending)
qsort(entries, count, sizeof(SYSCALL_ENTRY), cmpAddr);
// Step 3: SSN = index in sorted array
for (DWORD i = 0; i < count; i++) {
if (entries[i].hash == targetHash) {
resolvedSSN = (WORD)i; // Position = SSN
break;
}
}
Address Sorting vs. Stub Reading
The SysWhispers2 approach of sorting Zw* exports by address is an elegant alternative to reading stub bytes. Since stubs are laid out in SSN order, the function with the lowest address has SSN 0, the next has SSN 1, and so on. This works even when stubs are hooked because it only reads the EAT (function addresses), not the stub bytes themselves. The trade-off is that it requires enumerating and sorting all ~470 Zw* exports, whereas Hell's Gate only reads the specific stubs it needs.
Pop Quiz: Gate Extensions
Q1: In Halo's Gate, if the target stub is hooked and the nearest clean neighbor is 3 positions below with SSN 0x15, what is the target SSN?
Q2: What improvement does TartarusGate add over Halo's Gate?
0xE9 (JMP). Some EDRs preserve the 3-byte mov r10, rcx instruction but place a JMP at byte 3 (where mov eax, SSN should start). Halo's Gate only checks byte 0 for 0xE9 and misses this hooking pattern. TartarusGate catches it by also checking byte 3.Q3: Why does the SysWhispers2 address-sorting approach work even when stubs are hooked?