DLL Loading
The DLL search order, LoadLibrary internals, DllMain semantics, the KnownDLLs shortcut, and how DLL search order hijacking and side-loading give attackers persistent, signed-binary execution
A signed, legitimate Microsoft binary drops to a new folder. Alongside it sits a malicious DLL named version.dll. When the legitimate binary runs, it loads version.dll from its own directory first — before checking System32. Your malicious DLL loads, its DllMain runs, and you have code execution inside a signed Microsoft process. This is DLL side-loading (T1574.002) and it's one of the most common persistence techniques in targeted attacks.
DLL Search Order
When a process calls LoadLibrary("example.dll") with a DLL name but no path, Windows searches for the DLL in a specific order. The exact order depends on whether Safe DLL Search Mode is enabled (it is by default on modern Windows):
| Order | Location | Notes |
|---|---|---|
| 1 | Already-loaded DLL in process (loaded modules list) | If the DLL is already in the PEB module list, it's returned — no search |
| 2 | KnownDLLs list | Pre-mapped system DLLs, loaded from a fixed section. Can't be hijacked from user-writable paths. |
| 3 | Application directory (directory of the .exe) | The directory containing the calling executable. This is where DLL side-loading places its payload. |
| 4 | System directory (C:\Windows\System32) | The 64-bit (or 32-bit for WOW64) system DLL directory |
| 5 | Windows directory (C:\Windows) | |
| 6 | Current working directory | Only in Safe DLL Mode; in legacy mode this is #3 |
| 7 | PATH environment variable directories | In order of PATH entries |
Without Safe DLL Search Mode (SafeDllSearchMode=0 in the registry), the current directory is searched before System32. This made applications in the current directory trivially hijackable. Modern Windows enables Safe DLL Search Mode by default, moving the CWD search to position 6. Even with Safe mode, the application directory (position 3) comes before System32 — making side-loading in the app directory still viable.
LoadLibrary Flow
When LoadLibraryW is called, the Windows loader performs these steps:
- Check already-loaded modules: Walk the PEB InLoadOrderModuleList. If found, increment the DLL's load count and return its existing base address.
- Check KnownDLLs: Open
\KnownDlls\dllname.dllsection in the Object Namespace. If found, map from the pre-created section — no file search needed. - Apply search order: Search directories in order; open the DLL file when found.
- Create section object: Map the PE image into the process's address space, with ASLR if the DLL has DYNAMICBASE.
- Apply base relocations: If the DLL loaded at a different address than its preferred ImageBase, apply relocation fixups.
- Resolve imports: Recursively load the DLL's imports (this may cause further DLL loads).
- Process TLS: Initialize TLS data if the DLL has a TLS directory.
- Call DllMain with DLL_PROCESS_ATTACH: Execute the DLL's initialization code.
- Add to PEB module list: Insert the LDR_DATA_TABLE_ENTRY into all three module lists.
- Return base address: The module handle (= base address) is returned to the caller.
DllMain
Every DLL that needs initialization implements DllMain. The loader calls it on four events:
BOOL WINAPI DllMain(HMODULE hDll, DWORD reason, LPVOID reserved) {
switch (reason) {
case DLL_PROCESS_ATTACH:
// DLL loaded into a process for the first time
// Initialize global state here
// CAUTION: Loader lock is held — do not call LoadLibrary here!
break;
case DLL_THREAD_ATTACH:
// A new thread was created in the process
// Allocate per-thread resources
break;
case DLL_THREAD_DETACH:
// A thread is exiting
// Free per-thread resources
break;
case DLL_PROCESS_DETACH:
// DLL being unloaded (FreeLibrary or process exit)
// Cleanup global state
break;
}
return TRUE; // Return FALSE to fail the load (PROCESS_ATTACH only)
}
DllMain is called while the loader lock (a critical section in ntdll) is held. You cannot call LoadLibrary, FreeLibrary, or any function that triggers another DLL load from DllMain — this would deadlock. Malware that launches a new thread from DllMain and then calls LoadLibrary from that thread can deadlock its host process. The safe pattern for complex initialization: create a thread in DllMain, do all initialization in the thread.
KnownDLLs Protection
KnownDLLs is a registry list at HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs that includes critical system DLLs: ntdll.dll, kernel32.dll, user32.dll, etc. These DLLs are pre-mapped as section objects in the Object Namespace under \KnownDlls\ at system boot.
When the loader searches for a KnownDLL by name, it opens the pre-existing section object rather than searching any filesystem path. This means:
- A malicious
kernel32.dllplaced in the application directory cannot replace the real kernel32.dll — KnownDLLs check happens before the directory search - KnownDLLs protection is enforced system-wide without application participation
- Adding to the KnownDLLs list (requires registry write access to HKLM) can protect additional DLLs
DLL Side-Loading
DLL side-loading (ATT&CK T1574.002) places a malicious DLL with a name that a legitimate application loads, in a directory that takes search priority over System32. The technique works because:
- Many legitimate applications load DLLs by name only (no full path) — they rely on the search order
- The application directory (step 3 in the search order) comes before System32
- The attacker places the malicious DLL in the application's directory alongside the legitimate executable
- When the legitimate executable runs, it loads the malicious DLL instead of the real one from System32
- The malicious DLL typically proxies legitimate exports to the real DLL to avoid breaking the application
DLL Search Order Hijacking vs Side-Loading
| Technique | Description | Requirement | MITRE |
|---|---|---|---|
| DLL side-loading | Drop malicious DLL alongside a legitimate executable that loads it by name | Write access to app directory; app must load the target DLL name | T1574.002 |
| DLL search order hijack | Place malicious DLL in a search-order-preferred location: CWD, or a PATH directory before System32 | Control over search path or CWD | T1574.001 |
| DLL planting / phantom DLL | Place DLL at a path an app tries to load but which doesn't exist on clean systems (missing DLL) | Write access to any search path directory | T1574.001 |
Detection Signatures
# Sigma rule: DLL loaded from same directory as known Microsoft executable
# when that DLL is not in KnownDLLs and not signed by Microsoft
title: Suspicious DLL Side-Loading from Application Directory
logsource:
category: image_load
product: windows
detection:
selection:
ImageLoaded|startswith:
- 'C:\Program Files\'
- 'C:\Program Files (x86)\'
- 'C:\ProgramData\'
Signed: 'false'
filter_legit:
Signed: 'true'
condition: selection and not filter_legit
fields:
- Image
- ImageLoaded
- User
# Python: detect DLLs loading from non-standard paths
import re
SYSTEM_PATHS = {
r"c:\windows\system32",
r"c:\windows\syswow64",
r"c:\windows\sysnat",
}
def is_suspicious_dll_load(image_path: str, dll_path: str) -> bool:
dll_dir = dll_path.rsplit("\\", 1)[0].lower()
img_dir = image_path.rsplit("\\", 1)[0].lower()
# DLL loaded from same dir as executable, but not from system paths
if dll_dir == img_dir and dll_dir not in SYSTEM_PATHS:
return True
return False
Q & A
Why don't all DLLs end up in KnownDLLs to prevent side-loading?
Adding a DLL to KnownDLLs has consequences: (1) The DLL is pre-loaded into a shared section at boot time, consuming memory even for processes that never use it. If thousands of DLLs were KnownDLLs, boot memory consumption would be enormous. (2) The pre-loaded section must be version-consistent — when Windows updates a KnownDLL, it updates the section. Applications that have DLL-specific version dependencies can break. (3) Some applications legitimately need to load their own private version of a DLL — putting everything in KnownDLLs would prevent this. So KnownDLLs is reserved for the core system DLLs that: (a) every process needs, (b) must be the real system version, (c) are a common target for hijacking (kernel32.dll, ntdll.dll, user32.dll, advapi32.dll, etc.). The practical defense against side-loading for other DLLs is: code signing requirements enforced by WDAC/AppLocker policies, and careful application design to specify full DLL paths rather than bare names.
What happens if DllMain returns FALSE for DLL_PROCESS_ATTACH?
Returning FALSE from DllMain during DLL_PROCESS_ATTACH causes the DLL load to fail. The behavior depends on context: (1) If the DLL was loaded implicitly at process startup (via the import table), returning FALSE from DllMain causes the entire process creation to fail — the process terminates without reaching main(). (2) If the DLL was loaded explicitly via LoadLibrary, returning FALSE causes LoadLibrary to fail and return NULL. The caller gets NULL back and can check GetLastError. The loader calls DllMain with DLL_PROCESS_DETACH (with reserved = NULL, indicating a failed load) before failing, giving the DLL a chance to clean up any partial initialization. Malware rarely returns FALSE from DllMain — if the load fails, the payload doesn't execute. However, malware sometimes uses a FALSE return as an evasion: if running in a sandbox, return FALSE early to abort loading (the sandbox records "DLL failed to load") while on a real target, return TRUE.