Difficulty: Beginner

Module 2: Syscall Internals & SSN Resolution

How user-mode code crosses into Ring 0, and how to find the right number.

Module Objective

LayeredSyscall must know the correct System Service Number (SSN) for each Nt* function it wants to call. This module explains how the syscall instruction works at the CPU level, what the syscall stub does, and the various methods to resolve SSNs dynamically — culminating in the MDSec Exception Directory method that LayeredSyscall uses.

1. The x64 Syscall Instruction

The syscall instruction (opcode 0F 05) is the x64 mechanism for transitioning from user mode (Ring 3) to kernel mode (Ring 0). When it executes, the CPU performs these steps in hardware:

What syscall Does (CPU Microcode)

StepActionDetail
1Save RIP → RCXThe return address (next instruction) is saved to RCX
2Save RFLAGS → R11Current flags are preserved in R11
3Load RIP from IA32_LSTAR MSRKernel entry point (KiSystemCall64) is loaded
4Load CS/SS from IA32_STAR MSRSwitches to kernel code/stack segments
5Mask RFLAGS with IA32_FMASK MSRDisables interrupts during transition

Once in kernel mode, KiSystemCall64 reads the value in RAX (the SSN) and uses it as an index into the System Service Descriptor Table (SSDT). The SSDT maps SSN 0x18 → NtAllocateVirtualMemory, SSN 0x50 → NtCreateFile, and so on.

Why RCX Is Clobbered

The syscall instruction unconditionally writes the return address into RCX. But in the Windows x64 calling convention, RCX holds the first argument. If the stub didn't save RCX first, the first argument would be lost. This is why every stub begins with mov r10, rcx — it preserves the first argument in R10, which the kernel expects.

2. The ntdll Syscall Stub

Every Nt* function in ntdll.dll is a small assembly stub. Here is the complete stub with hex bytes, exactly as it appears in memory:

x86-64 ASMNtAllocateVirtualMemory:
  4C 8B D1          mov r10, rcx        ; preserve 1st arg (RCX will be clobbered)
  B8 18 00 00 00    mov eax, 0x18       ; load SSN into EAX
  0F 05             syscall             ; Ring 3 → Ring 0
  C3                ret                 ; return to caller (12 bytes total)

Byte-by-Byte Breakdown

OffsetBytesInstructionPurpose
+04C 8B D1mov r10, rcxSave 1st argument before syscall clobbers RCX
+3B8 XX 00 00 00mov eax, SSNLoad System Service Number (4-byte immediate)
+80F 05syscallTransition to kernel mode
+10C3retReturn to caller after kernel returns

The SSN at offset +4 is what changes between different Nt* functions. NtAllocateVirtualMemory might be 0x18, NtProtectVirtualMemory might be 0x50, and these numbers change between Windows versions. This is why SSN resolution must be dynamic.

The Zw* and Nt* Duality

In ntdll.dll, every Nt* function has a corresponding Zw* function that points to the same address. For example, NtAllocateVirtualMemory and ZwAllocateVirtualMemory share the exact same stub. The distinction matters in the kernel (Zw* sets PreviousMode to KernelMode), but in user mode they are identical. LayeredSyscall exploits this duality for SSN resolution.

3. SSN Resolution Methods — History

The challenge: if EDR hooks overwrite the stub bytes, you can't just read the SSN from offset +4 anymore. Several techniques evolved to solve this:

MethodAuthorHow It WorksLimitation
Hell's Gateam0nsec & smelly__vxRead SSN directly from the stub at offset +4 (B8 XX 00 00 00)Fails if the stub is hooked (first bytes overwritten by JMP)
Halo's Gatesektor7If target stub is hooked, look at neighboring stubs (SSN ± N) and calculate the offsetFails if neighbors are also hooked or if hook patterns vary
Tartarus Gatetrickster0Extends Halo's Gate to handle additional hook patterns (different EDR hooking styles)Still relies on finding at least one clean neighbor
SysWhispers2jthuraisamySort all Zw* export addresses in ascending order; the sort index equals the SSNRequires parsing the full export table; more code in the binary
FreshyCallscrummie5Similar to SysWhispers2 but resolves at runtime by sorting function pointersSame overhead as SysWhispers2
MDSec Exception DirectoryMDSec (Peter Winter-Smith)Use RUNTIME_FUNCTION entries from ntdll's Exception Directory to count Zw* functionsElegant; works regardless of hooks; used by LayeredSyscall

4. MDSec Exception Directory Method

This is the method LayeredSyscall implements in its GetSsnByName() function. It exploits a structural property of ntdll: the Exception Directory (IMAGE_DIRECTORY_ENTRY_EXCEPTION) contains RUNTIME_FUNCTION entries that are ordered by function address, and syscall stubs are ordered by SSN.

Step-by-Step Walkthrough

