What a Windows Executable Contains
Use the PE header and section map to understand where a Windows executable keeps code, data, imports, and addresses.
Cheatsheet for Lesson 5.1
A one-page summary of “What a Windows Executable Contains” you can keep beside your notes. Plain Markdown.
Lesson 5.1 — What a Windows Executable Contains: cheatsheet
- Remember: A PE file is a chain of headers. Each one tells the parser where the next begins, ending at the section table that maps file bytes to memory.
- Also useful: Static storage is a lifetime term; static linking is a packaging choice. Neither makes a file offset equal to a live virtual address.
Key terms
- Portable Executable: It is a Portable Executable, usually shortened to PE.
- file offset: A position in the file is a file offset.
- RVA: A position in the loaded image is an RVA, a relative virtual address, meaning a distance from wherever Windows put the module.
bytes: In the excerpts that follow,bytesis the whole EXE file read into aVec<u8>; these helpers check every field access against that file.
Formulas and patterns
file layout on disk image layout in a process;file offset -> bytes in one section <-> RVA -> mapped bytes in that section;+ live module base;= live virtual address
Review
- Q: Why is a PE RVA not always the same number as a file offset? A: A section's place in memory can differ from its place on disk
- Q: A PE section reports an instruction RVA of
0x1200, and Windows loads the module at0x7FF600000000. What is the live virtual address? A: 0x7FF600001200, the live base plus the RVA
Why an EXE needs a map
Section titled “Why an EXE needs a map”A Windows .exe is not one solid blob of CPU instructions. It is a Portable Executable, usually shortened to PE. Its headers tell Windows where the code, read-only data, writable data, imports, resources, and relocation information live.
First we will learn the file’s map and use it to translate positions on disk into live addresses. Then we will ask two different runtime questions: where are candidate bytes or values, and which instructions actually execute? Scanners, a disassembler, a debugger, and ETW each answer a different part. Knowing the map keeps their addresses anchored to a real build.
The same section occupies a place in the file and a place in memory, and those two places are numbered differently. A position in the file is a file offset. A position in the loaded image is an RVA, a relative virtual address, meaning a distance from wherever Windows put the module. Use two layouts connected by the section table:
file layout on disk image layout in a processfile offset -> bytes in one section <-> RVA -> mapped bytes in that section + live module base = live virtual addressThe layouts differ because sections have separate file and virtual alignments, zero-initialized data may occupy memory without taking equal file space, and Windows may apply relocations and resolve imports after mapping. A section row supplies the mapping information for converting a location in one layout to the corresponding location in the other; the numbers are related but not interchangeable.
1 The file: headers, then payloads
File on disk
File offsetsoffset 0end of fileheaders.text payload.data payloadThe file stores the headers and then each section’s bytes one after another.
2 The image: same bytes, new places
File on disk
File offsetsheaders.text payload.data payloadLoaded image (RVAs)
Image addressesRVA 0end of imageheaders.text payload.data payloadzero tailno file bytesSections are laid out by their memory addresses, so the spacing differs, and the image reserves a zero-filled tail that the file never stored.
3 A section row links the two
Section row
.textfile offset X→ RVA Ysize in filesize in imageLoaded image (RVAs)
Image addressesheaders.text payload.data payloadzero tailThe section table says which file offset becomes which RVA, so every payload byte can be found in both views.
The same payloads sit at different positions, and the image can have a zero-filled tail that the file does not store.
A parser follows the file’s own chain of verified offsets. Each header tells it where the next structure begins or how large that structure is:
A file ruler with five blocks: the DOS header, the PE signature at 0x80, the COFF header at 0x84, the optional header at 0x98, and the section table at 0x178. A cursor visits each in order, and each step shows how the previous header said where to look: e_lfanew gives 0x80, then 0x80 plus 4, plus 20, plus 0xE0.
Read the steps
- Every PE file starts with the DOS header. Its first two bytes, MZ, say this might be a PE image.
- At offset 0x3C the DOS header stores e_lfanew: a file offset. Here it is 0x80, so the PE header starts 0x80 bytes into the file. Nothing is guessed; the file says where to go.
- At 0x80 the parser checks the four bytes P, E, 0, 0 before trusting anything after them. A wrong signature stops the parse here.
- The COFF header follows right after the signature, at 0x80 + 4 = 0x84. It names the machine, here 0x014C for 32-bit x86, and how many sections exist: 3.
- The optional header is 20 bytes further on, at 0x84 + 20 = 0x98. Its magic, 0x10B, says PE32, and it holds the image base and the entry-point RVA.
- The section table begins after the optional header: 0x98 + 0xE0 = 0x178. Each row is 40 bytes (0x28), so the three rows start at 0x178, 0x1A0, and 0x1C8.
- Each section row says which bytes of the file become which range of the loaded image. The next lesson uses a row to turn an RVA into a file offset.
Each PE field tells the reader where or how to interpret the next field; the section table finally connects disk positions to image-relative addresses.
Never skip directly to a later box using a guessed offset. A damaged or wrong header should stop the parse before its numbers are trusted.
The path through the headers
Section titled “The path through the headers”The diagram above is a parser’s route, not six unrelated names. Each structure either confirms that this is a PE image or tells us where the next structure begins. Read this indentation as a chain of dependencies: we cannot trust a section row until the headers that locate its table have passed their checks.
DOS header (starts with MZ)└─ e_lfanew points to the PE signature └─ COFF header says how many sections exist └─ optional header holds the image base and entry-point RVA └─ section table describes .text, .data, .rdata, and friendsThe “optional” header is not optional for an executable image. That old name survives because object files can omit it. The parser’s useful result is a bounded section table; the next lesson uses its rows to translate an RVA into the corresponding file bytes.
Read numbers without pointer casts
Section titled “Read numbers without pointer casts”For an on-disk file, a byte slice is safer and easier than casting random offsets to Windows structs:
use anyhow::Context;
fn bytes_at(bytes: &[u8], offset: usize, count: usize) -> anyhow::Result<&[u8]> { // 📏 Validate both the arithmetic and the slice. Either can fail for a // damaged file, and neither failure should become a panic or pointer cast. let end = offset.checked_add(count).context("PE offset overflowed")?; bytes .get(offset..end) .with_context(|| format!("PE field {offset:#x}..{end:#x} is outside the file"))}
fn u32_at(bytes: &[u8], offset: usize) -> anyhow::Result<u32> { Ok(u32::from_le_bytes(bytes_at(bytes, offset, 4)?.try_into()?))}
fn u16_at(bytes: &[u8], offset: usize) -> anyhow::Result<u16> { Ok(u16::from_le_bytes(bytes_at(bytes, offset, 2)?.try_into()?))}
fn u64_at(bytes: &[u8], offset: usize) -> anyhow::Result<u64> { Ok(u64::from_le_bytes(bytes_at(bytes, offset, 8)?.try_into()?))}Three ideas are hiding in those few lines:
checked_addrefuses an integer wraparound.getrefuses an out-of-file slice.from_le_bytesstates that PE numbers use little-endian byte order.
That is what “safe parsing” means: malformed input becomes an error instead of an invalid memory access.
The three integer helpers differ only in how many bytes they take and which
little-endian integer type they construct. In the excerpts that follow,
bytes is the whole EXE file read into a Vec<u8>; these helpers check every
field access against that file. The complete lab handles opening the path and
reporting errors as well.
Find the PE and COFF headers
Section titled “Find the PE and COFF headers”The COFF header, named for the older Common Object File Format it comes from, is the small fixed block right after the four-byte PE\0\0 signature. It gives the basics a parser needs before anything else: which CPU the machine code targets, how many section rows follow, and how large the next header is.
anyhow::ensure!(bytes_at(&bytes, 0, 2)? == b"MZ", "missing DOS MZ signature");// 🧭 DOS field 0x3c contains a file offset, not an RVA or live address.let pe_offset = usize::try_from(u32_at(&bytes, 0x3c)?)?;anyhow::ensure!( bytes_at(&bytes, pe_offset, 4)? == b"PE\0\0", "missing PE signature");
// 📏 Skip the four-byte PE signature only after it has been verified above.let coff = pe_offset.checked_add(4).context("COFF offset overflowed")?;let machine = u16_at(&bytes, coff)?;let section_count = usize::from(u16_at(&bytes, coff + 2)?);let optional_size = usize::from(u16_at(&bytes, coff + 16)?);anyhow::ensure!(section_count <= 96, "unreasonable section count");The cap of 96 is deliberately boring. Real course targets do not need thousands of sections, so a wild count means the offset is wrong or the file is damaged.
Tell PE32 from PE32+
Section titled “Tell PE32 from PE32+”The optional-header magic is 0x10B for PE32 and 0x20B for PE32+. Because their fields have different sizes, the image-base offset changes:
let optional = coff.checked_add(20).context("optional-header overflowed")?;let magic = u16_at(&bytes, optional)?;let entry_rva = u32_at(&bytes, optional + 16)?;
let image_base = match magic { // 📦 PE32 stores a four-byte image base at +28. 0x10b => u64::from(u32_at(&bytes, optional + 28)?), // 📦 PE32+ stores an eight-byte image base at +24. 0x20b => u64_at(&bytes, optional + 24)?, other => anyhow::bail!("unsupported optional-header magic {other:#06x}"),};PE32+ also omits BaseOfData; its eight-byte ImageBase starts in the
space where PE32 has that four-byte data landmark. Offsets here are relative
to the optional header, as specified by Microsoft’s PE format:
| Field | PE32 | PE32+ |
|---|---|---|
BaseOfCode |
Four bytes at +20 | Four bytes at +20 |
BaseOfData |
Four bytes at +24 | Absent |
ImageBase |
Four bytes at +28 | Eight bytes at +24 |
BaseOfData being absent does not mean the image has no data sections.
The section rows still describe their RVAs and sizes.
The games used by the memory-patching chapters are the specified 32-bit course builds, so their magic should be 0x10B. The parser supports both formats because a tool should explain what it found instead of guessing.
This interactive lab needs JavaScript. The lesson text around it still explains the same idea.
Walk the 40-byte section rows
Section titled “Walk the 40-byte section rows”The section table begins immediately after the optional header. Each row is 40 bytes:
let section_table = optional .checked_add(optional_size) .context("section-table offset overflowed")?;
for index in 0..section_count { // 📏 Multiply and add separately with checks so a hostile count cannot wrap // around and point an apparently valid row back into an earlier header. let start = section_table .checked_add(index.checked_mul(40).context("section index overflowed")?) .context("section offset overflowed")?;
let name_bytes = bytes_at(&bytes, start, 8)?; // 🔍 PE names are fixed eight-byte fields and are not required to end in NUL. let name_end = name_bytes.iter().position(|byte| *byte == 0).unwrap_or(8); let name = String::from_utf8_lossy(&name_bytes[..name_end]); let virtual_size = u32_at(&bytes, start + 8)?; let virtual_address = u32_at(&bytes, start + 12)?; let raw_size = u32_at(&bytes, start + 16)?; let raw_offset = u32_at(&bytes, start + 20)?; let flags = u32_at(&bytes, start + 36)?;
println!( "{name:<8} RVA {virtual_address:#010x} virtual {virtual_size:#010x} file {raw_offset:#010x}+{raw_size:#010x} flags {flags:#010x}" );}.text is normally readable and executable. .rdata is normally readable. .data is normally readable and writable. Names are only conventions; the characteristic flags are what the loader actually uses to set each section’s page protection.
Linked code still needs a file-to-image map
Section titled “Linked code still needs a file-to-image map”Static linking copies the needed library implementation into the output module. DLL linking keeps that implementation in another module and records how to find its exported functions. An import library can help link an EXE to a DLL without copying the DLL’s implementation into the EXE. Microsoft’s DLL overview describes load-time and run-time linking. Once loaded, the DLL uses the calling process’s virtual address space and the calling thread’s stack.
Whichever linking choice produced the module, its file bytes still need the
section map. For an illustrative .text row with raw start 0x400 and RVA
start 0x1000, file byte 0x520 is 0x120 bytes into the section. Its RVA
is 0x1120; at live base 0x00400000, its virtual address is 0x00401120.
This example assumes the byte lies inside that section’s file-backed data.
Try the example below, then change the offset to see where other bytes go: the two strips share one scale, and the section table is the converter.
File on disk14336 bytes (0x3800)
Image in memory28672 bytes (0x7000)
- Section bytes that exist in both places
- Zero-filled by the loader: in memory only
- Padding or unused space: no section owns it
.text byte 0x0120: RVA 0x1120, virtual address 0x00401120.
- 0x0520 lies in .text, whose raw data covers 0x0400 to 0x1FFF.
- 0x0520 − 0x0400 = 0x0120 bytes into the section.
- RVA = section RVA 0x1000 + 0x0120 = 0x1120.
- Virtual address = module base 0x00400000 + RVA 0x1120 = 0x00401120.
- File offset
- 0x0520
- RVA
- 0x1120
- Virtual address
- 0x00401120
If Windows loads the module at a different base (ASLR), the file offset and the RVA stay the same, and only the virtual address moves. The module base must be a multiple of 0x10000.
| Section | In the file | In memory (RVA) | RVA − file offset | What does not match |
|---|---|---|---|---|
| .text machine code | 0x0400 to 0x1FFF, size 0x1C00 | 0x1000 to 0x2A3B, size 0x1A3C | 0x1000 − 0x0400 = +0x0C00 | 0x1C00 − 0x1A3C = 0x01C4 bytes at the end of the raw data are file padding that is never mapped. |
| .rdata read-only data and the import tables | 0x2000 to 0x2BFF, size 0x0C00 | 0x3000 to 0x3B0F, size 0x0B10 | 0x3000 − 0x2000 = +0x1000 | 0x0C00 − 0x0B10 = 0x00F0 bytes at the end of the raw data are file padding that is never mapped. |
| .data writable globals | 0x2C00 to 0x2FFF, size 0x0400 | 0x4000 to 0x57FF, size 0x1800 | 0x4000 − 0x2C00 = +0x1400 | 0x1800 − 0x0400 = 0x1400 bytes are zero-filled in memory, from RVA 0x4400. |
| .rsrc icons, dialogs and version info | 0x3000 to 0x37FF, size 0x0800 | 0x6000 to 0x664F, size 0x0650 | 0x6000 − 0x3000 = +0x3000 | 0x0800 − 0x0650 = 0x01B0 bytes at the end of the raw data are file padding that is never mapped. |
Headers are the exception: they are mapped at the start of the image, so for the first 0x0400 bytes the RVA equals the file offset.
The numbers a section row supplies are stored in the headers themselves. Here is the whole header chain with real file offsets, each one derived from the sizes before it:
PE32: 32-bit: the optional header is 224 bytes, so the section table starts at 0x0178
0x0000 to 0x03FF: the first 0x0400 bytes of the file (1024 bytes), drawn to scale. Section data begins right after.
A leftover from MS-DOS that every PE file still begins with. Two fields matter.
Where it starts. Starts at the first byte of the file, 0x0000, and is 64 (0x40) bytes long: 2 + 58 + 4 = 64.
- The two letters MZ (bytes 4D 5A). A parser checks them first: without MZ this is not a PE file.In the sample:
MZ (4D 5A)The two ASCII letters, always at the very start of the file. - Old MS-DOS values (page counts, relocation hints). Windows never reads them.
- The file offset of the PE signature. A file offset, not an RVA and not a live address.In the sample:
0x00000080The DOS header (0x40 bytes) plus a 0x40-byte DOS stub end at 0x0080, where the example puts the signature.
- The two letters MZ (bytes 4D 5A). A parser checks them first: without MZ this is not a PE file.In the sample:
A tiny MS-DOS program that Windows skips.
Where it starts. Starts where the DOS header ends, 0x0040, and runs up to e_lfanew 0x0080: 0x0080 − 0x0040 = 0x0040 = 64 bytes.
If someone starts the file under MS-DOS, this little 16-bit program prints “This program cannot be run in DOS mode.” and exits. Windows never runs it: it follows e_lfanew instead. Some toolchains also store build information here, so the size varies from file to file.
Four bytes that say: the headers of a PE file start here.
Where it starts. Starts at the file offset stored in e_lfanew: 0x0080.
- The letters P, E and two zero bytes: 50 45 00 00. As a little-endian number that is 0x00004550.In the sample:
PE\0\0 (50 45 00 00)Four fixed bytes. If they are missing, stop: the file is damaged or not a PE file.
- The letters P, E and two zero bytes: 50 45 00 00. As a little-endian number that is 0x00004550.In the sample:
The basics: which CPU, how many sections, how big the next header is.
Where it starts. Starts right after the signature: e_lfanew + 4 = 0x0080 + 4 = 0x0084. It is 20 bytes long: 2 + 2 + 4 + 4 + 4 + 2 + 2 = 20.
- Which CPU the machine code is for.In the sample:
0x014C (x86)The format defines 0x014C for 32-bit x86 and 0x8664 for x86-64. - How many 40-byte section rows follow the optional header.In the sample:
4One row each for .text, .rdata, .data, .rsrc. - When the linker built the file, in seconds since 1 January 1970. Some builds store a hash instead.
- File offset of an old COFF symbol table. Zero in modern programs, whose debug data lives elsewhere.
- Number of entries in that table: zero when there is none.
- Bytes in the optional header, data directories included. It tells a parser how far to jump to reach the section table.In the sample:
0x00E0 (224)96 bytes of fields + 16 directories × 8 bytes = 96 + 128 = 224 = 0xE0. - Flag bits: an executable image, a DLL, 32-bit machine, and more.In the sample:
0x01020x0002 (executable image) + 0x0100 (32-bit machine) = 0x0102.
- Which CPU the machine code is for.In the sample:
The first part of the optional header for PE32. It is required for programs despite its name.
Where it starts. The optional header starts right after the COFF header: e_lfanew + 4 + 20 = 0x0080 + 24 = 0x0098. These fields are 2 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + 4 = 28 bytes.
- Tells the parser how to read everything after it: 0x010B means PE32, 0x020B means PE32+.In the sample:
0x010BThe two magic numbers the format defines. - Major version of the linker that made the file.
- Minor version of that linker.
- Total file size of the code sections.In the sample:
0x00001C00Only .text holds code here, so this is its SizeOfRawData. - Total size of sections that hold initialised data.
- Total size of sections that are zero-filled in memory and take no file space.
- RVA of the first instruction Windows runs. It must lie inside the code section.In the sample:
0x000012C0Illustrative: any RVA inside .text (0x1000 to 0x2A3B) is valid; the linker picks the real one. - RVA where the code section begins.In the sample:
0x00001000The RVA of .text in the section table. - RVA where the data begins. PE32+ dropped this field to make room for a wider ImageBase.In the sample:
0x00003000The RVA of .rdata, the first data section.
- Tells the parser how to read everything after it: 0x010B means PE32, 0x020B means PE32+.In the sample:
Fields the Windows loader needs. ImageBase is 4 bytes wide.
Where it starts. Starts after the standard fields: optional header 0x0098 + 28 = 0x00B4. These 21 fields are 68 bytes long.
- The address the linker prefers for the first byte of the image. Always a multiple of 64 KiB.In the sample:
0x00400000The long-standing default for a 32-bit EXE. - Sections start at multiples of this in memory.In the sample:
0x00001000One 4 KiB page. - Raw section data starts at multiples of this in the file.In the sample:
0x00000200The default, 512 bytes (one old disk sector). - Major version of Windows the image asks for.
- Minor version of that Windows.
- Major version of this program, set by its author.
- Minor version of this program.
- Major version of the Windows subsystem it needs.
- Minor version of that subsystem.
- Reserved: must be zero.
- Bytes the whole image takes in memory, headers included. A multiple of SectionAlignment.In the sample:
0x00007000Last section .rsrc ends at RVA 0x6000 + 0x650 = 0x6650, and rounding up to a multiple of 0x1000 gives 0x7000. - DOS header, stub, PE headers and section table together, rounded up to FileAlignment. It is where the first section’s raw data begins.In the sample:
0x00000400The headers end at 0x218; rounded up to a multiple of 0x200 that is 0x400. - A file checksum. Windows checks it for drivers and boot-time DLLs; many programs leave it zero.
- What kind of program this is: 2 means a Windows GUI program, 3 a console program.In the sample:
0x0002A game with a window is a GUI program. - Flag bits for security features, such as 0x0040 (ASLR may move the image) and 0x0100 (DEP).
- Address space reserved for the first thread’s stack.In the sample:
0x001000001 MiB, the usual linker default. - How much of that stack is backed by memory at the start.In the sample:
0x00001000One 4 KiB page, the usual default. - Address space reserved for the default heap.In the sample:
0x001000001 MiB, the usual linker default. - How much of that heap is backed by memory at the start.In the sample:
0x00001000One 4 KiB page, the usual default. - Reserved: must be zero.
- How many data-directory entries follow. Check it before reading a directory.In the sample:
16The format defines 16 directory slots, and every modern linker writes all 16.
- The address the linker prefers for the first byte of the image. Always a multiple of 64 KiB.In the sample:
Sixteen RVA-plus-size pairs that point at tables inside the sections.
Where it starts. Starts after the Windows fields: 0x00B4 + 68 = 0x00F8, which is optional header + 96. 16 entries × 8 bytes = 128 bytes.
- The functions this module offers to others. DLLs have one; a game EXE usually does not.
- Which DLLs and functions this module needs. The loader reads it to fill the import address table.
- Icons, dialogs, version information and other embedded files.In the sample:
RVA 0x00006000, size 0x00000650Exactly the .rsrc row of the section table: its VirtualAddress and VirtualSize. - Unwind data the 64-bit system uses to handle exceptions. Normally empty in a 32-bit image.
- A digital signature. Its first number is a file offset, the one directory that is not an RVA.
- Places to patch if the image is loaded away from ImageBase. A relocatable program needs it.
- Points at debug information, for example the path of the .pdb file.
- Reserved: must be zero.
- An RVA for CPUs that keep a global pointer register. Its size must be zero.
- Thread-local storage: a template for per-thread variables and start-up callbacks.
- Security settings such as the stack cookie value and the exception-handler list.
- Pre-computed import addresses. Rarely used today.
- The array the loader fills with the real addresses of the imported functions.
- Imports that are only loaded the first time the program calls them.
- Present only in .NET programs: the entry to their metadata.
- Reserved: must be zero.
4 rows of 40 bytes, one per section. The first row is shown field by field.
Where it starts. Starts after the optional header: optional header 0x0098 + SizeOfOptionalHeader 224 (0x00E0) = 0x0178. 4 rows × 40 = 160 bytes, so the table ends at 0x0217.
Row n starts at 0x0178 + n × 40: .text at 0x0178, .rdata at 0x01A0, .data at 0x01C8, .rsrc at 0x01F0. The offset calculator above uses these rows.
- Eight bytes of text, padded with zeros. Only a convention: the flags decide what a section may do.In the sample:
.text (padded to 8 bytes)The first row of the table. - Bytes the section occupies in memory. If it is larger than SizeOfRawData, the extra is zero-filled.In the sample:
0x00001A3CIllustrative: the size of the code the compiler produced. - The RVA where the section starts in memory. A multiple of SectionAlignment.In the sample:
0x00001000The headers (0x0400 bytes) fit in the first page, so the first section starts at the next multiple of 0x1000. - Bytes the section occupies in the file. A multiple of FileAlignment.In the sample:
0x00001C00VirtualSize 0x1A3C rounded up to a multiple of 0x0200. - The file offset where the section’s bytes start. A multiple of FileAlignment.In the sample:
0x00000400Right after the headers, so it equals SizeOfHeaders. - Zero in an executable (object files use it).
- Zero: COFF line numbers are deprecated.
- Zero in an executable.
- Zero: COFF line numbers are deprecated.
- Flag bits that decide the protection of the section’s pages: code, readable, writable, executable.In the sample:
0x600000200x00000020 (contains code) + 0x20000000 (executable) + 0x40000000 (readable).
- Eight bytes of text, padded with zeros. Only a convention: the flags decide what a section may do.In the sample:
Zero bytes that bring the headers up to the next FileAlignment boundary.
Where it starts. The headers end at 0x0218. SizeOfHeaders rounds that up to a multiple of 0x0200: 0x0400. Padding = 0x0400 − 0x0218 = 488 bytes.
Nothing lives here. Raw section data must start at a multiple of FileAlignment, so the file is padded with zeros until then.
From here on the file holds the sections themselves.
Where it starts. The first section's PointerToRawData is 0x0400, equal to SizeOfHeaders.
.text starts at file offset 0x0400. The offset calculator above shows how a file offset becomes an address in memory.
PE32+: 64-bit: the optional header is 240 bytes, so the section table starts at 0x0188
0x0000 to 0x03FF: the first 0x0400 bytes of the file (1024 bytes), drawn to scale. Section data begins right after.
A leftover from MS-DOS that every PE file still begins with. Two fields matter.
Where it starts. Starts at the first byte of the file, 0x0000, and is 64 (0x40) bytes long: 2 + 58 + 4 = 64.
- The two letters MZ (bytes 4D 5A). A parser checks them first: without MZ this is not a PE file.In the sample:
MZ (4D 5A)The two ASCII letters, always at the very start of the file. - Old MS-DOS values (page counts, relocation hints). Windows never reads them.
- The file offset of the PE signature. A file offset, not an RVA and not a live address.In the sample:
0x00000080The DOS header (0x40 bytes) plus a 0x40-byte DOS stub end at 0x0080, where the example puts the signature.
- The two letters MZ (bytes 4D 5A). A parser checks them first: without MZ this is not a PE file.In the sample:
A tiny MS-DOS program that Windows skips.
Where it starts. Starts where the DOS header ends, 0x0040, and runs up to e_lfanew 0x0080: 0x0080 − 0x0040 = 0x0040 = 64 bytes.
If someone starts the file under MS-DOS, this little 16-bit program prints “This program cannot be run in DOS mode.” and exits. Windows never runs it: it follows e_lfanew instead. Some toolchains also store build information here, so the size varies from file to file.
Four bytes that say: the headers of a PE file start here.
Where it starts. Starts at the file offset stored in e_lfanew: 0x0080.
- The letters P, E and two zero bytes: 50 45 00 00. As a little-endian number that is 0x00004550.In the sample:
PE\0\0 (50 45 00 00)Four fixed bytes. If they are missing, stop: the file is damaged or not a PE file.
- The letters P, E and two zero bytes: 50 45 00 00. As a little-endian number that is 0x00004550.In the sample:
The basics: which CPU, how many sections, how big the next header is.
Where it starts. Starts right after the signature: e_lfanew + 4 = 0x0080 + 4 = 0x0084. It is 20 bytes long: 2 + 2 + 4 + 4 + 4 + 2 + 2 = 20.
- Which CPU the machine code is for.In the sample:
0x8664 (x64)The format defines 0x014C for 32-bit x86 and 0x8664 for x86-64. - How many 40-byte section rows follow the optional header.In the sample:
4One row each for .text, .rdata, .data, .rsrc. - When the linker built the file, in seconds since 1 January 1970. Some builds store a hash instead.
- File offset of an old COFF symbol table. Zero in modern programs, whose debug data lives elsewhere.
- Number of entries in that table: zero when there is none.
- Bytes in the optional header, data directories included. It tells a parser how far to jump to reach the section table.In the sample:
0x00F0 (240)112 bytes of fields + 16 directories × 8 bytes = 112 + 128 = 240 = 0xF0. - Flag bits: an executable image, a DLL, 32-bit machine, and more.In the sample:
0x00220x0002 (executable image) + 0x0020 (can use addresses above 2 GiB) = 0x0022.
- Which CPU the machine code is for.In the sample:
The first part of the optional header for PE32+. It is required for programs despite its name.
Where it starts. The optional header starts right after the COFF header: e_lfanew + 4 + 20 = 0x0080 + 24 = 0x0098. These fields are 2 + 1 + 1 + 4 + 4 + 4 + 4 + 4 = 24 bytes.
- Tells the parser how to read everything after it: 0x010B means PE32, 0x020B means PE32+.In the sample:
0x020BThe two magic numbers the format defines. - Major version of the linker that made the file.
- Minor version of that linker.
- Total file size of the code sections.In the sample:
0x00001C00Only .text holds code here, so this is its SizeOfRawData. - Total size of sections that hold initialised data.
- Total size of sections that are zero-filled in memory and take no file space.
- RVA of the first instruction Windows runs. It must lie inside the code section.In the sample:
0x000012C0Illustrative: any RVA inside .text (0x1000 to 0x2A3B) is valid; the linker picks the real one. - RVA where the code section begins.In the sample:
0x00001000The RVA of .text in the section table.
- Tells the parser how to read everything after it: 0x010B means PE32, 0x020B means PE32+.In the sample:
Fields the Windows loader needs. ImageBase and the stack and heap sizes are 8 bytes wide.
Where it starts. Starts after the standard fields: optional header 0x0098 + 24 = 0x00B0. These 21 fields are 88 bytes long.
- The address the linker prefers for the first byte of the image. Always a multiple of 64 KiB.In the sample:
0x0000000140000000The usual default for a 64-bit EXE; the field is 8 bytes wide. - Sections start at multiples of this in memory.In the sample:
0x00001000One 4 KiB page. - Raw section data starts at multiples of this in the file.In the sample:
0x00000200The default, 512 bytes (one old disk sector). - Major version of Windows the image asks for.
- Minor version of that Windows.
- Major version of this program, set by its author.
- Minor version of this program.
- Major version of the Windows subsystem it needs.
- Minor version of that subsystem.
- Reserved: must be zero.
- Bytes the whole image takes in memory, headers included. A multiple of SectionAlignment.In the sample:
0x00007000Last section .rsrc ends at RVA 0x6000 + 0x650 = 0x6650, and rounding up to a multiple of 0x1000 gives 0x7000. - DOS header, stub, PE headers and section table together, rounded up to FileAlignment. It is where the first section’s raw data begins.In the sample:
0x00000400The headers end at 0x228; rounded up to a multiple of 0x200 that is 0x400. - A file checksum. Windows checks it for drivers and boot-time DLLs; many programs leave it zero.
- What kind of program this is: 2 means a Windows GUI program, 3 a console program.In the sample:
0x0002A game with a window is a GUI program. - Flag bits for security features, such as 0x0040 (ASLR may move the image) and 0x0100 (DEP).
- Address space reserved for the first thread’s stack.In the sample:
0x00000000001000001 MiB, the usual linker default. - How much of that stack is backed by memory at the start.In the sample:
0x0000000000001000One 4 KiB page, the usual default. - Address space reserved for the default heap.In the sample:
0x00000000001000001 MiB, the usual linker default. - How much of that heap is backed by memory at the start.In the sample:
0x0000000000001000One 4 KiB page, the usual default. - Reserved: must be zero.
- How many data-directory entries follow. Check it before reading a directory.In the sample:
16The format defines 16 directory slots, and every modern linker writes all 16.
- The address the linker prefers for the first byte of the image. Always a multiple of 64 KiB.In the sample:
Sixteen RVA-plus-size pairs that point at tables inside the sections.
Where it starts. Starts after the Windows fields: 0x00B0 + 88 = 0x0108, which is optional header + 112. 16 entries × 8 bytes = 128 bytes.
- The functions this module offers to others. DLLs have one; a game EXE usually does not.
- Which DLLs and functions this module needs. The loader reads it to fill the import address table.
- Icons, dialogs, version information and other embedded files.In the sample:
RVA 0x00006000, size 0x00000650Exactly the .rsrc row of the section table: its VirtualAddress and VirtualSize. - Unwind data the 64-bit system uses to handle exceptions. Normally empty in a 32-bit image.
- A digital signature. Its first number is a file offset, the one directory that is not an RVA.
- Places to patch if the image is loaded away from ImageBase. A relocatable program needs it.
- Points at debug information, for example the path of the .pdb file.
- Reserved: must be zero.
- An RVA for CPUs that keep a global pointer register. Its size must be zero.
- Thread-local storage: a template for per-thread variables and start-up callbacks.
- Security settings such as the stack cookie value and the exception-handler list.
- Pre-computed import addresses. Rarely used today.
- The array the loader fills with the real addresses of the imported functions.
- Imports that are only loaded the first time the program calls them.
- Present only in .NET programs: the entry to their metadata.
- Reserved: must be zero.
4 rows of 40 bytes, one per section. The first row is shown field by field.
Where it starts. Starts after the optional header: optional header 0x0098 + SizeOfOptionalHeader 240 (0x00F0) = 0x0188. 4 rows × 40 = 160 bytes, so the table ends at 0x0227.
Row n starts at 0x0188 + n × 40: .text at 0x0188, .rdata at 0x01B0, .data at 0x01D8, .rsrc at 0x0200. The offset calculator above uses these rows.
- Eight bytes of text, padded with zeros. Only a convention: the flags decide what a section may do.In the sample:
.text (padded to 8 bytes)The first row of the table. - Bytes the section occupies in memory. If it is larger than SizeOfRawData, the extra is zero-filled.In the sample:
0x00001A3CIllustrative: the size of the code the compiler produced. - The RVA where the section starts in memory. A multiple of SectionAlignment.In the sample:
0x00001000The headers (0x0400 bytes) fit in the first page, so the first section starts at the next multiple of 0x1000. - Bytes the section occupies in the file. A multiple of FileAlignment.In the sample:
0x00001C00VirtualSize 0x1A3C rounded up to a multiple of 0x0200. - The file offset where the section’s bytes start. A multiple of FileAlignment.In the sample:
0x00000400Right after the headers, so it equals SizeOfHeaders. - Zero in an executable (object files use it).
- Zero: COFF line numbers are deprecated.
- Zero in an executable.
- Zero: COFF line numbers are deprecated.
- Flag bits that decide the protection of the section’s pages: code, readable, writable, executable.In the sample:
0x600000200x00000020 (contains code) + 0x20000000 (executable) + 0x40000000 (readable).
- Eight bytes of text, padded with zeros. Only a convention: the flags decide what a section may do.In the sample:
Zero bytes that bring the headers up to the next FileAlignment boundary.
Where it starts. The headers end at 0x0228. SizeOfHeaders rounds that up to a multiple of 0x0200: 0x0400. Padding = 0x0400 − 0x0228 = 472 bytes.
Nothing lives here. Raw section data must start at a multiple of FileAlignment, so the file is padded with zeros until then.
From here on the file holds the sections themselves.
Where it starts. The first section's PointerToRawData is 0x0400, equal to SizeOfHeaders.
.text starts at file offset 0x0400. The offset calculator above shows how a file offset becomes an address in memory.
| Field | PE32 | PE32+ |
|---|---|---|
| BaseOfData | 4 bytes at optional header + 24 | absent |
| ImageBase | 4 bytes at optional header + 28 | 8 bytes at optional header + 24 |
| SizeOfStackReserve | 4 bytes at optional header + 72 | 8 bytes at optional header + 72 |
| SizeOfStackCommit | 4 bytes at optional header + 76 | 8 bytes at optional header + 80 |
| SizeOfHeapReserve | 4 bytes at optional header + 80 | 8 bytes at optional header + 88 |
| SizeOfHeapCommit | 4 bytes at optional header + 84 | 8 bytes at optional header + 96 |
| LoaderFlags | 4 bytes at optional header + 88 | 4 bytes at optional header + 104 |
| NumberOfRvaAndSizes | 4 bytes at optional header + 92 | 4 bytes at optional header + 108 |
| SizeOfOptionalHeader | 224 bytes (0x00E0) | 240 bytes (0x00F0) |
| Section table starts at file offset | 0x0178 | 0x0188 |
| Section table ends before file offset | 0x0218 | 0x0228 |
The sample file is built to match the lesson: four sections, a 0x200 FileAlignment and a 0x1000 SectionAlignment. Real files differ in e_lfanew (0x80 and 0xE8 are both common), in the DOS stub and in their section list; the structures and their order do not change.
Static and DLL linking package the library differently, but a file offset still becomes an address through the same section rows:
Static linking: the library is copied into the program
The linker copies the library code the game needs into game.exe when the program is built. One file, one image.
On disk
- game.exe100 KiByour code 40 KiB + library copy 60 KiB
In the running process: user mode, one address space
- game.exe image
- your code
- library code (copied in)
- Function call into a system DLL: still user mode
- ntdll.dll image
- system-call stubs
- System call: the CPU switches to kernel mode
- The copy happens at build time, so the library cannot change unless you build and ship a new game.exe.
- Calling the library is an ordinary function call inside one image. It never leaves user mode.
Disk space for two programs that use this library: 2 × (40 + 60) = 200 KiB: each program carries its own copy.
Dynamic linking: the library stays in its own DLL
The linker only records which DLL and which functions the game needs. When the program starts, the loader maps library.dll into the same process.
On disk
- game.exe40 KiByour code 40 KiB and an import table
- library.dll60 KiBlibrary code 60 KiB
In the running process: user mode, one address space
- game.exe image
- your code
- Function call into another image: still user mode, same address space
- library.dll image
- library code
- Function call into a system DLL: still user mode
- ntdll.dll image
- system-call stubs
- System call: the CPU switches to kernel mode
- The loader resolves the imports at start-up, so a fixed library.dll improves every program that uses it without rebuilding them.
- Calling the library jumps into another image, but still in user mode: the same process, the same address space.
Disk space for two programs that use this library: 2 × 40 + 60 = 140 KiB: one library.dll file serves both.
- Function call: stays in user mode
- System call: crosses into the kernel
Sizes are illustrative: 40 KiB of your code and a 60 KiB library. On Windows a statically linked program still reaches the kernel through system DLLs such as ntdll.dll, because the system-call instruction lives there.
Run the real lab
Section titled “Run the real lab”Build the tool, then inspect one exact course executable:
cd windows-labscargo build --release --target i686-pc-windows-msvc --bin pe_inspector.\target\i686-pc-windows-msvc\release\pe_inspector.exe ` "C:\Games\Wesnoth 1.14.9\wesnoth.exe"For this build, the output shows:
- PE32 or PE32+;
- preferred image base;
- entry-point RVA;
.textRVA and size;- whether
.textis marked executable.
Then inspect ac_client.exe and Quake3-UrT.exe. The layout changes, but the map-reading process does not.
The complete buildable parser is pe_inspector.rs. It performs checked offset math for every field and never uses unsafe.
pe_inspector.rs
Show the codeHide the code
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
fn bytes_at(bytes: &[u8], offset: usize, count: usize) -> Result<&[u8]> {
let end = offset.checked_add(count).context("PE offset overflowed")?;
bytes
.get(offset..end)
.with_context(|| format!("PE field {offset:#x}..{end:#x} is outside the file"))
}
fn u16_at(bytes: &[u8], offset: usize) -> Result<u16> {
Ok(u16::from_le_bytes(bytes_at(bytes, offset, 2)?.try_into()?))
}
fn u32_at(bytes: &[u8], offset: usize) -> Result<u32> {
Ok(u32::from_le_bytes(bytes_at(bytes, offset, 4)?.try_into()?))
}
fn u64_at(bytes: &[u8], offset: usize) -> Result<u64> {
Ok(u64::from_le_bytes(bytes_at(bytes, offset, 8)?.try_into()?))
}
fn main() -> Result<()> {
let path = PathBuf::from(
std::env::args()
.nth(1)
.context("usage: pe_inspector <game.exe-or-dll>")?,
);
let bytes = fs::read(&path).with_context(|| format!("could not read {}", path.display()))?;
anyhow::ensure!(bytes_at(&bytes, 0, 2)? == b"MZ", "missing DOS MZ signature");
let pe_offset = usize::try_from(u32_at(&bytes, 0x3c)?)?;
anyhow::ensure!(
bytes_at(&bytes, pe_offset, 4)? == b"PE\0\0",
"missing PE signature"
);
let coff = pe_offset.checked_add(4).context("COFF offset overflowed")?;
let machine = u16_at(&bytes, coff)?;
let section_count = usize::from(u16_at(&bytes, coff + 2)?);
let optional_size = usize::from(u16_at(&bytes, coff + 16)?);
anyhow::ensure!(
section_count <= 96,
"unreasonable section count: {section_count}"
);
let optional = coff.checked_add(20).context("optional-header overflowed")?;
let magic = u16_at(&bytes, optional)?;
let entry_rva = u32_at(&bytes, optional + 16)?;
let image_base = match magic {
0x10b => u64::from(u32_at(&bytes, optional + 28)?),
0x20b => u64_at(&bytes, optional + 24)?,
other => anyhow::bail!("unsupported optional-header magic {other:#06x}"),
};
let section_alignment = u32_at(&bytes, optional + 32)?;
let file_alignment = u32_at(&bytes, optional + 36)?;
println!("{}", path.display());
println!(" machine: {machine:#06x}");
println!(
" PE kind: {}",
if magic == 0x10b { "PE32" } else { "PE32+" }
);
println!(" preferred base: {image_base:#018x}");
println!(" entry-point RVA: {entry_rva:#010x}");
println!(
" preferred entry: {:#018x}",
image_base + u64::from(entry_rva)
);
println!(" section alignment:{section_alignment:#010x}");
println!(" file alignment: {file_alignment:#010x}");
println!(" sections: {section_count}");
let section_table = optional
.checked_add(optional_size)
.context("section-table offset overflowed")?;
for index in 0..section_count {
let start = section_table
.checked_add(index.checked_mul(40).context("section index overflowed")?)
.context("section offset overflowed")?;
let name_bytes = bytes_at(&bytes, start, 8)?;
let name_end = name_bytes.iter().position(|byte| *byte == 0).unwrap_or(8);
let name = String::from_utf8_lossy(&name_bytes[..name_end]);
let virtual_size = u32_at(&bytes, start + 8)?;
let virtual_address = u32_at(&bytes, start + 12)?;
let raw_size = u32_at(&bytes, start + 16)?;
let raw_offset = u32_at(&bytes, start + 20)?;
let flags = u32_at(&bytes, start + 36)?;
let access = format!(
"{}{}{}",
if flags & 0x4000_0000 != 0 { 'R' } else { '-' },
if flags & 0x8000_0000 != 0 { 'W' } else { '-' },
if flags & 0x2000_0000 != 0 { 'X' } else { '-' },
);
println!(
" {name:<8} RVA {virtual_address:#010x} virtual {virtual_size:#010x} file {raw_offset:#010x}+{raw_size:#010x} {access}"
);
}
Ok(())
}What the PE map tells us
Section titled “What the PE map tells us”For the same instruction, a PE section can give a location within the mapped image without saying where its bytes sit in the file:
“The instruction is at RVA
0x003CCD91inside.text, not at file offset0x003CCD91, and Windows will add the live module base before the CPU can execute it.”
Reference: Microsoft’s PE/COFF format specification.
Use the correct coordinate system
Why is a PE RVA not always the same number as a file offset?
The section table maps locations in the loaded image to locations in the on-disk file.
Finished Lesson 5.1?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.