Level 0 · Chapter 3
The DLL loader, PEB, and module lists
How Windows finds, maps, and tracks shared libraries — and why search order is a security boundary, not a convenience.
The PE format told you what an import table records. This chapter is about what the loader actually does with it: turning a list of names like "user32.dll" into mapped memory and resolved addresses, in an order that's stable enough to be predictable and flexible enough to be exploited if you're not careful.
Watch it happen
Loader walkthrough
Step 1 of 6
Map the image
The loader maps app.exe's sections into the new address space and reads its import table.
That sequence — map the image, resolve each import depth-first, patch relocations, run DllMain — is the same for every process on every launch. The only thing that changes is which DLLs, and where the loader finds them.
The DLL search order
When a module is imported by name only (no full path), Windows doesn't search the whole disk — it follows a fixed, documented order, and that order is exactly why "DLL hijacking" is a real attack class rather than a theoretical one:
- The directory the application was loaded from.
- The system directory (
System32), unlessSetDllDirectoryor "safe DLL search mode" changes this. - The 16-bit system directory (legacy, rarely relevant today).
- The Windows directory.
- The current working directory (only in certain legacy search modes — modern safe search mode moves this after
System32). - Directories listed in the
PATHenvironment variable.
The loader stops at the first match. This is precisely why placing a maliciously-named DLL (say, version.dll or dwmapi.dll, names of real DLLs many applications don't statically depend on but will happily load if present) in an application's own directory can get it loaded instead of the legitimate system copy — the app's own directory is checked before System32. Applications that call LoadLibrary with an unqualified name, without pinning down the search path first, inherit this risk automatically.
PEB_LDR_DATA: the process's own module list
Once a module is loaded, Windows doesn't just forget how it got there. The PEB (Process Environment Block) — the same user-mode structure that carries the command line, environment block, and image base — contains a field called Ldr, pointing to a PEB_LDR_DATA structure. This is, in effect, the process's own private card catalog of everything currently loaded:
- InLoadOrderModuleList — modules in the order they were loaded.
- InMemoryOrderModuleList — modules ordered by their base address.
- InInitializationOrderModuleList — modules in the order their
DllMainran.
Each entry (an LDR_DATA_TABLE_ENTRY) carries the module's base address, size, full path, and a reference count. This is genuinely useful, working data, not incidental bookkeeping: it's exactly what functions like GetModuleHandle and GetProcAddress walk to find an already-loaded module without re-parsing its PE headers, and it's exactly what a debugger reads to show you a module list — and, less charitably, it's the structure a large class of malware and injection tooling has historically walked (and sometimes tampered with) to hide or discover loaded modules.
DllMain and the loader lock
Every DLL can export an optional entry point, DllMain, called at four transitions: DLL_PROCESS_ATTACH (first load into this process), DLL_THREAD_ATTACH/DLL_THREAD_DETACH (as threads come and go), and DLL_PROCESS_DETACH (unload). This is where a library typically initializes globals, allocates thread-local storage slots, or registers cleanup handlers.
The catch: DllMain runs while the loader holds an internal lock (informally, "the loader lock") that serializes all loading activity in the process. Calling back into the loader from inside DllMain — for instance, calling LoadLibrary on another DLL, or worse, waiting on a thread that itself needs the loader lock — can deadlock the entire process before it even starts. This is one of the most well-known "don't do this in DllMain" rules in all of Windows programming, and it exists precisely because of the depth-first, lock-held loading sequence shown in the animation above.
Reference counting, not one-shot loading
LoadLibrary (or an import resolved automatically) increments a reference count on the module rather than reloading it. A second LoadLibrary call for an already-loaded DLL just bumps the count and returns the existing handle — DllMain does not run again. FreeLibrary decrements it, and the module is only actually unmapped once the count reaches zero. This is why a DLL can be safely depended on by many components in the same process without either wasting memory on duplicate copies or unloading it out from under a component that's still using it.
A common mistake
It's easy to assume a DLL loads "because it exists on disk" — as if presence were sufficient. In practice, the loader still has to discover it through the search order above, and that discovery step is itself the security-relevant part. A DLL sitting in the wrong (or right, for an attacker) directory can silently change which code actually runs, with no error and no obvious symptom beyond "the app is doing something it shouldn't."
Where this connects
- WOW64 has to maintain two completely separate module lists and search paths — one for 32-bit DLLs, one for 64-bit — because the two architectures' code can't share the same address space.
- Every resolved import ultimately calls into a small set of true entry points in Ntdll, which is where user-mode code finally crosses into the kernel.
- Security descriptors & ACLs determine whether a process is even allowed to open a given DLL file in the first place — loading happens only after that check passes.