Back to course

PE File Layout

15 minbeginnerModule 1: FundamentalsContinues from pe-intro
15 min · Module 1: Fundamentals · Foundations

Why This Matters

You learned that a PE file begins with MZ and contains a PE signature somewhere later. That is the entry point into the format — not the format itself. Before decoding any single field, you need the whole map: which layers exist, in what order they sit on disk, and where one ends and the next begins.

Everyone who touches an executable needs this map. The Windows loader walks it in order — DOS header, PE header, section table — and then maps each section's raw bytes into memory. A debugger or disassembler walks it to find code. A packer, a code-signing tool, or a malware analyst rewrites parts of it. If you misplace even one layer — say, you trust a wrong SizeOfOptionalHeader — every section row after it is garbage and the file will not load.

A Simple Model

Think of the file as three zones front to back:

  1. The compatibility envelope — the MS-DOS header and stub, bytes 0 up to the PE signature. It exists so 1980s MS-DOS can still run (or politely refuse) the file, and so the NT loader has a fixed place to look: the 4 bytes at offset 0x3C.
  2. The instruction block — PE signature, 20-byte COFF header, optional header, and the section table, all contiguous. This block tells the loader everything: where execution starts, where the image prefers to live, how sections align on disk versus in memory, and one 40-byte row per section.
  3. The payload — raw section data: .text, .rdata, .data, and the rest, each located by its own PointerToRawData and padded to FileAlignment.

The model's missing piece: nothing in the instruction block stores a single master pointer to "the data." Section data is found only through the section table — one row at a time, each row carrying its own file offset. No table, no payload.

The Actual Detail

The PE specification fixes this order: MS-DOS stub, PE signature, COFF file header, optional header, section headers, then image pages. Here is the exact byte map of a real 64-bit PE (setuptools' cli-64.exe, 14336 bytes, 6 sections):

LayerFile rangeSizeWhat it is
MS-DOS header0x0000–0x003F64e_magic = MZ; e_lfanew at 0x3C = 0x100
MS-DOS stub0x0040–0x00FF9616-bit stub that prints "This program cannot be run in DOS mode."
PE signature0x0100–0x0103450 45 00 00 — "PE" plus two nulls
COFF header0x0104–0x011720Machine, section count, timestamp, sizes, characteristics
Optional header0x0118–0x0207240 (0xF0)Entry point, image base, alignments, subsystem, 16 data directories
Section table0x0208–0x02F7240 (6 × 40)One 40-byte row per section
Header padding0x02F8–0x03FF104Zeros out to SizeOfHeaders = 0x400
.text raw0x0400–0x1BFF0x1800Code; entry point resolves to file 0x1140
.rdata raw0x1C00–0x2FFF0x1400Read-only data (imports live here)
.data raw0x3000–0x31FF0x200Initialized data
.pdata raw0x3200–0x33FF0x200x64 exception table
.rsrc raw0x3400–0x35FF0x200Resources
.reloc raw0x3600–0x37FF0x200Base relocations; file ends at 0x3800 = 14336

Notice the two alignments at work: FileAlignment = 0x200 pads every raw section start on disk (0x400, 0x1C00, 0x3000…), while SectionAlignment = 0x1000 spaces sections in memory (0x1000, 0x3000, 0x5000…). Headers are zero-padded so the first section begins exactly at the 0x400 SizeOfHeaders boundary.

Compared with ELF: both formats put headers first and payload last, but the mechanics differ. ELF always has a fixed-size ELF header at byte 0 and finds its tables through explicit pointers (e_phoff, e_shoff) — the section header table often sits at the end of the file. PE has no operative header at byte 0 (that is the DOS layer), its section table is found by arithmetic, not by a pointer field, and it lives inside the header region — immediately after the optional header — never at the end. ELF also has a separate program-header table describing loadable segments; PE has none: the section table plus the optional header's alignment and size fields are the loader's entire map.

Common mistake. Do not assume the section table is at a fixed offset like 0x208. That offset is the result of e_lfanew (0x100) + 4 (signature) + 20 (COFF) + SizeOfOptionalHeader (0xF0). Change any of those and the section table moves. Compute it; never hardcode it.

A Real Example

The whole header region of cli-64.exe, at the layer boundaries:

00000000: 4d5a 9000 0300 0000 0400 0000 ffff 0000  MZ..............
00000010: b800 0000 0000 0000 4000 0000 0000 0000  ........@.......
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 0000 0000 0000 0000 0001 0000  ................
00000100: 5045 0000 6486 0600 e427 6864 0000 0000  PE..d....'hd....
00000110: 0000 0000 f000 2200                       ......".

Walk-through of the seams:

  1. Bytes 0x00–0x3F: DOS header. Bytes at 0x3C are 00 01 00 00 — little-endian 0x100 — so the instruction block starts at offset 0x100.
  2. Bytes 0x40–0xFF: DOS stub, ending in padding zeros. The instruction block begins exactly at 0x100 with 50 45 00 00.
  3. The COFF header's last field pair reads f000 2200: SizeOfOptionalHeader = 0xF0, so the optional header spans 0x118 to 0x207, and the section table starts at 0x208 — six rows of 40 bytes end at 0x2F8.
  4. The first section row's PointerToRawData is 0x400. Bytes 0x2F8–0x3FF are the zero pad, and the payload begins at 0x400. The last section (.reloc) ends at 0x3800, which is exactly the file size — no trailing data.
00000208: 2e74 6578 7400 0000 bc17 0000 0010 0000  .text...........
00000218: 0018 0000 0004 0000 0000 0000 0000 0000  ................
00000228: 0000 0000 2000 0060                      .... ..`.

That is the first section row: name .text, VirtualSize 0x17BC, VA 0x1000, SizeOfRawData 0x1800, PointerToRawData 0x400, Characteristics 0x60000020. The map and the table agree — that is the whole contract.

Try It

Any Windows executable works. On Linux or macOS, dump the layer boundaries, then let objdump reconstruct the section table for you:

$ xxd -s 0x100 -l 24 cli-64.exe
00000100: 5045 0000 6486 0600 e427 6864 0000 0000  PE..d....'hd....
00000110: 0000 0000 f000 2200                       ......".

$ objdump -h cli-64.exe
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         000017bc  0000000140001000  0000000140001000  00000400  2**4
  1 .rdata        0000132c  0000000140003000  0000000140003000  00001c00  2**4
  2 .data         00000200  0000000140005000  0000000140005000  00003000  2**4
...

What to look for: the File off column is PointerToRawData straight from the section table, and every value is a multiple of 0x200 — FileAlignment made visible. The VMA column shows the same sections at 0x1000-spaced addresses — SectionAlignment.

Check Your Understanding

Without looking back: in what order do a PE file's layers appear on disk?

Apply It

A PE file has e_lfanew = 0x100, SizeOfOptionalHeader = 0xF0, and 6 sections. Where does the section table start, and where does it end?

The pattern: every PE offset you compute chains from e_lfanew forward through sizes. None of them are constants.

Connect

You now have the map. The instruction block in the middle of that map has two identities: the 20-byte COFF header that PE inherited from the Common Object File Format — the same header that opens every .obj file — and the optional header that only images carry. The headers themselves come next, layer by layer.

Next: The COFF Heritage — why a Windows executable carries a Unix-lineage object-file header, and what the linker puts in it.