Every PE file opens with an MS-DOS header — a 64-byte structure defined in winnt.h as IMAGE_DOS_HEADER. Windows reads it before it reads anything else: first the magic MZ, then one pointer at offset 0x3C that jumps the loader years forward in time to the PE header. If either check fails, the file never loads.
Yet most of the 64 bytes describe a 16-bit MS-DOS program that modern Windows never runs. Learning to tell the two live fields apart from the historical ones is the difference between reading the DOS header and being confused by it. The specification is direct: the region from the MS-DOS header through the stub "is used for MS-DOS compatibility only."
Think of the DOS header as a business card from 1985 with one modern instruction written on the back:
e_lfanew — a single 32-bit file offset added when Windows NT came along. It points at the PE signature. This is the hinge between the 1980s half of the file and the modern half.The model's missing piece: the header's size is itself stored in the header. Field e_cparhdr gives the header size in 16-byte paragraphs — in our sample it is 4, so 4 × 16 = 64 bytes, which is exactly where the stub begins.
The full field walk of IMAGE_DOS_HEADER (offsets from the winnt.h structure), with Sample A's real values — setuptools' 64-bit cli-64.exe:
| Off | Size | Field | winnt.h comment | Sample A |
|---|---|---|---|---|
| 0x00 | 2 | e_magic | Magic number | 0x5A4D ("MZ") |
| 0x02 | 2 | e_cblp | Bytes on last page of file | 0x0090 (144) |
| 0x04 | 2 | e_cp | Pages in file | 0x0003 |
| 0x06 | 2 | e_crlc | Relocations | 0x0000 |
| 0x08 | 2 | e_cparhdr | Size of header in paragraphs | 0x0004 (4 × 16 = 64 bytes) |
| 0x0A | 2 | e_minalloc | Minimum extra paragraphs needed | 0x0000 |
| 0x0C | 2 | e_maxalloc | Maximum extra paragraphs needed | 0xFFFF |
| 0x0E | 2 | e_ss | Initial (relative) SS value | 0x0000 |
| 0x10 | 2 | e_sp | Initial SP value | 0x00B8 (184) |
| 0x12 | 2 | e_csum | Checksum | 0x0000 |
| 0x14 | 2 | e_ip | Initial IP value | 0x0000 |
| 0x16 | 2 | e_cs | Initial (relative) CS value | 0x0000 |
| 0x18 | 2 | e_lfarlc | File address of relocation table | 0x0040 |
| 0x1A | 2 | e_ovno | Overlay number | 0x0000 |
| 0x1C | 8 | e_res[4] | Reserved words | all zero |
| 0x24 | 2 | e_oemid | OEM identifier (for e_oeminfo) | 0x0000 |
| 0x26 | 2 | e_oeminfo | OEM information; e_oemid specific | 0x0000 |
| 0x28 | 20 | e_res2[10] | Reserved words | all zero |
| 0x3C | 4 | e_lfanew | File address of new exe header | 0x00000100 |
Why it still exists. The specification explains it plainly: at location 0x3c the stub stores the file offset to the PE signature, and "this information enables Windows to properly execute the image file, even though it has an MS-DOS stub." The stub itself is a valid MS-DOS application the linker places at the front — replaceable with the /STUB linker option. Tools that expect MZ executables still recognize the file; Windows reads MZ, follows e_lfanew, and never looks back.
e_cblp, e_sp, and e_cs describe the 16-bit stub's view of the world (size, stack, entry) — the NT loader never reads them. Corrupt e_lfanew and the file dies; corrupt e_sp and Windows does not care.The first 64 bytes of cli-64.exe:
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 ................
Walk-through:
4d 5a — "MZ", the magic named after Mark Zbikowski, an original MS-DOS architect. Any tool checking "is this a DOS or Windows executable?" stops here.04 00 — e_cparhdr = 4 paragraphs = 64 bytes. The DOS stub therefore begins at 0x40.00 01 00 00 — little-endian 0x00000100. The PE signature lives at file offset 0x100. This is the only field besides the magic that the NT loader trusts.And immediately after the 64-byte header, the stub (file offset 0x40) — real 16-bit code ending in its message string at 0x4E:
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....$.......
Decode the code: 0e 1f push cs / pop ds; ba 0e 00 mov dx, 0x000E — the message offset, 0x40 + 0x0E = 0x4E, where "This program…" begins; b4 09 cd 21 print string via DOS int 21h AH=9 (terminated by the $ at 0x78); b8 01 4c cd 21 exit with code 1. The whole compatibility story — runnable under DOS, decisive under Windows — is these 19 bytes.
Dump the header, then isolate the hinge field on any PE file:
$ xxd -l 64 cli-64.exe
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 ................
$ xxd -s 0x3c -l 4 cli-64.exe
0000003c: 0001 0000 ....On Windows, PowerShell reads the same field without extra tools:
> $b = [System.IO.File]::ReadAllBytes(".\notepad.exe")
> [System.BitConverter]::ToUInt32($b, 0x3C)
240 # e_lfanew — PE signature is at file offset 240What to look for: the first two bytes are always 4d 5a, and the four bytes at 0x3C are little-endian — swap them mentally (00 01 00 00 → 0x100) and you have the address of the next layer. Try two different executables; the e_lfanew values will often differ.
Without looking back: which DOS header fields does the NT loader actually depend on, and what does each do?
A PE file has e_cparhdr = 4 at offset 0x08. Where does the DOS stub begin, and why?
The pattern: MZ at 0, e_lfanew at 0x3C, stub at e_cparhdr × 16. Three numbers, and you can navigate the entire DOS layer.
You can now read all 64 bytes — and know that Windows only trusts two of them. Follow e_lfanew and you land on the 4-byte signature that opens the modern format, immediately followed by the 20-byte COFF header you already met from the object-file side.
Next: PE Signature and COFF Header — verifying PE\0\0, then decoding every COFF field, with both Characteristics flags fully unpacked.