Chapter 23

Module List

The PEB's three module lists, LDR_DATA_TABLE_ENTRY internals, module list unlinking as a rootkit technique, and cross-view detection to find hidden DLLs

Scenario

A post-exploitation implant injects a reflective DLL into a remote process. After injection it unlinks the DLL's LDR_DATA_TABLE_ENTRY from all three PEB module lists. Now EnumProcessModules returns nothing suspicious, Process Monitor shows no DLL load event for that address range, and a casual memory scan doesn't correlate the PE header with any known module. Yet the code is running. Finding it requires cross-view analysis: compare what the module lists say against what the VAD tree and memory headers say.

The Three Module Lists

The PEB's Ldr field points to a PEB_LDR_DATA structure, which contains three doubly-linked lists of LDR_DATA_TABLE_ENTRY structures — one per loaded DLL. The same entry appears in all three lists simultaneously, linked through three different LIST_ENTRY pairs embedded in the entry structure:

PEB (gs:[0x60])
  +0x018 Ldr → PEB_LDR_DATA
              +0x00C InLoadOrderModuleList.Flink  → LDTE₀ → LDTE₁ → ... → (back to head)
              +0x014 InMemoryOrderModuleList.Flink → LDTE₀ → LDTE₁ → ... → (back to head)
              +0x01C InInitializationOrderModuleList.Flink → LDTE₀ → ...

Each LDR_DATA_TABLE_ENTRY has three LIST_ENTRY pairs:
  +0x000 InLoadOrder    { Flink, Blink }   ← ordered by load sequence
  +0x010 InMemoryOrder  { Flink, Blink }   ← ordered by memory address
  +0x020 InInitOrder    { Flink, Blink }   ← ordered by initialization
  +0x030 DllBase        (load address)
  +0x038 EntryPoint     (address of DllMain)
  +0x040 SizeOfImage
  +0x048 FullDllName    UNICODE_STRING      (full path)
  +0x058 BaseDllName    UNICODE_STRING      (filename only)
  +0x068 Flags
  +0x074 LoadCount / ObsoleteLoadCount
  +0x094 TimeDateStamp
  +0x0A0 ParentDllBase  (DLL that caused this load)
List NameOrderTypical First EntryUsed By
InLoadOrder Chronological load sequence Main executable, then ntdll.dll, then kernel32.dll General enumeration (most code uses this)
InMemoryOrder Ascending VA Whatever loaded at the lowest address VA-to-module lookups
InInitializationOrder DllMain call sequence ntdll.dll (first initialized), then kernel32.dll Initialization dependencies

Walking the Module List in Assembly

; x64 — walk InLoadOrderModuleList, print DllBase for each entry
; gs:[0x60] = PEB
; PEB+0x18  = Ldr (PEB_LDR_DATA*)
; PEB_LDR_DATA+0x10 = InLoadOrderModuleList (LIST_ENTRY head)
; LDR_DATA_TABLE_ENTRY+0x30 = DllBase
; LDR_DATA_TABLE_ENTRY+0x58 = BaseDllName (UNICODE_STRING)

    mov rbx, qword [gs:0x60]    ; rbx = PEB
    mov rbx, [rbx + 0x18]       ; rbx = Ldr
    lea rbx, [rbx + 0x10]       ; rbx = &InLoadOrderModuleList (head)
    mov rcx, [rbx]              ; rcx = first entry Flink
.loop:
    cmp rcx, rbx                ; back to head? (circular list)
    je  .done
    ; LDR_DATA_TABLE_ENTRY starts at the InLoadOrder field
    ; so rcx IS the LDR_DATA_TABLE_ENTRY pointer here
    mov rax, [rcx + 0x30]       ; DllBase
    ; BaseDllName at +0x58: UNICODE_STRING{Length, MaxLen, Buffer}
    mov rdx, [rcx + 0x58 + 8]   ; Buffer (wchar_t*)
    ; ... print rdx as wide string, rax as base address ...
    mov rcx, [rcx]              ; rcx = Flink (next entry)
    jmp .loop
