Chapter 5

PE Format Overview

The Portable Executable file structure: DOS header and stub, NT headers, the optional header, data directories, and section headers — the fundamental container Windows uses for executables, DLLs, and drivers

Scenario

You're triaging a suspicious binary during an incident. You open it in a hex editor and see it starts with 4D 5A ("MZ") — a PE file. Your job is to understand what's inside before running it in a sandbox: Is it a DLL or an EXE? What's the entry point? What does it import? Where are its sections, and do any of them have anomalously high entropy? All of this is readable from the PE headers without executing the file.

What is a PE File?

The Portable Executable (PE) format is the standard file format for executables (.exe), dynamic-link libraries (.dll), kernel drivers (.sys), control panel applets (.cpl), and other executable code on Windows. The "portable" in the name is historical — it was designed to be portable across different CPU architectures. There are two variants:

The overall structure of a PE file on disk is a sequence of headers followed by sections:


  PE File Layout (on disk)
  ─────────────────────────────────────────────────────────────────────
  Offset 0x00  ┌─────────────────────────────────────────────────┐
               │  DOS Header (IMAGE_DOS_HEADER)         64 bytes  │
               │  Magic: 0x5A4D ("MZ")                            │
               │  e_lfanew: offset to NT Headers                  │
               ├─────────────────────────────────────────────────┤
               │  DOS Stub (variable)                             │
               │  Legacy x86 code: "This program cannot be..."   │
               ├─────────────────────────────────────────────────┤
  e_lfanew ──► │  NT Headers (IMAGE_NT_HEADERS)                   │
               │  ┌──────────────────────────────────────────┐   │
               │  │  Signature: 0x00004550 ("PE\0\0")        │   │
               │  ├──────────────────────────────────────────┤   │
               │  │  File Header (IMAGE_FILE_HEADER) 20 bytes │   │
               │  │  Machine, NumberOfSections, TimeDateStamp │   │
               │  │  Characteristics                          │   │
               │  ├──────────────────────────────────────────┤   │
               │  │  Optional Header                          │   │
               │  │  (variable size, despite the name)        │   │
               │  │  Entry point, image base, section alignment│   │
               │  │  Data directory array (16 entries)         │   │
               │  └──────────────────────────────────────────┘   │
               ├─────────────────────────────────────────────────┤
               │  Section Headers (array, 40 bytes each)          │
               │  IMAGE_SECTION_HEADER × NumberOfSections         │
               ├─────────────────────────────────────────────────┤
               │  .text section (code)                            │
               ├─────────────────────────────────────────────────┤
               │  .data section (initialized globals)             │
               ├─────────────────────────────────────────────────┤
               │  .rdata section (read-only data, imports)        │
               ├─────────────────────────────────────────────────┤
               │  ... other sections ...                          │
               └─────────────────────────────────────────────────┘

  

DOS Header and Stub

Every PE file begins with an IMAGE_DOS_HEADER structure. This is a legacy artifact from the MS-DOS era — it exists to provide a useful error message when someone tries to run a Windows executable on DOS. The two fields that actually matter are:

FieldOffsetValuePurpose
e_magic0x000x5A4D ("MZ") Magic number — identifies this as a PE file. "MZ" are the initials of Mark Zbikowski, one of the original DOS designers.
e_lfanew0x3CVariable RVA File offset of the NT Headers. This is the only DOS header field the Windows PE loader uses. Everything between the DOS header and NT headers is the DOS stub.

The DOS stub is a tiny x86 real-mode program stored between the DOS header and the NT headers. When executed on DOS, it prints "This program cannot be run in DOS mode" and exits. On Windows, the loader ignores it entirely — it jumps straight to the address in e_lfanew.

Malware trick

The DOS stub is arbitrary data from Windows' perspective. Malware authors use the DOS stub area to store: (1) encrypted payload that gets decrypted at runtime, (2) a rich header (build metadata), or (3) additional code. A tool that checks for the MZ magic and assumes the rest is safe until the NT headers is missing content that the malware loader can access.

NT Headers

