System Calls and SSDT: How User Mode Reaches the Kernel

Objective: Understand how Windows user-mode code transitions to ring 0 via the SYSCALL instruction, 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.


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 the SYSCALL instruction 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.

MSRAddressRole
IA32_LSTAR0xC0000082Kernel RIP to jump to on SYSCALL from 64-bit user mode. Holds KiSystemCall64 (or KiSystemCall64Shadow with KPTI).
IA32_STAR0xC0000081Encodes the kernel and user CS/SS selectors for SYSCALL/SYSRET.
IA32_FMASK0xC0000084RFLAGS mask — bits cleared on entry (notably IF, masking interrupts during the prologue).

The x64 Windows syscall ABI:

  • EAX holds the SSN (the index into KiServiceTable).
  • R10 holds the first argument. The user-mode stub copies RCX into R10 because SYSCALL itself clobbers RCX with the return RIP.
  • 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 here

The 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.


Flow diagram showing the sequence from user-mode code through the ntdll SYSCALL stub, CPU MSR-driven transition, KiSystemCall64 kernel entry point, SSDT dispatch, and final Nt* function execution
A single SYSCALL instruction bridges ring 3 and ring 0, with EAX carrying the SSN that indexes KiServiceTable for dispatch.

3. KiSystemCall64: The Kernel Entry Point

When the CPU executes SYSCALL from user mode:

  1. It loads RIP from IA32_LSTAR (→ KiSystemCall64).
  2. It loads CS/SS from IA32_STAR (kernel selectors).
  3. It saves the old user RIP in RCX and old RFLAGS in R11.
  4. It clears RFLAGS bits per IA32_FMASK.

KiSystemCall64 then:

  • Swaps GS via SWAPGS to access the per-CPU KPCR.
  • Switches from the user stack to the kernel stack stored in the KPCR.
  • Builds a KTRAP_FRAME capturing the user context.
  • Indexes KeServiceDescriptorTable (or the Shadow variant for Win32k GUI calls) using EAX.
  • Calls the resolved Nt* function.
  • On return, restores the frame and executes SYSRET to drop back to ring 3.

Selected KTRAP_FRAME fields (see WDK wdm.h for the full layout):

FieldDescription
RipSaved user-mode instruction pointer (from RCX at entry).
RspSaved user-mode stack pointer.
EFlagsSaved RFLAGS (from R11).
ErrCodeProcessor 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:

SymbolDescription
KeServiceDescriptorTableExported KSERVICE_TABLE_DESCRIPTOR. Covers the core Nt* services in ntoskrnl.exe.
KeServiceDescriptorTableShadowNot 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.
KiServiceTableThe actual function-pointer table referenced by the descriptor.
KiArgumentTableParallel 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.


Hierarchy diagram showing KeServiceDescriptorTable splitting into the core NT KiServiceTable and the Win32k shadow table, with EAX bit 12 selecting the descriptor and low 12 bits indexing into it
EAX bit 12 routes GUI syscalls to the Win32k shadow table while bits 11–0 index the specific service within the selected descriptor.

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 RCX

The 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
           -> SYSCALL

The 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:

  1. Locate the descriptor (KeServiceDescriptorTable is exported; the Shadow descriptor was pattern-scanned).
  2. Disable write protection (clear CR0.WP) or remap the page as writable.
  3. Save the original entry for the target SSN (e.g., NtQueryDirectoryFile, NtEnumerateValueKey).
  4. Overwrite the entry with a pointer to attacker code.
  5. 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 c0000082

9. 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; ret stub in attacker memory and calls it instead of ntdll!NtXxx. The hooked ntdll is never touched. SSNs are resolved at runtime (parsing ntdll, sorting Nt* exports by address — the “Hell’s Gate” / “Halo’s Gate” patterns).
  • Indirect syscalls. The mov eax, ssn happens in attacker memory, but the syscall instruction itself is reached by jumping to the syscall byte sequence inside ntdll.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:

TechniqueWhat it bypassesWhat still sees it
Direct syscallntdll user-mode hooksStack walk shows syscall from unbacked / private memory.
Indirect syscallntdll hooks and naive stack-walk checksKernel 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.


Graph diagram contrasting direct and indirect syscall evasion paths against EDR user-mode hooks, Sysmon CallTrace detection, and kernel-level ETW-TI telemetry firing after the syscall transition
Direct syscalls skip ntdll entirely while indirect syscalls camouflage the return address; ETW-TI catches both because it fires inside the kernel after the ring transition.

