Systems Programming · Binary Formats

Mach-O Load Commands and the Segments a dyld Image Is Built From

How the Mach-O header addresses its command list, why __PAGEZERO occupies the low address space, and how chained fixups replaced the older rebase and bind opcode streams.

Mach-O and ELF solve the same problem with opposite dispositions. ELF separates the loading view from the linking view into two tables that can disagree, as ELF Program Headers and the Mapping a Loader Performs Before _start describes; Mach-O folds both into a single ordered list of typed commands immediately after the header. Where ELF has a fixed structure for segments and another for sections, Mach-O has a variable-length command stream in which a segment command contains its own section descriptors inline. The result is denser and more flexible, and considerably harder to parse correctly, because the meaning of a byte depends entirely on which command it falls inside.

The command list is also where Mach-O records things that ELF spreads across separate sections: the entry point, the code signature location, the dylib dependency list, the symbol table location, and the binding metadata that dyld consumes. A Mach-O reader that understands the command list has most of the file.

The header and the command stream

A 64-bit Mach-O file begins with a 32-byte mach_header_64: magic, cputype, cpusubtype, filetype, ncmds, sizeofcmds, flags, and a reserved word. The magic is 0xfeedfacf when the file’s own byte order matches the reader’s. A file in the opposite byte order presents the same value byte-reversed, and the format reserves a second constant for that case rather than requiring the reader to detect endianness from context. Reading the magic as a native integer and comparing it against a single constant, without handling the swapped form, is the first place a portable parser fails.

ncmds is the number of commands and sizeofcmds is their total byte length. Both are needed because commands are not fixed size: a segment command with five sections is far larger than a dylib reference, and a reader cannot compute the count from the size. The two fields also provide a consistency check that matters for untrusted input: the sum of the individual cmdsize fields must equal sizeofcmds, and each cmdsize must be a multiple of eight on 64-bit objects and of four on 32-bit ones. A command whose size is zero, or not a multiple of the alignment, would cause an infinite loop or a misaligned read in a parser that simply advances by cmdsize.

Every command starts with a cmd value and a cmdsize value, both 32-bit. The cmd value determines the structure that follows, and the structure’s own fields determine how many trailing bytes are meaningful. Segment commands embed an array of section records, each 80 bytes on 64-bit, and carry a nsects count; a reader must still check that the count agrees with the command size, since (nsects * 80) + 72 must equal cmdsize for a LC_SEGMENT_64 command. A file where the count and the size disagree has two plausible interpretations and no way to choose between them.

/* Walk the load command stream, validating sizes as it goes. */
#include <mach-o/loader.h>
#include <stdio.h>

int main(int argc, char **argv) {
    FILE *f = fopen(argv[1], "rb");
    if (!f) return 1;
    struct mach_header_64 mh;
    if (fread(&mh, sizeof mh, 1, f) != 1) return 1;
    if (mh.magic != MH_MAGIC_64) return 1;

    uint32_t left = mh.sizeofcmds;
    for (uint32_t i = 0; i < mh.ncmds && left >= 8; i++) {
        struct load_command lc;
        if (fread(&lc, sizeof lc, 1, f) != 1) return 1;
        if (lc.cmdsize < 8 || lc.cmdsize > left) return 1;
        printf("cmd=%#x size=%u\n", lc.cmd, lc.cmdsize);
        /* skip the remainder of this command; cmdsize includes the 8-byte header */
        if (fseek(f, (long)(lc.cmdsize - sizeof lc), SEEK_CUR) != 0) return 1;
        left -= lc.cmdsize;
    }
    return 0;
}

The loop condition on left is the important part. A crafted file can claim a large ncmds while sizeofcmds is small, and a parser that iterates ncmds times without tracking the remaining bytes reads past the command area into whatever follows. This is the same class of defect discussed in Parsing Untrusted Binaries Without Trusting Their Length Fields, and Mach-O is more exposed to it than ELF because there is no separate table to bound the stream.

