Skip to content

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\050 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:

Measured 2026-09-13 — pip 25's distlib launchers under /usr/local/lib/python3.14/site-packages/pip/_vendor/distlib, read with struct in a Python one-liner. Not machine-checked: the files belong to that pip
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.

Measured 2026-09-13 — file --mime-type -b t64.exe, file-5.41 on macOS 26.6.2 and file-5.45 in ubuntu:24.04. Not machine-checked: the MIME string is a property of the file(1) release
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

  1. Find a PE on your machine — pip's distlib/*.exe, a .dll in a Wine prefix, a UEFI .efi on an EFI partition — and xxd -l 4 -s $((16#3c)) it, then xxd -l 4 -s <that offset>. 50 45 00 00.
  2. From the same file read the 16-bit number 24 bytes after PE\0\0. 0b 01 or 0b 02, and now you know how wide ImageBase is.
  3. file --mime-type on it under two versions of file, or against the table above, and decide which string your scripts will grep.
  4. Find NumberOfSections and SizeOfOptionalHeader in the COFF header and compute where the section table begins. Dump 40 bytes there and read the name.
  5. 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 /offset section 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?0x3C and 0x10B are 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