11. Common Attacker Techniques

TechniqueDescription
SSDT hook (legacy)Overwrite KiServiceTable[SSN] to filter results for hiding rootkit artifacts; killed by PatchGuard on x64.
Shadow SSDT hookSame against W32pServiceTable to intercept GUI/keyboard/clipboard syscalls.
Direct syscall stubEmbedded mov eax, ssn; syscall in attacker memory to bypass ntdll hooks.
Indirect syscallJump to the syscall gadget inside ntdll so call stacks look legitimate.
Hell’s Gate / Halo’s GateRuntime SSN resolution by parsing/sorting Nt* exports in mapped ntdll.
Fresh-copy ntdllRead clean ntdll.dll from disk to re-derive unhooked stubs and SSNs.
Heaven’s GateFar jump from 32-bit (CS=0x23) to 64-bit (CS=0x33) to execute 64-bit syscalls from a Wow64 process.
Driver-based hookingWhere 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 IDFieldWhy it matters
1ParentImage, CommandLineBaseline; correlates injection target lineage.
10GrantedAccess, CallTraceThe CallTrace field is the primary direct-syscall tell — legitimate stacks contain ntdll.dll; direct syscalls show UNKNOWN(...) or RWX private memory regions.
25Process 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.t1106

ETW Providers Worth Subscribing To

ProviderUse
Microsoft-Windows-Threat-IntelligenceKernel 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-ProcessProcess and thread creation, image loads.
Microsoft-Windows-Kernel-Audit-API-CallsAudits selected Nt API calls (verify against current SDK).

Audit Policy

  • Audit Sensitive Privilege Use — catches SeDebugPrivilege enabling, 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 KiSystemCall64Shadow is 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

ToolDescriptionLink
WinDbgKernel debugger; resolves nt!KeServiceDescriptorTable, nt!KiServiceTable, reads MSRs via rdmsr.learn.microsoft.com
Process HackerLive handle, thread, and module inspection; surfaces RWX private memory regions.processhacker.sourceforge.io
Process MonitorBoot-time and runtime Nt* activity captured via minifilter.learn.microsoft.com
SysmonView / SysmonEID 10 CallTrace, EID 25 telemetry.learn.microsoft.com
HollowsHunter / pe-sieveDetects unbacked / hollowed / patched modules — strong correlator for direct-syscall loaders.github.com/hasherezade
SwishDbgExtWinDbg extension with SSDT dumping and decode of the encoded-offset format.github.com
Volatility 3Memory forensics; windows.ssdt plugin walks the descriptor and decodes entries.volatilityfoundation.org
j00ru syscall tablesAuthoritative per-version SSN reference.j00ru.vexillium.org
SilkETW / SealighterTIUser-friendly consumers for ETW providers including Microsoft-Windows-Threat-Intelligence.github.com

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Native APIT1106EID 10 CallTrace containing UNKNOWN; ETW-TI AllocVm/ProtectVm from unbacked memory.
Process InjectionT1055Cross-process NtAllocateVirtualMemory + NtWriteVirtualMemory + NtCreateThreadEx chain via ETW-TI.
DLL InjectionT1055.001EID 7/8 plus ETW-TI write/protect events into a remote PID.
PE InjectionT1055.002RWX private allocations followed by remote thread creation.
Process HollowingT1055.012NtUnmapViewOfSection followed by NtWriteVirtualMemory into the primary image base.
RootkitT1014PatchGuard 0x109 bugchecks; SSDT integrity scans in memory forensics.
Impair Defenses: Disable/Modify ToolsT1562.001Driver loads with revoked or vulnerable signatures; HVCI/DSE violations.

Summary

  • Every Windows syscall is a SYSCALL instruction that lands at KiSystemCall64 via MSR_LSTAR and is dispatched through KiServiceTable using the EAX SSN.
  • The SSDT on x64 stores encoded offsets, not raw pointers — base + (entry >> 4) — and the EAX bit 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

References

User Mode vs Kernel Mode: Privilege Rings and the Boundary

Objective: Understand the architectural separation between user mode (Ring 3) and kernel mode (Ring 0) on Windows — how Intel hardware enforces it, how the Windows OS layers process isolation and the system call dispatch path on top, and why this boundary is the central battleground for rootkits, EDR, and modern kernel hardening.


