PE loader from scratch

Writing a minimal PE loader to understand how Windows maps and executes binaries, and where detection can land.

Sep 05, 2026·18 min read·Research difficulty·Lab derived
Share
  • #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:

  1. 01Allocate memory at `ImageBase` (or anywhere if ASLR/relocatable).
  2. 02Copy headers + sections into place, honoring `VirtualAddress`/`SizeOfRawData`.
  3. 03Apply base relocations if loaded at a different base.
  4. 04Resolve imports (IAT) by walking `IMAGE_IMPORT_DESCRIPTOR`.
  5. 05Set section protections per `Characteristics`.
  6. 06Call `DllMain`/entry point.

§Minimal loader

c
#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

c
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

c
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

c
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

  1. 01`VirtualAlloc` of a large RW region followed by an RW→RX flip on unbacked memory → PE-sieve/Moneta flag.
  2. 02`LoadLibraryA` from an unbacked module → kernel callback (PsSetLoadImageNotifyRoutine) fires for the loaded DLL, but the calling module is unbacked → correlation alert.
  3. 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

bash
# 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. "

MJ

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.

Next dispatch

One long-form entry, roughly twice a month.

Root cause, reproduction, and the detection path — the same standard every time.