System Calls and SSDT: How User Mode Reaches the Kernel
Objective: Understand how Windows user-mode code transitions to ring 0 via the
SYSCALLinstruction, how the System Service Descriptor Table (SSDT) dispatches those calls, and why SSDT hooking, direct syscalls, and modern kernel hardening (PatchGuard, HVCI, MWTI ETW) are central to both offensive tradecraft and defensive telemetry.
Contents
- 1 1. Why System Calls Exist
- 2 2. The Mechanics of SYSCALL on x64
- 3 3. KiSystemCall64: The Kernel Entry Point
- 4 4. The SSDT and KSERVICE_TABLE_DESCRIPTOR
- 5 5. The x64 Encoded-Offset Format
- 6 6. Tracing a Syscall End-to-End: NtOpenProcess
- 7 7. Wow64 and Heaven’s Gate
- 8 8. SSDT Hooking: The Classic Rootkit Technique
- 9 9. PatchGuard (KPP) and Why SSDT Hooking Died
- 10 10. Direct and Indirect Syscalls (Modern Red Team TTPs)
- 11 11. Common Attacker Techniques
- 12 12. Defensive Strategies & Detection
- 13 13. Tools for Syscall and SSDT Analysis
- 14 14. MITRE ATT&CK Mapping
- 15 Summary
- 16 Related Tutorials
- 17 References
1. Why System Calls Exist
User-mode code runs at CPL 3 (ring 3). The kernel runs at CPL 0 (ring 0). Privileged operations — opening another process, mapping physical pages, accessing the file system, talking to drivers — require ring 0. The CPU enforces this with segment descriptors and page-table permissions; a direct CALL into kernel memory from user mode faults immediately.
The bridge is a controlled transition: the user-mode side specifies what it wants by number, the CPU switches to ring 0 at a fixed, kernel-controlled entry point, and the kernel validates and dispatches. That number is the System Service Number (SSN), and the dispatch table is the SSDT.
This design has two consequences that drive everything in this post:
- The kernel entry point is fixed and well-known, so an attacker who can write to ring 0 memory (a kernel rootkit) can redirect every syscall by patching one table.
- The user-mode side of the syscall (the stub in
ntdll.dll) is not privileged, so an EDR can hook it — and a red teamer can bypass that hook by issuing theSYSCALLinstruction themselves.
2. The Mechanics of SYSCALL on x64
SYSCALL is a dedicated x86-64 instruction designed for fast ring-3 → ring-0 transitions. It does not use the legacy interrupt gate (int 2Eh); it reads MSRs and jumps.
| MSR | Address | Role |
|---|---|---|
IA32_LSTAR | 0xC0000082 | Kernel RIP to jump to on SYSCALL from 64-bit user mode. Holds KiSystemCall64 (or KiSystemCall64Shadow with KPTI). |
IA32_STAR | 0xC0000081 | Encodes the kernel and user CS/SS selectors for SYSCALL/SYSRET. |
IA32_FMASK | 0xC0000084 | RFLAGS mask — bits cleared on entry (notably IF, masking interrupts during the prologue). |
The x64 Windows syscall ABI:
EAXholds the SSN (the index intoKiServiceTable).R10holds the first argument. The user-mode stub copiesRCXintoR10becauseSYSCALLitself clobbersRCXwith the returnRIP.RDX,R8,R9, then stack — match the standard x64 calling convention for the remaining arguments.
A minimal user-mode stub, exactly as ntdll lays it out:
; NtFooBar — illustrative ntdll-style syscall stub (x64)
NtFooBar:
mov r10, rcx ; SYSCALL clobbers RCX; preserve arg0 in R10
mov eax, 0x???? ; SSN — VERSION-SPECIFIC, resolve at runtime
syscall ; ring-3 -> ring-0 via LSTAR
ret ; SYSRET returns hereThe 32-bit predecessor was SYSENTER (with entry stored in IA32_SYSENTER_EIP). On modern 64-bit Windows, SYSENTER is only relevant inside the Wow64 path.

