PE¶
Level: 201 · for anyone with a hex editor open
One line: A PE is found by following a 32-bit offset at 0x3C to PE\0\0, then a COFF header, then an optional header whose own magic — 0x10B or 0x20B — decides whether ImageBase is four bytes or eight and moves every later field by the difference; and a section's address in the file is not its address in memory, so a loader keeps a table for the arithmetic.
What the loader reads¶
The Portable Executable is the format of every Windows .exe, .dll, .sys and driver since Windows NT 3.1, and of UEFI firmware, specified in Microsoft's PE Format ↗ page. It is three headers in a row, and the first is somebody else's:
| where | what | width |
|---|---|---|
| 0 | the MZ header and DOS stub — MZ, then a program that prints This program cannot be run in DOS mode |
usually 128 bytes |
0x3C |
e_lfanew, a 32-bit little-endian file offset |
4 |
e_lfanew |
PE\0\0 — 50 45 00 00 |
4 |
| +4 | the COFF file header: Machine, NumberOfSections, TimeDateStamp, PointerToSymbolTable, NumberOfSymbols, SizeOfOptionalHeader, Characteristics |
20 |
| +24 | the optional header, which is not optional in an executable: its own Magic, then the entry point, the image base, the alignments, the sizes, and 16 data directories |
224 or 240 |
| after it | the section table, 40 bytes per section | 40 × n |
Everything is little-endian, fixed by the specification, and nothing in the file says so. Machine is 0x14C for i386, 0x8664 for x86-64, 0xAA64 for ARM64 — the same table as COFF, which is where this header came from.
In Python¶
Verified output of pe_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. THREE MAGICS, AND WHERE EACH ONE IS FOUND
------------------------------------------------------------------------
offset 0 4d 5a 'MZ' the DOS header, present in every PE
offset 0x3c 80 00 00 00 e_lfanew = 0x80: where the PE header starts
offset 0x80 50 45 00 00 'PE\0\0' the signature
offset 0x80+4 64 86 01 00 00 00 00 00 00 00 00 00 00 00 00 00 f0 00 22 00 the COFF file header, 20 bytes
offset 0x80+24 0b 02 0x20b: the optional header's own magic
Three signatures, and only the first is at a fixed place. The
second is wherever a 32-bit little-endian field says, and the third
is a number, not text: 0x10b means PE32 and 0x20b means PE32+.
2. THE OPTIONAL HEADER'S MAGIC DECIDES ITS OWN WIDTH
------------------------------------------------------------------------
PE32 machine 0x014c I386 SizeOfOptionalHeader 224 = 96 fixed + 16 directories x 8
ImageBase 0x400000 SizeOfStackReserve 0x100000 has BaseOfData
PE32+ machine 0x8664 AMD64 SizeOfOptionalHeader 240 = 112 fixed + 16 directories x 8
ImageBase 0x140000000 SizeOfStackReserve 0x100000 no BaseOfData
Same fields, two sizes: 224 and 240. PE32+ drops BaseOfData and
widens ImageBase and the four Size...Reserve/Commit fields from 4
bytes to 8, so every field after ImageBase sits at a different
offset in the two layouts. The width is the optional header's own
magic, not the machine type, which is a separate field 22 bytes
earlier: two fields, two facts.
3. A SECTION HAS AN ADDRESS IN THE FILE AND ANOTHER IN MEMORY
------------------------------------------------------------------------
.text VirtualAddress 0x1000 VirtualSize 0x100 PointerToRawData 0x0400 SizeOfRawData 0x200
RVA 0x1000 -> file offset 0x0400 in .text
RVA 0x1010 -> file offset 0x0410 in .text
RVA 0x2000 -> no section: not in the file at all
AddressOfEntryPoint is an RVA, an offset from ImageBase once the
image is mapped. The bytes for it are at a different offset in
the file, because sections are 0x200-aligned on disk and 0x1000-
aligned in memory. The section table is the conversion, and an
RVA in a gap between sections is a number with no bytes behind it.
4. THE NAME FIELD IS EIGHT BYTES AND NO TERMINATOR
------------------------------------------------------------------------
2e 74 65 78 74 00 00 00 b'.text\x00\x00\x00' -> '.text'
2e 72 64 61 74 61 00 00 b'.rdata\x00\x00' -> '.rdata'
2e 74 65 78 74 62 73 73 b'.textbss' -> '.textbss'
Eight bytes exactly: a shorter name is NUL-padded and an eight-byte
name has no NUL at all, so strlen() on it runs into the next field.
COFF's escape for longer names, /offset, is on the COFF page.
The magic that decides a width¶
The optional header opens with a 16-bit number: 0x10B is PE32, 0x20B is PE32+, and 0x107 a ROM image. PE32+ is the 64-bit layout, and it is not a new header — it is the same fields with ImageBase and the four stack and heap sizes widened from 4 bytes to 8, and BaseOfData dropped to make room. Section 2 above builds both around one section and prints the two sizes, 224 and 240; every field after ImageBase is at a different offset in the two, and a reader that used the 32-bit offsets on a 64-bit file would read SectionAlignment out of the top half of ImageBase.
The width is not in Machine. The machine type is 22 bytes earlier and answers a different question, and Ghidra's OptionalHeader reads the magic, not the machine, to decide which fields to read. Two fields, two facts — the same separation ELF makes with two bytes.
Two addresses for every byte¶
A section has PointerToRawData — where its bytes are in the file — and VirtualAddress, an RVA, where they will be once the image is mapped, as an offset from ImageBase. The two differ because the file is FileAlignment-aligned (0x200 is the convention) and memory is SectionAlignment-aligned (0x1000, a page). So every address the header quotes — the entry point, every data directory, every import — is an RVA, and turning it into a place in the file means finding the section that contains it and subtracting. Section 3 above does the arithmetic for three RVAs, and the third lands in no section: a number with no bytes behind it, which is legal and means uninitialised.
This is why a PE cannot be read by seeking to the numbers in its header, and why xxd -s 0x1000 shows you the wrong bytes. Raw binary is the loader with no table, and every pointer in a raw file has the same problem with nothing to solve it.
Measured on real files¶
pip ships four PE launchers with every Python, so this Mac has a PE to look at without Windows:
file e_lfanew sig Machine optional Magic
t32.exe 0xe8 PE\0\0 0x014c 0x10b PE32, i386
t64.exe 0xf8 PE\0\0 0x8664 0x20b PE32+, x86-64
t64-arm.exe 0x108 PE\0\0 0xaa64 0x20b PE32+, ARM64
t64.exe e_cblp 144 e_cp 3 -> DOS sees 1,168 bytes of a 108,032-byte file
NumberOfSections 6 SizeOfOptionalHeader 240 Characteristics 0x22
stub text at 78: 'This program cannot be run in DOS mode.\r'
Three launchers, three machines, one layout rule. e_lfanew differs because the DOS stub in front is a different length in each; the stub's own header says DOS may read 1,168 bytes, which is the MZ page's arithmetic.
file-5.41 application/x-dosexec
file-5.45 application/vnd.microsoft.portable-executable
Worth knowing beside finding 22, which measured file's MIME output stable across those same two releases on nine files: on a PE it is not. The English changed too — 5.45 appends , 6 sections. A script that greps either is depending on a release note.
What Ghidra checks¶
PeLoader ↗ builds a PortableExecutable and accepts the file when it finds an NT header with an optional header behind the DOS header's e_lfanew; it then takes ImageBase from that optional header as the load address and Machine from the COFF header to choose a language, and asks a compiler-opinion pass which toolchain built the file. The name it shows is Portable Executable (PE).
If you are coming from Python or ABAP¶
Python. pefile is the library everybody uses and it is not in the standard library; the header is small enough that struct will do, and the two things to get right are the ones above — read the optional header's magic before choosing a format string, and never seek to an RVA. struct.unpack_from('<I', data, 0x3C) is e_lfanew; the section table starts at e_lfanew + 24 + SizeOfOptionalHeader, and that field is in the COFF header, so read it rather than assuming 224 or 240. A section name is an 8s: rstrip(b'\0') it, and never split(b'\0') a full-length one.
ABAP. (Not machine-checked — CI cannot run ABAP.) The lesson that transfers is the two addresses. A record that stores an offset into itself — an IDoc segment pointing at another by number, a SOLIX table holding a document with an internal table of contents — is quoting a position in some layout, and the layout it means is not necessarily the one in the xstring you hold; a PE's RVA is a position in the mapped image, not in the file. Keep the conversion in one place and name what the number is relative to. Everything else is +off(len) slices and big-endian assignments with the bytes reversed first, as on the ELF page.
Try it¶
- Find a PE on your machine — pip's
distlib/*.exe, a.dllin a Wine prefix, a UEFI.efion an EFI partition — andxxd -l 4 -s $((16#3c))it, thenxxd -l 4 -s <that offset>.50 45 00 00. - From the same file read the 16-bit number 24 bytes after
PE\0\0.0b 01or0b 02, and now you know how wideImageBaseis. file --mime-typeon it under two versions offile, or against the table above, and decide which string your scripts will grep.- Find
NumberOfSectionsandSizeOfOptionalHeaderin the COFF header and compute where the section table begins. Dump 40 bytes there and read the name. - Take an RVA from the header —
AddressOfEntryPoint— and convert it to a file offset by hand through the section table. Then check with any PE tool you have.
See also¶
- MZ — the header in front of every PE, and how much of the file DOS thinks there is
- COFF — the object format whose 20-byte header sits after
PE\0\0, with its/offsetsection names and its two-shape symbol names - NE — the previous format to sit behind
e_lfanew, for 16-bit Windows - DBG — the debug information a PE can have split off into a second file
- ELF — the same idea, section names in a string table, and a header that says its own byte order where this one does not
- Which base did you mean? —
0x3Cand0x10Bare numbers this page writes in hex; the file writes them in bytes - PE Format ↗ — Microsoft's specification, which the optional-header layout above was checked against