Step 1: Find ntdll Base Address via PEB

Walk the Process Environment Block (PEB) through Ldr → InLoadOrderModuleList to locate ntdll.dll's base address. This avoids calling GetModuleHandle (which EDRs monitor).

C++// Walk PEB->Ldr->InLoadOrderModuleList
PPEB pPeb = (PPEB)__readgsqword(0x60);
PLDR_DATA_TABLE_ENTRY pEntry =
    (PLDR_DATA_TABLE_ENTRY)pPeb->Ldr->InLoadOrderModuleList.Flink;

// ntdll is typically the second entry (after the EXE itself)
pEntry = (PLDR_DATA_TABLE_ENTRY)pEntry->InLoadOrderLinks.Flink;
PVOID ntdllBase = pEntry->DllBase;

Step 2: Parse the Exception Directory

The Exception Directory contains an array of RUNTIME_FUNCTION structures. Each entry describes a function's start address, end address, and unwind information:

C++typedef struct _RUNTIME_FUNCTION {
    DWORD BeginAddress;    // RVA of function start
    DWORD EndAddress;      // RVA of function end
    DWORD UnwindData;      // RVA of unwind info
} RUNTIME_FUNCTION;

Get the Exception Directory from the PE optional header:

C++PIMAGE_DATA_DIRECTORY pExceptionDir =
    &pOptHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];

PRUNTIME_FUNCTION pRuntimeFunc = (PRUNTIME_FUNCTION)(
    (ULONG_PTR)ntdllBase + pExceptionDir->VirtualAddress);

DWORD numFunctions = pExceptionDir->Size / sizeof(RUNTIME_FUNCTION);

Step 3: Parse the Export Address Table

Also parse the Export Directory to get function names and their RVAs. This lets us match RUNTIME_FUNCTION entries to named exports.

C++PIMAGE_DATA_DIRECTORY pExportDir =
    &pOptHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];

PIMAGE_EXPORT_DIRECTORY pExports = (PIMAGE_EXPORT_DIRECTORY)(
    (ULONG_PTR)ntdllBase + pExportDir->VirtualAddress);

PDWORD pNames    = (PDWORD)((ULONG_PTR)ntdllBase + pExports->AddressOfNames);
PDWORD pFuncs    = (PDWORD)((ULONG_PTR)ntdllBase + pExports->AddressOfFunctions);
PWORD  pOrdinals = (PWORD)((ULONG_PTR)ntdllBase + pExports->AddressOfNameOrdinals);

Step 4: Count Zw* Functions (The Core Algorithm)

Iterate through each RUNTIME_FUNCTION entry. For each one, find its matching export name. If the name starts with "Zw", increment a counter. When the name matches the target function, the counter value IS the SSN.

C++// From LayeredSyscall's HookModule.cpp - GetSsnByName()
DWORD ssnCounter = 0;

for (DWORD i = 0; i < numFunctions; i++) {
    // Match RUNTIME_FUNCTION BeginAddress against export RVAs
    for (DWORD j = 0; j < pExports->NumberOfNames; j++) {
        if (pFuncs[pOrdinals[j]] == pRuntimeFunc[i].BeginAddress) {

            PCHAR funcName = (PCHAR)((ULONG_PTR)ntdllBase + pNames[j]);

            // Check if name starts with "Zw" (little-endian: 'wZ')
            if (*(USHORT*)funcName == 'wZ') {
                // Is this our target function?
                if (StringCompare(funcName + 2, targetName + 2)) {
                    return ssnCounter;  // SSN found!
                }
                ssnCounter++;
            }
            break;  // Move to next RUNTIME_FUNCTION
        }
    }
}

The key insight: *(USHORT*)funcName == 'wZ' checks the first two bytes as a 16-bit value. Due to x86 little-endian byte order, the bytes in memory for "Zw" are 5A 77, which as a USHORT is 0x775A. The character literal 'wZ' also evaluates to 0x775A (multi-character literal, implementation-defined but consistent on MSVC).

5. Why RUNTIME_FUNCTION Works

This method works because of two structural guarantees in ntdll.dll:

Guarantee 1: Exception Directory Entries Are Ordered by Address

The PE specification requires RUNTIME_FUNCTION entries to be sorted by BeginAddress in ascending order. Since syscall stubs in ntdll are laid out sequentially in memory (in SSN order), iterating the exception directory visits them in SSN order.

Guarantee 2: Zw* and Nt* Share Entry Points

ZwAllocateVirtualMemory and NtAllocateVirtualMemory resolve to the same address in ntdll. The function checks for "Zw" prefix because both Zw* and Nt* exports exist, and counting only one set avoids double-counting. The choice of Zw* is arbitrary — Nt* would also work.

Exception Directory ↔ SSN Mapping

