Skip to content
λmaldev wiki/
pagesManual PE Mapping
T1620WindowsC / C++PEWindows API

Manual PE Mapping

updated 2026-09-049 min readthehackersbrain
Authorized use only. This material is published for detection engineering, malware analysis and authorized red-team engagements. Running these techniques against systems you do not own or have written permission to test is illegal.

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.

Caution.

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”.

Note.

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

  1. 1
    Read the DOS and PE headers
    Walk the MZ header to e_lfanew, then the COFF header and data directories.
  2. 2
    Reserve the image region
    VirtualAlloc a region at the image PreferredBase (or a free base) sized to ImageSize.
  3. 3
    Copy the sections
    For each section, commit its raw size and copy the file bytes into place.
  4. 4
    Apply base relocations
    If the image is not at its preferred base, walk the BaseReloc table and patch deltas.
  5. 5
    Bind the import table
    Resolve each imported function address from its DLL and write it into the IAT.
  6. 6
    Run the entry point
    Call 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.

manual_map.cC
#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.

windbg
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

Detection

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.

MEMORY SCAN
A committed region beginning MZ that is absent from the PEB loaded-module list.
SYSMON EID 5
A large commit to a private region big enough for a PE, with no EID 7 image load for it.
SYSMON EID 10
Cross-process memory writes by a parent that never loaded the child's image.
EVENT 21
A PAGEFAULT storm into an anonymous private region with no corresponding image load.

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.

Was this page useful?edit this page ↗