Back to course

The MS-DOS Header

15 minintermediateModule 2: HeadersContinues from pe-coff-basics
15 min · Module 1: Fundamentals · Foundations

Why This Matters

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

A Simple Model

Think of the DOS header as a business card from 1985 with one modern instruction written on the back:

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 Actual Detail

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:

OffSizeFieldwinnt.h commentSample A
0x002e_magicMagic number0x5A4D ("MZ")
0x022e_cblpBytes on last page of file0x0090 (144)
0x042e_cpPages in file0x0003
0x062e_crlcRelocations0x0000
0x082e_cparhdrSize of header in paragraphs0x0004 (4 × 16 = 64 bytes)
0x0A2e_minallocMinimum extra paragraphs needed0x0000
0x0C2e_maxallocMaximum extra paragraphs needed0xFFFF
0x0E2e_ssInitial (relative) SS value0x0000
0x102e_spInitial SP value0x00B8 (184)
0x122e_csumChecksum0x0000
0x142e_ipInitial IP value0x0000
0x162e_csInitial (relative) CS value0x0000
0x182e_lfarlcFile address of relocation table0x0040
0x1A2e_ovnoOverlay number0x0000
0x1C8e_res[4]Reserved wordsall zero
0x242e_oemidOEM identifier (for e_oeminfo)0x0000
0x262e_oeminfoOEM information; e_oemid specific0x0000
0x2820e_res2[10]Reserved wordsall zero
0x3C4e_lfanewFile address of new exe header0x00000100

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.

Common mistake. Believing all 64 bytes must be valid for Windows to run the file. Matt Pietrek's classic MSDN PE article puts it bluntly: "The only two values of any importance are e_magic and e_lfanew." Fields like 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.

A Real Example

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:

  1. Bytes 0–1: 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.
  2. Bytes 0x08–0x09: 04 00 — e_cparhdr = 4 paragraphs = 64 bytes. The DOS stub therefore begins at 0x40.
  3. Bytes 0x3C–0x3F: 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.

Try It

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 240

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

Check Your Understanding

Without looking back: which DOS header fields does the NT loader actually depend on, and what does each do?

Apply It

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.

Connect

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.