Reflective DLL Loading
Overview
A reflective DLL carries its own loader. Rather than asking Windows to map it, an exported bootstrap
function replicates what the loader does — copy headers and sections, apply relocations, resolve
imports, run TLS callbacks, call DllMain — from inside the target process.
The payoff is the absence of a module list entry. GetModuleHandle, the loaded-module inventory in
your EDR console, and Sysmon image-load events all miss it, because from the operating system’s point
of view no image was ever loaded.
Import resolution still goes through LoadLibraryA for dependencies. A loader that pulls in an
unusual DLL — ws2_32, wininet — into a process that has no business with networking is a decent
second-order signal.
The call chain
- 1(bootstrap stub)Position-independent prologue locates its own base by walking backwards to the MZ header.
- 2resolve kernel32 by hashWalk the PEB loader list and hash export names to find LoadLibraryA and GetProcAddress.
- 3allocate + copy sectionsMap headers and each section at the chosen base with the right protection per section.
- 4process relocations + importsApply the base delta and build the IAT by hand, exactly as the real loader would.
- 5call DllMain(DLL_PROCESS_ATTACH)Hand control to the image; the module never appears in PEB_LDR_DATA.
Reference implementation
Abridged: the relocation and import loops are the bulk of a real loader and are omitted here.
// resolve exports by hash so no strings survive in the payload
PVOID k32 = find_module_by_hash(0x6A4ABC5B); // kernel32.dll
fnLoadLibraryA pLoadLibraryA = find_export_by_hash(k32, 0xEC0E4E8E);
fnVirtualAlloc pVirtualAlloc = find_export_by_hash(k32, 0x91AFCA54);
// map at the preferred base if it is free, anywhere otherwise
PVOID base = pVirtualAlloc((PVOID)nt->OptionalHeader.ImageBase,
nt->OptionalHeader.SizeOfImage,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
copy_headers(base, raw);
copy_sections(base, raw, nt);
apply_relocations(base, nt); // delta = base - OptionalHeader.ImageBase
resolve_imports(base, nt, pLoadLibraryA);
protect_sections(base, nt); // per-section RX / R / RW — never blanket RWX
((fnDllMain)(base + nt->OptionalHeader.AddressOfEntryPoint))(base, DLL_PROCESS_ATTACH, NULL);// resolve exports by hash so no strings survive in the payload
PVOID k32 = find_module_by_hash(0x6A4ABC5B); // kernel32.dll
fnLoadLibraryA pLoadLibraryA = find_export_by_hash(k32, 0xEC0E4E8E);
fnVirtualAlloc pVirtualAlloc = find_export_by_hash(k32, 0x91AFCA54);
// map at the preferred base if it is free, anywhere otherwise
PVOID base = pVirtualAlloc((PVOID)nt->OptionalHeader.ImageBase,
nt->OptionalHeader.SizeOfImage,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
copy_headers(base, raw);
copy_sections(base, raw, nt);
apply_relocations(base, nt); // delta = base - OptionalHeader.ImageBase
resolve_imports(base, nt, pLoadLibraryA);
protect_sections(base, nt); // per-section RX / R / RW — never blanket RWX
((fnDllMain)(base + nt->OptionalHeader.AddressOfEntryPoint))(base, DLL_PROCESS_ATTACH, NULL);Detection
Scanning committed private memory for MZ at page granularity finds the naive implementations
cheaply. Loaders that erase their headers after mapping defeat that check, which is why the YARA
signature targets the bootstrap code rather than the PE structure.