← Back to list

I Wrote a Windows PE File From Scratch, in Assembly

AI From First Principles has one piece left — Transformers, from the ground up. After that I’m diverting: technical reports, reverse…

Ramadhan Zome · 2026-07-03 13:51 · 6 claps · 11.2 min read
#binary #portable-executable #x86-assembly #malware-analysis #reverse-engineering
Open on Medium ↗
Wiki topics: 🔒 · Cybersecurity

I Wrote a Windows PE File From Scratch, in Assembly

source : unsplash

source : unsplash

AI From First Principles has one piece left — Transformers, from the ground up. After that I’m diverting: technical reports, reverse engineering, malware analysis, AI security.

I built a Windows PE executable completely from scratch, with no compiler, no linker, no section directives doing layout for me. Every DOS header field, every NT header field, every section header, every import table entry, hand-assembled in NASM with -f bin, byte by byte, addresses computed by hand. The result is a real, working Win64 GUI binary: it pops a MessageBoxA asking "Do you want to continue?", branches on Yes or No, and exits 0 or 1 accordingly. Code: github.com/RamadhanAdam/raw-pe.

This is the format in depth, field by field, and the specific bug that took the binary from “assembles with zero warnings” to 0xc0000005 at address zero.

DOS header — 64 bytes almost none of which matter anymore

Every PE opens with IMAGE_DOS_HEADER, a structure inherited wholesale from MS-DOS. It exists so DOS, and 16-bit Windows, could recognize a file it couldn't execute and print "This program cannot be run in DOS mode" instead of interpreting 64-bit instructions as 8086 opcodes and hanging.

4D 5A — "MZ" — Mark Zbikowski's initials, one of the original DOS architects. Every .exe on every Windows machine, including mine, still opens with these two bytes.

The next eighteen fields — e_cblp (bytes on last page), e_cp (pages in file), e_crlc (relocations), e_cparhdr (header size in paragraphs), e_minalloc/e_maxalloc (extra paragraphs needed), e_ss/e_sp (initial stack segment/pointer), e_csum (checksum), e_ip/e_cs (initial instruction pointer/code segment), e_lfarlc (relocation table offset), e_ovno (overlay number), plus reserved arrays e_res[4] and e_res2[10] — describe how a segmented 16-bit executable gets relocated and loaded by the DOS loader. None of it is read by the NT loader at all. I set every one of them to zero. There's exactly one field in the entire 64-byte structure that the modern loader actually consumes:

dd 0x00000080   ; e_lfanew — offset to the NT header

e_lfanew is a 4-byte pointer whose only job is telling LoadLibrary/CreateProcess where the real headers start, so it can skip the DOS-era structure entirely. Mine is 0x80. If this field is off by even four bytes, the loader reads the COFF File Header from the wrong byte offset — meaning Machine, NumberOfSections, SizeOfOptionalHeader, Characteristics, all of it, get parsed from whatever bytes happen to sit there instead. NASM will assemble that file with zero complaints, because e_lfanew is just a dd value to the assembler — there's no semantic check that it actually points at a PE\0\0 signature. You only find out it's wrong when the loader either refuses the file outright or, worse, parses a header that happens to look coincidentally valid and does something undefined.

Between the DOS header and the NT headers sits the DOS stub — 64 bytes in my build holding the ASCII string "This program must be run under Win32.", right-padded with zeros. This is genuinely optional; an all-zero stub of the same length works identically, since nothing about the DOS stub's content is checked, only that e_lfanew correctly points past whatever sits there.

PE signature and COFF File Header

At offset 0x80: dd 0x00004550"PE\0\0" in little-endian, meaning the bytes on disk are 50 45 00 00. This is the second and last magic-value check the loader performs on the front matter.

Immediately after, the 20-byte IMAGE_FILE_HEADER:

dw 0x8664       ; Machine — IMAGE_FILE_MACHINE_AMD64
dw 0x0004       ; NumberOfSections
dd 0x6A443E92   ; TimeDateStamp
dd 0x00000000   ; PointerToSymbolTable — obsolete for images
dd 0x00000000   ; NumberOfSymbols — obsolete for images
dw 0x00F0       ; SizeOfOptionalHeader
dw 0x0223       ; Characteristics