.done:

Module List DKOM (DLL Hiding)

The PEB module lists are ordinary user-mode doubly-linked lists — there's no kernel protection over them. Any code with write access to the process's memory (including the process itself) can unlink an entry from the list by patching the neighboring entries' Flink/Blink pointers, making the DLL invisible to any tool that enumerates via the PEB:

// Unlink a DLL from all three PEB module lists
// entry = LDR_DATA_TABLE_ENTRY* for the DLL to hide
void HideModule(PLDR_DATA_TABLE_ENTRY entry)
{
    // LIST_ENTRY: { Flink, Blink }
    // Unlinking: prev->Flink = next; next->Blink = prev
    PLIST_ENTRY p;

    // InLoadOrder
    p = &entry->InLoadOrderLinks;
    p->Blink->Flink = p->Flink;
    p->Flink->Blink = p->Blink;

    // InMemoryOrder
    p = &entry->InMemoryOrderLinks;
    p->Blink->Flink = p->Flink;
    p->Flink->Blink = p->Blink;

    // InInitializationOrder
    p = &entry->InInitializationOrderLinks;
    p->Blink->Flink = p->Flink;
    p->Flink->Blink = p->Blink;
}
Why this is user-mode only

Module list DKOM is strictly user-mode. The kernel's process object (EPROCESS) also has references to loaded modules accessible from kernel tools like !process in WinDbg or kernel callbacks. A kernel-mode rootkit can hide modules from kernel enumeration too (different structures), but a user-mode DLL can only hide from user-mode enumeration. This means EDR drivers using kernel callbacks (ObRegisterCallbacks, PsSetLoadImageNotifyRoutine) see all module loads before unlinking can occur — the load event fires at load time, before any user-mode code runs.

Tools and APIs for Module Enumeration

MethodSourceAffected by PEB unlink?
EnumProcessModules (psapi) PEB InMemoryOrderModuleList (user-mode) Yes — unlinked DLLs invisible
CreateToolhelp32Snapshot (TH32CS_SNAPMODULE) PEB InLoadOrderModuleList (user-mode) Yes — unlinked DLLs invisible
VirtualQueryEx loop VAD tree (kernel) — scans all committed regions No — finds all PE headers in memory regardless of PEB
NtQueryVirtualMemory (MemoryMappedFilenameInformation) File-backed sections: gets backing file path from VAD No — VAD-based, not PEB-based
Kernel debugger (!lm) Kernel module list (EPROCESS, kernel-side LDR) No — separate structure

Cross-View Detection

The canonical approach to finding hidden DLLs is cross-view comparison: collect module lists from two independent sources and find discrepancies.

"""
Cross-view module detection:
  View 1 = PEB module list (what EnumProcessModules sees)
  View 2 = VAD walk via VirtualQueryEx (what's actually in memory)
Discrepancy = image region in VAD with no matching PEB entry.
"""
import ctypes, ctypes.wintypes

MEM_IMAGE    = 0x1000000
MEM_COMMIT   = 0x1000
PAGE_EXECUTE = 0x10

class MEMORY_BASIC_INFORMATION(ctypes.Structure):
    _fields_ = [
        ("BaseAddress",       ctypes.c_uint64),
        ("AllocationBase",    ctypes.c_uint64),
        ("AllocationProtect", ctypes.c_uint32),
        ("__alignment1",      ctypes.c_uint32),
        ("RegionSize",        ctypes.c_uint64),
        ("State",             ctypes.c_uint32),
        ("Protect",           ctypes.c_uint32),
        ("Type",              ctypes.c_uint32),
        ("__alignment2",      ctypes.c_uint32),
    ]

def get_image_bases_from_vad(hProc: int) -> set[int]:
    """Walk VAD via VirtualQueryEx, collect MEM_IMAGE AllocationBases."""
    bases = set()
    addr  = 0
    mbi   = MEMORY_BASIC_INFORMATION()
    while ctypes.windll.kernel32.VirtualQueryEx(
            hProc, ctypes.c_void_p(addr),
            ctypes.byref(mbi), ctypes.sizeof(mbi)) == ctypes.sizeof(mbi):
        if mbi.State == MEM_COMMIT and mbi.Type == MEM_IMAGE:
            bases.add(mbi.AllocationBase)
        addr = mbi.BaseAddress + mbi.RegionSize
        if addr >= (1 << 47):
            break
    return bases

