DEV Community

iamsopotatoe
iamsopotatoe

Posted on

How I load an exe directly into memory without touching disk — manual PE mapping

most people think running an exe means writing it to disk first. it doesn't.

as part of building TinyLoad, a Windows PE packer, I had to write a PE loader that maps an executable directly into memory and runs it without ever creating a file. here's how it works.

what is a PE file

PE (Portable Executable) is the format Windows uses for .exe and .dll files. it's basically a structured blob with a header describing how to load it, followed by sections containing code, data, resources etc.

to run a PE file manually you have to do what the Windows loader does — but yourself, in memory.

step 1: parse the headers

every PE starts with a DOS header, then an NT header. the NT header tells you everything you need:

  • SizeOfImage — how much memory to allocate
  • ImageBase — where the linker expected the binary to live
  • AddressOfEntryPoint — where to jump to start execution
  • SizeOfHeaders — how much of the front to copy as-is
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)data.data();
IMAGE_NT_HEADERS64* nt = (IMAGE_NT_HEADERS64*)(data.data() + dos->e_lfanew);
Enter fullscreen mode Exit fullscreen mode

step 2: allocate memory and copy sections

allocate a block of memory the size of the image, then copy the headers in. after that, iterate the section table and copy each section to its virtual address:

void* base = VirtualAlloc(NULL, nt->OptionalHeader.SizeOfImage,
    MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

memcpy(base, data.data(), nt->OptionalHeader.SizeOfHeaders);

IMAGE_SECTION_HEADER* sect = IMAGE_FIRST_SECTION(nt);
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
    if (sect[i].SizeOfRawData > 0)
        memcpy((BYTE*)base + sect[i].VirtualAddress,
               data.data() + sect[i].PointerToRawData,
               sect[i].SizeOfRawData);
}
Enter fullscreen mode Exit fullscreen mode

step 3: fix relocations

the linker assumed the binary would load at ImageBase. if it lands somewhere else (which it usually does since ASLR), every absolute address in the binary is wrong by delta = actual_base - preferred_base.

the relocation table tells you exactly which addresses need fixing:

size_t delta = (size_t)base - nt->OptionalHeader.ImageBase;
if (delta != 0) {
    auto* rel = (IMAGE_BASE_RELOCATION*)((BYTE*)base + relDir->VirtualAddress);
    while (rel->VirtualAddress > 0) {
        WORD* list = (WORD*)(rel + 1);
        DWORD count = (rel->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
        for (DWORD i = 0; i < count; i++) {
            if ((list[i] >> 12) == IMAGE_REL_BASED_DIR64) {
                size_t* p = (size_t*)((BYTE*)base + rel->VirtualAddress + (list[i] & 0xFFF));
                *p += delta;
            }
        }
        rel = (IMAGE_BASE_RELOCATION*)((BYTE*)rel + rel->SizeOfBlock);
    }
}
Enter fullscreen mode Exit fullscreen mode

step 4: resolve imports

the import directory tells you which DLLs the binary needs and which functions to load from each. iterate the import descriptors, load each DLL, resolve each function by name or ordinal, and write the function addresses into the import address table:

auto* imp = (IMAGE_IMPORT_DESCRIPTOR*)((BYTE*)base + impDir->VirtualAddress);
while (imp->Name) {
    HMODULE mod = LoadLibraryA((char*)((BYTE*)base + imp->Name));
    auto* thunk = (IMAGE_THUNK_DATA64*)((BYTE*)base + imp->FirstThunk);
    auto* orig  = (IMAGE_THUNK_DATA64*)((BYTE*)base + imp->OriginalFirstThunk);
    while (orig->u1.AddressOfData) {
        if (IMAGE_SNAP_BY_ORDINAL64(orig->u1.Ordinal)) {
            thunk->u1.Function = (size_t)GetProcAddress(mod,
                (char*)(orig->u1.Ordinal & 0xFFFF));
        } else {
            auto* name = (IMAGE_IMPORT_BY_NAME*)((BYTE*)base + orig->u1.AddressOfData);
            thunk->u1.Function = (size_t)GetProcAddress(mod, name->Name);
        }
        thunk++; orig++;
    }
    imp++;
}
Enter fullscreen mode Exit fullscreen mode

step 5: jump to entry point

using EntryPoint = void(WINAPI*)();
EntryPoint entry = (EntryPoint)((BYTE*)base + nt->OptionalHeader.AddressOfEntryPoint);
entry();
Enter fullscreen mode Exit fullscreen mode

that's it. the exe runs directly from the allocated memory block, no file on disk, no Windows loader involved.

where this is used in TinyLoad

TinyLoad packs your exe with LZ77 compression and VM encryption. when the packed stub runs, it decrypts and decompresses the original exe in memory, then calls this loader directly. the original exe never exists as a file — it goes straight from encrypted bytes to running process.

full source (single .cpp file, no deps): https://github.com/iamsopotatoe-coder/TinyLoad

Top comments (0)