Skip to content
Lesson 5.1Executable Maps, Instruction References, and Scans

What a Windows Executable Contains

Use the PE header and section map to understand where a Windows executable keeps code, data, imports, and addresses.

Lesson 5.1
Read or listenPrint chapter or book
About 24 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 5.1Key ideas, reference and questions

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, bytes is the whole EXE file read into a Vec<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 at 0x7FF600000000. What is the live virtual address? A: 0x7FF600001200, the live base plus the RVA
Download cheatsheet (.md)

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 process
file offset -> bytes in one section <-> RVA -> mapped bytes in that section
+ live module base
= live virtual address

The 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.

A PE file on disk and the image Windows maps from itThe same payloads sit at different positions, and the image can have a zero-filled tail that the file does not store.
  1. 1 The file: headers, then payloads

    File on disk

    File offsets
    offset 0end of file
    headers
    .text payload
    .data payload

    The file stores the headers and then each section’s bytes one after another.

  2. 2 The image: same bytes, new places

    File on disk

    File offsets
    headers
    .text payload
    .data payload

    Loaded image (RVAs)

    Image addresses
    RVA 0end of image
    headers
    .text payload
    .data payload
    zero tailno file bytes

    Sections 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. 3 A section row links the two

    Section row

    .text
    file offset X
    → RVA Y
    size in file
    size in image

    Loaded image (RVAs)

    Image addresses
    headers
    .text payload
    .data payload
    zero tail

    The 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:

Watch it happenReading a PE file, one verified offset at a time

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
  1. Every PE file starts with the DOS header. Its first two bytes, MZ, say this might be a PE image.
  2. 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.
  3. At 0x80 the parser checks the four bytes P, E, 0, 0 before trusting anything after them. A wrong signature stops the parse here.
  4. 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.
  5. 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.
  6. 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.
  7. 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 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 friends

The “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.

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:

  1. checked_add refuses an integer wraparound.
  2. get refuses an out-of-file slice.
  3. from_le_bytes states 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.

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.

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.

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.

From a file offset to a live addressA section sits at one distance from the start of the file and a different distance from the start of the image. Both strips use the same scale, so the image is 2 times as long as the file.

File on disk14336 bytes (0x3800)

0x0000 Headers
0x0400 .text
0x2000 .rdata
0x2C00 .data
0x3000 .rsrc
0x0520

Image in memory28672 bytes (0x7000)

0x0000 Headers
0x1000 .text
0x3000 .rdata
0x4000 .data
0x4400 .data zeros
0x6000 .rsrc
0x1120
  • 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.

  1. 0x0520 lies in .text, whose raw data covers 0x0400 to 0x1FFF.
  2. 0x0520 − 0x0400 = 0x0120 bytes into the section.
  3. RVA = section RVA 0x1000 + 0x0120 = 0x1120.
  4. 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.

The section table, with everything you need to convert either way. Alignments: 0x0200 in the file, 0x1000 in memory.
SectionIn the fileIn memory (RVA)RVA − file offsetWhat does not match
.text machine code0x0400 to 0x1FFF, size 0x1C000x1000 to 0x2A3B, size 0x1A3C0x1000 − 0x0400 = +0x0C000x1C00 − 0x1A3C = 0x01C4 bytes at the end of the raw data are file padding that is never mapped.
.rdata read-only data and the import tables0x2000 to 0x2BFF, size 0x0C000x3000 to 0x3B0F, size 0x0B100x3000 − 0x2000 = +0x10000x0C00 − 0x0B10 = 0x00F0 bytes at the end of the raw data are file padding that is never mapped.
.data writable globals0x2C00 to 0x2FFF, size 0x04000x4000 to 0x57FF, size 0x18000x4000 − 0x2C00 = +0x14000x1800 − 0x0400 = 0x1400 bytes are zero-filled in memory, from RVA 0x4400.
.rsrc icons, dialogs and version info0x3000 to 0x37FF, size 0x08000x6000 to 0x664F, size 0x06500x6000 − 0x3000 = +0x30000x0800 − 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:

The headers of a PE file, byte by byteOffsets are counted from the first byte of the file. Open a part to read its fields, and point at a field to highlight it. The sample file puts the PE signature at 0x0080.

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.

  1. 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.
  2. 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.

  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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).
  9. 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.

  10. 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.

What changes in PE32+. Offsets in the middle columns are counted from the start of the optional header.
FieldPE32PE32+
BaseOfData4 bytes at optional header + 24absent
ImageBase4 bytes at optional header + 288 bytes at optional header + 24
SizeOfStackReserve4 bytes at optional header + 728 bytes at optional header + 72
SizeOfStackCommit4 bytes at optional header + 768 bytes at optional header + 80
SizeOfHeapReserve4 bytes at optional header + 808 bytes at optional header + 88
SizeOfHeapCommit4 bytes at optional header + 848 bytes at optional header + 96
LoaderFlags4 bytes at optional header + 884 bytes at optional header + 104
NumberOfRvaAndSizes4 bytes at optional header + 924 bytes at optional header + 108
SizeOfOptionalHeader224 bytes (0x00E0)240 bytes (0x00F0)
Section table starts at file offset0x01780x0188
Section table ends before file offset0x02180x0228

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 and dynamic linking, in one processBoth pictures show the same program calling the same library. Choose how the library was linked and follow the call down to the kernel.

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

  1. game.exe image
    • your code
    • library code (copied in)
    One image holds your code and its own copy of the library.
  2. Function call into a system DLL: still user mode
  3. ntdll.dll image
    • system-call stubs
    A system DLL every Windows process has; its stubs run the system call.
  4. System call: the CPU switches to kernel mode
Windows kernelkernel mode: only the kernel and drivers run here
  • 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

  1. game.exe image
    • your code
    Your code only, plus the import address table the loader fills in.
  2. Function call into another image: still user mode, same address space
  3. library.dll image
    • library code
    A separate image, mapped into the same process on the calling thread’s stack.
  4. Function call into a system DLL: still user mode
  5. ntdll.dll image
    • system-call stubs
    A system DLL every Windows process has; its stubs run the system call.
  6. System call: the CPU switches to kernel mode
Windows kernelkernel mode: only the kernel and drivers run here
  • 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.

Windows VM

Build the tool, then inspect one exact course executable:

Terminal window
cd windows-labs
cargo 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;
  • .text RVA and size;
  • whether .text is 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

windows-labs/src/bin/ · Rust, 101 lines

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(())
}

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 0x003CCD91 inside .text, not at file offset 0x003CCD91, and Windows will add the live module base before the CPU can execute it.”

Reference: Microsoft’s PE/COFF format specification.

Knowledge check

Use the correct coordinate system

Why is a PE RVA not always the same number as a file offset?

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.

Chapter 5: 11 lessons

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.