PE to Shellcode
Overview
PE-to-shellcode is the conversion of a portable executable into a self-contained shellcode blob. The PE’s bytes are embedded as a C array, and a small position-independent preamble is written that, when it runs, finds free memory, copies the embedded image in, applies the relocations and import fixups a loader would normally do, and jumps to the entry point. After that jump, you are executing the original PE in memory
- no file on disk, no module the OS registered.
This is the bridge between two things people often conflate. On one side is raw shellcode, which is just bytes that do one thing. On the other is a full PE with sections, an import table and an entry point. PE-to-shellcode makes the second look like the first: the thing you inject is a blob of machine code, but what that blob boots is a complete image. Tools like Donut exist precisely to generate this blob for you so you do not have to hand-write the preamble.
The preamble must be position-independent. It runs at an unknown address before it has set anything up, so it cannot use absolute references to itself. See position independent code for why this is the single most common source of “works in x64dbg, crashes in the real target” bugs.
The call chain
- 1Embed the PE as a byte arrayThe PE file becomes an unsigned char payload[] inside the shellcode.
- 2Find a free regionLocate space for the image via the PEB Ldr table or a free-range search.
- 3Copy the PE bytes in placeWrite the embedded payload into the reserved region; the shellcode must be relocatable.
- 4Run the manual-map fixupsApply relocations and bind the IAT on the copied image, as a loader would.
- 5Jump to the entry pointTransfer control to the mapped entry point; the shellcode's job is done.
Reference implementation
The first step is mechanical: turn the PE into a byte array.
# turn the PE into a C array the shellcode can carry xxd -i implant.dll > implant_bytes.h
# turn the PE into a C array the shellcode can carry
xxd -i implant.dll > implant_bytes.hWhat the generated shellcode then does, conceptually. This is the anatomy of the blob, not a complete implementation - a generator such as Donut emits the real thing.
// position-independent preamble, abridged
extern const unsigned char payload[]; // the embedded PE (from xxd -i)
extern const unsigned long payload_len;
void __fastcall shellcode_entry() {
// 1. find a free region large enough for the image
// - walk the PEB Ldr data table for a free range, or
// - VirtualAlloc a fresh block sized to SizeOfImage
// 2. copy the embedded bytes in place <-- the image now exists in memory
memcpy(region, payload, payload_len);
// 3. apply relocations if region != PreferredBase, then bind the IAT
// (identical steps to a manual map, on the copied bytes)
fixup_relocations(region);
fixup_imports(region);
// 4. jump to the entry point - control never returns here
((void(*)())region)();
}// position-independent preamble, abridged
extern const unsigned char payload[]; // the embedded PE (from xxd -i)
extern const unsigned long payload_len;
void __fastcall shellcode_entry() {
// 1. find a free region large enough for the image
// - walk the PEB Ldr data table for a free range, or
// - VirtualAlloc a fresh block sized to SizeOfImage
// 2. copy the embedded bytes in place <-- the image now exists in memory
memcpy(region, payload, payload_len);
// 3. apply relocations if region != PreferredBase, then bind the IAT
// (identical steps to a manual map, on the copied bytes)
fixup_relocations(region);
fixup_imports(region);
// 4. jump to the entry point - control never returns here
((void(*)())region)();
}The shellcode and the embedded PE are two different position-independent problems. The preamble must be PIC before it runs; the PE must be rebased after it is copied in. Tools that generate the blob handle both, but a hand-rolled preamble that forgets the second step will crash on the PE’s first import call.
Verifying in the lab
Generate the blob, drop it into a target, and watch the PE appear in memory. WinDbg will show an
MZ region that is not in the module list, and the process executing the implant’s code.
0:000> !process 0 0 ... 0:000> lm (no module for the implant) <-- injected as shellcode, not a module 0:000> dd <region> L2 <region> 4d5a 9000 0300 0000 ^ the embedded PE, now running
0:000> !process 0 0
...
0:000> lm
(no module for the implant) <-- injected as shellcode, not a module
0:000> dd <region> L2
<region> 4d5a 9000 0300 0000 ^ the embedded PE, now runningDetection
The detection surface is the same as manual mapping, plus the fact that a full PE landed inside what should have been a small shellcode blob.
Rank them: the static scan for an embedded MZ plus fixup logic catches the blob before it
runs and is the cheapest control. The EID 8 remote-thread-with-private-start check catches the
injection at runtime. The memory scan is the highest-fidelity but periodic catch-all.