Difficulty: Intermediate

Module 4: SSN Resolution & Indirect Syscalls

Finding syscall numbers, handling EDR hooks, and jumping to the syscall instruction inside ntdll.

Module Objective

Draugr needs two things before it can execute a spoofed syscall: the System Service Number (SSN) for the target function, and the address of the syscall instruction inside ntdll. This module covers how Draugr resolves SSNs from clean and hooked stubs, the difference between direct and indirect syscalls, and how Draugr combines indirect syscalls with synthetic stack frames for full evasion.

1. Direct vs Indirect Syscalls

There are two fundamental approaches to executing syscalls without going through EDR-hooked ntdll stubs. Each has different detection characteristics:

Direct Syscall
  • The syscall instruction is in your code (EXE, BOF, shellcode)
  • SSN loaded into EAX, syscall executed from attacker memory
  • Return address (RCX after syscall) points to your code
  • Detected by: InstrumentationCallback checks return address
  • If return address is outside ntdll, flagged immediately
  • Used by: SysWhispers1, early Hell's Gate implementations
Indirect Syscall
  • Your code jumps to the syscall;ret inside ntdll's own memory
  • SSN loaded in your code, then JMP to ntdll stub's syscall instruction
  • Return address after syscall points to ntdll memory
  • Passes: InstrumentationCallback (return addr in ntdll)
  • Fails: Call stack analysis (caller is still unbacked)
  • Used by: SysWhispers2/3, HWSyscalls, Draugr (+ stack spoofing)

Draugr uses indirect syscalls. The syscall instruction executes from ntdll's memory, passing InstrumentationCallback checks. The call stack problem is solved separately by synthetic frames (covered in Modules 5-7). This two-pronged approach gives Draugr evasion against both Layer 2 (return address validation) and Layer 3 (call stack analysis).

Direct vs Indirect Execution Flow

Direct Syscall

mov r10, rcx (in your code)
mov eax, SSN (in your code)
syscall (in your code = DETECTED)
ret (return addr = your code)

Indirect Syscall (Draugr)

mov r10, rcx (in spoof routine)
mov eax, SSN (in spoof routine)
jmp [ntdll syscall addr] (jump INTO ntdll)
syscall; ret (executes from ntdll memory)

2. The ntdll Syscall Stub

Every Nt* function in ntdll.dll is a thin stub that transitions to the kernel. In its clean (unhooked) state, the stub is only about 12 bytes:

x86-64 ASM; Clean ntdll syscall stub (e.g., NtAllocateVirtualMemory)
; Total: 12 bytes
;
4C 8B D1          mov  r10, rcx       ; Save 1st arg (syscall clobbers RCX)
B8 18 00 00 00    mov  eax, 0x18      ; SSN = 0x18 (NtAllocateVirtualMemory)
0F 05             syscall             ; Transition to Ring 0
C3                ret                 ; Return to caller

; Byte pattern: 4C 8B D1 B8 [SSN_LO] [SSN_HI] 00 00 0F 05 C3

Why mov r10, rcx?

The syscall instruction uses RCX to save the return address (RIP) and R11 to save RFLAGS. This means the first argument (normally in RCX per the x64 calling convention) would be destroyed. The stub copies RCX to R10 first, and the kernel reads the first argument from R10 instead of RCX. This is a fundamental part of the Windows syscall ABI.

The SSN (System Service Number) is the index into the System Service Descriptor Table (SSDT) in the kernel. Each Nt* function has a unique SSN. The kernel uses EAX as the index to dispatch to the correct kernel function.

3. Draugr's Primary SSN Resolution

Draugr resolves SSNs by pattern-matching the known clean stub bytes. When initializing, it locates each target Nt* function in ntdll and checks if the first bytes match the expected pattern:

C (Draugr SSN Resolution)// Primary resolution: pattern match for clean stub
// Looking for: 4C 8B D1 B8 [SSN_LO] [SSN_HI] 00 00
//              mov r10,rcx; mov eax, SSN

BOOL ResolveSsn(PVOID functionAddress, PWORD outSsn) {
    PBYTE stub = (PBYTE)functionAddress;

    // Check for the clean stub signature: 4C 8B D1 B8
    if (stub[0] == 0x4C &&    // mov r10, rcx (byte 1)
        stub[1] == 0x8B &&    // mov r10, rcx (byte 2)
        stub[2] == 0xD1 &&    // mov r10, rcx (byte 3)
        stub[3] == 0xB8) {    // mov eax, imm32 (opcode)

        // SSN is the 16-bit value at offset 4-5
        // (bytes 6-7 are always 0x00 0x00 since SSNs are small)
        *outSsn = *(PWORD)(stub + 4);
        return TRUE;
    }

    // Stub is hooked - primary resolution failed
    return FALSE;
}

