✳ OMNIVIEWER DMG viewer Archive tools ← back

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.

On this page A short history The file layout The koly trailer The property list blkx & the mish block map Chunk types & compression Reading a 20 GB image Where the spec lives

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:

OffsetSizeFieldMeaning
04Signature"koly"
44VersionUDIF version (4 today)
84HeaderSize512
124Flagsbit 0 = flattened, bit 2 = internet-enabled
248DataForkOffsetstart of the data fork (usually 0)
328DataForkLengthcompressed size of the disk contents
40 / 488 eachRsrcForkOffset/Lengthlegacy resource fork (0 in modern images)
6416SegmentIDa UUID shared across split segments
80136DataForkChecksumtype + 32-bit size + 128 bytes (a CRC-32)
2168XMLOffsetwhere the property list starts
2248XMLLengthproperty-list length in bytes
352136MasterChecksumchecksum over all the block checksums
4884ImageVariant1 = device image, 2 = partition image
4928SectorCounttotal 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:

EntryTypeNameWhat it is
0x00000000zero-filla run of zeroed sectors — stored as nothing at all
0x00000001uncompressedraw sectors copied verbatim (UDRO/UDRW images)
0x00000002ignore / freeunallocated space, not written
0x80000005UDZOzlib (DEFLATE) — the classic, widely-compatible default
0x80000006UDBZbzip2 — smaller, slower, needs a newer macOS
0x80000007ULFOLZFSE — Apple's own codec, the modern default
0x80000008ULMOLZMA — the newest, smallest option
0xFFFFFFFFterminatormarks 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.

Inspect, don't extract. Reading the block map tells you what a .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:

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.

See it on a real image

Open the /dmg toolkit → Drop any .dmg: the PARTITIONS tab shows every partition and its compression, read straight from the block map. 100% local. Try the sample → A small GPT-formatted UDIF image with five partitions and a mix of zlib, raw and zero-fill chunks to explore.