1. Why Rings Exist — The Hardware Contract

Intel x86/x64 CPUs define four hardware privilege levels — called rings — numbered 0 (most privileged) through 3 (least privileged). The currently executing privilege level is encoded in the low two bits of the CS segment register and is referred to as the Current Privilege Level (CPL). Every memory access, every instruction fetch, and every attempt at a privileged instruction is checked against this value by the CPU itself, before any OS code runs.

Windows collapses Intel’s four rings into two:

FeatureUser ModeKernel Mode
Ring / CPLRing 3 (CPL = 3)Ring 0 (CPL = 0)
Memory accessUser VA onlyFull kernel + user VA
Privileged instructionsFaults with #GPAllowed
Address space isolationPer-process privateSingle shared VA across all drivers
Crash blast radiusProcess terminationBug check (BSOD)
Entry mechanismNative executionSYSCALL / interrupt / exception

Rings 1 and 2 exist in hardware but are unused by Windows — using only Ring 0 and Ring 3 maps cleanly to the “supervisor vs. user” model and is portable to architectures (ARM64, older RISC) that don’t expose intermediate levels. The instant Ring 3 code attempts to execute LGDT, LIDT, RDMSR, WRMSR, HLT, CLI, STI, or any I/O instruction outside its IOPB, the CPU raises a General Protection Fault (#GP) and the kernel terminates the offending thread.

This single hardware guarantee — CPL is checked by silicon, not software — is what makes the user/kernel boundary trustworthy in the first place.


Hierarchy diagram showing Intel's four privilege rings with Ring 0 (kernel) and Ring 3 (user) used by Windows, Rings 1 and 2 unused, and the CPU's CPL enforcing the boundary.
Windows collapses Intel’s four hardware rings into two; the CPU’s Current Privilege Level field in CS enforces the boundary in silicon.

2. User Mode: The Sandboxed World

When Windows launches an application, it creates a process with its own private virtual address space, its own handle table, and a security token. On x64, the user-mode half of the address space spans 0x00000000000000000x00007FFFFFFFFFFF (128 TB). Anything above that canonical boundary is kernel territory and is unmapped from user mode page tables (especially under KVA Shadow).

User-mode code can:

  • Allocate memory in its own VA via VirtualAlloc.
  • Open handles to kernel objects through documented APIs.
  • Spawn threads and processes via the Win32 subsystem (csrss.exe).

User-mode code cannot:

  • Read or write another process’s memory without explicit handle access.
  • Touch kernel VA, modify page tables, or read MSRs directly.
  • Service interrupts, install drivers, or hook the IDT/GDT.

Every meaningful operation that touches hardware, files, networking, or kernel objects must therefore traverse the user/kernel boundary through a system call.


3. Kernel Mode: The Shared Kingdom

In contrast to user mode’s per-process isolation, all kernel-mode code shares a single virtual address space. ntoskrnl.exe, the HAL, file system drivers, network stack drivers, and every third-party driver loaded on the system all coexist in the same address space, on the same privilege level, with no memory protection between them.

RegionPurpose
Non-paged poolKernel allocations that must remain resident (DPC/ISR code, kernel objects)
Paged poolKernel allocations that can be paged out
System PTE regionKernel-managed page table entries for I/O mapping
HAL / driver image rangeLoaded driver .text/.data sections

A buggy driver writing to the wrong pointer can corrupt another driver’s structures or the kernel’s own state. A crash in any kernel component triggers a bug check (BSOD) because, unlike user mode, there is no isolation boundary to contain the damage. This is also exactly why attackers want Ring 0: once executing in kernel mode, malicious code has the same authority over the OS as the OS itself.


4. Crossing the Boundary — The SYSCALL Path

Every Win32 API that touches the kernel eventually reaches an ntdll.dll stub. On x64 those stubs all have the same shape:

; ntdll!NtReadFile (representative)
mov   r10, rcx              ; preserve arg1 (RCX is clobbered by SYSCALL)
mov   eax, 0x06             ; syscall number (build-specific; illustrative)
syscall                     ; user -> kernel transition
ret

The SYSCALL instruction is the choreography of the boundary crossing. The CPU performs all of the following atomically:

StepCPU action
1Saves user RIP into RCX
2Saves user RFLAGS into R11
3Masks RFLAGS per IA32_FMASK (MSR 0xC0000084) — clears IF so interrupts are off at kernel entry
4Loads new CS/SS selectors from IA32_STAR (MSR 0xC0000081)
5Loads RIP from IA32_LSTAR (MSR 0xC0000082) — points to nt!KiSystemCall64
6Transitions CPL from 3 to 0

From here, control is in Windows. The kernel-side dispatch chain is:

FunctionRole
nt!KiSystemCall64Entry point loaded from IA32_LSTAR. Executes swapgs to swap user GS for kernel GS, switches to the kernel stack, allocates and populates a _KTRAP_FRAME with the saved user-mode register state. With KVA Shadow (KPTI) enabled, the variant KiSystemCall64Shadow is used to swap page tables first.
nt!KiSystemServiceUserLocates the current _KTHREAD via GS:[0x188] and sets KTHREAD.PreviousMode = UserMode (1) so the kernel knows arguments came from Ring 3 and must be probed.
nt!KiSystemServiceStartSplits the syscall number in EAX into a table identifier (high bits) and a service index (low bits).
nt!KiSystemServiceRepeatSelects KeServiceDescriptorTable (Nt* executive calls) or KeServiceDescriptorTableShadow (Win32k GUI calls), validates the argument count, and dispatches.
Service routine (e.g. nt!NtReadFile)Validates user pointers (ProbeForRead / ProbeForWrite) and performs the work.
SYSRETRestores RIP from RCX, RFLAGS from R11, transitions CPL from 0 back to 3, and the caller returns from ntdll.

The key takeaway for defenders: every user-mode action eventually appears in EAX as a syscall number — and EDR products that hook only in user space (in ntdll) can be bypassed by re-implementing this exact stub in attacker code (direct/indirect syscalls).


Flow diagram tracing a system call from Win32 API through ntdll stub, SYSCALL instruction, IA32_LSTAR MSR, KiSystemCall64, SSDT lookup, and finally the kernel service routine.
Every user-mode kernel request follows this exact dispatch chain — EAX carries the syscall number across the Ring 3 to Ring 0 boundary.

5. The SSDT — Routing Calls Inside the Kernel

The System Service Descriptor Table (SSDT) is the array of function pointers that turns EAX into a kernel routine address.

SymbolDescription
KeServiceDescriptorTableExported; primary SSDT for Nt* executive system calls
KeServiceDescriptorTableShadowNot exported; adds the Win32k.sys GUI calls used by threads with a Win32 subsystem context
ServiceTableField inside each descriptor — pointer to an array of encoded function offsets (on x64 these are relative offsets right-shifted by 4)
NumberOfServicesCount of valid entries

Patching SSDT entries to redirect kernel calls was the classic 32-bit rootkit technique (and the canonical kernel hook for early HIPS products). On x64, PatchGuard (KPP) periodically verifies the SSDT and several other critical structures; modification triggers Bug Check 0x109CRITICAL_STRUCTURE_CORRUPTION.


6. Key Kernel Structures at the Boundary

The kernel maintains per-CPU and per-thread state that defenders inspect to understand mode transitions.

// Conceptual layout — verify offsets against your build's symbols.
typedef struct _KPCR {
    // ...
    struct _KPRCB Prcb;        // at +0x180 on x64; embedded
} KPCR, *PKPCR;

typedef struct _KPRCB {
    // ...
    struct _KTHREAD *CurrentThread;   // GS:[0x188] in kernel mode
} KPRCB, *PKPRCB;

typedef struct _KTHREAD {
    // ...
    UCHAR           PreviousMode;     // 0 = KernelMode, 1 = UserMode
    PKTRAP_FRAME    TrapFrame;        // saved register state from SYSCALL
} KTHREAD, *PKTHREAD;

PreviousMode is one of the most consequential bytes in the system: kernel routines branch on it to decide whether to probe and capture caller-supplied pointers (user mode) or trust them directly (kernel mode). Bugs in that check have been the root cause of multiple Windows LPE CVEs.

Inspect any of these live in WinDbg on a kernel debug target:

0: kd> rdmsr 0xC0000082          ; IA32_LSTAR -> KiSystemCall64
0: kd> dg cs                     ; show CS selector + CPL
0: kd> dt nt!_KPCR @$pcr
0: kd> dt nt!_KTHREAD @$thread PreviousMode TrapFrame
0: kd> dt nt!_KTRAP_FRAME @$thread->TrapFrame
0: kd> dps KeServiceDescriptorTable L4

7. Hardening the Boundary

Microsoft has spent two decades hardening the user/kernel boundary in layers. Each mechanism closes a class of attacks against Ring 0.

MechanismWhat it enforces
PatchGuard (KPP)Periodic integrity checks on SSDT, IDT, GDT, KPCR, MSRs, and kernel code sections. Tampering triggers Bug Check 0x109.
Driver Signature Enforcement (DSE)All kernel drivers must be signed. Enforced by ci.dll. Disabling DSE (bcdedit /set testsigning on) is a strong adversary indicator.
Secure BootUEFI-rooted trust chain prevents unsigned bootloaders/drivers from loading before Windows starts.
HVCI (Memory Integrity)A VTL1 hypervisor enforces W^X on kernel pages — unsigned code cannot execute even from Ring 0.
KVA Shadow (KPTI)User page tables contain only minimal kernel mappings; full mapping is installed only while CPL = 0. Mitigates Meltdown-class speculative leaks.
Microsoft Vulnerable Driver BlocklistMaintained list of known-abused drivers; enforced by HVCI/CI.

Together these turn Ring 0 from “anything goes once you’re in” into a far more constrained environment — and explain why modern attackers gravitate toward Bring Your Own Vulnerable Driver (BYOVD) as their cleanest path to kernel code execution.


Hierarchy diagram showing five Windows hardening mechanisms — HVCI, PatchGuard, DSE, KVA Shadow, and the Vulnerable Driver Blocklist — each targeting the Ring 0 attack surface.
Microsoft’s layered kernel hardening forces modern attackers toward BYOVD as the remaining practical path to Ring 0 code execution.

8. Common Attacker Techniques

The boundary is a target precisely because Ring 0 sits underneath every defensive product. Attackers care about three categories of abuse:

TechniqueDescription
Direct / indirect syscallsRebuild the ntdll stub (mov r10, rcx; mov eax, <N>; syscall) inside the implant to bypass user-mode hooks placed by EDR.
BYOVDLoad a legitimately signed but vulnerable driver, then exploit it to gain arbitrary Ring 0 read/write — used to disable EDR, blank tokens, or clear callbacks.
Kernel exploitation (LPE)Exploit a kernel vulnerability (write-what-where, type confusion, double-fetch on user pointers when PreviousMode == UserMode) to escalate Ring 3 → Ring 0.
SSDT hooking (legacy)Patch entries in KeServiceDescriptorTable to intercept syscalls — blocked on x64 by PatchGuard but still relevant for 32-bit forensics.
DKOM (Direct Kernel Object Manipulation)Unlink _EPROCESS entries from ActiveProcessLinks to hide processes; clear PsActiveProcessHead linkages.
Callback removalWalk PsSetCreateProcessNotifyRoutine / PsSetLoadImageNotifyRoutine arrays and null EDR callbacks.
PreviousMode overwriteSet KTHREAD.PreviousMode = KernelMode (0) to make subsequent Nt* calls skip user-pointer validation.

9. Defensive Strategies & Detection

The fact that all roads cross the boundary is a defender’s leverage: even attackers using direct syscalls leave telemetry at driver load, privilege use, and kernel object access layers.

Sysmon coverage

Event IDNameRelevance
1Process CreateParent/child + command line; catches bcdedit, sc.exe create … type= kernel
6Driver LoadedFires on every kernel driver load; fields include ImageLoaded, Hashes, Signed, Signature — primary BYOVD signal
7Image LoadedDLL loads; detect ntdll.dll loaded from non-standard paths
10Process AccessCross-process handle opens with sensitive GrantedAccess masks (precursor to injection)
255Sysmon ErrorTampering with the Sysmon kernel driver may surface here

Windows audit policies

PolicyEvent IDsDetects
Audit Sensitive Privilege Use4673Use of SeLoadDriverPrivilege — required to load any kernel driver
Audit Security System Extension4697, 7045New service / kernel driver installed
Audit Kernel Object4656, 4663Access to kernel objects via SACL-tagged handles
Audit Policy Change4719Audit-policy tampering (a common pre-attack step)

High-value ETW providers

  • Microsoft-Windows-Kernel-Process — process/thread/image events at the kernel boundary.
  • Microsoft-Windows-Kernel-File / Microsoft-Windows-Kernel-Registry — kernel-side file and registry ops, useful for catching driver-stage persistence.
  • Microsoft-Windows-Threat-Intelligence (ETWTI) — emits high-fidelity events for ReadProcessMemory, WriteProcessMemory, MapViewOfSection, QueueUserApc. Consumption requires a PPL or kernel consumer; verify provider availability on your build with logman query providers.

Sigma — BYOVD pattern

title: Suspicious Kernel Driver Load - BYOVD Pattern
logsource:
  product: windows
  category: driver_load
detection:
  selection:
    EventID: 6
    Signed: 'false'
  filter_legit_path:
    ImageLoaded|startswith:
      - 'C:\Windows\System32\drivers\'
      - 'C:\Windows\SysWOW64\drivers\'
  condition: selection and not filter_legit_path
fields:
  - ImageLoaded
  - Hashes
  - Signature
  - SignatureStatus
level: high

Sigma — SeLoadDriverPrivilege exercised by non-system principal

title: SeLoadDriverPrivilege Use by Non-System Account
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4673
    PrivilegeList|contains: 'SeLoadDriverPrivilege'
  filter_machine_accounts:
    SubjectUserName|endswith: '$'
  condition: selection and not filter_machine_accounts
level: medium

Hardening checklist

  • Enable HVCI / Memory Integrity (HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard).
  • Enable Secure Boot in UEFI.
  • Apply the Microsoft Vulnerable Driver Blocklist (HVCI-enforced).
  • Verify Meltdown mitigations / KVA Shadow via Get-SpeculationControlSettings.
  • Alert on bcdedit /set testsigning on and on driver loads where Signed=false or hashes match loldrivers.io.
  • Enable Kernel DMA Protection for laptops with Thunderbolt/USB4.
  • Limit SeLoadDriverPrivilege assignment and monitor every use via Event 4673.

10. Tools for Boundary Analysis

ToolDescriptionLink
WinDbgKernel debugger; inspect _KPCR, _KTHREAD, _KTRAP_FRAME, MSRs, SSDTaka.ms/windbg
SysmonProcess/driver/handle telemetry — EIDs 1/6/7/10sysinternals.com
Process HackerView loaded drivers, handles, tokens, KPP-safe inspectionprocesshacker.sourceforge.io
Process MonitorFile/registry/thread activity at the boundarysysinternals.com
Volatility 3Memory forensics; walk _EPROCESS, hidden processes via DKOMvolatilityfoundation.org
DriverView / DriverQueryEnumerate loaded kernel drivers and signing statenirsoft.net
ETW / logmanEnumerate and capture kernel-mode ETW providersbuilt-in
loldrivers.ioCatalog of known-vulnerable signed driversloldrivers.io

11. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
RootkitT1014Volatility scans for unlinked _EPROCESS; PatchGuard bug checks 0x109
Process InjectionT1055Sysmon EID 8/10; ETWTI WriteProcessMemory / QueueUserApc
Exploitation for Privilege EscalationT1068Bug check telemetry, unusual PreviousMode transitions, EDR kernel callbacks
Create or Modify System Process: ServiceT1543.003Security EID 4697, System EID 7045, Sysmon EID 6
Impair DefensesT1562.001Driver loads correlated with subsequent loss of EDR telemetry; EID 4673 with SeLoadDriverPrivilege
Exploitation for Defense Evasion (BYOVD)T1211Sysmon EID 6 with unsigned driver or known-vulnerable hash; loldrivers.io match

Summary

  • The user/kernel boundary is enforced by silicon — CPL in CS — not by software, which is what makes it trustworthy.
  • Windows uses only Ring 0 and Ring 3; user mode runs in a per-process private VA, kernel mode runs in a single shared VA where any bug is a BSOD.
  • Every user→kernel transition flows through SYSCALLIA32_LSTARKiSystemCall64 → SSDT dispatch, leaving EAX and KTHREAD.PreviousMode as the canonical fingerprints.
  • Modern hardening — PatchGuard, DSE, HVCI, KVA Shadow, and the vulnerable driver blocklist — has pushed attackers toward BYOVD and direct syscalls.
  • Defenders watch the boundary through Sysmon EID 6, Security EID 4673 (SeLoadDriverPrivilege), ETWTI, and kernel-callback EDR telemetry — every Ring 0 attack eventually touches one of them.

Related Tutorials

References