PE loader from scratch
Writing a minimal PE loader to understand how Windows maps and executes binaries, and where detection can land.
- #PE
- #Loader
- #Windows
- #Reflective
- #Implants
§Why write a PE loader
Reflective loaders (Stephen Fewer's, Cobalt Strike's, etc.) are the backbone of in-memory execution. Understanding them means understanding the PE format, section mapping, relocations, and imports. This post builds the smallest loader that can run a real PE in memory, then explains where EDR catches each step.
§The PE layout you need
A PE has: DOS header → NT headers (file + optional) → section table → sections (.text, .data, .rdata, .reloc, .idata). The loader's job:
- 01Allocate memory at `ImageBase` (or anywhere if ASLR/relocatable).
- 02Copy headers + sections into place, honoring `VirtualAddress`/`SizeOfRawData`.
- 03Apply base relocations if loaded at a different base.
- 04Resolve imports (IAT) by walking `IMAGE_IMPORT_DESCRIPTOR`.
- 05Set section protections per `Characteristics`.
- 06Call `DllMain`/entry point.
§Minimal loader
#include <windows.h>
#include <stdio.h>
typedef BOOL (WINAPI *DllMain_t)(HINSTANCE, DWORD, LPVOID);
int load_pe(unsigned char *raw, size_t raw_size) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)raw;
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return 1;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(raw + dos->e_lfanew);
if (nt->Signature != IMAGE_NT_SIGNATURE) return 1;
size_t image_size = nt->OptionalHeader.SizeOfImage;
void *base = VirtualAlloc(NULL, image_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!base) return 2;
// copy headers
memcpy(base, raw, nt->OptionalHeader.SizeOfHeaders);
// copy sections
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
if (sec->SizeOfRawData == 0) continue;
memcpy((char*)base + sec->VirtualAddress, raw + sec->PointerToRawData, sec->SizeOfRawData);
}
// relocations
if (nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size) {
apply_relocs(base, nt);
}
// imports
if (nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size) {
resolve_imports(base, nt);
}
// protections
sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
DWORD prot = section_prot(sec->Characteristics);
DWORD old;
VirtualProtect((char*)base + sec->VirtualAddress, sec->Misc.VirtualSize, prot, &old);
}
// entry point
if (nt->FileHeader.Characteristics & IMAGE_FILE_DLL) {
DllMain_t dm = (DllMain_t)((char*)base + nt->OptionalHeader.AddressOfEntryPoint);
dm((HINSTANCE)base, DLL_PROCESS_ATTACH, NULL);
} else {
void (*ep)() = (void(*)())((char*)base + nt->OptionalHeader.AddressOfEntryPoint);
ep();
}
return 0;
}§Relocations
void apply_relocs(void *base, PIMAGE_NT_HEADERS nt) {
ptrdiff_t delta = (char*)base - (char*)nt->OptionalHeader.ImageBase;
if (delta == 0) return;
PIMAGE_BASE_RELOCATION reloc = (PIMAGE_BASE_RELOCATION)(
(char*)base + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
size_t total = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size;
size_t done = 0;
while (done < total && reloc->SizeOfBlock) {
char *page = (char*)base + reloc->VirtualAddress;
WORD *entry = (WORD*)(reloc + 1);
size_t count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
for (size_t i = 0; i < count; i++) {
WORD type = entry[i] >> 12;
WORD off = entry[i] & 0xFFF;
if (type == IMAGE_REL_BASED_DIR64) {
*(ULONGLONG*)(page + off) += delta;
} else if (type == IMAGE_REL_BASED_HIGHLOW) {
*(DWORD*)(page + off) += (DWORD)delta;
}
}
done += reloc->SizeOfBlock;
reloc = (PIMAGE_BASE_RELOCATION)((char*)reloc + reloc->SizeOfBlock);
}
}§Imports
void resolve_imports(void *base, PIMAGE_NT_HEADERS nt) {
PIMAGE_IMPORT_DESCRIPTOR imp = (PIMAGE_IMPORT_DESCRIPTOR)(
(char*)base + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
for (; imp->Name; imp++) {
HMODULE mod = LoadLibraryA((char*)base + imp->Name);
PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)((char*)base + imp->FirstThunk);
PIMAGE_THUNK_DATA orig = imp->OriginalFirstThunk
? (PIMAGE_THUNK_DATA)((char*)base + imp->OriginalFirstThunk)
: thunk;
for (; orig->u1.AddressOfData; orig++, thunk++) {
FARPROC fn;
if (orig->u1.Ordinal & IMAGE_ORDINAL_FLAG) {
fn = GetProcAddress(mod, (LPCSTR)(orig->u1.Ordinal & 0xFFFF));
} else {
PIMAGE_IMPORT_BY_NAME byname = (PIMAGE_IMPORT_BY_NAME)((char*)base + orig->u1.AddressOfData);
fn = GetProcAddress(mod, byname->Name);
}
thunk->u1.Function = (ULONGLONG)fn;
}
}
}§Section protections
DWORD section_prot(DWORD ch) {
if (ch & IMAGE_SCN_MEM_EXECUTE) {
if (ch & IMAGE_SCN_MEM_WRITE) return PAGE_EXECUTE_READWRITE;
return PAGE_EXECUTE_READ;
}
if (ch & IMAGE_SCN_MEM_WRITE) return PAGE_READWRITE;
return PAGE_READONLY;
}§Where EDR catches this
- 01`VirtualAlloc` of a large RW region followed by an RW→RX flip on unbacked memory → PE-sieve/Moneta flag.
- 02`LoadLibraryA` from an unbacked module → kernel callback (PsSetLoadImageNotifyRoutine) fires for the loaded DLL, but the calling module is unbacked → correlation alert.
- 03The entry point invocation from unbacked executable memory → stack walk shows no valid module → ETW `Microsoft-Windows-Threat-Intelligence` (if enabled) logs `NtMapViewOfSection`/allocation events.
§Detection evasion notes (research)
- Use existing committed memory (heap spray region) instead of fresh VirtualAlloc.
- Resolve imports manually via PEB walk instead of LoadLibraryA.
- Avoid the RW→RX transition by using a single RWX allocation (trades one detection for another).
§Compile
# Build a test DLL to load
cl /LD testdll.c /Fe:testdll.dll
# Build the loader
cl /O2 loader.c /link /OUT:loader.exe§Takeaway
A PE loader is ~100 lines. The detection surface is not the loader's logic — it is the memory operations and the import resolution that surround it. Every EDR in the market keys on those, which is why modern loaders spend 90% of their code on stealth, not on correctness. "
Written by
MJ — offensive security researcher
I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.