At offset e_lfanew, the NT Headers begin with the 4-byte PE signature (50 45 00 00 = "PE\0\0"), followed by two fixed structures:

IMAGE_FILE_HEADER (20 bytes)

FieldSizeDescription
Machine2Target CPU architecture: 0x014C = x86 (32-bit), 0x8664 = x64 (AMD64)
NumberOfSections2Number of sections in the section table
TimeDateStamp4Unix timestamp of when the file was linked. Often falsified by malware.
PointerToSymbolTable4Offset to COFF symbol table (usually 0 for images)
NumberOfSymbols4Number of symbols (usually 0)
SizeOfOptionalHeader2Size of the Optional Header (required despite the name)
Characteristics2Flags: 0x0002 = EXE, 0x2000 = DLL, 0x0100 = 32-bit, 0x0020 = no relocs

Optional Header

The Optional Header is not optional for executable images — it's always present for .exe and .dll files. It contains the fields the loader needs to map and execute the file.

FieldPE32 OffsetDescription
Magic0x000x010B = PE32 (32-bit), 0x020B = PE32+ (64-bit)
AddressOfEntryPoint0x10RVA of the first instruction to execute. For EXEs: program entry point. For DLLs: DllMain. Can be 0 for DLLs without DllMain.
ImageBase0x1C / 0x18Preferred load address. Default: 0x00400000 for EXE, 0x10000000 for DLL. ASLR overrides this.
SectionAlignment0x20 / 0x20Alignment of sections in memory (typically 4096 = 0x1000, one page)
FileAlignment0x24 / 0x24Alignment of sections in the file (typically 512 = 0x200)
SizeOfImage0x38 / 0x38Total size of the image in memory, aligned to SectionAlignment
SizeOfHeaders0x3C / 0x3CSize of all headers (DOS + NT + Section table), aligned to FileAlignment
Subsystem0x44 / 0x440x0002 = GUI, 0x0003 = console, 0x0001 = native (drivers)
DllCharacteristics0x46 / 0x46Security feature flags: 0x0040 = ASLR (DYNAMICBASE), 0x0100 = NX (NXCOMPAT), 0x4000 = CFG
NumberOfRvaAndSizes0x5C / 0x6CNumber of valid data directory entries (typically 16)

Data Directories

At the end of the Optional Header is an array of 16 IMAGE_DATA_DIRECTORY entries, each 8 bytes (4-byte RVA + 4-byte Size). Each entry points to a specific structural component of the PE file:

IndexNamePoints To
0Export TableEAT — list of functions this module exports (DLLs)
1Import TableIAT/ILT — list of DLLs and functions this module imports
2Resource Table.rsrc section — icons, strings, embedded files, dialogs
3Exception TablePDATA — structured exception handling (SEH) entries
4Certificate TableAuthenticode signature (file offset, not RVA)
5Base Relocation.reloc section — fixups needed when not loaded at ImageBase
6Debug DirectoryDebug information (PDB path, CodeView record)
9TLS DirectoryThread-local storage callbacks (execute before entry point)
12Import Address TableDirect pointer to the IAT (same as what import table points to after loading)
14CLR Runtime Header.NET managed code header (for managed assemblies)

Section Headers

After the NT Headers comes the section table — an array of IMAGE_SECTION_HEADER structures (40 bytes each), one per section. Each section header describes one block of content in the PE file.

FieldSizeDescription
Name8 bytesSection name (null-padded, not null-terminated if exactly 8 bytes). Examples: ".text", ".data", ".rdata"
VirtualSize4Actual size of section content in memory (may differ from raw size)
VirtualAddress4RVA where the section is mapped in memory
SizeOfRawData4Size of the section data on disk (aligned to FileAlignment)
PointerToRawData4File offset of the section data on disk
Characteristics4Flags: 0x20000000 = executable, 0x40000000 = readable, 0x80000000 = writable

  RVA vs Raw Offset — The Address Gap
  ─────────────────────────────────────────────────────────────────
  On disk:                        In memory (after loading):
  ┌──────────────────┐  0x000     ┌──────────────────┐  ImageBase
  │  All headers     │            │  All headers     │  + 0x0000
  │                  │            │                  │
  └──────────────────┘  0x400     └──────────────────┘  + 0x1000
  ┌──────────────────┐            ┌──────────────────┐
  │  .text section   │            │  .text section   │
  │  Raw data starts │            │  Mapped at RVA   │
  │  at 0x400        │            │  0x1000          │
  └──────────────────┘  0x800     └──────────────────┘  + 0x3000
  ┌──────────────────┐            ┌──────────────────┐
  │  .data section   │            │  .data section   │
  └──────────────────┘            └──────────────────┘

  VA (Virtual Address) = ImageBase + RVA
  Raw File Offset = PointerToRawData + (RVA - VirtualAddress)

  

