Lesson 2.1 · Binary Formats· 40 min
PE Headers: DOS, NT and Optional Header
Walk the headers at the start of every Windows executable, byte by byte, and learn which fields analysts trust, which they doubt and which malware abuses.
Objectives
- Locate the DOS header, PE signature, file header and optional header in a raw hexdump
- Interpret Machine, TimeDateStamp, Characteristics, AddressOfEntryPoint, ImageBase and DllCharacteristics
- Tell PE32 from PE32+ and explain what each data directory points to
- Use the Rich header and timestamps as clustering evidence while treating them as forgeable
- Spot header anomalies typical of packed or tampered malware
Almost every Windows sample you will ever triage is a Portable Executable
(PE) file: .exe, .dll, .sys, .scr, .cpl, .ocx — the extension is
cosmetic, the format is the same. Before a single instruction of a PE runs, the
Windows loader reads a few hundred bytes of headers that describe how to map the
file into memory, where to start executing and what the program expects from the
system.
Those headers are also the first thing an analyst reads. They tell you the target CPU, whether the file is an EXE or a DLL, when it was (allegedly) built, which compiler produced it, and whether something about it looks wrong. This lesson walks through them in the order the loader does.
The big picture
A PE file is a stack of headers followed by sections. The headers are small and fixed-shape; the sections hold code, data and resources.
file offset
0x0000 ┌─────────────────────────────┐
│ IMAGE_DOS_HEADER (64 bytes) │ "MZ" ... e_lfanew ──┐
0x0040 ├─────────────────────────────┤ │
│ DOS stub (16-bit program) │ │
├─────────────────────────────┤ │
│ Rich header (MSVC only) │ │
e_lfanew├─────────────────────────────┤ ◄────────────────────┘
│ "PE\0\0" signature (4 bytes) │ ┐
│ IMAGE_FILE_HEADER (20 bytes) │ │ IMAGE_NT_HEADERS
│ IMAGE_OPTIONAL_HEADER │ │
│ ... DataDirectory[16] │ ┘
├─────────────────────────────┤
│ Section table (40 B each) │
├─────────────────────────────┤ SizeOfHeaders
│ .text .rdata .data ... │
└─────────────────────────────┘All multi-byte fields are little-endian (see
endianness): the bytes 4d 5a read as the 16-bit value
0x5A4D. Addresses inside the headers are mostly RVAs — relative virtual
addresses, offsets from wherever the image ends up in memory — not file
offsets. The next lesson, PE Sections and Memory Layout,
shows how to convert between the two.
The DOS header and stub
IMAGE_DOS_HEADER is a 64-byte relic from MS-DOS. Windows ignores almost all of
it. Two fields matter:
| Offset | Field | Size | Meaning |
|---|---|---|---|
0x00 | e_magic | WORD | Must be 0x5A4D, the ASCII bytes MZ |
0x3C | e_lfanew | DWORD | File offset of the PE\0\0 signature |
Everything between those two fields (e_cblp, e_cp, e_ss, e_sp and so on)
describes the DOS program and is irrelevant to the Windows loader. That makes it
free real estate: packers and hand-crafted "tiny PE" files overlap other headers
on top of it, and some malware stashes a few bytes of configuration there.
Immediately after the DOS header comes the DOS stub, a small 16-bit program
that prints This program cannot be run in DOS mode. and exits. Its presence is
convention, not requirement. A missing or unusual stub is not proof of
malice, but it is a hint that the file was produced by an unusual toolchain or
modified after linking.
Tip: Carving tools find embedded executables by searching for
MZ, then checking thate_lfanewpoints atPE\0\0. That two-step check is exactly what you should do by hand when you suspect a PE is hidden inside another file, a memory dump or a document.
The Rich header: an unofficial fingerprint
Between the stub and the PE signature, binaries linked by Microsoft's linker
carry an undocumented block called the Rich header. It is easy to spot
because it ends with the ASCII marker Rich followed by a 4-byte XOR key; the
start marker DanS is XOR-encrypted with that same key.
Decoded, it is a list of (product ID, build number, count) entries: which
compiler, assembler and linker versions contributed how many object files. For
an analyst this is gold:
- Two samples with identical Rich headers were very likely built on the same build environment, which helps cluster a family or link it to an actor.
- The toolchain versions give a lower bound on when the build machine was set up.
- YARA and several threat-intel platforms can hash the Rich header for pivoting.
Warning: The Rich header is not signed or checked by anyone. Attackers can strip it or copy another group's header wholesale — a documented false-flag case involved a sample whose Rich header was transplanted to point attribution at a different actor. Inconsistency is the real signal: a Rich header listing only a Visual Basic 6 linker in front of 64-bit code built with a modern compiler is a forgery.
Non-Microsoft toolchains (mingw-w64, Go, Delphi, Rust with the GNU target) produce no Rich header at all, so its absence mostly tells you about the toolchain.
The PE signature and file header
At e_lfanew sit four bytes, 50 45 00 00 (PE\0\0), followed by the
20-byte IMAGE_FILE_HEADER, also called the COFF header:
| Offset | Field | Notes |
|---|---|---|
+0x00 | Machine | 0x14C x86, 0x8664 x64, 0xAA64 ARM64 |
+0x02 | NumberOfSections | Entries in the section table |
+0x04 | TimeDateStamp | Seconds since 1970-01-01 UTC |
+0x08 | PointerToSymbolTable | COFF symbols; normally 0 in images |
+0x0C | NumberOfSymbols | Normally 0 in images |
+0x10 | SizeOfOptionalHeader | 0xE0 for PE32, 0xF0 for PE32+ |
+0x12 | Characteristics | Bit flags, see below |
(Offsets are relative to the start of the file header, i.e. e_lfanew + 4.)
Characteristics
The most useful flags:
| Flag | Value | Meaning |
|---|---|---|
RELOCS_STRIPPED | 0x0001 | No relocations; must load at ImageBase |
EXECUTABLE_IMAGE | 0x0002 | A valid, linked image |
LARGE_ADDRESS_AWARE | 0x0020 | Can handle addresses above 2 GB |
32BIT_MACHINE | 0x0100 | 32-bit word machine |
DEBUG_STRIPPED | 0x0200 | Debug info removed |
SYSTEM | 0x1000 | System file (drivers) |
DLL | 0x2000 | The image is a DLL |
The DLL bit, not the file extension, decides whether Windows treats the image
as a library. Malware routinely ships DLLs renamed to .dat, .png or .tmp
and loads them with rundll32.exe or regsvr32.exe, so check this bit on every
"data file" dropped by a sample.
TimeDateStamp: useful, never trusted
The linker writes the build time here. Across a campaign, timestamps help you order samples, spot the working hours of a build machine and group related files. But the field is trivially editable, and there are several benign reasons it can look odd:
- Zero or obviously fake values (
0x00000000, dates in 1970 or 2099) are common in malware that wipes the field to frustrate timelines. - Delphi binaries historically carried a constant timestamp from 19 June 1992, regardless of real build date.
- Reproducible builds (MSVC
/Brepro, used for many Windows system files) replace the timestamp with a hash, so it decodes to a random date, often in the future.
Cross-check it against other timestamps in the file — the debug directory, the export directory and the resource directory each have their own. A header saying 2014 and a debug directory saying 2023 means someone edited one of them.
The optional header
Despite the name, the optional header is mandatory for executables. It comes in
two shapes, distinguished by its first field, Magic:
Magic | Name | Used by | ImageBase size |
|---|---|---|---|
0x10B | PE32 | 32-bit images | 4 bytes |
0x20B | PE32+ | 64-bit images | 8 bytes |
PE32+ drops the BaseOfData field and widens ImageBase and the four
stack/heap sizes to 64 bits; everything else lines up. The key fields, with
offsets from the start of the optional header:
| PE32 | PE32+ | Field | Why analysts care |
|---|---|---|---|
0x00 | 0x00 | Magic | 32 vs 64-bit |
0x02 | 0x02 | Major/MinorLinkerVersion | Toolchain hint |
0x10 | 0x10 | AddressOfEntryPoint | RVA where execution starts |
0x1C | 0x18 | ImageBase | Preferred load address |
0x20 | 0x20 | SectionAlignment | Section alignment in memory (usually 0x1000) |
0x24 | 0x24 | FileAlignment | Section alignment on disk (usually 0x200) |
0x38 | 0x38 | SizeOfImage | Total size once mapped |
0x3C | 0x3C | SizeOfHeaders | Bytes of headers on disk |
0x40 | 0x40 | CheckSum | Verified only for drivers and some system DLLs |
0x44 | 0x44 | Subsystem | 2 = GUI, 3 = console, 1 = native |
0x46 | 0x46 | DllCharacteristics | Security mitigations |
0x5C | 0x6C | NumberOfRvaAndSizes | Usually 16 |
0x60 | 0x70 | DataDirectory[] | 16 × (RVA, size) |
Entry point and image base
AddressOfEntryPoint is an RVA. The virtual address of the first instruction is
ImageBase + AddressOfEntryPoint — assuming the image loaded at its preferred
base. Typical defaults are 0x400000 for 32-bit EXEs, 0x140000000 for 64-bit
EXEs and 0x10000000 or 0x180000000 for DLLs.
Note that the entry point is not main. It points at compiler runtime code
(mainCRTStartup or similar) that initialises the C runtime and then calls
main. For a DLL, the entry point is DllMain's wrapper and may be zero if the
DLL has no initialisation code. And the entry point is not necessarily the
first code to run: TLS callbacks execute before
it, a classic place for anti-debugging checks.
DllCharacteristics: mitigation flags
Despite the name, this field applies to EXEs too:
| Flag | Value | Meaning |
|---|---|---|
HIGH_ENTROPY_VA | 0x0020 | 64-bit ASLR with full entropy |
DYNAMIC_BASE | 0x0040 | Can be relocated (ASLR) |
FORCE_INTEGRITY | 0x0080 | Signature must verify |
NX_COMPAT | 0x0100 | Compatible with DEP |
NO_SEH | 0x0400 | No structured exception handlers |
GUARD_CF | 0x4000 | Control Flow Guard enabled |
TERMINAL_SERVER_AWARE | 0x8000 | Terminal-server aware |
Modern compilers set DYNAMIC_BASE and NX_COMPAT by default. Their absence
suggests an old or unusual toolchain, a hand-built binary, or a packer that
rebuilt the headers. ASLR itself is covered in
PIE and ASLR.
Data directories
The optional header ends with an array of IMAGE_DATA_DIRECTORY entries, each an
(RVA, Size) pair. They are the table of contents for everything the loader and
tools need beyond raw sections:
| # | Directory | What it locates |
|---|---|---|
| 0 | Export | Functions this image exports |
| 1 | Import | DLLs and functions this image needs |
| 2 | Resource | Icons, dialogs, version info, embedded blobs |
| 3 | Exception | x64 unwind tables (.pdata) |
| 4 | Certificate | Authenticode signature — a file offset, not an RVA |
| 5 | Base relocation | Fix-ups for loading at a different base |
| 6 | Debug | PDB path, debug timestamps |
| 7 | Architecture | Reserved, must be zero |
| 8 | Global pointer | Unused on x86/x64 |
| 9 | TLS | Thread-local storage, including TLS callbacks |
| 10 | Load config | Security cookie, CFG tables, SEH tables |
| 11 | Bound import | Pre-resolved import addresses (legacy) |
| 12 | IAT | The import address table range |
| 13 | Delay import | Imports resolved on first use |
| 14 | CLR runtime | .NET metadata — this is a managed binary |
| 15 | Reserved | Must be zero |
A quick scan of which directories are non-zero is itself triage: a CLR entry
means you need a .NET decompiler, not a disassembler; a certificate entry means
check the signature; a TLS entry means look for callbacks; a debug entry may leak
a PDB path such as C:\Users\dev\Desktop\stealer\Release\stealer.pdb. Imports and
exports get their own lesson, Imports, Exports and the IAT.
Header anomalies that deserve a second look
None of these proves maliciousness on its own, but each should raise a question:
- Entry point outside the code section — in the last section, in
.rsrc, in a writable section or in the headers themselves. Packers typically point the entry point at their unpacking stub in an added section; see entry point obfuscation and UPX packing. - Zeroed, future or impossible timestamps, or disagreement between header and debug-directory timestamps.
SizeOfImageorSizeOfHeadersinconsistent with the section table, orNumberOfSectionsof 0 or a huge number — tricks that crash naive parsers.- Missing
DYNAMIC_BASE/NX_COMPATon a binary that claims a modern linker version. - A Rich header that contradicts the linker version or the code you see.
e_lfanewpointing far into the file or overlapping other structures.
There is one more anomaly you only see in memory. After loading, some malware
zeroes its own MZ and PE headers so that memory scanners walking a process
cannot recognise the injected module as a PE. See
PE header erasure. When you dump such a
region, you will need to reconstruct the headers by hand — which is only
possible if you know them as well as this lesson describes.
Tip: Windows itself is lenient. It loads files that violate the spec in ways analysis tools do not expect. When
pefileand PE-bear disagree about a file, that disagreement is a finding worth writing down.
Lab: walk the headers of your own PE
You will build a harmless 64-bit Windows console program and read its headers
three ways. The steps work on Linux, macOS or WSL with mingw-w64
(apt install mingw-w64 or brew install mingw-w64); on Windows you can use
MSVC instead.
-
Write and compile the program:
c // hello.c #include <stdio.h> int main(void) { puts("hello from a benign PE"); return 0; }bash x86_64-w64-mingw32-gcc -O0 -s -o hello.exe hello.cOn Windows with a Developer Command Prompt:
cl /O2 hello.c. Keep both builds if you can — only the MSVC one will contain a Rich header. -
Find the headers by hand. Dump the first bytes:
bash xxd -l 0x40 hello.exetext 00000000: 4d5a 9000 0300 0000 0400 0000 ffff 0000 MZ.............. 00000010: b800 0000 0000 0000 4000 0000 0000 0000 ........@....... 00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000030: 0000 0000 0000 0000 0000 0000 8000 0000 ................The last DWORD, at
0x3C, reads80 00 00 00→e_lfanew = 0x80. Jump there (your value may differ):bash xxd -s 0x80 -l 0x30 hello.exetext 00000080: 5045 0000 6486 0900 d8b8 bb6a 0000 0000 PE..d......j.... 00000090: 0000 0000 f000 2e02 0b02 022e 001c 0000 ................ 000000a0: 0020 0000 0002 0000 4014 0000 0010 0000 . ......@.......Decode it yourself:
PE\0\0, thenMachine = 0x8664(x64), nine sections,TimeDateStamp = 0x6ABBB8D8,SizeOfOptionalHeader = 0xF0,Characteristics = 0x022E. The optional header starts at0x98withMagic = 0x20B(PE32+), andAddressOfEntryPointsits 0x10 bytes later at0xA8:0x1440. -
Automate the same walk with
struct. No libraries — just offsets from the tables above:python # headers.py import struct, sys, datetime data = open(sys.argv[1], "rb").read() e_lfanew, = struct.unpack_from("<I", data, 0x3C) assert data[:2] == b"MZ" and data[e_lfanew:e_lfanew + 4] == b"PE\0\0" fh = e_lfanew + 4 machine, nsec, stamp, _, _, opt_size, chars = struct.unpack_from("<HHIIIHH", data, fh) print(f"Machine {machine:#x} sections {nsec} chars {chars:#06x}") print("Built", datetime.datetime.fromtimestamp(stamp, datetime.timezone.utc)) oh = fh + 20 magic, = struct.unpack_from("<H", data, oh) entry, = struct.unpack_from("<I", data, oh + 0x10) if magic == 0x20B: image_base, = struct.unpack_from("<Q", data, oh + 0x18) else: image_base, = struct.unpack_from("<I", data, oh + 0x1C) subsystem, dllchars = struct.unpack_from("<HH", data, oh + 0x44) print(f"Magic {magic:#x} EP {image_base + entry:#x} " f"subsystem {subsystem} DllCharacteristics {dllchars:#06x}")bash python3 headers.py hello.exe -
Compare with
pefile. Install it (pip install pefile) and let it do the parsing:python import pefile pe = pefile.PE("hello.exe") print(hex(pe.FILE_HEADER.Characteristics), hex(pe.OPTIONAL_HEADER.DllCharacteristics)) for i, d in enumerate(pe.OPTIONAL_HEADER.DATA_DIRECTORY): if d.VirtualAddress: print(i, d.name, hex(d.VirtualAddress), hex(d.Size)) print(pe.parse_rich_header()) # None for a mingw buildprint(pe.dump_info())prints everything at once — skim it and match each field against your hand-decoded values. -
Open the file in PE-bear. Browse DOS Header, NT Headers → File Header and Optional Header. PE-bear decodes the flags for you and shows the Rich header (for the MSVC build) in its own tab.
-
Tamper with a copy. Make a copy, zero the timestamp and clear the
NX_COMPATbit, then re-open it:python pe = pefile.PE("hello.exe") pe.FILE_HEADER.TimeDateStamp = 0 pe.OPTIONAL_HEADER.DllCharacteristics &= ~0x0100 pe.write("hello-tampered.exe")Notice that nothing stops you, and that the SHA-256 changes — any header field is part of the file hash.
Questions to answer: Which Characteristics bits make up 0x022E? Which
data directories are non-zero in your build, and why does a program this small
have a TLS directory? If you built with MSVC, which tools does the Rich header
list? Which fields would you use to cluster two samples, and which would an
attacker change first?
Key takeaways
e_magicat0x00ande_lfanewat0x3Care the only DOS-header fields Windows needs;e_lfanewleads toPE\0\0, the file header and the optional header.Magic(0x10B/0x20B) decides the optional header layout; the entry point is an RVA added toImageBase.Characteristicssays EXE vs DLL;DllCharacteristicsrecords mitigations like ASLR and DEP; the 16 data directories map out imports, exports, resources, TLS and more.- Timestamps and the Rich header are excellent clustering evidence and trivial to forge — trust consistency, not individual values.
- Entry points outside the code section, zeroed timestamps and inconsistent sizes are classic signs of packing or tampering.