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
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:
- PE32: 32-bit executable;
OptionalHeader.Magic = 0x010B - PE32+ (PE64): 64-bit executable;
OptionalHeader.Magic = 0x020B
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:
| Field | Offset | Value | Purpose |
|---|---|---|---|
e_magic | 0x00 | 0x5A4D ("MZ") |
Magic number — identifies this as a PE file. "MZ" are the initials of Mark Zbikowski, one of the original DOS designers. |
e_lfanew | 0x3C | Variable 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.
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)
| Field | Size | Description |
|---|---|---|
Machine | 2 | Target CPU architecture: 0x014C = x86 (32-bit), 0x8664 = x64 (AMD64) |
NumberOfSections | 2 | Number of sections in the section table |
TimeDateStamp | 4 | Unix timestamp of when the file was linked. Often falsified by malware. |
PointerToSymbolTable | 4 | Offset to COFF symbol table (usually 0 for images) |
NumberOfSymbols | 4 | Number of symbols (usually 0) |
SizeOfOptionalHeader | 2 | Size of the Optional Header (required despite the name) |
Characteristics | 2 | Flags: 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.
| Field | PE32 Offset | Description |
|---|---|---|
Magic | 0x00 | 0x010B = PE32 (32-bit), 0x020B = PE32+ (64-bit) |
AddressOfEntryPoint | 0x10 | RVA of the first instruction to execute. For EXEs: program entry point. For DLLs: DllMain. Can be 0 for DLLs without DllMain. |
ImageBase | 0x1C / 0x18 | Preferred load address. Default: 0x00400000 for EXE, 0x10000000 for DLL. ASLR overrides this. |
SectionAlignment | 0x20 / 0x20 | Alignment of sections in memory (typically 4096 = 0x1000, one page) |
FileAlignment | 0x24 / 0x24 | Alignment of sections in the file (typically 512 = 0x200) |
SizeOfImage | 0x38 / 0x38 | Total size of the image in memory, aligned to SectionAlignment |
SizeOfHeaders | 0x3C / 0x3C | Size of all headers (DOS + NT + Section table), aligned to FileAlignment |
Subsystem | 0x44 / 0x44 | 0x0002 = GUI, 0x0003 = console, 0x0001 = native (drivers) |
DllCharacteristics | 0x46 / 0x46 | Security feature flags: 0x0040 = ASLR (DYNAMICBASE), 0x0100 = NX (NXCOMPAT), 0x4000 = CFG |
NumberOfRvaAndSizes | 0x5C / 0x6C | Number 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:
| Index | Name | Points To |
|---|---|---|
| 0 | Export Table | EAT — list of functions this module exports (DLLs) |
| 1 | Import Table | IAT/ILT — list of DLLs and functions this module imports |
| 2 | Resource Table | .rsrc section — icons, strings, embedded files, dialogs |
| 3 | Exception Table | PDATA — structured exception handling (SEH) entries |
| 4 | Certificate Table | Authenticode signature (file offset, not RVA) |
| 5 | Base Relocation | .reloc section — fixups needed when not loaded at ImageBase |
| 6 | Debug Directory | Debug information (PDB path, CodeView record) |
| 9 | TLS Directory | Thread-local storage callbacks (execute before entry point) |
| 12 | Import Address Table | Direct pointer to the IAT (same as what import table points to after loading) |
| 14 | CLR 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.
| Field | Size | Description |
|---|---|---|
Name | 8 bytes | Section name (null-padded, not null-terminated if exactly 8 bytes). Examples: ".text", ".data", ".rdata" |
VirtualSize | 4 | Actual size of section content in memory (may differ from raw size) |
VirtualAddress | 4 | RVA where the section is mapped in memory |
SizeOfRawData | 4 | Size of the section data on disk (aligned to FileAlignment) |
PointerToRawData | 4 | File offset of the section data on disk |
Characteristics | 4 | Flags: 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.
| Anomaly | What It Suggests | False 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.