3. KiSystemCall64: The Kernel Entry Point
When the CPU executes SYSCALL from user mode:
- It loads
RIPfromIA32_LSTAR(→KiSystemCall64). - It loads
CS/SSfromIA32_STAR(kernel selectors). - It saves the old user
RIPinRCXand oldRFLAGSinR11. - It clears
RFLAGSbits perIA32_FMASK.
KiSystemCall64 then:
- Swaps
GSviaSWAPGSto access the per-CPUKPCR. - Switches from the user stack to the kernel stack stored in the
KPCR. - Builds a
KTRAP_FRAMEcapturing the user context. - Indexes
KeServiceDescriptorTable(or the Shadow variant for Win32k GUI calls) usingEAX. - Calls the resolved
Nt*function. - On return, restores the frame and executes
SYSRETto drop back to ring 3.
Selected KTRAP_FRAME fields (see WDK wdm.h for the full layout):
| Field | Description |
|---|---|
Rip | Saved user-mode instruction pointer (from RCX at entry). |
Rsp | Saved user-mode stack pointer. |
EFlags | Saved RFLAGS (from R11). |
ErrCode | Processor error code; 0 for syscalls. |
With Kernel Page-Table Isolation (KPTI) active, IA32_LSTAR points instead at KiSystemCall64Shadow, a thin trampoline that swaps from the user CR3 (which maps only a minimal kernel trampoline) to the full kernel CR3 before falling through into the normal dispatcher. This is the Meltdown mitigation.
4. The SSDT and KSERVICE_TABLE_DESCRIPTOR
The “SSDT” in casual use refers to two related objects:
| Symbol | Description |
|---|---|
KeServiceDescriptorTable | Exported KSERVICE_TABLE_DESCRIPTOR. Covers the core Nt* services in ntoskrnl.exe. |
KeServiceDescriptorTableShadow | Not exported. Adds a second entry for win32k!W32pServiceTable — the GUI/USER/GDI syscall surface. Rootkits historically located it by pattern scanning around KeAddSystemServiceTable or via debugger symbols. |
KiServiceTable | The actual function-pointer table referenced by the descriptor. |
KiArgumentTable | Parallel array of argument byte counts per service. |
Approximate layout from public symbols:
typedef struct _KSERVICE_TABLE_DESCRIPTOR {
PULONG_PTR ServiceTable; // -> KiServiceTable (encoded offsets on x64)
PULONG CounterTable; // call counters (typically NULL in retail)
ULONG TableSize; // number of services
PUCHAR ArgumentTable; // bytes of stack args per service
} KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;The SSN (EAX) is split: the low 12 bits index the table, and bit 12 selects which descriptor — 0 for KeServiceDescriptorTable, 1 for the Win32k shadow table. This is how GUI syscalls (NtUserCreateWindowEx, NtGdiBitBlt, …) coexist with kernel-proper syscalls in the same SSN space.