Parsing PE Headers with Python

import pefile
import math
from collections import Counter

def analyze_pe(filepath):
    pe = pefile.PE(filepath)
    dos = pe.DOS_HEADER
    nt = pe.NT_HEADERS
    opt = pe.OPTIONAL_HEADER

    print(f"Magic: {'PE32+' if opt.Magic == 0x20B else 'PE32'}")
    print(f"Machine: {hex(nt.FILE_HEADER.Machine)}")
    print(f"Entry Point (RVA): {hex(opt.AddressOfEntryPoint)}")
    print(f"Image Base: {hex(opt.ImageBase)}")
    print(f"Subsystem: {opt.Subsystem}")  # 2=GUI, 3=console
    print(f"TimeDateStamp: {hex(nt.FILE_HEADER.TimeDateStamp)}")
    is_dll = bool(nt.FILE_HEADER.Characteristics & 0x2000)
    print(f"Is DLL: {is_dll}")
    aslr = bool(opt.DllCharacteristics & 0x0040)
    nx   = bool(opt.DllCharacteristics & 0x0100)
    cfg  = bool(opt.DllCharacteristics & 0x4000)
    print(f"ASLR={aslr}  NX={nx}  CFG={cfg}")

    print("\nSections:")
    for section in pe.sections:
        name = section.Name.decode("utf-8", errors="replace").strip("\x00")
        data = section.get_data()
        entropy = calculate_entropy(data)
        flags = []
        if section.Characteristics & 0x20000000: flags.append("exec")
        if section.Characteristics & 0x40000000: flags.append("read")
        if section.Characteristics & 0x80000000: flags.append("write")
        print(f"  {name:10s}  VA={hex(section.VirtualAddress)}  "
              f"RawSize={hex(section.SizeOfRawData)}  "
              f"Entropy={entropy:.2f}  [{','.join(flags)}]")
    pe.close()

def calculate_entropy(data: bytes) -> float:
    if not data:
        return 0.0
    counts = Counter(data)
    total = len(data)
    return -sum((c/total) * math.log2(c/total) for c in counts.values())

Malware PE Tricks

High-Entropy Sections (Packed Payloads)

Legitimate code sections have entropy around 5.0–6.5 out of 8.0. Encrypted or compressed payloads are near-random, pushing entropy above 7.0 or 7.5. A .text section with entropy 7.8 is a strong indicator of a packed or encrypted payload that decrypts itself at runtime.

Section Name Anomalies

Legitimate compilers produce standard section names: .text, .data, .rdata, .bss, .rsrc, .reloc. Malware uses: unusual names like .upx0/.upx1 (UPX packer), blank names, names with non-ASCII characters, or sections named to look like Windows sections but with unexpected permissions.

Section Permission Anomalies

Normal sections: .text = exec+read (never writable), .data = read+write (never executable). A section that is both writable and executable (0xE0000020) is a major red flag — it's a common property of shellcode loaders that write their payload into memory and then execute from the same region.

Missing or Zeroed Timestamps

The TimeDateStamp field in the File Header is the Unix timestamp when the linker produced the executable. Malware often falsifies this: setting it to 0, setting it to a date before Windows existed, or setting it to a future date. Correlating the compile timestamp against when the file first appeared in your environment is a useful signal.

Minimal Imports

