Skip to content

Leçon 2.1 · Formats binaires· 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.

Cette leçon n’est disponible qu’en anglais pour le moment.

Objectifs

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

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

OffsetFieldSizeMeaning
0x00e_magicWORDMust be 0x5A4D, the ASCII bytes MZ
0x3Ce_lfanewDWORDFile 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 that e_lfanew points at PE\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:

OffsetFieldNotes
+0x00Machine0x14C x86, 0x8664 x64, 0xAA64 ARM64
+0x02NumberOfSectionsEntries in the section table
+0x04TimeDateStampSeconds since 1970-01-01 UTC
+0x08PointerToSymbolTableCOFF symbols; normally 0 in images
+0x0CNumberOfSymbolsNormally 0 in images
+0x10SizeOfOptionalHeader0xE0 for PE32, 0xF0 for PE32+
+0x12CharacteristicsBit flags, see below

(Offsets are relative to the start of the file header, i.e. e_lfanew + 4.)

Characteristics

The most useful flags:

FlagValueMeaning
RELOCS_STRIPPED0x0001No relocations; must load at ImageBase
EXECUTABLE_IMAGE0x0002A valid, linked image
LARGE_ADDRESS_AWARE0x0020Can handle addresses above 2 GB
32BIT_MACHINE0x010032-bit word machine
DEBUG_STRIPPED0x0200Debug info removed
SYSTEM0x1000System file (drivers)
DLL0x2000The 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:

MagicNameUsed byImageBase size
0x10BPE3232-bit images4 bytes
0x20BPE32+64-bit images8 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:

PE32PE32+FieldWhy analysts care
0x000x00Magic32 vs 64-bit
0x020x02Major/MinorLinkerVersionToolchain hint
0x100x10AddressOfEntryPointRVA where execution starts
0x1C0x18ImageBasePreferred load address
0x200x20SectionAlignmentSection alignment in memory (usually 0x1000)
0x240x24FileAlignmentSection alignment on disk (usually 0x200)
0x380x38SizeOfImageTotal size once mapped
0x3C0x3CSizeOfHeadersBytes of headers on disk
0x400x40CheckSumVerified only for drivers and some system DLLs
0x440x44Subsystem2 = GUI, 3 = console, 1 = native
0x460x46DllCharacteristicsSecurity mitigations
0x5C0x6CNumberOfRvaAndSizesUsually 16
0x600x70DataDirectory[]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:

FlagValueMeaning
HIGH_ENTROPY_VA0x002064-bit ASLR with full entropy
DYNAMIC_BASE0x0040Can be relocated (ASLR)
FORCE_INTEGRITY0x0080Signature must verify
NX_COMPAT0x0100Compatible with DEP
NO_SEH0x0400No structured exception handlers
GUARD_CF0x4000Control Flow Guard enabled
TERMINAL_SERVER_AWARE0x8000Terminal-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:

#DirectoryWhat it locates
0ExportFunctions this image exports
1ImportDLLs and functions this image needs
2ResourceIcons, dialogs, version info, embedded blobs
3Exceptionx64 unwind tables (.pdata)
4CertificateAuthenticode signature — a file offset, not an RVA
5Base relocationFix-ups for loading at a different base
6DebugPDB path, debug timestamps
7ArchitectureReserved, must be zero
8Global pointerUnused on x86/x64
9TLSThread-local storage, including TLS callbacks
10Load configSecurity cookie, CFG tables, SEH tables
11Bound importPre-resolved import addresses (legacy)
12IATThe import address table range
13Delay importImports resolved on first use
14CLR runtime.NET metadata — this is a managed binary
15ReservedMust 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.
  • SizeOfImage or SizeOfHeaders inconsistent with the section table, or NumberOfSections of 0 or a huge number — tricks that crash naive parsers.
  • Missing DYNAMIC_BASE / NX_COMPAT on a binary that claims a modern linker version.
  • A Rich header that contradicts the linker version or the code you see.
  • e_lfanew pointing 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 pefile and 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.

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

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

  2. Find the headers by hand. Dump the first bytes:

    bash
    xxd -l 0x40 hello.exe
    text
    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, reads 80 00 00 00 → e_lfanew = 0x80. Jump there (your value may differ):

    bash
    xxd -s 0x80 -l 0x30 hello.exe
    text
    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, then Machine = 0x8664 (x64), nine sections, TimeDateStamp = 0x6ABBB8D8, SizeOfOptionalHeader = 0xF0, Characteristics = 0x022E. The optional header starts at 0x98 with Magic = 0x20B (PE32+), and AddressOfEntryPoint sits 0x10 bytes later at 0xA8: 0x1440.

  3. 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
  4. 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 build

    print(pe.dump_info()) prints everything at once — skim it and match each field against your hand-decoded values.

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

  6. Tamper with a copy. Make a copy, zero the timestamp and clear the NX_COMPAT bit, 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_magic at 0x00 and e_lfanew at 0x3C are the only DOS-header fields Windows needs; e_lfanew leads to PE\0\0, the file header and the optional header.
  • Magic (0x10B / 0x20B) decides the optional header layout; the entry point is an RVA added to ImageBase.
  • Characteristics says EXE vs DLL; DllCharacteristics records 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.