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
syscallinstruction is in your code (EXE, BOF, shellcode) - SSN loaded into EAX,
syscallexecuted 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;retinside 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
Indirect Syscall (Draugr)
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:
- Get ntdll base address from the PEB (Process Environment Block)
- Parse the PE headers to find the Export Directory
- Walk the AddressOfNames array, hash each name, compare to target hash
- Use the matching ordinal to index into AddressOfFunctions
- 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
| Technique | Author | Method | Handles Hooks? | Weakness |
|---|---|---|---|---|
| Hell's Gate | am0nsec, smelly__vx | Pattern match on target stub | No — fails if stub is hooked | No fallback mechanism |
| Halo's Gate | Sektor7 | Hell's Gate + neighbor scan | Yes — scans up/down neighbors | Fixed 32-byte stride assumption |
| Tartarus' Gate | trickster0 | Halo's Gate + multi-byte JMP detection | Yes — handles E9 and FF 25 hooks | Still relies on neighbor availability |
| SysWhispers2 | jthuraisamy | Sort Zw* functions by address | Yes — doesn't read stub bytes | Requires iterating entire EAT |
| MDSec Exception Dir | MDSec | Walk RUNTIME_FUNCTION entries | Yes — uses metadata, not bytes | Assumes .pdata ordering = SSN ordering |
| Draugr | NtDallas | Pattern match + neighbor fallback | Yes — combined approach | Same 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
(pattern / fallback)
addr in ntdll
stack frames
R10 = arg1
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
| Step | Action | Output |
|---|---|---|
| 1 | Hash target function name (e.g., NtAllocateVirtualMemory) | Hash value for EAT lookup |
| 2 | Walk ntdll EAT, match hash to export name | Function RVA (stub address) |
| 3 | Check stub bytes for 4C 8B D1 B8 pattern | SSN (if unhooked) or failure |
| 4 | If hooked: scan neighbors at 32-byte intervals | SSN via neighbor offset arithmetic |
| 5 | Scan stub for 0F 05 C3 (syscall;ret) pattern | Indirect syscall jump target address |
| 6 | Store SSN + syscall address in VxTable entry | Ready 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?
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?