def get_module_bases_from_peb(hProc: int) -> set[int]:
    """Use EnumProcessModules (PEB-backed) to get known module bases."""
    EnumProcessModules = ctypes.windll.psapi.EnumProcessModules
    buf = (ctypes.wintypes.HMODULE * 1024)()
    needed = ctypes.wintypes.DWORD()
    EnumProcessModules(hProc, buf, ctypes.sizeof(buf), ctypes.byref(needed))
    count = needed.value // ctypes.sizeof(ctypes.wintypes.HMODULE)
    return {buf[i] for i in range(count)}

def find_hidden_modules(pid: int):
    OpenProcess = ctypes.windll.kernel32.OpenProcess
    hProc = OpenProcess(0x0410, 0, pid)  # QUERY_INFORMATION | VM_READ
    vad_bases = get_image_bases_from_vad(hProc)
    peb_bases = get_module_bases_from_peb(hProc)
    hidden = vad_bases - peb_bases
    if hidden:
        print(f"[!] PID {pid}: {len(hidden)} image region(s) not in PEB module list:")
        for b in hidden:
            print(f"    Hidden image base: 0x{b:016X}")
    ctypes.windll.kernel32.CloseHandle(hProc)
    return hidden

Q & A

If PsSetLoadImageNotifyRoutine fires before the DLL is in user-mode memory, how does the unlinking technique evade EDR detection?

It doesn't — completely. PsSetLoadImageNotifyRoutine fires immediately when the image section is mapped into user-mode memory, before the loader inserts the LDR_DATA_TABLE_ENTRY and before DllMain runs. An EDR with a kernel driver registers this callback and receives the notification (module base, image size, full path) at that point. This gives the EDR a chance to hash the module, check its signature, and decide whether to allow it. The EDR records: "this address range is module X." Later, when user-mode code unlinks the entry from the PEB lists, the kernel's callback record still shows the module was loaded. Where unlinking succeeds as evasion: (1) User-mode scanners that rely solely on PEB enumeration (legacy AV, simple tooling). (2) In-process self-defense checks — if the malicious DLL unlinks itself, the host process's own security checks don't see it when enumerating its own modules. (3) Memory forensics tools that enumerate via APIs rather than raw VAD analysis. Against a kernel-mode EDR with proper callback use, PEB unlinking alone is not sufficient evasion. The combination of reflective loading (no kernel load event) + PEB unlinking is more effective because reflective loading maps the PE manually without going through the standard loader path, avoiding the PsSetLoadImageNotifyRoutine callback entirely in some cases. More recent Windows versions (HVCI-enabled systems) further restrict this.

What is the HashLinks field in LDR_DATA_TABLE_ENTRY and why does it matter for unhooking?

Modern versions of LDR_DATA_TABLE_ENTRY include a HashLinks field: a LIST_ENTRY that inserts the entry into a hash table indexed by DLL name. The hash table (LdrpHashTable in ntdll) is used for fast name-based lookups: when a new DllMain calls LoadLibrary("kernel32.dll"), the loader hashes the name and walks only the hash bucket, rather than scanning all 50+ loaded modules linearly. For DLL hiding via unlinking: if you only unlink from the three ordered lists (InLoadOrder/InMemoryOrder/InInitOrder) but forget to unlink from HashLinks, then: (1) A name-based search using the internal hash table will still find your module. (2) GetModuleHandle("yourdll.dll") succeeds via hash table lookup even though PEB enumeration doesn't show it. Complete hiding requires unlinking from all four lists. Some rootkit implementations miss the hash table, leaving a detectable artifact. Defenders can walk the hash table independently and cross-reference against the ordered lists to find entries present in one but not the other.