// writeup
Practical Binary Analysis, Ch. 3: The PE Format
Notes on Chapter 3 of Practical Binary Analysis by Dennis Andriesse, plus my solutions to the exercises. This one is a shorter chapter: the book treats PE as a compare-and-contrast against ELF rather than a format worth the full tour, and so will I. Everything below comes from the same hello.c used in Chapter 2, this time compiled with MSVC into a real PE32+ binary.
# Chapter 2 (Linux): gcc hello.c -o a.out → ELF64 · 15,960 bytes
cl /nologo /O2 hello.c /Fe:hello.exe # → PE32+ · 140,288 bytes (static CRT)
# binutils speaks PE too, so the ELF muscle memory carries over
objdump -f hello.exe # file format pei-x86-64, entry point
objdump -x hello.exe # headers + DataDirectory + sections
objdump -h hello.exe # section table only
objdump -M intel -d hello.exe # disassemble
xxd hello.exe | head # read the MS-DOS header by hand A PE32+ at a glance
PE is a modified COFF, the object format Unix used before ELF, which is why it is sometimes written PE/COFF. The 64-bit variant is confusingly named PE32+, but it differs from 32-bit PE only in a few field widths, so everyone just says “PE.”
Read that top to bottom and the shape of the format is really four ideas: a fossil (the MS-DOS header and stub), an executable header split into three pieces, one section header table, and the sections. The single most important structural difference from ELF is what is absent: there is no program header table.
The MS-DOS header and stub
Every PE file begins with an IMAGE_DOS_HEADER, starting with the ASCII magic MZ (the initials of Mark Zbikowski, who designed the original DOS executable format). Immediately after it sits an MS-DOS stub: a genuine 16-bit DOS program, which on a DOS machine runs instead of the real program and prints “This program cannot be run in DOS mode.”
The point was a smoother transition in the early 90s when users had both DOS and PE binaries. Thirty-plus years later every .exe still carries it.
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 0001 0000 ................
00000040: 0e1f ba0e 00b4 09cd 21b8 014c cd21 5468 ........!..L.!Th
00000050: 6973 2070 726f 6772 616d 2063 616e 6e6f is program canno
00000060: 7420 6265 2072 756e 2069 6e20 444f 5320 t be run in DOS
00000070: 6d6f 6465 2e0d 0d0a 2400 0000 0000 0000 mode....$....... Only one field in that header still matters: e_lfanew, the last one, at offset 0x3c. It holds the file offset where the real PE headers begin, 0x00000100 in the dump above. A PE-aware loader reads the DOS header, jumps to e_lfanew, and never looks back.
The PE signature, file header, and optional header
Where ELF has one Elf64_Ehdr, PE splits the job three ways. The Windows SDK wraps all three in IMAGE_NT_HEADERS64, but in practice they are treated separately.
PE signature: the four bytes 50 45 00 00, i.e. "PE\0\0". This is the real magic; the MZ at offset 0 only tells you it is some DOS-compatible file.
PE file header (IMAGE_FILE_HEADER, 20 bytes): general properties. The fields that matter are Machine (0x8664 for x86-64, exactly like ELF’s e_machine), NumberOfSections, SizeOfOptionalHeader, and Characteristics (a flag word covering things like “is a DLL” and “has been stripped”). It also carries PointerToSymbolTable and NumberOfSymbols, both deprecated, since modern PE files put symbols in a separate PDB instead.
PE optional header (IMAGE_OPTIONAL_HEADER64): not optional in any executable, despite the name; it may be missing only in object files.
DOS e_magic 4d5a 'MZ'
DOS e_lfanew 0x100 -> file offset of the PE signature
PE signature 50450000 'PE'\0\0
COFF Machine 0x8664 IMAGE_FILE_MACHINE_AMD64
COFF NumberOfSections 6
COFF SizeOfOptionalHeader 240
COFF Characteristics 0x0022 EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE
OPT Magic 0x20b PE32+ (64-bit)
OPT AddressOfEntryPoint 0x12e4 <- an RVA, not an address
OPT ImageBase 0x140000000 <- preferred load address
OPT SectionAlignment 0x1000 FileAlignment 0x200
OPT SizeOfImage 0x27000
OPT Subsystem 3 (Windows CUI, i.e. console)
OPT DllCharacteristics 0x8160 DYNAMIC_BASE(ASLR) | NX_COMPAT RVAs and ImageBase
This is the field convention that trips up everyone coming from ELF. PE binaries are designed to load at one specific address, ImageBase (0x140000000 here, the standard base for 64-bit executables). Almost every other pointer in the file is a relative virtual address, an offset you add to ImageBase to get a real address.
So the entry point is not at 0x12e4. It is at ImageBase + AddressOfEntryPoint = 0x140000000 + 0x12e4 = 0x1400012e4, which is exactly what objdump reports. Every RVA in the file works this way, and none of them means anything on its own.
hello.exe: file format pei-x86-64
architecture: i386:x86-64, flags 0x0000012f:
HAS_RELOC, EXEC_P, HAS_LINENO, HAS_DEBUG, HAS_LOCALS, D_PAGED
start address 0x00000001400012e4 # = ImageBase 0x140000000 + RVA 0x12e4 The DataDirectory
The least self-explanatory field in the optional header is the DataDirectory: a fixed array of 16 IMAGE_DATA_DIRECTORY structs, each just an (RVA, size) pair. The index determines the meaning: entry 0 is the export table, entry 1 the import table, entry 5 the base relocations, entry 12 the IAT.
The Data Directory
Entry 0 0000000000000000 00000000 Export Directory [.edata]
Entry 1 000000000001ff8c 00000028 Import Directory [parts of .idata]
Entry 3 0000000000023000 000012d8 Exception Directory [.pdata]
Entry 5 0000000000026000 00000680 Base Relocation Directory [.reloc]
Entry 6 000000000001e680 0000001c Debug Directory
Entry a 000000000001e540 00000140 Load Configuration Directory
Entry c 0000000000016000 00000268 Import Address Table Directory The section header table
Structurally this is close to ELF’s, an array of IMAGE_SECTION_HEADER, each describing one section: SizeOfRawData and VirtualSize (size on disk and in memory), PointerToRawData and VirtualAddress (file offset and RVA), relocation info, and a Characteristics flag word for executable/readable/writable.
Two differences stand out. First, the name: PE section headers hold the name inline in a BYTE Name[8] array, not as an index into a string table. There is no .shstrtab equivalent, and section names cannot exceed 8 characters. Second, and more consequential:
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00014d50 0000000140001000 0000000140001000 00000400 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rdata 0000a7b2 0000000140016000 0000000140016000 00015200 2**4
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .data 00000c00 0000000140021000 0000000140021000 0001fa00 2**4
CONTENTS, ALLOC, LOAD, DATA
3 .pdata 000012d8 0000000140023000 0000000140023000 00020600 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .fptable 00000100 0000000140025000 0000000140025000 00021a00 2**2
CONTENTS, ALLOC, LOAD, DATA
5 .reloc 00000680 0000000140026000 0000000140026000 00021c00 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA Size here is SizeOfRawData, the on-disk size. objdump does not show VirtualSize in this view, which is the field that matters for .data. Exercise 2 comes back to that.
Sections
Most map onto ELF sections you already know, occasionally under a different name:
.text: code, same name and same job as ELF..rdata: read-only data. This is ELF’s.rodata, renamed. It also absorbs the import tables and the IAT..data: initialized, writable data, same as ELF..bss: zero-initialized data. Often absent as a separate section; MSVC folds it into.databy giving that section aVirtualSizelarger than itsSizeOfRawDataand letting the loader zero the difference..pdata: exception/unwind tables. Windows x64 requires table-driven unwinding, so this is mandatory rather than optional; ELF’s rough analogue is.eh_frame..reloc: base relocations, used when the image cannot load atImageBase..rsrc: resources (icons, version info, dialogs). No ELF equivalent at all..edata/.idata: export and import tables, the two PE sections with no direct ELF counterpart.
Imports, exports, and the IAT
.idata records what the binary imports from DLLs; .edata records what a DLL exports. To bind them, the loader matches each import against the exporting DLL’s export table and writes the resolved address into the Import Address Table.
hello.exe built above.
The IAT is a flat array of pointer slots, the same idea as ELF’s GOT. On disk each slot points at a Hint/Name entry (an ordinal hint plus the function’s name string); after loading, each slot holds the real function address. The book describes calls reaching those slots through a thunk, a one-instruction stub that jumps indirectly through the slot.
My binary imports 76 functions, all from KERNEL32.dll, which the IAT’s own size confirms: 0x268 / 8 = 77 slots, i.e. 76 imports plus the null terminator that ends the table.
140001603: 48 ff 25 16 4a 01 00 rex.W jmp QWORD PTR [rip+0x14a16] # 0x140016020
14000180f: 48 ff 25 3a 48 01 00 rex.W jmp QWORD PTR [rip+0x1483a] # 0x140016050
1400023a2: 48 ff 25 07 3d 01 00 rex.W jmp QWORD PTR [rip+0x13d07] # 0x1400160b0
1400044b4: 48 ff 25 d5 1b 01 00 rex.W jmp QWORD PTR [rip+0x11bd5] # 0x140016090
; IAT = DataDirectory[12]: RVA 0x16000, size 0x268
; → VA range 0x140016000 .. 0x140016268
; 16 of the 17 distinct jmp-through-pointer targets fall inside it. The Import Tables (interpreted .rdata section contents)
DLL Name: KERNEL32.dll
vma: Hint/Ord Member-Name
20220 1153 QueryPerformanceCounter
2023a 578 GetCurrentProcessId
20250 582 GetCurrentThreadId
20266 794 GetSystemTimeAsFileTime
20280 922 InitializeSListHead
20296 1289 RtlCaptureContext
202aa 1297 RtlLookupFunctionEntry
202c4 1304 RtlVirtualUnwind
202d8 944 IsDebuggerPresent
… (76 imports in total, all from KERNEL32.dll) Exercises
1. Manual header inspection
Just as you did for ELF binaries in Chapter 2, use a hex viewer like xxd to view the bytes in a PE binary. […] Can you identify the bytes representing the PE header and make sense of all of the header fields?
Unlike ELF, the interesting header is not at offset 0. You have to follow e_lfanew first. At offset 0x3c the DOS header ends with 00 01 00 00, little-endian for 0x100, so the PE headers start there.
00000100: 5045 0000 6486 0600 547b 706a 0000 0000 PE..d...T{pj....
00000110: 0000 0000 f000 2200 0b02 0e2c 004e 0100 ......"....,.N..
00000120: 00e2 0000 0000 0000 e412 0000 0010 0000 ................
00000130: 0000 0040 0100 0000 0010 0000 0002 0000 ...@............
00000140: 0600 0000 0000 0000 0600 0000 0000 0000 ................
00000150: 0070 0200 0004 0000 0000 0000 0300 6081 .p............`. Decoded field by field, little-endian throughout:
50 45 00 00 PE signature "PE\0\0"
; ── IMAGE_FILE_HEADER · 20 bytes ───────────────────────────────
64 86 Machine 0x8664 = AMD64
06 00 NumberOfSections 6
54 7b 70 6a TimeDateStamp 0x6a707b54
00 00 00 00 PointerToSymbolTable 0 ← deprecated
00 00 00 00 NumberOfSymbols 0 ← deprecated, symbols → PDB
f0 00 SizeOfOptionalHeader 0xf0 = 240
22 00 Characteristics 0x0022
EXECUTABLE_IMAGE|LARGE_ADDRESS_AWARE
; ── IMAGE_OPTIONAL_HEADER64 ────────────────────────────────────
0b 02 Magic 0x020b = PE32+
0e 2c Linker version 14.44
00 4e 01 00 SizeOfCode 0x14e00
00 e2 00 00 SizeOfInitializedData 0xe200
00 00 00 00 SizeOfUninitializedData 0 ← no .bss section
e4 12 00 00 AddressOfEntryPoint RVA 0x12e4
00 10 00 00 BaseOfCode RVA 0x1000
00 00 00 40 01 00 00 00 ImageBase 0x140000000
00 10 00 00 SectionAlignment 0x1000 (4 KB page)
00 02 00 00 FileAlignment 0x200 (512 bytes)
06 00 / 00 00 OS version 6.0
06 00 / 00 00 Subsystem version 6.0
00 70 02 00 SizeOfImage 0x27000
00 04 00 00 SizeOfHeaders 0x400
03 00 Subsystem 3 = Windows CUI
60 81 DllCharacteristics 0x8160
HIGH_ENTROPY_VA|DYNAMIC_BASE|
NX_COMPAT|TERMINAL_SERVER_AWARE Everything cross-checks: entry 0x140000000 + 0x12e4 = 0x1400012e4, matching objdump -f; six sections, matching the section table; PE32+. Two fields are worth dwelling on. PointerToSymbolTable and NumberOfSymbols are both zero, which is the deprecation from the file header made concrete: there are no embedded symbols, so a disassembler shows .text as one unnamed blob exactly like a stripped ELF, and the names live in a .pdb I was never given. And SizeOfUninitializedData is zero despite the program obviously having uninitialized globals in the CRT, because MSVC folded them into .data’s VirtualSize rather than emitting a .bss.
2. Disk representation vs. memory representation
Use readelf to view the contents of a PE binary. Then make an illustration of the binary’s on-disk representation versus its representation in memory. What are the major differences?
.data is where PE hides uninitialized data: VirtualSize exceeds
SizeOfRawData and the loader zero-fills the rest, the job ELF gives to .bss.
Four differences matter:
Two different alignments. On disk sections are packed to FileAlignment (0x200, 512 bytes) to keep the file small. In memory they are laid out on SectionAlignment (0x1000, one page) because permissions are enforced per page. Every section therefore starts at a different offset in each view (.rdata is at file offset 0x15200 but RVA 0x16000), and the gaps between sections in memory are pure alignment slack that does not exist on disk.
Everything shifts by ImageBase. On disk you index from 0. In memory every address is 0x140000000 plus the RVA, assuming the image got its preferred base at all.
VirtualSize can exceed SizeOfRawData. .data occupies 0xc00 bytes in the file but 0x1be0 in memory; the loader zero-fills the extra 0xfe0. This is exactly ELF’s p_memsz > p_filesz trick, and it is how PE expresses uninitialized data without a separate .bss.
Some sections are discardable. .reloc carries IMAGE_SCN_MEM_DISCARDABLE; once the loader has applied base relocations it may drop those pages entirely. ELF has no equivalent: it never maps .symtab in the first place, whereas PE maps .reloc and then throws it away.
3. PE vs. ELF
Use objdump to disassemble an ELF and a PE binary. Do the binaries use different kinds of code and data constructs? Can you identify some code or data patterns that are typical for the ELF compiler and the PE compiler you’re using, respectively?
Same main, both toolchains, side by side:
; ── gcc → ELF ────────────────────────────────────────────────
0000000000001149 <main>:
1149: endbr64 ; CET landing pad
114d: push rbp ; frame pointer (-O0)
114e: mov rbp,rsp
1151: sub rsp,0x10
115c: lea rax,[rip+0xea1] # 2004 ; "Hello, world!"
1163: mov rdi,rax ; arg 1 in RDI (System V)
1166: call 1050 <puts@plt> ; printf → puts, via PLT
116b: mov eax,0x0
1170: leave
1171: ret
; ── MSVC → PE ────────────────────────────────────────────────
140001010: sub rsp,0x28 ; 32B shadow space + align
140001014: lea rcx,[rip+0x15315] # 140016330 ; "Hello, world!"
; arg 1 in RCX (Microsoft x64)
14000101b: call 0x140001030 ; direct call: CRT is static
140001020: xor eax,eax ; return 0
140001022: add rsp,0x28
140001026: ret
140001027: int3 ; padding, not a nop The constructs differ in five visible ways:
Calling convention. System V AMD64 passes the first integer argument in rdi; Microsoft x64 uses rcx. The two ABIs disagree on argument registers, callee-saved registers, and red zone, so the same C source produces visibly different register traffic.
Shadow space. sub rsp,0x28 is not a local-variable frame. Microsoft x64 requires the caller to reserve 32 bytes of “shadow space” for the callee to spill its four register arguments into, plus 8 for stack alignment. A sub rsp,0x28 in a function whose body has no locals at all is a reliable Microsoft-ABI tell; System V has no shadow space and instead gives leaf functions a 128-byte red zone below rsp.
int3 padding. GCC pads with nop; MSVC pads with int3 (0xcc), the breakpoint instruction. My 140 KB hello world contains 1,646 of them. The logic is defensive: padding is never meant to execute, and if control ever reaches it, trapping to the debugger beats silently running through junk.
Direct calls vs PLT calls. The ELF binary reaches puts through puts@plt, a stub, because libc is a shared object. The PE binary calls 0x140001030 directly, because MSVC statically linked the CRT, so printf is compiled into the binary itself. Only genuine OS calls into KERNEL32.dll go through the IAT.
Different control-flow integrity. The gcc build contains 9 endbr64 instructions, Intel CET landing pads; the MSVC build contains zero. That does not mean it is unprotected. It uses Windows’ own scheme, Control Flow Guard, and the evidence is in the Load Configuration Directory rather than in the instruction stream:
LoadConfig RVA 0x1e540 size 0x140 # DataDirectory[10]
GuardCFCheckFunctionPointer 0x140016268 (.rdata)
GuardCFDispatchFunctionPointer 0x140016278 (.rdata)
GuardFlags 0x00000100 CF_INSTRUMENTED So the same defensive goal shows up in two completely different places: as an opcode at every function entry on Linux, and as a pair of pointers plus a flag word in a header directory on Windows. If you grep a PE for endbr64 and conclude “no CFI,” you have looked in the wrong place.
Summary
Everything from Chapter 2 transfers, with a rename and one structural subtraction. The executable header becomes three headers behind a DOS fossil; addresses become RVAs relative to ImageBase; .rodata becomes .rdata; the GOT becomes the IAT and PLT stubs become thunks; and .bss dissolves into a VirtualSize that outruns SizeOfRawData. The subtraction is the program header table: PE has no section/segment split, only a DataDirectory of shortcuts, which is why a PE section has to carry its own address and permissions.
The practical upshot for reverse engineering is that PE gives you less structure to lean on. No segment view to tell you what the loader really maps, symbols exiled to a PDB you probably do not have, and a compiler that will happily mix constants into .text.