← Back to Research
EDR EVASION RED TEAM TRADECRAFT

Bypassing User-Land Hooks with Indirect Syscalls

Modern Endpoint Detection and Response (EDR) solutions rely heavily on monitoring processes from within user space by injecting DLLs and placing JMP hook instructions at the beginning of sensitive Windows API functions — functions like NtAllocateVirtualMemory, NtWriteVirtualMemory, and NtCreateThreadEx. This research demonstrates how indirect syscalls defeat both hook-based detection and modern call-stack validation.

The Hook Problem

When an EDR hooks NtAllocateVirtualMemory, the first bytes of the function inside ntdll.dll are overwritten with a JMP to the EDR's inspection routine. The EDR sees every call, logs suspicious context, and can terminate the process before the syscall executes.

Direct Syscalls — Why They Failed

The first evasion technique was Direct Syscalls: manually hardcoding the System Service Number (SSN) and executing the syscall instruction from within your own binary, bypassing ntdll.dll entirely. EDR vendors quickly adapted: they now monitor the call stack origin. If a syscall instruction originates from outside the legitimate ntdll.dll memory region, it's flagged immediately.

The Indirect Syscall Solution

Indirect Syscalls solve the call-stack problem. Instead of executing the syscall instruction from our binary, we load the SSN and arguments into registers, then jump to an unhooked syscall gadget that already exists inside ntdll.dll. From the EDR's call-stack perspective, the syscall originates from a legitimate Windows library.

// Rust: Dynamically resolve SSN and find the syscall gadget in ntdll use std::arch::asm; fn indirect_syscall(ssn: u32, syscall_addr: usize, args: ...) { unsafe { asm!( "mov r10, rcx", "mov eax, {ssn:e}", // Load the SSN "jmp {addr}", // Jump INTO ntdll.dll's syscall instruction ssn = in(reg) ssn, addr = in(reg) syscall_addr, ); } } fn get_syscall_addr(func_name: &str) -> usize { // Walk ntdll.dll export table to find the function // Scan bytes for: 0x0F 0x05 (syscall instruction) // Return address of first unhooked syscall gadget find_syscall_gadget_in_ntdll(func_name) }

Results on Live Engagements

Implemented in a custom Rust loader using this technique, we successfully blinded the telemetry of two major EDR platforms during our Q1 2026 engagements — loading reflective DLLs entirely undetected and maintaining persistent access for 14 days before simulated detection. The Rust implementation provides the added benefits of:

  • No managed runtime — no CLR or JVM to detect
  • Compiler-level optimizations that make static analysis harder
  • Memory safety preventing accidental crashes that would alert defenders
  • Easy cross-compilation to target ARM (cloud) and x86-64 (workstation)

Detection Recommendations

Defenders aren't helpless. These techniques can still be caught with the right controls:

  • Enable kernel-mode call-stack validation (requires EDR with kernel sensor)
  • Monitor for anomalous modules with low entropy loaded into memory
  • Alert on processes calling sensitive APIs without a corresponding ntdll import table entry
  • Deploy deception technology — canary tokens in LSASS that trigger on delegated reads
! This research is published for defensive purposes only. Code samples are provided to help red teams and blue teams understand modern offensive tradecraft. Misuse outside authorized engagements is illegal.

Test your EDR against real adversary techniques

We bring this tradecraft to authorized red team engagements — proving exactly what your EDR misses.

Request Red Team Engagement