System Calls
How Win32 API calls become kernel operations: ntdll.dll stubs, syscall numbers, the SSDT, EDR hooking in ntdll, and how sophisticated malware bypasses those hooks with direct syscalls
Malware uses NtAllocateVirtualMemory followed by NtWriteVirtualMemory and NtCreateThreadEx — the NT API directly — instead of the Win32 VirtualAllocEx/WriteProcessMemory/CreateRemoteThread equivalents. More sophisticated samples skip even ntdll and execute syscall instructions inline. To understand why and what it means for detection, you need to understand how the Win32 → NT → syscall chain works.
The Windows API Layers
Windows exposes multiple API layers, each at a different abstraction level. From highest to lowest:
Windows API Hierarchy (user mode)
─────────────────────────────────────────────────────────────────
Layer 4: Win32 Wrappers
┌─────────────────────────────────────────────────────────────┐
│ kernel32.dll, advapi32.dll, shell32.dll, user32.dll │
│ CreateFile(), OpenProcess(), CreateRemoteThread() │
│ High-level, documented, argument validation, error mapping │
└──────────────────────────┬──────────────────────────────────┘
│ calls
Layer 3: NT API (Native API)
┌──────────────────────────▼──────────────────────────────────┐
│ ntdll.dll │
│ NtCreateFile(), NtOpenProcess(), NtCreateThreadEx() │
│ Direct kernel interface; minimal validation; NTSTATUS codes│
└──────────────────────────┬──────────────────────────────────┘
│ SYSCALL instruction
═══════════════════════════╪═══════════════ USER/KERNEL BOUNDARY
Layer 2: System Call Dispatcher
┌──────────────────────────▼──────────────────────────────────┐
│ KiSystemCall64 (in ntoskrnl.exe) │
│ Reads SSN from EAX → looks up in SSDT → calls handler │
└──────────────────────────┬──────────────────────────────────┘
│
Layer 1: NT Executive Implementation
┌──────────────────────────▼──────────────────────────────────┐
│ NtCreateFile(), NtOpenProcess() in the Executive │
│ Full implementation: access checks, object creation, etc. │
└─────────────────────────────────────────────────────────────┘
ntdll.dll — The Gateway
ntdll.dll is the lowest-level user-mode DLL. It's the only DLL that's guaranteed to be loaded in every Windows process. Its two primary roles:
- System call stubs: Contains a stub function for every NT API. Each stub loads a syscall number into EAX and executes
SYSCALLto transition to the kernel. - Windows runtime support: The loader (
LdrLoadDll, process initialization), the heap manager (RtlAllocateHeap), the exception dispatcher, and various runtime library functions.
ntdll vs kernel32
The relationship between kernel32 and ntdll is a wrapper/implementation split. CreateFile() in kernel32 validates arguments, converts the Windows path to an NT path (prepends \\??\\), fills out the correct IO_STATUS_BLOCK structure, and calls NtCreateFile() in ntdll. The ntdll stub doesn't validate anything — it immediately invokes the system call. Malware that calls ntdll directly bypasses kernel32's argument validation and, importantly, bypasses EDR hooks placed on kernel32 functions.
Syscall Numbers (SSNs)
Every NT API function has a corresponding System Service Number (SSN) — an integer index into the SSDT. The SSN tells the kernel dispatcher which kernel function to invoke. SSNs are not stable across Windows versions; they change between major releases and even service packs. This is why malware that hardcodes SSNs breaks on different OS versions.
| NT Function | SSN (Win10 22H2 x64) | Win11 22H2 x64 |
|---|---|---|
| NtReadFile | 0x06 | 0x06 |
| NtWriteFile | 0x08 | 0x08 |
| NtAllocateVirtualMemory | 0x18 | 0x18 |
| NtProtectVirtualMemory | 0x50 | 0x50 |
| NtCreateThreadEx | 0xC1 | 0xC2 |
| NtOpenProcess | 0x26 | 0x26 |
| NtCreateFile | 0x55 | 0x55 |
Syscall Stub Disassembly
Every ntdll syscall stub follows the same pattern. Here's the actual disassembly of NtAllocateVirtualMemory from ntdll:
; NtAllocateVirtualMemory in ntdll.dll (Win10 x64)
; Disassembly from WinDbg: u ntdll!NtAllocateVirtualMemory
NtAllocateVirtualMemory:
mov r10, rcx ; save first argument (ProcessHandle)
; SYSCALL clobbers RCX, so move it to R10 first
mov eax, 18h ; SSN = 0x18 (NtAllocateVirtualMemory on Win10 22H2)
test [7FFE0308h], byte ptr 1 ; check SharedUserData.SystemCall flag
jne short syscall_alt ; on very old systems, use INT 0x2E instead
syscall ; transition to kernel mode (Ring 3 → Ring 0)
ret ; SYSRET returns here; return NTSTATUS in RAX
syscall_alt:
int 2Eh ; legacy path for pre-Vista compatibility
ret
That's it — 5 instructions for the actual system call dispatch. The real work happens in the kernel. The stub just needs to: (1) save RCX (which SYSCALL destroys by saving the return RIP into it), (2) load the SSN, (3) execute SYSCALL.
What SYSCALL Does to Registers
| Register | Before SYSCALL | After SYSCALL (kernel entry) |
|---|---|---|
| RAX | SSN (syscall number) | SSN still in RAX; kernel reads it here |
| RCX | 1st user-mode argument | User-mode return RIP (saved by CPU) |
| R10 | Copy of original RCX | Original 1st argument (kernel reads it here) |
| R11 | Any value | User-mode RFLAGS (saved by CPU) |
| RSP | User-mode stack pointer | Kernel stack pointer (from IA32_LSTAR MSR) |
The System Service Descriptor Table (SSDT)
The SSDT is an array of kernel function pointers (stored as RVA offsets from the table base on x64). The kernel dispatcher indexes it with the SSN to find the actual kernel implementation to call.
SSDT Structure (x64, simplified) ───────────────────────────────────────────────────────────────── KiServiceTable (= KeServiceDescriptorTable.Base): Index 0x00: NtMapUserPhysicalPagesScatter → offset → ntoskrnl+0xAAAAA Index 0x01: NtWaitForSingleObject → offset → ntoskrnl+0xBBBBB Index 0x02: NtCallbackReturn → offset → ntoskrnl+0xCCCCC ... Index 0x18: NtAllocateVirtualMemory → offset → ntoskrnl+0xDDDDD ... Index 0xC1: NtCreateThreadEx → offset → ntoskrnl+0xEEEEE On 64-bit Windows, entries store a right-shifted RVA (the bottom 4 bits are used to count the number of stack arguments). The dispatcher reconstructs the full address as: KiServiceTable + (entry >> 4)
On 32-bit Windows, SSDT hooking was the dominant rootkit technique — replace an entry to redirect system calls to malicious code. On 64-bit Windows, KernelPatchGuard monitors the SSDT and triggers a 0xC0000420 bugcheck if modifications are detected, making SSDT hooking impractical without also defeating PatchGuard.
32-bit Processes on 64-bit Windows (Brief)
When a 32-bit process calls VirtualAllocEx(), it goes through a different path: the 32-bit ntdll (in C:\Windows\SysWOW64\ntdll.dll) executes a 32-bit syscall sequence. The WOW64 layer intercepts this and translates it to a 64-bit system call. This involves the "heaven's gate" technique — a far jump from 32-bit code segment (CS=0x23) to 64-bit code segment (CS=0x33) to reach the 64-bit NTDLL's system call stub. This is covered in depth in Chapter 4.
Direct Syscalls — Bypassing ntdll
EDR products typically hook ntdll functions — they overwrite the first bytes of functions like NtCreateThreadEx with a jump to their monitoring code. Direct syscalls bypass this by placing the syscall instruction inline in the malware's own code, never touching ntdll at all.
; Direct syscall example (x64 MASM) — NtAllocateVirtualMemory
; The malware defines its own syscall stub, hardcoded with the SSN
NtAllocateVirtualMemory_Direct PROC
mov r10, rcx
mov eax, 18h ; SSN must be correct for target OS version
syscall
ret
NtAllocateVirtualMemory_Direct ENDP
// C implementation using inline assembly (MSVC x64 note: no inline asm in x64 MSVC)
// Use a separate .asm file or a shellcode stub approach
// Hell's Gate — dynamically resolves SSN from ntdll at runtime
// to avoid hardcoding SSNs that break across OS versions
typedef NTSTATUS (NTAPI* pNtAllocateVirtualMemory)(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
// Hell's Gate reads the SSN directly from ntdll's stub in memory
WORD GetSyscallNumber(LPCSTR funcName) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
BYTE* stub = (BYTE*)GetProcAddress(ntdll, funcName);
// If NOT hooked: mov r10,rcx (4C 8B D1) + mov eax,SSN (B8 xx xx 00 00)
// SSN is at stub[4] as a WORD
if (stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1 &&
stub[3] == 0xB8) {
return *(WORD*)(stub + 4);
}
// If hooked (first bytes patched to JMP): Halo's Gate scans nearby stubs
return 0; // simplified
}
How EDR Products Hook ntdll
Most EDR products inject a monitoring DLL into every process and hook key ntdll functions by patching their first bytes with a JMP to the EDR's code. This lets the EDR intercept every system call attempt and inspect its arguments before the call reaches the kernel.
EDR ntdll Hook (inline hook / "trampoline hook")
─────────────────────────────────────────────────────────────────
Before EDR injection (original NtCreateThreadEx stub):
┌────────────────────────────────────────────┐
│ 4C 8B D1 mov r10, rcx │
│ B8 C1 00 00 00 mov eax, 0xC1 │
│ 0F 05 syscall │
│ C3 ret │
└────────────────────────────────────────────┘
After EDR hooks NtCreateThreadEx:
┌────────────────────────────────────────────┐
│ FF 25 00 00 00 00 jmp [rip+0] │ ← Overwrites first 14 bytes
│ AA BB CC DD EE FF 11 22 (EDR hook addr) │ with absolute indirect JMP
└─── trampoline ─────────────────────────────┘
(saved original bytes + jump back)
Flow:
Application calls NtCreateThreadEx()
→ JMP to EDR monitoring DLL
→ EDR logs call, inspects arguments
→ EDR decides: allow or block
→ If allow: JMP to trampoline (executes saved original bytes)
→ Trampoline: executes original stub (syscall)
→ Returns to application
Detecting EDR Hooks
A process can detect whether its ntdll stubs have been hooked by reading the first bytes of each stub and checking if they match the expected 4C 8B D1 B8 pattern. If the first byte is E9 (relative JMP) or FF 25 (indirect JMP), the stub has been patched by an EDR or another hook.
BOOL IsNtFunctionHooked(LPCSTR funcName) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
BYTE* func = (BYTE*)GetProcAddress(ntdll, funcName);
// Expected first bytes of unhooked NT stub:
// 4C 8B D1 = mov r10, rcx
// B8 xx xx = mov eax, SSN
if (func[0] == 0x4C && func[1] == 0x8B && func[2] == 0xD1) {
return FALSE; // not hooked
}
if (func[0] == 0xE9 || func[0] == 0xFF) {
return TRUE; // hooked (JMP at start)
}
return TRUE; // unexpected — assume hooked
}
Q & A
Why do sophisticated malware samples prefer NtCreateThreadEx over CreateRemoteThread?
Two reasons: (1) Lower-level API = fewer hooks to bypass. EDR products hook both layers, but by calling the NT API directly instead of the Win32 wrapper, the malware bypasses any logic in kernel32 (argument transformation, path conversion, Win32 error code mapping). More importantly, kernel32 itself calls ntdll, so if the malware calls ntdll directly it skips the kernel32 monitoring path entirely, potentially evading EDR hooks on kernel32 functions. (2) Reduced observable behavior. CreateRemoteThread() internally calls several Win32 functions and generates more kernel32-level telemetry. NtCreateThreadEx() is a single direct call that does the same thing with a smaller observable footprint. Detection engineering implication: your Sysmon rule for remote thread injection should match on the underlying behavior (a new thread created in a remote process), not just on the Win32 API name. Sysmon Event ID 8 (CreateRemoteThread) captures this at the kernel level regardless of whether the caller used CreateRemoteThread, NtCreateThreadEx, or a direct syscall — because Sysmon's kernel driver monitors the actual thread creation event, not the user-mode API call.
Can EDR hooks be defeated by loading a clean copy of ntdll from disk?
Yes, and this is a known evasion technique called "ntdll unhooking." The attack: read the clean (unhooked) ntdll from disk using the NT file API (not Win32 — to avoid the hooked path), map it into memory, and overwrite the modified in-memory ntdll's .text section with the clean bytes. After this, all subsequent ntdll calls go through the original, unhooked code. The key insight is that the EDR only hooks the in-memory copy of ntdll; the file on disk is unchanged. Modern EDRs defend against this in several ways: (1) using a kernel-mode driver callback (PsSetLoadImageNotifyRoutine) to re-apply hooks after any module load or modification; (2) monitoring for NtMapViewOfSection calls that map ntdll; (3) checking whether the ntdll file being loaded matches the signed, expected version; (4) running in kernel mode entirely, bypassing user-mode hooks. The arms race continues: more advanced techniques load a second instance of ntdll from a clean file handle, avoiding the Win32 layer entirely while reading from a different file copy in a temp directory.
What is Hell's Gate and why does it matter?
Hell's Gate (by am0nsec and RtlMateusz, published 2021) is a technique for resolving syscall numbers dynamically at runtime without hardcoding SSNs. The problem it solves: direct syscall malware that hardcodes SSNs like mov eax, 0x18 breaks when deployed on a different Windows version because SSNs aren't stable. Hell's Gate reads the SSN directly from the in-memory ntdll stub: it calls GetProcAddress(ntdll, funcName) to get the stub address, then reads the 2 bytes at offset +4 (the SSN operand from the mov eax, SSN instruction). It then uses that SSN in a custom syscall stub. The limitation: if ntdll is already hooked by an EDR, the first bytes are a JMP, not mov r10, rcx / mov eax, SSN — the SSN read fails. Halo's Gate (the follow-up technique) solves this by searching neighboring ntdll stubs when the target one is hooked — scanning +/- a few stubs to find unhooked ones and inferring the target's SSN from the pattern. Both techniques are now well-known to EDR vendors, who in turn monitor for the behavioral pattern of a process reading its own ntdll stub bytes.
If malware uses direct syscalls, how can EDR still detect it?
Direct syscalls bypass user-mode ntdll hooks, but EDRs have moved defenses to the kernel in response. Three detection layers that survive direct syscalls: (1) Kernel callbacks: PsSetCreateProcessNotifyRoutine, PsSetCreateThreadNotifyRoutine, ObRegisterCallbacks — these are kernel-mode notification APIs that fire regardless of how a system call was made. When NtCreateThreadEx executes in the kernel, the thread creation callback fires whether it was invoked through ntdll, via direct syscall, or any other path. (2) ETW (Event Tracing for Windows): The kernel emits ETW events for many operations (process creation, image load, network connections). Since ETW is in the kernel, it fires after the syscall dispatcher runs — again, unaffected by ntdll hook bypasses. Some EDRs rely heavily on ETW for telemetry. (3) SYSCALL address validation: When a process executes a syscall instruction, the CPU saves the user-mode RIP (return address). For legitimate ntdll-based syscalls, this RIP points into ntdll's .text section. For direct syscall malware, the RIP points into the malware's own memory. A kernel-mode EDR can check whether the syscall's return address is within ntdll's legitimate address range — if not, it's a suspicious direct syscall that deserves investigation.