Indirect syscalls are not a personality

Why indirect syscalls buy less than people think, and what the detection side actually keys on.

Aug 28, 2026·10 min read·Medium difficulty·Lab derived
Share
  • #Syscalls
  • #EDR Evasion
  • #Detection
  • #Implants

§The appeal

Direct syscalls (calling `syscall` instruction directly from your code) bypass userland hooks placed by EDR on ntdll exports. Indirect syscalls go one step further: you jump into ntdll's own `syscall` instruction, so the call stack shows a legitimate ntdll frame. The marketing writes itself. The reality is more boring.

§What indirect syscalls actually do

Instead of `syscall` in your code, you locate the `syscall; ret` gadget inside ntdll (e.g. in `NtAllocateVirtualMemory`) and call it with the syscall number in EAX and arguments in the registers. The stack frame now points into ntdll, which defeats naive "is the caller in a known module" checks.

c
// Hell's Gate style: read syscall number from ntdll stub
DWORD get_syscall_number(const char *name) {
    HMODULE ntdll = GetModuleHandleA("ntdll.dll");
    void *fn = GetProcAddress(ntdll, name);
    // stub: mov r10, rcx; mov eax, <ssn>; syscall; ret
    unsigned char *p = (unsigned char *)fn;
    if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8) {
        return *(DWORD *)(p + 4);
    }
    return 0;
}

// Indirect: jump into ntdll's syscall gadget
extern void do_syscall(void *gadget, DWORD ssn, ...);

void *find_gadget(const char *name) {
    void *fn = GetProcAddress(GetModuleHandleA("ntdll.dll"), name);
    unsigned char *p = (unsigned char *)fn;
    for (int i = 0; i < 32; i++) {
        if (p[i] == 0x0F && p[i+1] == 0x05) return &p[i]; // syscall
    }
    return NULL;
}

§Where detection actually lives

  1. 01**ETW Threat Intelligence** (requires EDR kernel driver, enabled on Windows 10+): logs `NtAllocateVirtualMemory`, `NtProtectVirtualMemory`, `NtWriteVirtualMemory` regardless of how they are invoked. Direct and indirect syscalls both trigger ETW TI events. This is the single biggest reason syscall-based evasion is dead against modern EDRs.
  1. 01**Call stack analysis**: indirect syscalls produce a stack with an ntdll frame, but the *return address* into ntdll points to a `syscall` instruction, not a function entry. EDRs that walk the stack (Elastic, CrowdStrike) check whether the return address is at a function boundary. A `syscall` instruction in the middle of `NtAllocateVirtualMemory` is suspicious.
  1. 01**Frequency/volume**: a process making 1000 syscalls per second with no corresponding ntdll call exports is anomalous. Behavioral models catch this.
  1. 01**Memory scanning of the gadget**: the gadget address is a known ntdll offset. EDRs can check whether the thread's RIP is at a `syscall` instruction inside ntdll *and* whether the preceding instructions match the expected stub. Indirect calls from unusual callers still stand out in ETW.

§What still works (honestly)

  • Against **userland-only** EDRs (older, hook-only), indirect syscalls still bypass hooks.
  • Against **specific** detection rules that only check the calling module name, indirect wins over direct.
  • Combined with sleep obfuscation and call-stack spoofing (e.g., SilentMoonwalk), the stack-walk detection can be defeated, but ETW TI remains.

§The honest take

Indirect syscalls are a stepping stone, not a destination. They solve the 2018 problem (userland hooks) and fail against the 2024 problem (kernel ETW + behavioral). If your threat model includes a modern EDR with TI enabled, syscalls alone will not save you. Spend the complexity budget on call-stack spoofing, ETW patching (which itself is a detection), or living inside a trusted process.

§Compile

bash
# MinGW
x86_64-w64-mingw32-gcc -O2 -s indirect.c -o indirect.exe -lntdll

# MSVC
cl /O2 /GS- indirect.c /link /OUT:indirect.exe ntdll.lib

§Detection (defender side)

  • Enable ETW Threat Intelligence (requires ELAM driver or Defender).
  • Alert on `NtAllocateVirtualMemory`/`NtProtectVirtualMemory` events where the calling module is not a known signed binary.
  • Stack-walk: flag threads whose RIP is at a `syscall` instruction but whose return address is not at a function entry in ntdll.

"

MJ

Written by

MJ — offensive security researcher

I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.

Next dispatch

One long-form entry, roughly twice a month.

Root cause, reproduction, and the detection path — the same standard every time.