core-jmp core-jmpdeath of core jump

NTDLL Unhooking Rants: KnownDlls, VADs, and RWX Page Detection

A technical deep dive into NTDLL unhooking techniques using KnownDlls section mapping, Virtual Address Descriptors, and RWX page detection to bypass EDR hooks—covering Windows memory internals, kernel behavior, and practical code examples.

oxfemale August 7, 2026 8 min read 105 reads
Export PDF
NTDLL Unhooking Rants: KnownDlls, VADs, and RWX Page Detection
Original text: “NTDLL unhooking rants”@whokilleddb, DB Yaps (August 2, 2026). Code blocks, tables, and figures below are reproduced verbatim with attribution captions.

Executive Summary

This article explores the technical landscape of NTDLL unhooking—a critical technique for bypassing EDR hooks and defeating API hooking defenses. Rather than a comprehensive tutorial, it presents real-world observations and lessons learned while implementing NTDLL unhooking via KnownDlls section mapping. The author examines how Windows memory management, Virtual Address Descriptors (VADs), and section objects interact to enable the creation of clean NTDLL copies, then demonstrates detection evasion strategies based on page protection anomalies.

The core value lies in understanding the Windows kernel’s section object architecture, how system tools like System Informer enumerate loaded modules, and practical implications for detection and evasion. The article uses real Procmon traces, C code examples, Frida instrumentation, and WinDbg disassembly to ground these concepts, making it essential reading for security engineers working on offensive tooling, EDR bypass, or memory forensics.

The KnownDlls Mapping Technique

Disclaimer: This article is NOT a tutorial about unhooking NTDLL—it’s more of a rant about the different things encountered while unhooking NTDLL: memory permissions, VADs, mapping stuff, which the author thinks other people might find interesting.

A common approach to NTDLL unhooking is mapping a clean copy from the KnownDlls section object. The technique is straightforward: open the \KnownDlls\ntdll.dll section object, map it into the current process, and use it as a reference to restore hooked pages. Here is a pseudo-code snippet showing the basic approach:

OBJECT_ATTRIBUTES objattr = {0};
HANDLE hSection = NULL; 
uniNtdll.Buffer = (PWSTR) KNOWN_NTDLL;
uniNtdll.Length = wcslen(KNOWN_NTDLL) * sizeof(WCHAR);
uniNtdll.MaximumLength = uniNtdll.Length + sizeof(WCHAR);

InitializeObjectAttributes(&objattr, &uniNtdll, OBJ_CASE_INSENSITIVE, NULL, NULL);
HMODULE hmodNtdll = GetModuleHandleA("ntdll");
    
fNtOpenSection pNtOpenSection = (fNtOpenSection) GetProcAddress(hmodNtdll, "NtOpenSection");
    
NTSTATUS status = pNtOpenSection(&hSection, SECTION_MAP_READ, &objattr);
LPVOID pNtdllMapped = MapViewOfFile(hSection, FILE_MAP_READ, 0, 0, 0);
CloseHandle(hSection);

This code opens the KnownDlls NTDLL section and maps it into memory. The process should now have two copies of NTDLL: the original (hooked) and the clean mapped copy. But one question immediately arises: why does this show up as a module in process introspection tools?

System Informer and Virtual Address Descriptors

To understand how tools like System Informer enumerate loaded modules, we first examine the \KnownDlls\ntdll.dll section object:

System Informer showing two ntdll.dll modules mapped into one process
Two NTDLL copies mapped in the same process. Source: original article.

The section is a SEC_IMAGE object. To confirm, Procmon was set to log boot-time events. Here’s what happens when smss.exe creates the section:

Object manager view of \KnownDlls\ntdll.dll showing SEC_IMAGE
KnownDlls ntdll.dll section object properties. Source: original article.
Process NamePIDOperationPathResultDetail
smss.exe512Load ImageC:\Windows\System32\ntdll.dllSUCCESSImage Base: 0x7ff901ea0000, Image Size: 0x266000
smss.exe512CreateFileC:\Windows\System32\ntdll.dllSUCCESSDesired Access: Execute/Traverse, Read Control, Synchronize, Disposition: Open, Options: Synchronous IO Non-Alert, Non-Directory File
smss.exe512CreateFileMappingC:\Windows\System32\ntdll.dllFILE LOCKED WITH ONLY READERSSyncType: SyncTypeCreateSection, PageProtection: PAGE_EXECUTE
smss.exe512CreateFileMappingC:\Windows\System32\ntdll.dllSUCCESSSyncType: SyncTypeOther
smss.exe512Load ImageC:\Windows\System32\ntdll.dllSUCCESSImage Base: 0x1fd86800000, Image Size: 0x266000
smss.exe512CloseFileC:\Windows\System32\ntdll.dllSUCCESS
Procmon trace of KnownDlls NTDLL section creation at boot. Source: original article.