5. The x64 Encoded-Offset Format
A critical detail anyone writing an SSDT scanner gets wrong the first time: on x64 Windows, KiServiceTable entries are not function pointers. Each entry is a 32-bit value encoding a signed offset from the base of KiServiceTable itself, with the low 4 bits used to communicate the argument-count category to the dispatcher.
The decode is:
// Recover the real Nt* function address from KiServiceTable[i]
ULONG_PTR DecodeSsdtEntry(PULONG ServiceTable, ULONG index)
{
LONG encoded = (LONG)ServiceTable[index]; // signed 32-bit
LONG offset = encoded >> 4; // arithmetic shift
return (ULONG_PTR)ServiceTable + offset; // base + offset
}The arithmetic right shift matters — it preserves the sign, allowing functions located before KiServiceTable in memory to be addressed. A naive unsigned >> 4 will silently miss those entries and produce a corrupt scanner.
6. Tracing a Syscall End-to-End: NtOpenProcess
Following an OpenProcess call from a user-mode debugger target:
kernel32!OpenProcess
└─> kernelbase!OpenProcess
└─> ntdll!NtOpenProcess ; the syscall stub
mov r10, rcx
mov eax, <SSN> ; version-specific
syscall
ret
─────────── ring 3 / ring 0 boundary ───────────
CPU: RIP <- LSTAR (KiSystemCall64[Shadow])
nt!KiSystemCall64
├─ SWAPGS, switch to kernel stack
├─ build KTRAP_FRAME
├─ idx = EAX & 0xFFF
├─ desc = (EAX & 0x1000) ? Shadow : KeServiceDescriptorTable
├─ fn = desc->ServiceTable + (desc->ServiceTable[idx] >> 4)
└─ call nt!NtOpenProcess
nt!NtOpenProcess
├─ ObReferenceObjectByName / ByHandle
├─ SeAccessCheck (DesiredAccess vs token)
└─ ObOpenObjectByPointer -> HANDLE
SYSRET back to user-mode RIP saved in RCXThe SSN for NtOpenProcess changes between Windows builds; never hardcode it. Tooling either resolves it from the on-disk ntdll.dll, parses the in-memory stub, or consults a versioned table such as j00ru’s syscall reference.
A practical SSN extractor parses the Nt* export’s first instructions and reads the MOV EAX, imm32 (B8 xx xx xx xx) byte pattern:
# Parse SSNs from a clean on-disk ntdll.dll (illustrative)
import pefile, struct
pe = pefile.PE(r"C:\Windows\System32\ntdll.dll", fast_load=False)
pe.parse_data_directories()
image = pe.get_memory_mapped_image()
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
name = exp.name.decode() if exp.name else ""
if not name.startswith("Nt"):
continue
stub = image[exp.address: exp.address + 24]
# Classic stub: 4C 8B D1 B8 ss ss 00 00 F6 04 25 ... 0F 05 C3
if stub[0:3] == b"\x4c\x8b\xd1" and stub[3] == 0xB8:
ssn = struct.unpack("<I", stub[4:8])[0]
print(f"{name:40s} SSN=0x{ssn:04x}")Red-team loaders use the same idea at runtime — sometimes against a fresh copy of ntdll read from disk to defeat in-memory EDR hooks (the “Perun’s Fart” / fresh-copy pattern).
7. Wow64 and Heaven’s Gate
A 32-bit process on 64-bit Windows still ultimately issues a 64-bit SYSCALL, because the only kernel entry the CPU honors from a 64-bit process is KiSystemCall64. The Wow64 layer bridges this:
32-bit app -> wow64cpu!CpupReturnFromSimulatedCode
-> far jmp 0x33:<addr> ; CS=0x23 (32-bit) -> CS=0x33 (64-bit)
-> wow64.dll / 64-bit ntdll
-> SYSCALLThe 0x33 / 0x23 CS selector switch is the so-called Heaven’s Gate (community label, not an official Microsoft term). Malware abuses it to:
- Execute 64-bit shellcode from a process that defenders are monitoring as a 32-bit target.
- Issue syscalls that bypass 32-bit ntdll hooks if the EDR only instruments the Wow64 layer.
Analysts should treat any unexpected far jmp to CS=0x33 in 32-bit code as a strong IOC.
8. SSDT Hooking: The Classic Rootkit Technique
Pre-Vista x64, kernel rootkits manipulated KiServiceTable directly:
- Locate the descriptor (
KeServiceDescriptorTableis exported; the Shadow descriptor was pattern-scanned). - Disable write protection (clear
CR0.WP) or remap the page as writable. - Save the original entry for the target SSN (e.g.,
NtQueryDirectoryFile,NtEnumerateValueKey). - Overwrite the entry with a pointer to attacker code.
- The hook calls the original after filtering results — hiding files, registry keys, processes, or network connections.
The illustrative read-only inspection (do not modify) inside a signed test driver:
extern PKSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable;
VOID DumpSsdtSizeAndSample(VOID)
{
PKSERVICE_TABLE_DESCRIPTOR d = KeServiceDescriptorTable;
PULONG table = (PULONG)d->ServiceTable;
DbgPrint("[SSDT] TableSize = %lu\n", d->TableSize);
for (ULONG i = 0; i < 4 && i < d->TableSize; i++) {
LONG enc = (LONG)table[i];
ULONG_PTR addr = (ULONG_PTR)table + (enc >> 4);
DbgPrint("[SSDT] [%lu] encoded=0x%08x -> 0x%p\n", i, enc, (PVOID)addr);
}
}
// Reading LSTAR to confirm KiSystemCall64[Shadow]
VOID DumpLstar(VOID)
{
ULONG64 lstar = __readmsr(0xC0000082);
DbgPrint("[MSR] IA32_LSTAR = 0x%llx (KiSystemCall64[Shadow])\n", lstar);
}Live inspection from WinDbg on a kernel-debugged target:
0: kd> dt nt!_KSERVICE_TABLE_DESCRIPTOR nt!KeServiceDescriptorTable
0: kd> dq nt!KeServiceDescriptorTable L4
0: kd> dd nt!KiServiceTable L20
0: kd> u poi(nt!KiServiceTable) L5
0: kd> rdmsr c00000829. PatchGuard (KPP) and Why SSDT Hooking Died
Since x64 Vista, Kernel Patch Protection periodically validates a set of protected structures, including KiServiceTable, IDT, GDT, MSR_LSTAR, kernel image code sections, and several driver objects. On mismatch, KPP issues bugcheck 0x109 — CRITICAL_STRUCTURE_CORRUPTION. The checks run from randomized timers and contexts to resist disablement.
The practical result:
- SSDT hooking is no longer a viable persistence or hiding primitive on supported 64-bit Windows. Any survival window is short and ends in a BSOD.
- Modern kernel-mode attackers use driver callbacks (
PsSetCreateProcessNotifyRoutine,ObRegisterCallbacks, minifilters) rather than SSDT patching, because those are the supported extension points and are not policed by KPP. - With HVCI/Memory Integrity enabled, even loading the malicious driver is gated: kernel pages cannot be both writable and executable, and unsigned kernel code cannot enter ring 0 at all. The hypervisor enforces this at the EPT level — PatchGuard becomes a second line, not the first.
10. Direct and Indirect Syscalls (Modern Red Team TTPs)
Because KPP closed the kernel-side door, evasion moved into user mode. Many EDRs hook the Nt* stubs in ntdll.dll by overwriting the first bytes with a JMP into their inspection DLL. Two techniques bypass that:
- Direct syscalls. The loader embeds its own
mov eax, ssn; syscall; retstub in attacker memory and calls it instead ofntdll!NtXxx. The hooked ntdll is never touched. SSNs are resolved at runtime (parsing ntdll, sortingNt*exports by address — the “Hell’s Gate” / “Halo’s Gate” patterns). - Indirect syscalls. The
mov eax, ssnhappens in attacker memory, but thesyscallinstruction itself is reached by jumping to thesyscallbyte sequence insidentdll.dll. The kernel-side return address therefore points back into ntdll, matching what legitimate code looks like in stack-walk telemetry.
The detection signal flips between the two:
| Technique | What it bypasses | What still sees it |
|---|---|---|
| Direct syscall | ntdll user-mode hooks | Stack walk shows syscall from unbacked / private memory. |
| Indirect syscall | ntdll hooks and naive stack-walk checks | Kernel ETW (Microsoft-Windows-Threat-Intelligence) sees the syscall regardless of where it was issued from. |
ETW-TI is the answer to indirect syscalls: it fires from inside the kernel dispatcher, after the SYSCALL has already landed in KiSystemCall64, so the user-mode evasion is irrelevant.

11. Common Attacker Techniques
| Technique | Description |
|---|---|
| SSDT hook (legacy) | Overwrite KiServiceTable[SSN] to filter results for hiding rootkit artifacts; killed by PatchGuard on x64. |
| Shadow SSDT hook | Same against W32pServiceTable to intercept GUI/keyboard/clipboard syscalls. |
| Direct syscall stub | Embedded mov eax, ssn; syscall in attacker memory to bypass ntdll hooks. |
| Indirect syscall | Jump to the syscall gadget inside ntdll so call stacks look legitimate. |
| Hell’s Gate / Halo’s Gate | Runtime SSN resolution by parsing/sorting Nt* exports in mapped ntdll. |
| Fresh-copy ntdll | Read clean ntdll.dll from disk to re-derive unhooked stubs and SSNs. |
| Heaven’s Gate | Far jump from 32-bit (CS=0x23) to 64-bit (CS=0x33) to execute 64-bit syscalls from a Wow64 process. |
| Driver-based hooking | Where HVCI is off, signed-but-vulnerable drivers (“BYOVD”) are used to write to MSRs or protected pages. |
12. Defensive Strategies & Detection
The detection model has shifted from “watch the SSDT” (PatchGuard already does that) to watch how syscalls are issued from user mode and consume kernel ETW.
Sysmon
| Event ID | Field | Why it matters |
|---|---|---|
1 | ParentImage, CommandLine | Baseline; correlates injection target lineage. |
10 | GrantedAccess, CallTrace | The CallTrace field is the primary direct-syscall tell — legitimate stacks contain ntdll.dll; direct syscalls show UNKNOWN(...) or RWX private memory regions. |
25 | — | Process image tampering / hollowing. |
Sigma — direct-syscall NtOpenProcess against LSASS
title: Process Access to LSASS via Direct Syscall (Unbacked Call Stack)
id: 8d0c2a4e-syscall-lsass-unbacked
status: experimental
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 10
TargetImage|endswith: '\lsass.exe'
GrantedAccess:
- '0x1010'
- '0x1410'
- '0x1fffff'
unbacked:
CallTrace|contains:
- 'UNKNOWN'
- 'UNKNOWN('
filter_legit:
SourceImage|endswith:
- '\MsMpEng.exe'
- '\MsSense.exe'
condition: selection and unbacked and not filter_legit
level: high
tags:
- attack.credential_access
- attack.t1003.001
- attack.t1106ETW Providers Worth Subscribing To
| Provider | Use |
|---|---|
Microsoft-Windows-Threat-Intelligence | Kernel ETW provider exposing AllocVm, ProtectVm, MapViewOfSection, ReadVm/WriteVm events. Fires from inside the kernel dispatcher, so direct and indirect syscalls are still visible. Consumer must run as PPL. |
Microsoft-Windows-Kernel-Process | Process and thread creation, image loads. |
Microsoft-Windows-Kernel-Audit-API-Calls | Audits selected Nt API calls (verify against current SDK). |
Audit Policy
- Audit Sensitive Privilege Use — catches
SeDebugPrivilegeenabling, a near-universal precursor to syscall-based cross-process injection. - Audit Process Creation with command-line capture.
- Audit Handle Manipulation with object SACLs on
lsass.exe.
Hardening
- HVCI / Memory Integrity — single highest-value control. Blocks unsigned and W^X-violating kernel code; defeats BYOVD primitives that try to disable PatchGuard, patch the SSDT, or clear
CR0.WP. - VBS + Credential Guard — keeps LSASS secrets off the path even if a syscall reaches
NtOpenProcess. - KPTI — Meltdown mitigation; also implies
KiSystemCall64Shadowis the LSTAR target. - Driver Signature Enforcement + Microsoft vulnerable-driver blocklist — limits BYOVD options.
- EDR ntdll instrumentation — still valuable as a low-cost filter against commodity malware; layer with kernel ETW for the sophisticated cases.
13. Tools for Syscall and SSDT Analysis
| Tool | Description | Link |
|---|---|---|
| WinDbg | Kernel debugger; resolves nt!KeServiceDescriptorTable, nt!KiServiceTable, reads MSRs via rdmsr. | learn.microsoft.com |
| Process Hacker | Live handle, thread, and module inspection; surfaces RWX private memory regions. | processhacker.sourceforge.io |
| Process Monitor | Boot-time and runtime Nt* activity captured via minifilter. | learn.microsoft.com |
| SysmonView / Sysmon | EID 10 CallTrace, EID 25 telemetry. | learn.microsoft.com |
| HollowsHunter / pe-sieve | Detects unbacked / hollowed / patched modules — strong correlator for direct-syscall loaders. | github.com/hasherezade |
| SwishDbgExt | WinDbg extension with SSDT dumping and decode of the encoded-offset format. | github.com |
| Volatility 3 | Memory forensics; windows.ssdt plugin walks the descriptor and decodes entries. | volatilityfoundation.org |
| j00ru syscall tables | Authoritative per-version SSN reference. | j00ru.vexillium.org |
| SilkETW / SealighterTI | User-friendly consumers for ETW providers including Microsoft-Windows-Threat-Intelligence. | github.com |
14. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Native API | T1106 | EID 10 CallTrace containing UNKNOWN; ETW-TI AllocVm/ProtectVm from unbacked memory. |
| Process Injection | T1055 | Cross-process NtAllocateVirtualMemory + NtWriteVirtualMemory + NtCreateThreadEx chain via ETW-TI. |
| DLL Injection | T1055.001 | EID 7/8 plus ETW-TI write/protect events into a remote PID. |
| PE Injection | T1055.002 | RWX private allocations followed by remote thread creation. |
| Process Hollowing | T1055.012 | NtUnmapViewOfSection followed by NtWriteVirtualMemory into the primary image base. |
| Rootkit | T1014 | PatchGuard 0x109 bugchecks; SSDT integrity scans in memory forensics. |
| Impair Defenses: Disable/Modify Tools | T1562.001 | Driver loads with revoked or vulnerable signatures; HVCI/DSE violations. |
Summary
- Every Windows syscall is a
SYSCALLinstruction that lands atKiSystemCall64viaMSR_LSTARand is dispatched throughKiServiceTableusing theEAXSSN. - The SSDT on x64 stores encoded offsets, not raw pointers —
base + (entry >> 4)— and theEAXbit 12 selects between the core and Win32k Shadow tables. - PatchGuard killed SSDT hooking on x64; modern offense has moved to direct and indirect syscalls in user mode and to BYOVD when ring 0 is required.
- HVCI/VBS is the strongest defense against the kernel half; kernel ETW (
Microsoft-Windows-Threat-Intelligence) is the strongest defense against direct/indirect syscalls because it fires after the transition. - Detect with Sysmon EID 10
CallTrace(unbacked memory in the stack), enrich with ETW-TI, and map to MITRE T1106 / T1055 for response.
Related Tutorials
- User Mode vs Kernel Mode: Privilege Rings and the Boundary
- Fibers: User-Mode Cooperative Threads
- Access Tokens and Privileges: The Kernel’s Security Context
- APCs: Asynchronous Procedure Calls and Thread Hijacking Surface
- DPCs: Deferred Procedure Calls and Interrupt Deferral
References
- Using Nt and Zw Versions of the Native System Services Routines — Microsoft Learn (Windows Drivers)
- Libraries and Headers (Ntdll.dll & System Calls) — Microsoft Learn (Windows Drivers)
- Native API (T1106) — MITRE ATT&CK Enterprise
- Input Capture: Credential API Hooking (T1056.004) — MITRE ATT&CK Enterprise
- Glimpse into SSDT in Windows x64 Kernel — Red Team Notes (ired.team)
- Exploring Malicious Windows Drivers (Part 1): Introduction to the Kernel and Drivers — Cisco Talos Intelligence
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups — no spam, unsubscribe anytime.