Machine = 0x8664 tells the loader (and every disassembler downstream) to decode this as x86-64, not x86, ARM, or IA64 — this single field is why file-type detection in tools like file and PE-bear works reliably even without an extension. NumberOfSections = 4 has to exactly match the number of IMAGE_SECTION_HEADER entries that follow — get this wrong and the loader either stops reading sections early (silently dropping a section from its internal table) or reads past your real section headers into whatever bytes come next and interprets them as a fifth, garbage section. TimeDateStamp is a Unix epoch value nothing in the loader validates for plausibility — it can be any 32-bit value, including a date in 1970 or 2099, and the file loads identically either way. This is also, worth noting for anyone doing malware triage, a field trivially and commonly forged, which is why timestamp-based attribution always needs corroborating signals.

SizeOfOptionalHeader = 0x00F0 (240 bytes) is arithmetic you have to get exactly right: 24 bytes of standard fields, plus 88 bytes of Windows-specific fields, plus 16 data directories × 8 bytes each (128 bytes) = 240. Characteristics = 0x0223 is a bitfield: RELOCS_STRIPPED | EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE | DEBUG_STRIPPED. Setting RELOCS_STRIPPEDhere without actually providing a .reloc section — which I did — is a decision with a real consequence I'll come back to.

Optional Header, not really optional, and where the two coordinate systems are born

IMAGE_OPTIONAL_HEADER64 starts with Magic = 0x020B, which distinguishes PE32+ (64-bit) from PE32's 0x010B — this single value changes how several downstream fields are sized, since PE32+ uses 8-byte fields for ImageBase, stack/heap reserve and commit sizes, where PE32 uses 4.

The two fields that decide the entire shape of the problem this article is actually about:

dd 0x00001000   ; SectionAlignment
dd 0x00000200   ; FileAlignment

SectionAlignment is the granularity sections are spaced at once mapped into a process's virtual address space — 0x1000, one x86-64 page. This isn't an arbitrary round number; it's forced by the hardware. The MMU applies page-table protection bits (execute, write, read) at page granularity. You cannot have half a page executable and half read-only. So every section boundary, in memory, is forced onto a 4 KB boundary regardless of how small the section's actual content is — a one-byte .bss section still consumes a full page.

FileAlignment is the granularity sections are packed at on disk — 0x200, 512 bytes, a holdover from disk sector size, with no hardware constraint forcing it beyond "the loader has to be able to seek and read chunks efficiently." This is why a tiny section that occupies a full page in memory can occupy as little as one 512-byte chunk on disk.

The direct consequence: RVA (offset from ImageBase, the in-memory addressing scheme) and PointerToRawData (file-offset addressing scheme) diverge for any layout where the sections aren't coincidentally sized to exact page multiples. Here's the concrete layout I built:

SectionPointerToRawDataVirtualAddress (RVA)Disk→memory gapheaders0x0000x000.text0x4000x1000+0xC00.data0x6000x2000+0x1A00.bssnone (0 bytes on disk)0x3000.idata0x8000x4000+0x3800

.text and .idata: 0x800 − 0x400 = 0x400 bytes apart on disk. 0x4000 − 0x1000 = 0x3000 bytes apart in memory. A factor of twelve difference, between the exact same two sections, purely a function of which addressing axis you're standing on.