The key event is the CreateFileMapping with SyncType: SyncTypeCreateSection and PageProtection: PAGE_EXECUTE. This is Procmon’s representation of NtCreateSection being called with SEC_IMAGE semantics. The flow is: smss.exe reads ntdll.dll from disk, creates a section object (the FILE LOCKED WITH ONLY READERS is normal—shared lock for section creation), verifies the image, then closes the disk file handle.

System Informer does not just walk the Process Environment Block (PEB) and Loader Data Table (LDR). It also uses Virtual Address Descriptors. From Windows Internals Part 1:

When a process reserves address space or maps a view of a section, the memory manager creates a VAD to store any information supplied by the allocation request, such as the range of addresses being reserved, whether the range will be shared or private, whether a child process can inherit the contents of the range, and the page protection applied to pages in the range.

When smss.exe creates the section object with SEC_IMAGE, it tells the kernel memory manager: “this section backs a PE image”. When mapping it via MapViewOfFile(), the kernel does NOT perform a flat byte-for-byte file mapping. Instead, it maps each PE section at its virtual address offset (not raw file offset)—.text at one RVA, .data at another, etc. From Windows Internals:

The section object pointers structure points to one or two control areas. One control area is used to map the file when it is accessed as a data file and the other is used to map the file when it is run as an executable image. A control area in turn points to subsection structures that describe the mapping information for each section of the file (read-only, read/write, copy-on write, and so on). The control area also points to a segment structure allocated in paged pool, which in turn points to the prototype PTEs used to map to the actual pages mapped by the section object.

This architecture means:

  • Image files get a distinct image control area (separate from the data control area)
  • The control area points to subsection structures, one per PE section, each describing a different range with its own protection attributes
  • The segment’s prototype PTEs ultimately map virtual addresses to physical pages

The memory manager tags every page in the resulting region with memory type MEM_IMAGE (0x1000000). This allows querying via NtQueryVirtualMemory() with MemoryMappedFilenameInformation:

void EnumImageMappings() {
    MEMORY_BASIC_INFORMATION mbi = {0};
    MEMORY_MAPPED_FILE_NAME_INFORMATION nameInfo = {0};
    UCHAR *addr = NULL;
    SIZE_T retLen = 0;
    LPVOID lastBase = NULL;

    printf("\n[+] === MEM_IMAGE mappings (what System Informer sees) ===\n");
    printf("    %-18s  %-10s  %s\n", "Base Address", "Size", "Mapped File");
    printf("    %-18s  %-10s  %s\n", "------------", "----", "-----------");

    while (VirtualQuery(addr, &mbi, sizeof(mbi))) {
        if (mbi.Type == MEM_IMAGE && mbi.AllocationBase != lastBase) {
            lastBase = mbi.AllocationBase;

            NTSTATUS status = NtQueryVirtualMemory(
                GetCurrentProcess(),
                mbi.AllocationBase,
                MemoryMappedFilenameInformation,
                &nameInfo,
                sizeof(nameInfo),
                &retLen
            );

            if (status == STATUS_SUCCESS) {
                printf("    0x%-16p  0x%-8lx  %.*ls\n",
                    mbi.AllocationBase,
                    (unsigned long)mbi.RegionSize,
                    (int)(nameInfo.Name.Length / sizeof(WCHAR)),
                    nameInfo.Name.Buffer);
            } else {
                printf("    0x%-16p  0x%-8lx  \n",
                    mbi.AllocationBase,
                    (unsigned long)mbi.RegionSize,
                    status);
            }
        }

        addr += mbi.RegionSize;
        if (addr < (UCHAR*)mbi.BaseAddress)
            break;
    }

    printf("\n");
}

Running this function before and after mapping NTDLL verifies the claim:

Console output of EnumImageMappings listing MEM_IMAGE regions
MEM_IMAGE mappings before and after mapping NTDLL from KnownDlls. Source: original article.

Detecting and Removing RWX Pages

Now that we understand how to map a clean NTDLL copy, the next step is to identify and restore hooked pages. For testing, Frida is used to hook NtAllocateVirtualMemory():