Segments, sections, and the page-zero convention

LC_SEGMENT_64 describes a mapped region with segname, vmaddr, vmsize, fileoff, filesize, maxprot, initprot, nsects, and flags. The naming convention is fixed by the toolchain rather than by the format: __TEXT holds headers, code, and read-only data and is mapped execute-only or read-execute; __DATA holds writable data; __LINKEDIT holds the symbol table, string table, binding metadata, and code signature, and is mapped read-only. Section names begin with two underscores and are limited to 16 bytes, so __text, __cstring, __const, __got, and __la_symbol_ptr are the familiar inhabitants.

Sections here are nested inside their segment’s command rather than listed in a separate table. ELF takes the other approach, with a section table addressed independently of the segments; the trade between the two is examined in Section Headers, String Tables, and the Bookkeeping a Linker Needs.

The __PAGEZERO segment has no file content and no permissions. Its purpose is to occupy the low end of the address space, typically the first 4 GiB in a 64-bit executable, so that dereferencing a null pointer or a small integer offset faults immediately rather than landing in mapped memory. This is the Mach-O analogue of the unmapped zero page that many systems provide, made explicit in the file rather than left to kernel policy. Its vmsize is large and its filesize is zero, and a reader that attempts to read file content for it will find none.

Protections in maxprot and initprot are expressed as VM_PROT_READ, VM_PROT_WRITE, and VM_PROT_EXECUTE bits. Unlike ELF, where permissions come from p_flags on the segment and are adjusted by the loader for page-sharing, Mach-O states the maximum and initial protections separately and lets the kernel enforce the maximum. A segment that requests write and execute simultaneously can be rejected outright on systems that enforce the write-xor-execute policy, which is why JIT implementations must allocate executable memory through a separate mechanism rather than by marking a segment executable and writable.

The base-relative addressing that makes relocation cheap in this format is the same idea ELF implements through PC-relative instruction encodings, covered in Position-Independent Executables and the Cost of PC-relative Addressing. Mach-O expresses it in the fixup metadata rather than in the instruction stream.

The entry point moved from a thread state to an offset

Older Mach-O executables encoded the initial program counter inside an LC_UNIXTHREAD command, which carried a complete saved thread state: registers, stack pointer, and program counter, in a layout specific to the CPU family. That made the entry point a CPU-dependent blob and made the command list awkward for tools that only wanted the address.

LC_MAIN replaced it with two fields: entryoff, an offset from the start of the __TEXT segment’s file content to the entry point, and stacksize, a requested initial stack size where zero means the system default. The offset is relative to the segment rather than absolute, which keeps the value valid under address-space layout randomisation: the loader computes the entry address as the segment’s loaded base plus entryoff. An executable that lacks both LC_MAIN and LC_UNIXTHREAD is not runnable, even if every other command is present, which is a useful sanity check when inspecting a file that appears complete.

The LC_MAIN entry is not the C main function. It is the start of the C runtime’s startup stub, which reads the argument vector and environment from the stack and calls main. On Darwin the stack layout at entry follows the platform ABI rather than the System V convention, and the startup stub is what reconciles the two.

Dynamic linking metadata and the two generations of fixups

A dynamically linked Mach-O image records its dependencies as LC_LOAD_DYLIB commands, each holding a string table offset for the install name, a timestamp, a current version, and a compatibility version. The versions are packed as 16 bits each for major, minor, and patch, which is why version numbers in Mach-O are limited to those field widths. LC_ID_DYLIB marks a dylib’s own identity, and LC_REEXPORT_DYLIB marks a library whose symbols are re-exported without being linked against directly.

The interesting part is how dyld learns which locations need adjustment. In the older scheme, LC_DYLD_INFO_ONLY points at four opcode streams: rebase, bind, weak bind, and lazy bind. Each stream is a byte-coded program in which opcodes such as SET_TYPE_IMM, SET_SEGMENT_AND_OFFSET_ULEB, and DO_BIND advance an implicit pointer and perform one action. The streams are compact because most locations are consecutive and share a type, but they require a small interpreter to read, and that interpreter is part of dyld rather than of the file format. The set of fixups they express is the same set that ELF records in its relocation sections, described in Relocation Entries and the Arithmetic a Linker Performs; only the encoding differs.