This is the fast path. If the stub hasn't been hooked by an EDR, the SSN is right there at byte offset 4. Extract it and move on.

How Draugr Finds the Function Address

Draugr uses the Export Address Table (EAT) of ntdll.dll to resolve function addresses. The process:

  1. Get ntdll base address from the PEB (Process Environment Block)
  2. Parse the PE headers to find the Export Directory
  3. Walk the AddressOfNames array, hash each name, compare to target hash
  4. Use the matching ordinal to index into AddressOfFunctions
  5. The result is the RVA of the syscall stub

4. Hooked Stub Fallback

When an EDR hooks a syscall stub, the first bytes are overwritten with a JMP instruction. The clean pattern (4C 8B D1 B8) is destroyed:

x86-64 ASM; Hooked NtAllocateVirtualMemory (EDR has patched it):
E9 XX XX XX XX    jmp  edr_hook_handler    ; 5-byte relative jump
00 00 00          ; remaining bytes (corrupted)
0F 05             syscall                  ; still present but unreachable via normal flow
C3                ret

; The original 4C 8B D1 B8 18 00 00 00 has been replaced
; Pattern matching fails - Draugr cannot extract the SSN from here

When primary resolution fails, Draugr falls back to a neighbor scanning technique. This exploits a fundamental property of the SSDT: SSNs are sequential. Adjacent Nt* functions in ntdll's export table have consecutive SSNs.

C (Neighbor Scan Fallback)// Fallback: scan neighboring stubs at 32-byte intervals
// ntdll syscall stubs are laid out sequentially, each ~32 bytes apart
// SSNs increment by 1 for each adjacent stub

BOOL ResolveSsnFallback(PVOID functionAddress, PWORD outSsn) {
    PBYTE stub = (PBYTE)functionAddress;

    // Search downward (previous stubs)
    for (int i = 1; i < 500; i++) {
        PBYTE neighbor = stub - (i * 32);  // 32-byte stub spacing

        if (neighbor[0] == 0x4C &&
            neighbor[1] == 0x8B &&
            neighbor[2] == 0xD1 &&
            neighbor[3] == 0xB8) {
            // Found an unhooked neighbor!
            // Its SSN + i = our SSN (since SSNs are sequential)
            *outSsn = *(PWORD)(neighbor + 4) + (WORD)i;
            return TRUE;
        }
    }

    // Search upward (next stubs)
    for (int i = 1; i < 500; i++) {
        PBYTE neighbor = stub + (i * 32);

        if (neighbor[0] == 0x4C &&
            neighbor[1] == 0x8B &&
            neighbor[2] == 0xD1 &&
            neighbor[3] == 0xB8) {
            // Found an unhooked neighbor!
            // Its SSN - i = our SSN
            *outSsn = *(PWORD)(neighbor + 4) - (WORD)i;
            return TRUE;
        }
    }

    return FALSE;  // All neighbors hooked (extremely unlikely)
}

Why This Works

EDRs hook specific high-value functions (NtAllocateVirtualMemory, NtWriteVirtualMemory, NtOpenProcess, etc.) but they don't hook every single Nt* function. There are 400+ syscalls in modern Windows. Hooking them all would cause severe performance degradation. So there are always unhooked neighbors nearby. The neighbor's SSN ± the offset gives the target function's SSN.

This technique is conceptually similar to Halo's Gate (by Sektor7 / reenz0h), which also scans neighbors when the target stub is hooked. The key insight: even if the EDR hooks your target function, it almost certainly left a neighbor unhooked within a few stubs.

5. Comparison with Other SSN Resolution Methods

SSN Resolution Technique Comparison

TechniqueAuthorMethodHandles Hooks?Weakness
Hell's Gateam0nsec, smelly__vxPattern match on target stubNo — fails if stub is hookedNo fallback mechanism
Halo's GateSektor7Hell's Gate + neighbor scanYes — scans up/down neighborsFixed 32-byte stride assumption
Tartarus' Gatetrickster0Halo's Gate + multi-byte JMP detectionYes — handles E9 and FF 25 hooksStill relies on neighbor availability
SysWhispers2jthuraisamySort Zw* functions by addressYes — doesn't read stub bytesRequires iterating entire EAT
MDSec Exception DirMDSecWalk RUNTIME_FUNCTION entriesYes — uses metadata, not bytesAssumes .pdata ordering = SSN ordering
DraugrNtDallasPattern match + neighbor fallbackYes — combined approachSame neighbor availability assumption

Draugr's approach is pragmatic: try the fast path first (direct pattern match), fall back to neighbor scanning if hooked. This provides reliable SSN resolution with minimal code complexity — important for a BOF where every byte counts.

6. How Draugr Executes the Syscall

After resolving the SSN and locating the syscall instruction address inside ntdll, Draugr's spoof routine executes the actual system call. The sequence combines SSN resolution, synthetic frame construction, and indirect syscall execution:

Draugr Syscall Execution Chain

Resolve SSN
(pattern / fallback)
Find syscall;ret
addr in ntdll
Build synthetic
stack frames
Set EAX = SSN
R10 = arg1
JMP to ntdll
syscall;ret

The spoof assembly routine (detailed in Module 5) performs these register operations just before the jump:

x86-64 ASM (Spoof Routine - Syscall Phase); At this point, synthetic frames are already built on the stack
; Arguments are in their correct positions (RCX/RDX/R8/R9 + stack)

mov  r10, rcx              ; Copy 1st arg to R10 (syscall ABI requirement)
mov  eax, [ssn_value]      ; Load the resolved System Service Number
jmp  qword ptr [syscall_addr]  ; Jump to syscall;ret inside ntdll

; Execution continues at ntdll:
;   0F 05    syscall        ; Transition to kernel (Ring 0)
;   C3       ret            ; Return to our spoof routine's cleanup
;
; At this moment, if the EDR inspects the stack:
;   - InstrumentationCallback sees return addr in ntdll (PASS)
;   - Stack walker sees BaseThreadInitThunk + RtlUserThreadStart (PASS)
;   - All return addresses are in backed memory (PASS)

The Syscall Address: Where to Jump

Draugr needs the address of the syscall instruction (bytes 0F 05) inside the target function's stub in ntdll. Even if the stub is hooked, the syscall;ret bytes at the end of the stub are typically preserved (the hook only replaces the first 5-7 bytes). Draugr locates these bytes by scanning forward from the stub address:

C// Find the syscall instruction within the stub
PVOID FindSyscallAddr(PVOID stubAddr) {
    PBYTE p = (PBYTE)stubAddr;
    for (int i = 0; i < 32; i++) {
        // Look for: 0F 05 C3 (syscall; ret)
        if (p[i] == 0x0F && p[i+1] == 0x05 && p[i+2] == 0xC3) {
            return &p[i];  // Address of syscall instruction
        }
    }
    return NULL;
}

The jump target is the 0F 05 address, so execution flows: syscall (kernel transition) → ret (return to Draugr's cleanup code). The return address on the stack at this point is controlled by Draugr's synthetic frame layout.

Complete SSN + Syscall Resolution Flow

StepActionOutput
1Hash target function name (e.g., NtAllocateVirtualMemory)Hash value for EAT lookup
2Walk ntdll EAT, match hash to export nameFunction RVA (stub address)
3Check stub bytes for 4C 8B D1 B8 patternSSN (if unhooked) or failure
4If hooked: scan neighbors at 32-byte intervalsSSN via neighbor offset arithmetic
5Scan stub for 0F 05 C3 (syscall;ret) patternIndirect syscall jump target address
6Store SSN + syscall address in VxTable entryReady for spoof routine to use

All of this resolution happens once during Draugr's initialization phase (InitVxTable). The resolved SSNs and syscall addresses are stored in a table structure so subsequent calls don't need to re-resolve.

Module 4 Quiz: SSN Resolution & Indirect Syscalls

Q1: Why does the syscall stub execute mov r10, rcx before syscall?

The x86-64 syscall instruction saves the current RIP into RCX and RFLAGS into R11. This destroys whatever was in RCX (the first argument per the Windows x64 calling convention). The stub copies RCX to R10 first so the kernel can read the first argument from R10. This is specific to the Windows syscall ABI — Linux uses a different register convention.

Q2: When Draugr's primary SSN resolution fails (stub is hooked), how does the neighbor fallback determine the correct SSN?

Syscall stubs in ntdll are laid out sequentially (~32 bytes apart), and their SSNs are consecutive integers. If NtAllocateVirtualMemory (SSN 0x18) is hooked but NtOpenSection (SSN 0x1A, two stubs later) is not, Draugr reads SSN 0x1A from the clean neighbor and subtracts the offset (2) to get 0x18. This works because EDRs hook selectively — they never hook all 400+ syscall stubs.