var pNtAllocateVirtualMemory = Module.findExportByName("ntdll.dll", "NtAllocateVirtualMemory");

Interceptor.attach(pNtAllocateVirtualMemory, {
    onEnter: function (args) {
        send("[+] Called NtAllocateVirtualMemory [+]");
    }
});

Before hooking, the address looks like this:

Memory view of NtAllocateVirtualMemory prior to hooking
NtAllocateVirtualMemory before Frida hook. Source: original article.

After hooking, the address protection changes dramatically:

Memory view after hooking showing PAGE_EXECUTE_READWRITE protection
NtAllocateVirtualMemory after Frida hook. Note the protection change to PAGE_EXECUTE_READWRITE. Source: original article.

The crucial observation: the memory protection changed from PAGE_EXECUTE_READ to PAGE_EXECUTE_READWRITE. This suggests a strategy: instead of mapping and restoring the entire .text section, simply scan for pages with RWX permissions and overwrite them with clean bytes from the mapped NTDLL:

BOOL FindAndRestoreHookedPages(LPVOID pOrigTextSection, LPVOID pCleanTextSection, DWORD textSize) {
    MEMORY_BASIC_INFORMATION mbi = {0};
    UCHAR *addr = (UCHAR *)pOrigTextSection;
    UCHAR *end  = addr + textSize;
    BOOL found = FALSE;

    printf("\n[+] === Scanning original NTDLL .text for RWX pages ===\n");

    while (addr < end && VirtualQuery(addr, &mbi, sizeof(mbi))) {
        DWORD prot = mbi.Protect & 0xFF;
        if (prot == PAGE_EXECUTE_READWRITE) {
            ULONG_PTR offset = (ULONG_PTR)mbi.BaseAddress - (ULONG_PTR)pOrigTextSection;
            SIZE_T regionSize = mbi.RegionSize;

            if ((UCHAR *)mbi.BaseAddress + regionSize > end)
                regionSize = end - (UCHAR *)mbi.BaseAddress;

            printf("[!] RWX page found at 0x%p (offset 0x%llx, size 0x%llx)\n",
                mbi.BaseAddress,
                (unsigned long long)offset,
                (unsigned long long)regionSize);

            memcpy(mbi.BaseAddress, (UCHAR *)pCleanTextSection + offset, regionSize);
            printf("[+] Restored %llu bytes from clean NTDLL copy\n", (unsigned long long)regionSize);

           found = TRUE;
        }

        addr += mbi.RegionSize;
        if (addr < (UCHAR *)mbi.BaseAddress)
            break;
    }

    if (!found)
        printf("[+] No RWX pages found in .text section (no hooks detected)\n");

    return found;
}

Running the Frida hook again shows the detection in action:

Frida Interceptor attached to NtAllocateVirtualMemory
Frida hook installed. The RWX detection strategy will target pages with this protection. Source: original article.

Pressing Enter triggers the restoration routine:

Output of FindAndRestoreHookedPages restoring bytes from the clean NTDLL copy
RWX page scan output. Hooked bytes are identified and overwritten with clean bytes from the mapped NTDLL. Source: original article.

And on the WinDbg side:

WinDbg disassembly showing the hook removed after restoration
WinDbg view showing the successfully restored NtAllocateVirtualMemory prologue. Source: original article.

The hook has been successfully overwritten. Unmapping the section also removes it from the list of loaded modules. However, RWX memory regions are not always reliable indicators of EDR hooks—many EDR solutions revert to RX permissions after installation. Using NtQueryVirtualMemory(MemoryWorkingSetExInformation) to find private pages may be a more robust detection vector, but that is left as an exercise to the reader.

Another outstanding challenge is eliminating the “second NTDLL loaded” indicator of compromise. The author is still experimenting with approaches and will publish more when confident.

Key Takeaways

Defensive Recommendations

Conclusion

NTDLL unhooking via KnownDlls mapping is a sophisticated technique that exploits deep Windows kernel internals—specifically, the architecture of section objects, VADs, and image mapping semantics. Success requires understanding not just how to call the APIs but how the kernel tracks, manages, and exposes image memory regions. The RWX page detection method is effective against modern EDR hooks but represents only one detection vector; defenders must implement layered monitoring at both user and kernel levels to catch all variants of this technique.

References

Original text: “NTDLL unhooking rants” by @whokilleddb at DB Yaps.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion