Inside a DMG: the koly trailer, block maps & how a UDIF disk image works
A .dmg is the file you download to install a Mac app, but it is really a disk in a file: a compressed image of a filesystem, wrapped in Apple's Universal Disk Image Format. Its most surprising trait is where the index lives — not at the front, but in a fixed 512-byte trailer at the very end of the file. This is a tour of that trailer (the koly block), the property list it points at, the blkx block map and its mish chunk tables — and how all of that lets you read what a multi-gigabyte image contains from a handful of tiny reads.
A short history: NDIF, IMG, UDIF
Apple has shipped disk images since the late 1980s, when floppy-disk masters were distributed as byte-for-byte .image files. Classic Mac OS used NDIF (New Disk Image Format) and the .img extension, read by the old Disk Copy utility. When Mac OS X arrived, Apple replaced NDIF with UDIF — the Universal Disk Image Format — and the .dmg extension you know today. UDIF is what hdiutil and Disk Utility produce, and it has been the delivery vehicle for Mac software for two decades: drag-to-Applications installers, OS updates, and forensic disk captures all ride in it. The .smi ("self-mounting image") variant embedded a tiny mounter for pre-OS-X machines; .dmgpart files are segments of an image split for distribution. Apple has never published a formal UDIF specification — everything known about it comes from reverse engineering, which is why the field names below are the community's, not Apple's.
The file layout: index at the back
Most containers put a header or magic number at offset 0. UDIF does the opposite. A .dmg begins straight into the data fork — the compressed disk contents — with no signature at all, and ends with two structures that describe it: an XML property list, and then the fixed 512-byte koly trailer that points at everything.
┌───────────────────────────────────────────────┐ offset 0
│ data fork │
│ compressed run · compressed run · zero-fill │ ← the actual disk,
│ … (this is the multi-gigabyte part) … │ chunk by chunk
├───────────────────────────────────────────────┤ ← koly.XMLOffset
│ XML property list (resource fork) │
│ <key>blkx</key> → one entry per partition │ ← the index
├───────────────────────────────────────────────┤ ← fileSize − 512
│ koly trailer (exactly 512 bytes) │
│ "koly" · checksums · XMLOffset · XMLLength │ ← points back up ↑
└───────────────────────────────────────────────┘ ← end of file
To read a UDIF image you therefore start at the end: seek to fileSize − 512, confirm the koly magic, and it tells you where the property list is. Nothing about that changes with file size — a 30 KB sample and a 30 GB installer are read the same way.
The koly trailer, field by field
The trailer's name is its magic number: the four ASCII bytes koly (6B 6F 6C 79). Every multi-byte field is big-endian (a QuickTime inheritance). Here is the full 512-byte layout:
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 | Signature | "koly" |
| 4 | 4 | Version | UDIF version (4 today) |
| 8 | 4 | HeaderSize | 512 |
| 12 | 4 | Flags | bit 0 = flattened, bit 2 = internet-enabled |
| 24 | 8 | DataForkOffset | start of the data fork (usually 0) |
| 32 | 8 | DataForkLength | compressed size of the disk contents |
| 40 / 48 | 8 each | RsrcForkOffset/Length | legacy resource fork (0 in modern images) |
| 64 | 16 | SegmentID | a UUID shared across split segments |
| 80 | 136 | DataForkChecksum | type + 32-bit size + 128 bytes (a CRC-32) |
| 216 | 8 | XMLOffset | where the property list starts |
| 224 | 8 | XMLLength | property-list length in bytes |
| 352 | 136 | MasterChecksum | checksum over all the block checksums |
| 488 | 4 | ImageVariant | 1 = device image, 2 = partition image |
| 492 | 8 | SectorCount | total 512-byte sectors when uncompressed |
Two fields carry the whole picture: SectorCount × 512 is the true (uncompressed) size of the disk, and XMLOffset/XMLLength point at the index. Reading it is a few lines over a DataView — note the 64-bit fields, composed from two big-endian halves so they stay within JavaScript's safe integer range:
const be32 = (dv, o) => dv.getUint32(o, false);
const be64 = (dv, o) => dv.getUint32(o, false) * 2 ** 32 + dv.getUint32(o + 4, false);
function parseKoly(bytes) { // bytes = the last 512 of the file
const dv = new DataView(bytes.buffer, bytes.byteOffset, 512);
if (be32(dv, 0) !== 0x6b6f6c79) return null; // not "koly"
return {
version: be32(dv, 4),
sectorCount: be64(dv, 492), // × 512 = uncompressed size
xmlOffset: be64(dv, 216),
xmlLength: be64(dv, 224),
};
}
The property list: an index you can read
The structure koly points at is an ordinary Apple XML property list — the same <plist> format used for macOS preferences. Its resource-fork dictionary holds a blkx array, and each element describes one partition of the image: a human name, a resource ID, and a base64-encoded binary blob called the block map.
<key>blkx</key>
<array>
<dict>
<key>CFName</key><string>disk image (Apple_HFS : 3)</string>
<key>Data</key> <data>bWlzaAAAAAE…</data> <!-- base64 "mish…" -->
<key>ID</key> <string>3</string>
<key>Name</key> <string>disk image (Apple_HFS : 3)</string>
</dict>
… one dict per partition …
</array>
The plist is small — kilobytes to a few megabytes even for a huge image, because it indexes runs of sectors, not the sectors themselves. That is the whole reason a disk image can be inspected cheaply: the index is tiny and self-describing.
blkx & the mish block map
Decode one Data blob from base64 and you get a binary structure whose own magic number is mish (6D 69 73 68). A mish block is a header, a checksum, and then a table of fixed 40-byte chunk descriptors — the recipe for reconstructing that partition's sectors:
mish header (204 bytes)
├── "mish" · Version
├── SectorNumber · SectorCount where this partition sits in the output
├── DataOffset · BuffersNeeded
├── Checksum (type + size + 128 bytes)
└── NumberOfBlockChunks ← how many entries follow
each chunk entry (40 bytes):
┌──────────────┬─────────┬───────────────┬───────────────┬──────────────────┐
│ EntryType u32│ Comment │ SectorNumber │ SectorCount │ CompressedOffset │
│ │ u32 │ u64 │ u64 │ + Length u64×2 │
└──────────────┴─────────┴───────────────┴───────────────┴──────────────────┘
Each entry says: "sectors SectorNumber…SectorNumber+SectorCount of this partition come from CompressedLength bytes at CompressedOffset in the data fork, decoded with method EntryType." A partition is just an ordered list of these runs, terminated by a special entry. Sum the SectorCounts and you have the partition's uncompressed size; sum the CompressedLengths and you have what it costs on disk — the ratio is the compression the image achieved.
Chunk types: how a DMG compresses
The EntryType field names the codec for that run. The high bit marks a compressed method; the low values are markers:
| EntryType | Name | What it is |
|---|---|---|
0x00000000 | zero-fill | a run of zeroed sectors — stored as nothing at all |
0x00000001 | uncompressed | raw sectors copied verbatim (UDRO/UDRW images) |
0x00000002 | ignore / free | unallocated space, not written |
0x80000005 | UDZO | zlib (DEFLATE) — the classic, widely-compatible default |
0x80000006 | UDBZ | bzip2 — smaller, slower, needs a newer macOS |
0x80000007 | ULFO | LZFSE — Apple's own codec, the modern default |
0x80000008 | ULMO | LZMA — the newest, smallest option |
0xFFFFFFFF | terminator | marks the end of the chunk list |
Those four-letter codes — UDZO, UDBZ, ULFO — are the same ones you pass to hdiutil convert -format. A mostly-zero disk compresses spectacularly because the zero runs cost zero bytes; a disk full of already-compressed media barely shrinks. OmniViewer reads the chunk table to show you that breakdown per partition, but it never decodes a chunk — inspecting the structure needs only the index.
Reading a 20 GB image from a 512-byte trailer
Put the pieces together and the scale story falls out for free. To learn everything above about an image of any size, you read exactly two regions: the last 512 bytes, and the property list they point at.
async function readImage(file) { // file: a Blob/File handle
// 1. the trailer — one 512-byte read at the end
const tail = new Uint8Array(await file.slice(file.size - 512).arrayBuffer());
const koly = parseKoly(tail);
if (!koly) return { udif: false }; // no trailer: not a UDIF image
// 2. the property list — one bounded read (kilobytes to a few MB)
const xmlBytes = await file.slice(koly.xmlOffset,
koly.xmlOffset + koly.xmlLength);
const xml = new TextDecoder().decode(await xmlBytes.arrayBuffer());
// 3. parse the blkx array + each mish table — pure in-memory work
return assemble(koly, xml); // partitions, sizes, compression
}
The data fork — the compressed filesystem, which is 99.99% of a big image — is never read. A 20 GB installer and the 33 KB sample below cost the same two slice reads, and because a File is a lazy handle, those reads never pull the whole file into memory. That is exactly how OmniViewer's /dmg toolkit opens an image instantly and entirely in your browser — no upload, no hdiutil, no mounting.
.dmg contains and how it's laid out; it does not decompress or mount the filesystem inside. That's the right trade for a quick look at a download you didn't build — you see the partitions, sizes and compression without trusting the image enough to mount it.Where the "spec" actually lives
Because Apple never published UDIF, the reference material is community reverse engineering. The most useful starting points:
- Wikipedia: Apple Disk Image — the format overview, variants and history.
- Jonathan Levin, "Demystifying the DMG File Format" — the canonical field-by-field write-up of
kolyandmish. - libdmg-hfsplus / dmg2img — a working open-source reader (the C structs match the tables above).
- hdiutil — Apple's own command-line tool that writes these images (
hdiutil create,hdiutil convert -format UDZO).
OmniViewer's reader is built from those same sources; it is dependency-free and runs off the main thread, the same discipline behind reading a 20 GB MP4 and a PNG chunk by chunk.