Follow e_lfanew and the first thing you meet is a 4-byte signature: 50 45 00 00 — the letters P, E, then two null bytes. The specification fixes it exactly: the signature "is PE\0\0". This is the moment Windows stops seeing a 1980s DOS file and starts seeing a modern image. Right behind it, the 20-byte COFF header answers the loader's first real questions: which CPU, how many sections, executable or DLL, 32-bit machine or not?
Every PE parser in existence — objdump, dumpbin, debuggers, signature checkers, packers — makes the same two checks in the same order: find MZ, find PE, then decode these 20 bytes. If you can read this layer by hand, you can confirm what any tool claims about a binary.
Picture a checkpoint with two documents:
PE\0\0, four bytes, no negotiation. Wrong bytes, wrong file type; the loader stops here.Everything else in the header region — optional header, section table — is positioned by arithmetic from these 20 bytes: signature (4) + COFF (20) + SizeOfOptionalHeader. The COFF header stores no pointer to the section table; its size field is the pointer.
The model's missing piece: Characteristics is not a single meaning but independent flags OR-ed together, and the same hex value means nothing until you decompose it against the specification's flag table.
Both sample files, signature + COFF header, verbatim — Sample A (PE32+ x64, cli-64.exe) and Sample B (PE32 i386, cli-32.exe), each dumped from file offset 0x100:
A: 00000100: 5045 0000 6486 0600 e427 6864 0000 0000 PE..d....'hd.... A: 00000110: 0000 0000 f000 2200 ......". B: 00000100: 5045 0000 4c01 0500 da27 6864 0000 0000 PE..L....'hd.... B: 00000110: 0000 0000 e000 0201 ........
Full decode — signature at 0x100, then the seven COFF fields at 0x104:
| COFF field | Off | Sample A bytes | Sample A value | Sample B bytes | Sample B value |
|---|---|---|---|---|---|
| Machine | 0x104 | 64 86 | 0x8664 — x64 | 4c 01 | 0x014C — i386 |
| NumberOfSections | 0x106 | 06 00 | 6 | 05 00 | 5 |
| TimeDateStamp | 0x108 | e4 27 68 64 | 0x646827E4 | da 27 68 64 | 0x646827DA |
| PointerToSymbolTable | 0x10C | 00 00 00 00 | 0 | 00 00 00 00 | 0 |
| NumberOfSymbols | 0x110 | 00 00 00 00 | 0 | 00 00 00 00 | 0 |
| SizeOfOptionalHeader | 0x114 | f0 00 | 0x00F0 (240 — PE32+) | e0 00 | 0x00E0 (224 — PE32) |
| Characteristics | 0x116 | 22 00 | 0x0022 | 02 01 | 0x0102 |
Characteristics, decoded against the specification's flag table. The defined flags near our values: 0x0001 RELOCS_STRIPPED, 0x0002 EXECUTABLE_IMAGE ("the image file is valid and can be run… if this flag is not set, it indicates a linker error"), 0x0020 LARGE_ADDRESS_AWARE ("application can handle greater than 2-GB addresses"), 0x0100 32BIT_MACHINE ("machine is based on a 32-bit-word architecture"), 0x2000 DLL. Now the OR:
| Sample | Value | Decomposition | Plain English |
|---|---|---|---|
A (cli-64.exe) | 0x0022 | 0x0002 | 0x0020 | Executable image, large-address-aware (x64, so no 32BIT_MACHINE bit) |
B (cli-32.exe) | 0x0102 | 0x0002 | 0x0100 | Executable image, 32-bit-word machine (not large-address-aware) |
Neither has 0x2000 — both are EXEs, not DLLs. The two files were built ten seconds apart (timestamps 0x646827E4 and 0x646827DA, both 2023-05-20), same linker, same source tree: 64-bit and 32-bit builds of one tool.
DllCharacteristics in the optional header — different header, different table, covered in the next concept.Hand-decode Sample A's COFF header — the exact bytes objdump agrees with:
50 45 00 00 — P, E, null, null. Four bytes, checked before anything else.64 86 → little-endian 0x8664 → IMAGE_FILE_MACHINE_AMD64. The loader will now map this only on x64 (or under emulation).06 00 → 6. The section table right after the optional header has exactly six 40-byte rows — the loader reads that many and no more (spec cap: 96).e4 27 68 64 → 0x646827E4 → 2023-05-20 01:52:36 UTC (a C time_t; objdump prints it in local time).00 00 00 00 × 2 → no COFF symbol table, as the spec requires for images.f0 00 → 0x00F0 = 240 bytes. Optional header spans 0x118 to 0x207; the section table begins at 0x208.22 00 → 0x0022 → EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE — a runnable 64-bit image that may address more than 2 GB.Run the same steps on Sample B and every answer flips exactly where the format says it should: Machine 0x014C, 5 sections, optional header 0xE0 bytes, Characteristics 0x0102. One layout, two architectures — this is the "portable" in Portable Executable.
Dump the signature+COFF region of both samples (or any PE you have), then compare with objdump's decode:
$ 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 ......".
$ xxd -s 0x100 -l 24 cli-32.exe
00000100: 5045 0000 4c01 0500 da27 6864 0000 0000 PE..L....'hd....
00000110: 0000 0000 e000 0201 ........
$ objdump -x cli-32.exe | sed -n '/^Characteristics/,/^$/p'
Characteristics 0x102
executable
32 bit wordsWhat to look for: bytes 0–3 are always 50 45 00 00; bytes 4–5 are Machine (64 86 vs 4c 01); the last four bytes are SizeOfOptionalHeader and Characteristics (f0 00 22 00 vs e0 00 02 01). objdump's "executable / 32 bit words" is precisely 0x0002 | 0x0100, named.
Without looking back: a PE's Characteristics reads 0x0102. Decompose it against the spec table.
You find 4c 01 05 00 at file offset 0x104 of an unknown PE. What machine and section count do you decode before touching anything else?
The pattern: signature first (4 bytes), Machine second, NumberOfSections third, SizeOfOptionalHeader fourth — and with that last number you can already compute where the section table starts.
The COFF header ends with SizeOfOptionalHeader — a size field that points straight into the next layer. For Sample A it promises 240 bytes of loader instructions; for Sample B, 224. That layer is called "optional" for a historical reason that surprises almost everyone.
Next: The Optional Header — entry point, image base, alignments, subsystem, and a full side-by-side walk of the PE32 and PE32+ layouts.