The newer scheme, LC_DYLD_CHAINED_FIXUPS, replaces the streams with a linked structure embedded in the data itself. Each fixup location holds an encoded value whose bits describe the kind of fixup and, in the common case, the offset to the next fixup in the same chain. dyld walks the chains rather than a separate opcode stream, which removes the side table and lets the kernel validate the chain structure during mapping. The format is documented in the dyld source and in Apple’s header files, and it is version-dependent: a binary may carry both, or only one, and a reader must consult the commands present rather than assume a generation.

Code signing as a load command

LC_CODE_SIGNATURE gives the file offset and size of a signature blob, conventionally at the end of __LINKEDIT. The blob is a SuperBlob containing a code directory, requirements, entitlements, and, for distributed software, a CMS signature. The code directory records a hash of each page of the file, so verification is a page-by-page comparison rather than a single digest over the whole file, and the signature itself is excluded from the hashed range by construction.

Two consequences follow from the page hashing. Appending bytes to a signed binary invalidates it, because the hashed page count no longer covers the file. And the granularity of the hash limits what can be modified without re-signing: a change to any byte in a hashed page breaks that page’s hash. Ad-hoc signatures, which carry no certificate chain, are used for locally built binaries and for binaries whose integrity is checked by other means; they still enforce the page hashes, which is why modifying a locally built binary and running it fails in the same way as modifying a distributed one.

The signature blob is the last thing in the file and its position is recorded in a load command that is itself covered by the signature’s own hash of the header page. That circularity is resolved by hashing the header page including the command, and excluding only the blob’s own bytes from the page hashes it declares.

Fat binaries and the magic collision

A single file can contain several Mach-O images for different CPU types. It begins with a fat_header: magic of 0xcafebabe, then nfat_arch, then an array of fat_arch records giving cputype, cpusubtype, offset, size, and align for each embedded image. A 64-bit variant with a different magic uses 64-bit offset and size fields, which is required once an image exceeds 4 GiB.

The magic 0xcafebabe is the same value used by Java class files. The two are distinguished by reading the following field: in a fat binary it is a plausible architecture count, and in a class file it is a version number that, interpreted as a count, would be absurd. Tools that check only the magic misclassify Java bytecode as a universal binary, and the resulting error message is confusing because the file is otherwise well formed.

Each embedded image is a complete Mach-O with its own header, commands, and code signature. A fat binary’s own structure is not signed; the signatures live inside the individual slices. Stripping a fat file to a single architecture is therefore not a byte-range copy that preserves validity in general, because the slice’s internal offsets are relative to the slice’s own start and remain correct only if the slice is extracted whole.

Where the two formats diverge in practice

The structural difference that matters most to a tool author is that Mach-O has no equivalent of ELF’s section header table reachable independently of the load commands. Everything is in one stream, and the stream’s validity depends on every preceding command having a size that agrees with the remaining budget. There is no checksum and no redundancy, and the only integrity mechanism is the code signature, which is optional in the format and mandatory only by platform policy.

That single-stream design also makes Mach-O easier to extend. Adding a new kind of metadata means defining a command number and appending a record; the loader skips commands it does not recognise by their size. ELF achieves the same extensibility through the e_phentsize indirection and the section table, at the cost of two structures that can disagree. Neither approach is more correct; they are two answers to the same question about how much the loader should be able to ignore.

References

  1. Mach-O Programming Topics — Apple Developer Documentation
  2. mach-o/loader.h — XNU source — Apple Inc.
  3. Mac OS X Internals: A Systems Approach — Amit Singh, Addison-Wesley, 2006
  4. otool(1) — Darwin manual pages — Apple Inc.