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)
| Step | Action | Detail |
|---|---|---|
| 1 | Save RIP → RCX | The return address (next instruction) is saved to RCX |
| 2 | Save RFLAGS → R11 | Current flags are preserved in R11 |
| 3 | Load RIP from IA32_LSTAR MSR | Kernel entry point (KiSystemCall64) is loaded |
| 4 | Load CS/SS from IA32_STAR MSR | Switches to kernel code/stack segments |
| 5 | Mask RFLAGS with IA32_FMASK MSR | Disables 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
| Offset | Bytes | Instruction | Purpose |
|---|---|---|---|
| +0 | 4C 8B D1 | mov r10, rcx | Save 1st argument before syscall clobbers RCX |
| +3 | B8 XX 00 00 00 | mov eax, SSN | Load System Service Number (4-byte immediate) |
| +8 | 0F 05 | syscall | Transition to kernel mode |
| +10 | C3 | ret | Return 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:
| Method | Author | How It Works | Limitation |
|---|---|---|---|
| Hell's Gate | am0nsec & smelly__vx | Read 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 Gate | sektor7 | If target stub is hooked, look at neighboring stubs (SSN ± N) and calculate the offset | Fails if neighbors are also hooked or if hook patterns vary |
| Tartarus Gate | trickster0 | Extends Halo's Gate to handle additional hook patterns (different EDR hooking styles) | Still relies on finding at least one clean neighbor |
| SysWhispers2 | jthuraisamy | Sort all Zw* export addresses in ascending order; the sort index equals the SSN | Requires parsing the full export table; more code in the binary |
| FreshyCalls | crummie5 | Similar to SysWhispers2 but resolves at runtime by sorting function pointers | Same overhead as SysWhispers2 |
| MDSec Exception Directory | MDSec (Peter Winter-Smith) | Use RUNTIME_FUNCTION entries from ntdll's Exception Directory to count Zw* functions | Elegant; 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
ZwAccessCheck → SSN 0
ZwAddAtom → SSN 1
SSN N
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
| Function | Win10 1809 | Win10 21H2 | Win11 22H2 |
|---|---|---|---|
NtAllocateVirtualMemory | 0x18 | 0x18 | 0x18 |
NtProtectVirtualMemory | 0x50 | 0x50 | 0x50 |
NtCreateThreadEx | 0xBD | 0xC1 | 0xC2 |
NtWriteVirtualMemory | 0x3A | 0x3A | 0x3A |
NtCreateSection | 0x4A | 0x4A | 0x4A |
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
| Term | Definition |
|---|---|
| SSDT | System Service Descriptor Table — kernel-side table mapping SSNs to kernel function addresses |
| RUNTIME_FUNCTION | PE structure describing a function's address range and unwind info, stored in the Exception Directory |
| Export Address Table | PE structure mapping function names/ordinals to their RVAs (Relative Virtual Addresses) |
| PEB | Process Environment Block — per-process structure containing loaded module list, heap info, and more |
| InLoadOrderModuleList | Doubly-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?
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?
NtAllocateVirtualMemory's kernel implementation.Q3: Why does the MDSec Exception Directory method work even when syscall stubs are hooked?