Pavel Yosifovich, co-author of “Windows Internals 7th Edition, Part 1” and author of “Windows Native API Programming”, has already hidden a process with a kernel driver, hidden one partially through hooking, and hidden a service. This time the target is a DLL, and the whole technique runs in user mode, with nothing more than a few pointer writes. This post is for Windows systems programmers, malware analysts, and defenders who want to know exactly which tools a technique like this fools, and which ones it does not.
Where Does the Module List Actually Live?
Attach WinDbg to any process, say a plain cmd.exe, and the module list is right there: the executable plus every loaded DLL, with addresses and symbol information if you use the lm command. The question is where that list comes from. The answer is the Process Environment Block, or PEB, a user mode data structure that is not officially documented but has well understood fields. Run !peb and one of them is Ldr, short for loader, which points to the loader data. From there you get three separate lists: the initialization order module list, the load order module list, and the in-memory order module list. They do not have to agree on ordering, because initialization can happen out of step with loading, but all three are built on the same LIST_ENTRY structure, a doubly linked list node embedded directly inside each entry.
That embedding is the whole idea. If the module list is just a LIST_ENTRY chain, then removing a node is ordinary pointer surgery, and whatever walked that particular list will no longer see the module.
Enumerating Modules the Official Way
Before touching anything, it helps to enumerate modules through the documented API, so there is a baseline to compare against. EnumProcessModules from psapi.h takes a process handle, an array of HMODULE, and the array’s size in bytes, and hands back how many bytes were actually needed. HMODULE here is just the virtual address where a module is mapped. The function only gives you addresses, not names, so for a readable name you call GetModuleBaseName per module, which returns the short name rather than the full path. Run it against your own process and you get the expected list: the executable itself, ntdll.dll, and whatever else is loaded.
Walking the Same List Through the PEB
EnumProcessModules is doing this work for you. Doing it by hand means getting to the PEB yourself, and the simplest route for the current process is NtCurrentTeb()->ProcessEnvironmentBlock, the thread environment block’s pointer to the process environment block. The catch is which header declares the PEB. winternl.h, Microsoft’s own semi-internal header, declares PEB_LDR_DATA with most of its fields marked reserved, on the theory that undocumented layouts can change and nobody should depend on them. It still gives you enough: the Ldr field is there, and PEB_LDR_DATA exposes at least the in-memory order module list head. That head is the anchor, an item that only joins the list and holds no useful data itself. From it you get a LIST_ENTRY, and the structure that LIST_ENTRY is actually embedded in is LDR_DATA_TABLE_ENTRY, which winternl.h also redacts somewhat, but it does expose DllBase and FullDllName.
Walking the list means following Flink, the forward pointer, starting at the head, and stopping only when you land back on the head. These lists are circular, so there is no null terminator to watch for; you have to track where you started. At each node, CONTAINING_RECORD converts a pointer to the embedded LIST_ENTRY back into a pointer to the enclosing LDR_DATA_TABLE_ENTRY, using the offset of that member within the structure. FullDllName is a UNICODE_STRING, a pointer-plus-length descriptor rather than a null-terminated string, so printing it with plain %ws is a mistake; UNICODE_STRING values are not guaranteed to be null-terminated, and the right format specifier is %wZ. Run the manual walk next to EnumProcessModules and, unsurprisingly, the two agree.
Switching Headers to Get the Short Name
Hiding a specific DLL means matching it by name, and winternl.h‘s redacted LDR_DATA_TABLE_ENTRY only exposes the full path, not the short base name that is easiest to compare against. Rather than scanning the full path by hand, the practical move is to stop using winternl.h for this part and include PHNT instead, the header set used by Process Hacker and System Informer that declares the native structures in full, nothing marked reserved. The same LDR_DATA_TABLE_ENTRY under PHNT exposes the other two list heads too, since the PEB really does carry three, and critically it exposes BaseDllName, the short name field that winternl.h leaves out.
Comparing two UNICODE_STRING values correctly means avoiding the C runtime string functions like wcsicmp, because those assume null termination and UNICODE_STRING does not guarantee it. The correct call is RtlEqualUnicodeString, with case sensitivity set to false. Since the name you are hiding starts life as a plain C string, you have to wrap it first with RtlInitUnicodeString, which measures the length and sets up the descriptor without any allocation. It just points at the string that is already there.
Unlinking the Entry
Once the matching LDR_DATA_TABLE_ENTRY is found, hiding it is a doubly linked list removal, nothing specific to the PEB. Call the node you are removing next, with previous tracked as you walk, held via next->Blink, the backward link. The whole operation is two pointer writes: previous->Flink is set to next->Flink, so the previous node now points straight past the hidden one, and next->Flink->Blink is set to previous, so the node after the hidden one points backward correctly too, in case anyone walks the list in reverse. After that, the hidden LDR_DATA_TABLE_ENTRY itself is simply never reached by a forward or backward traversal starting from the head.
Run both enumeration functions again after unlinking a target like kernelbase.dll, and the official API no longer reports it. EnumProcessModules, and anything built on it, is fooled.
What a Debugger Sees, and What It Does Not
The more interesting test is WinDbg. Set a breakpoint before the unlink call, run !peb, and kernelbase.dll is listed, as expected, since nothing has happened yet. Step past the unlink, run !peb again, and it is gone from the loader data, same as the user mode enumeration. Run lm instead, and kernelbase.dll is still there. The lm command does not rely on the PEB’s loader lists at all; it has its own, more general way of finding modules. The same is true of the !address command, which shows the entire address space and still reports kernelbase.dll mapped exactly where it was, because unlinking the loader entry never unmapped anything. The module is still present in memory and still fully usable; the only thing that changed is whether a particular bookkeeping list mentions it.
That distinction is the whole lesson. Any Windows API built on EnumProcessModules, or any tool that walks the PEB directly, loses track of the module. A process explorer style tool that instead scans the address space with VirtualQueryEx, the way the real Process Explorer and Process Hacker both do, never asked the PEB in the first place, so there is nothing for this trick to fool there. Checked against a small Process Explorer style clone built the same way, the hidden DLL shows up exactly as before.
What This Means Practically
- The PEB’s
Ldrfield anchors threeLIST_ENTRY-based lists (init order, load order, in-memory order); unlinking aLDR_DATA_TABLE_ENTRYfrom one list removes it from that enumeration path only EnumProcessModulesand WinDbg’s!pebboth read from the PEB’s loader data, so both miss an unlinked module- WinDbg’s
lm, theaddresscommand, and tools that scan the address space withVirtualQueryEx(Process Explorer, Process Hacker) do not use the PEB loader lists, so unlinking does not fool them winternl.hredacts most ofPEB_LDR_DATAandLDR_DATA_TABLE_ENTRY; PHNT exposes the full layout, includingBaseDllName, needed to match on the short module name- Compare
UNICODE_STRINGvalues withRtlEqualUnicodeString, and build one from a plain string withRtlInitUnicodeString; the C runtime string functions assume null termination thatUNICODE_STRINGdoes not guarantee - Unlinking is two pointer writes on a doubly linked list (fix the forward link of the previous node, the backward link of the next node); the module stays mapped, nothing is unloaded
- From a defensive standpoint, a detector that only trusts
EnumProcessModulesor the PEB has a blind spot; one that also walks the address space directly does not
Keep Learning
If you want to understand these techniques well enough to build and detect them, these TrainSec courses go deeper:
- Malware Analysis and Development: stealth and anti-detection techniques like this one, and how they look from the defender’s side
- Windows System Programming: the Win32 and native API foundations this relies on, from process handles to native data structures
- EDR Internals: Research and Development: how an endpoint product builds visibility that does not depend solely on the PEB
Related reading in the knowledge library: DLL Injection with SetWinEventHook and DLL Injection with Windows Application Verifier, two different ways a DLL ends up in a process in the first place, and What is the OBJECT_ATTRIBUTES structure and how is it used?, another look at a native data structure most Win32 code never has to see directly.