RUNTIME_FUNCTION[0]
ZwAccessCheck → SSN 0
RUNTIME_FUNCTION[1]
ZwAddAtom → SSN 1
...
SSN N
RUNTIME_FUNCTION[K]
ZwAllocateVirtualMemory → SSN 0x18

Counting Zw* entries encountered = SSN of the current function. Works regardless of hooks because it reads PE metadata, not stub bytes.

Hook Resilience

Unlike Hell's Gate (which reads the SSN from the stub bytes at offset +4), the Exception Directory method never reads the stub instructions at all. It only reads PE structural metadata (export table and exception directory), which EDRs do not modify. Even if every single syscall stub is hooked, the SSN resolution still works correctly.

6. Additional Resolution: Syscall Address

Beyond the SSN, LayeredSyscall also needs the address of the syscall instruction within the stub. This is where hardware breakpoints will be set. The code scans forward from the function entry, looking for the two-byte sequence 0F 05:

C++// Scan for syscall (0F 05) within 25 bytes of function start
PVOID funcAddr = (PVOID)((ULONG_PTR)ntdllBase + funcRva);
for (DWORD offset = 0; offset < 25; offset++) {
    BYTE* p = (BYTE*)funcAddr + offset;
    if (p[0] == 0x0F && p[1] == 0x05) {
        // Found syscall instruction
        syscallAddr = (PVOID)p;
        retAddr     = (PVOID)(p + 2);  // ret is right after syscall
        break;
    }
}

This scan handles the case where an EDR hook might have modified early bytes but left the syscall/ret sequence intact (which is typical, since hooks redirect before the syscall is reached).

7. SSN Versioning & Why Dynamic Resolution Matters

SSNs are not stable across Windows versions. Microsoft can add, remove, or reorder system calls between updates. A hardcoded SSN table that works on Windows 10 21H2 may crash on Windows 11 23H2.

Example SSN Changes Across Versions

FunctionWin10 1809Win10 21H2Win11 22H2
NtAllocateVirtualMemory0x180x180x18
NtProtectVirtualMemory0x500x500x50
NtCreateThreadEx0xBD0xC10xC2
NtWriteVirtualMemory0x3A0x3A0x3A
NtCreateSection0x4A0x4A0x4A

Note: While many common SSNs remain stable, newer or less-used functions can shift. Any technique relying on hardcoded numbers is fragile.

This is exactly why dynamic resolution methods like the Exception Directory approach are essential. They resolve the correct SSN at runtime by inspecting the actual ntdll loaded in the process, regardless of which Windows build is running.

LayeredSyscall's Resolution Strategy

During initialization, LayeredSyscall calls GetSsnByName() for each Nt* function it wraps. The resolved SSNs and syscall/ret addresses are stored in a lookup table. When a wrapped function is invoked, the pre-resolved SSN is used immediately — no repeated PE parsing is needed.

C++// Initialization - resolve once, use many times
typedef struct _SYSCALL_INFO {
    DWORD  ssn;             // System Service Number
    PVOID  syscallAddr;     // Address of 0F 05 in the stub
    PVOID  retAddr;         // Address of C3 after syscall
} SYSCALL_INFO;

SYSCALL_INFO g_NtAllocateVM;
g_NtAllocateVM.ssn = GetSsnByName("ZwAllocateVirtualMemory");
// + find syscall/ret addresses...

Key Terminology

TermDefinition
SSDTSystem Service Descriptor Table — kernel-side table mapping SSNs to kernel function addresses
RUNTIME_FUNCTIONPE structure describing a function's address range and unwind info, stored in the Exception Directory
Export Address TablePE structure mapping function names/ordinals to their RVAs (Relative Virtual Addresses)
PEBProcess Environment Block — per-process structure containing loaded module list, heap info, and more
InLoadOrderModuleListDoubly-linked list in PEB->Ldr containing all loaded DLLs in load order (EXE first, ntdll second)

Module 2 Quiz: Syscall Internals

Q1: Why does every syscall stub begin with mov r10, rcx?

The syscall instruction saves the return address (RIP of the next instruction) into RCX, destroying whatever was there. Since RCX holds the first argument in the Windows x64 calling convention, the stub must save it to R10 first. The kernel's system call handler reads the first argument from R10.

Q2: What is the purpose of the SSN (System Service Number) stored in EAX?

The kernel reads EAX after the syscall transition and uses it as an index into the System Service Descriptor Table (SSDT). Each SSN maps to a specific kernel function. For example, SSN 0x18 might map to NtAllocateVirtualMemory's kernel implementation.

Q3: Why does the MDSec Exception Directory method work even when syscall stubs are hooked?

The Exception Directory method resolves SSNs by counting Zw* entries in the RUNTIME_FUNCTION table, which is PE structural metadata. EDR hooks modify the code bytes of syscall stubs but do not alter the PE exception directory or export table. This makes the resolution completely hook-agnostic.