Packed or reflectively-loaded malware often has an import table with only one or two DLLs — typically kernel32.dll with just LoadLibrary and GetProcAddress. This is enough to load all other needed DLLs dynamically at runtime, hiding the true import list from static analysis.

AnomalyWhat It SuggestsFalse Positive Rate
Entropy > 7.0 in any section Packed/encrypted payload Low — legitimate high-entropy: .NET compressed, some archives
Section with RWX (write+exec) permissions Self-modifying code or shellcode loader Very low
Only LoadLibrary + GetProcAddress in IAT Dynamic import resolution (packed) Low
TimeDateStamp = 0 or implausibly old/future Timestamp was falsified Medium — some tools zero the timestamp
VirtualSize >> SizeOfRawData Section expands at runtime (BSS-like data region) Medium — legitimate for .bss
Overlay data (file continues after last section) Hidden payload appended to file Medium — installers do this legitimately

Q & A

What's the difference between a VA, an RVA, and a file offset?

These three terms appear constantly in PE analysis and reversing:

  • VA (Virtual Address): The absolute address in memory where something resides after the image is loaded. VA = ImageBase + RVA. This is what you see in a debugger.
  • RVA (Relative Virtual Address): An offset relative to the image base. Used in PE headers to express addresses without tying them to a specific load address. When ASLR is active, the ImageBase changes, but RVAs remain constant. RVA = VA - ImageBase.
  • File offset (Raw offset): The byte position within the file on disk. Different from the RVA because sections are aligned to different boundaries on disk (FileAlignment, typically 0x200) versus in memory (SectionAlignment, typically 0x1000). To convert an RVA to a file offset: find which section contains the RVA, then FileOffset = PointerToRawData + (RVA - VirtualAddress). Most PE parsers (pefile, PE-Bear) handle this conversion automatically.
Why do DLLs have a preferred ImageBase if ASLR will override it anyway?

The ImageBase in the Optional Header is the compiler/linker's preferred load address from pre-ASLR times. When ASLR was introduced, it randomized this on each load. The ImageBase persists for several reasons: (1) ASLR is optional: it only applies to images that opt in via the DYNAMICBASE flag in DllCharacteristics. An image without that flag is still loaded at its preferred ImageBase when possible. (2) Base relocations: if a DLL is loaded at an address different from its ImageBase, the loader must apply base relocations (from the .reloc section) to fix up all absolute addresses. Knowing the preferred ImageBase lets the loader calculate the delta. (3) Kernel32.dll and system DLLs: despite having ASLR, they use Address Space Layout Randomization with system-wide shared randomization — all processes load ntdll.dll at the same ASLR-chosen address within a single boot cycle, for performance (shared read-only pages). The ImageBase fields in their headers are just historical defaults.

Can you have a PE file with no entry point?

Yes. AddressOfEntryPoint = 0 is valid and common for DLLs that don't implement DllMain — they're loaded and their exports called directly, but the loader doesn't call an entry point function. Some malware uses this to complicate analysis: a packed PE with no declared entry point that instead executes from a TLS callback (which fires before the entry point), or from code injected into a legitimate process's thread. If you see AddressOfEntryPoint = 0 on a DLL, that's perfectly normal. On an EXE, it's unusual and worth investigating. The loader will refuse to run an EXE with zero entry point in most cases.

Why does high section entropy indicate malware? Aren't legitimate binaries also compiled code?

Compiled code is not truly random even when disassembled — it contains repeated instruction patterns, NOP sleds, alignment padding, and predictable opcode distributions. Typical compiled x86-64 code has entropy in the 5.0–6.5 range. Encrypted or compressed data is intentionally randomized to maximize information density, pushing entropy near the theoretical maximum of 8.0. When a PE section containing "code" has entropy above 7.0, it almost always means the apparent code is actually encrypted or compressed data that will be decoded at runtime. The true code is a small decryption stub in the loader; the high-entropy section is the payload. This technique (packing or encryption) is used to evade signature-based AV detection. Note the caveat: legitimate software also uses packed sections sometimes — game assets embedded in executables, self-extracting installers. Always correlate entropy with the section's declared permissions and the file's overall import table before concluding malicious.