Further down the Optional Header, the remaining Windows-specific fields I had to set correctly with no compiler defaulting them for me: ImageBase = 0x140000000 (the standard 64-bit preferred load address for non-DLL images — chosen specifically to be well above the 4 GB boundary so 32-bit pointer truncation bugs surface immediately rather than silently succeeding), SizeOfImage = 0x5000 (has to be ≥ the highest RVA plus its section's size, across every section — I initially forgot to extend this after adding .idata and had to go back and fix it), SizeOfHeaders = 0x400 (has to exactly equal where .text's PointerToRawData begins, since it's literally defining "everything before the first section"), Subsystem = 0x0002(IMAGE_SUBSYSTEM_WINDOWS_GUI — this single value is why the process detaches from a console shell immediately on launch instead of blocking on stdio), and NumberOfRvaAndSizes = 0x00000010 (16 — the data directory count, which has to match how many 8-byte RVA/Size pairs actually follow, or the loader either truncates or overreads the directory array).

Data directories — sixteen RVA/Size pairs, most of them zero, two load-bearing

The 128 bytes of data directories are what tell the loader where to find specific structures inside the sections you’ve already laid out. Fourteen of mine are 0, 0. Two aren't:

dd (import_dir - idata_start) + 0x4000
dd 0x0000003C   ; Import Table: RVA, Size = 60 bytes (3 × 20-byte descriptors)
dd (user32_iat - idata_start) + 0x4000
dd 0x00000020   ; IAT: RVA, Size = 32 bytes (2 DLLs × 16 bytes each)

import_dir - idata_start is intra-section symbolic math — both labels live inside .idata, so NASM's flat file-offset computation and the real runtime RVA computation agree, since nothing separates two labels in the same section except contiguous bytes, identically, in both file and memory representations. Add 0x4000, .idata's base RVA, and you get the correct data directory entry. This pattern — intra-section symbolic offset plus a hardcoded section base RVA — is safe everywhere in this file. It is specifically cross-section references that break, which is the entire subject of the next section.

The section header table — 40 bytes per entry, and every number has to independently agree

Each IMAGE_SECTION_HEADER is 40 bytes: an 8-byte name, VirtualSize, VirtualAddress, SizeOfRawData, PointerToRawData, two obsolete relocation/linenumber pointer+count pairs (zero for images), and Characteristics. Four of these, one per section, and every numeric field in every one of them has to be independently correct and consistent with the corresponding Optional Header fields — nothing cross-validates this for you at assemble time.

Concretely, for .idata:

db ".idata",0,0
dd (idata_end - idata_start)                              ; VirtualSize
dd 0x00004000                                              ; VirtualAddress
dd ((idata_end - idata_start + 0x1FF) / 0x200) * 0x200     ; SizeOfRawData
dd 0x00000800                                               ; PointerToRawData

SizeOfRawData has to be FileAlignment-rounded — I computed it as ((size + 0x1FF) / 0x200) * 0x200, integer-division rounding up to the next 512-byte multiple, since the actual byte count almost never lands exactly on a boundary. PointerToRawData = 0x800 has to exactly match where I actually placed the times (0x800 - ($ - $$)) db 0 padding directive earlier in the file — two independent statements of the same fact, in two different places in the source, with nothing forcing them to agree except me getting both right by hand.

Mistakes I made here that NASM never flagged: SizeOfImage not extended after adding .idata (the loader would have refused to reserve enough address space for the last section); SizeOfInitializedData not summing .idata's rounded size (a field nothing actually enforces at load time on most systems, but wrong regardless); SizeOfHeaders and the earlier sections' PointerToRawData values going stale after I inserted the .idata section header into the table, since every earlier calculation had assumed a 3-section header block. Every one of these assembled cleanly, every time.

The import table — INT, IAT, and how a hand-written binary finds MessageBoxA

A PE that calls external code doesn’t embed real addresses, because user32.dll and kernel32.dll load at different base addresses on every machine and potentially every run (system DLLs are ASLR-relocated). Instead, .idata holds a request, and the loader fills in the real address before your first instruction ever executes.

The Import Directory Table is an array of 20-byte IMAGE_IMPORT_DESCRIPTOR structures, one per imported DLL, terminated by a fully zeroed 20-byte entry — a null terminator I forgot on my first pass, since nothing about assembling the file without it produces an error; the table simply has no declared end, and the loader either walks into adjacent bytes and misinterprets them as a phantom DLL descriptor, or (depending on loader version and what garbage happens to follow) fails more subtly.

Each descriptor:

dd (user32_int - idata_start) + 0x4000   ; OriginalFirstThunk — RVA to the INT
dd 0x00000000                             ; TimeDateStamp — 0 = not bound
dd 0x00000000                             ; ForwarderChain
dd (user32_name - idata_start) + 0x4000  ; Name — RVA to "user32.dll"
dd (user32_iat - idata_start) + 0x4000   ; FirstThunk — RVA to the IAT

OriginalFirstThunk and FirstThunk point to two parallel arrays — the Import Name Table and the Import Address Table — that start out byte-identical. Each entry in both is an 8-byte thunk (PE32+) that, pre-load, points to a Hint/Name structure: a 2-byte ordinal hint followed by the null-terminated ASCII function name, "MessageBoxA" or "ExitProcess" in my case. The hint is a soft optimization — a suggested starting index into the DLL's export ordinal table so the loader can skip straight there instead of doing a full name comparison against every export; 0 means "no hint, do a normal name lookup," which is what I used.

At load time, the loader walks the INT, resolves each name against the target DLL’s export table, and — critically — overwrites the IAT copy (not the INT) with the real resolved function address. The INT stays untouched as a record of what was requested; the IAT becomes a table of real, callable pointers. This is exactly why call [rel user32_iat], dereferencing the IAT slot, is correct in intent — you're supposed to call through that pointer, letting the loader's resolved value be whatever ends up there. The bug wasn't in that logic. It was in how the address of user32_iat itself got computed.

Where it broke: RIP-relative addressing computed on the wrong axis

call [rel user32_iat]

[rel label] encodes as an opcode plus a signed 32-bit displacement (disp32). At runtime: effective_address = RIP_of_next_instruction + disp32. NASM's -f bin output format has no notion of sections at all — the entire output is one contiguous byte stream to it, starting at address 0, and every label's "address" for the purpose of computing that displacement is its raw file byte offset. That's exactly correct for two labels inside the same section. It is wrong for two labels in different sections, because — as established above — .text and .idata sit 0x400 bytes apart on disk and 0x3000 bytes apart in memory. NASM computed disp32 using the disk distance. The CPU, at runtime, added that same disp32 to RIP, which is a memory address, not a file offset — producing a target address 0x2C00 short of where user32_iat actually lives in memory. That address landed on an unmapped or unrelated page. The CPU dereferenced it as a function pointer, jumped, and the process died: 0xc0000005, access violation, fault offset 0x0000000000000000, faulting module unknown — not a crash inside a recognizable function, a jump straight to address zero, consistent with reading a garbage 8-byte value that happened to decode as zero and calling through it.

The same wrong-axis computation independently broke mov rdx, message (.text → .data) and mov [result], rax (.text → .bss) — every cross-section reference I'd written using flat symbolic labels was wrong for the identical reason, simultaneously.

Fix: stop trusting the assembler’s flat-address model for anything crossing a section boundary. Compute the absolute virtual address by hand — ImageBase + section_RVA + intra_section_offset — as a value NASM can still resolve at assemble time (it's all compile-time constants), load it as a 64-bit immediate, and dereference explicitly:

; broken — file-offset delta used as if it were a runtime RVA delta
call [rel user32_iat]
; fixed — absolute VA, computed at assemble time, loaded as an immediate
mov rax, 0x140000000 + 0x4000 + (user32_iat - idata_start)
call [rax]

Both versions assembled with zero warnings, both times. There is nothing in x86–64’s instruction encoding that distinguishes “correct RIP-relative displacement” from “incorrect RIP-relative displacement” — both are syntactically and semantically valid instructions to the ISA. The only thing that exposes the difference is executing the instruction against the actual memory layout the loader built, which happens strictly after assembly, strictly after the loader has already committed to mapping your sections at their declared RVAs.

A second, scarier failure after the fix

After the fix, the exact same crash — 0xc0000005, fault offset 0x0 — recurred on a later run of what I'd confirmed was the corrected binary (AppTimeStamp in Event Viewer's Details tab matched the current PE header's TimeDateStamp exactly, ruling out a stale cached copy).

Two explanations, not mutually exclusive: most likely, a duplicate msgbox (1).exe sitting in the same download folder got launched by mistake. But worth stating regardless of which one actually happened: this binary sets RELOCS_STRIPPED and ships no .reloc section, while hardcoding absolute VAs against a fixed ImageBase. If the loader ever can't map the image at 0x140000000 — address space contention, a security mitigation forcing relocation, anything — and silently rebases it without applying relocations it was never given, every hardcoded 0x140000000 + ... constant baked into .text now points at stale memory. Same crash signature, and specifically intermittent, gated on system memory conditions at launch. Deleting the duplicate file and reproducing cleanly across several runs pointed at the first explanation. The second one stays true regardless, and it's the actual reason a production PE needs a .reloc section — not a nice-to-have, a structural requirement the moment you can't guarantee your preferred base address.

Code: [github.com/RamadhanAdam/raw-pe]

6152 616D 6864 6E61 4120 6164 206D 6F5A 656D


메타데이터
post_id
d011cb0097cb
slug
i-wrote-a-windows-pe-file-from-scratch-in-assembly-d011cb0097cb
url
https://medium.com/@ramadhanzome4/i-wrote-a-windows-pe-file-from-scratch-in-assembly-d011cb0097cb
canonical_url
https://medium.com/@ramadhanzome4/i-wrote-a-windows-pe-file-from-scratch-in-assembly-d011cb0097cb
author_url
https://medium.com/@ramadhanzome4
status
ok
fetched_at
2026-07-09 03:40:04