Manual PE Mapping
Overview
Manual mapping - also called reflective loading - loads a portable executable into a process
without asking the Windows loader to do it. Instead of LoadLibrary or an image on disk, the
code reads the PE’s headers itself, reserves the memory the image needs, copies the sections in,
patches the places that depend on where the image landed, resolves the imports, and calls the
entry point. The result is a fully functional DLL or EXE that the operating system never saw
arrive: no file handle to the image, no module in the process’s module list, and the code sits
at whatever address the loader picked.
The reason this is worth the effort is that the loader is a detector. A normal image load produces an image-load event, a module entry in the PEB, and a file-backed mapping. Manual mapping produces none of those by default, which is exactly why it is the backbone of in-memory execution, shellcode that wants a full PE, and hollowing. The cost is that every step the loader would normally do - relocations, import binding, section permissions - is now your bug to get right.
Manual mapping is not position-independent by default. If the preferred base is taken you must rebase the image, which means relocations must be applied and the import table rebuilt. Get either wrong and the image crashes on its first import call, often long after the map “succeeded”.
This is the map performed by a loader that is separate from the image - code that already holds the PE in a file or a buffer and maps it into memory. A reflective DLL is the case where the image carries that loader and maps itself from an exported bootstrap stub. The steps are identical; only the home of the mapping code differs.
The call chain
- 1Read the DOS and PE headersWalk the MZ header to e_lfanew, then the COFF header and data directories.
- 2Reserve the image regionVirtualAlloc a region at the image PreferredBase (or a free base) sized to ImageSize.
- 3Copy the sectionsFor each section, commit its raw size and copy the file bytes into place.
- 4Apply base relocationsIf the image is not at its preferred base, walk the BaseReloc table and patch deltas.
- 5Bind the import tableResolve each imported function address from its DLL and write it into the IAT.
- 6Run the entry pointCall DllMain (DLL) or the entry point (EXE) with the mapped base as hModule.
Reference implementation
The map below is abridged - it reserves the region and copies the sections. Relocation and import binding are the parts that actually differ most between implementations, so they are flagged rather than fully written out.
#include <windows.h>
HMODULE ManualMap(const char* path) {
HANDLE hf = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, 0, NULL);
if (hf == INVALID_HANDLE_VALUE) return NULL;
// --- headers --------------------------------------------------------
IMAGE_DOS_HEADER dos;
ReadFile(hf, &dos, sizeof(dos), NULL, NULL);
SetFilePointer(hf, dos.e_lfanew, NULL, FILE_BEGIN);
IMAGE_NT_HEADERS nt;
ReadFile(hf, &nt, sizeof(nt), NULL, NULL);
IMAGE_SECTION_HEADER* s = IMAGE_FIRST_SECTION(&nt);
SIZE_T imageSize = nt.OptionalHeader.SizeOfImage;
DWORD align = nt.OptionalHeader.SectionAlignment;
// --- reserve the image region at the preferred base -----------------
LPVOID img = VirtualAlloc((LPVOID)nt.OptionalHeader.ImageBase,
imageSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!img) // base is taken: rebase, which forces relocations + IAT fixup
img = VirtualAlloc(NULL, imageSize,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
LPVOID base = img;
// --- copy the sections ---------------------------------------------
for (WORD i = 0; i < nt.FileHeader.NumberOfSections; i++) {
if (s[i].SizeOfRawData == 0) continue;
LPVOID dst = (LPBYTE)base + s[i].VirtualAddress;
SIZE_T n = ((s[i].Misc.VirtualSize + 0xFFF) / 0x1000) * 0x1000;
VirtualAlloc(dst, n, MEM_COMMIT, PAGE_READWRITE);
SetFilePointer(hf, s[i].PointerToRawData, NULL, FILE_BEGIN);
ReadFile(hf, dst, s[i].SizeOfRawData, NULL, NULL);
}
// --- relocations + IAT + entry point <-- omitted for brevity -------
// if (base != ImageBase) apply the BaseReloc delta table,
// then walk the ImportTable and ResolveImport each function,
// then: ((DWORD(*)(HMODULE,DWORD,PVOID))base)(base, DLL_PROCESS_ATTACH, NULL);
CloseHandle(hf);
return (HMODULE)base;
}#include <windows.h>
HMODULE ManualMap(const char* path) {
HANDLE hf = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, 0, NULL);
if (hf == INVALID_HANDLE_VALUE) return NULL;
// --- headers --------------------------------------------------------
IMAGE_DOS_HEADER dos;
ReadFile(hf, &dos, sizeof(dos), NULL, NULL);
SetFilePointer(hf, dos.e_lfanew, NULL, FILE_BEGIN);
IMAGE_NT_HEADERS nt;
ReadFile(hf, &nt, sizeof(nt), NULL, NULL);
IMAGE_SECTION_HEADER* s = IMAGE_FIRST_SECTION(&nt);
SIZE_T imageSize = nt.OptionalHeader.SizeOfImage;
DWORD align = nt.OptionalHeader.SectionAlignment;
// --- reserve the image region at the preferred base -----------------
LPVOID img = VirtualAlloc((LPVOID)nt.OptionalHeader.ImageBase,
imageSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!img) // base is taken: rebase, which forces relocations + IAT fixup
img = VirtualAlloc(NULL, imageSize,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
LPVOID base = img;
// --- copy the sections ---------------------------------------------
for (WORD i = 0; i < nt.FileHeader.NumberOfSections; i++) {
if (s[i].SizeOfRawData == 0) continue;
LPVOID dst = (LPBYTE)base + s[i].VirtualAddress;
SIZE_T n = ((s[i].Misc.VirtualSize + 0xFFF) / 0x1000) * 0x1000;
VirtualAlloc(dst, n, MEM_COMMIT, PAGE_READWRITE);
SetFilePointer(hf, s[i].PointerToRawData, NULL, FILE_BEGIN);
ReadFile(hf, dst, s[i].SizeOfRawData, NULL, NULL);
}
// --- relocations + IAT + entry point <-- omitted for brevity -------
// if (base != ImageBase) apply the BaseReloc delta table,
// then walk the ImportTable and ResolveImport each function,
// then: ((DWORD(*)(HMODULE,DWORD,PVOID))base)(base, DLL_PROCESS_ATTACH, NULL);
CloseHandle(hf);
return (HMODULE)base;
}Verifying in the lab
Load the map in WinDbg against the target process. The tell is a committed region whose first
bytes are MZ that does not appear in the module list.
0:000> !peb ... 0:000> lm start end module name 7c800000 7c9xxx00 ntdll 74xx0000 74xxyy00 kernel32 (no entry for the mapped image) <-- absent from the module list 0:000> dd <mapped_base> L2 <mapped_base> 4d5a 9000 0300 0000 ^ MZ header in a private region
0:000> !peb
...
0:000> lm
start end module name
7c800000 7c9xxx00 ntdll
74xx0000 74xxyy00 kernel32
(no entry for the mapped image) <-- absent from the module list
0:000> dd <mapped_base> L2
<mapped_base> 4d5a 9000 0300 0000 ^ MZ header in a private regionDetection
Without a loader, there is no image-load event and no module entry. Detection has to look at memory itself or at the writes that put the image there.
Rank them: the memory scan for an MZ region outside the module list is the highest-fidelity
check but is periodic and expensive. The SYSMON EID 5 / EID 10 correlation catches the act of
writing the image and runs continuously. The PAGEFAULT signal is noisy on its own but useful as
a tripwire to trigger a deeper scan.