Back to course

Chained Fixups

20 minintermediateModule 6: Modern Linking & EntryContinues from macho-export-trie
20 min · Module 6: Modern Linking & Entry

Why This Matters

The dyld_info streams work, but they sit in __LINKEDIT while the pointers they describe sit in __DATA_CONST — far apart, and dyld must read them before anything runs. fixup-chains.h states the modern goal outright: the per-segment starts are "passed down to the kernel for page-in linking", so a page's fixups can be applied the moment that page faults in, without hunting through a side table. ELF's answer to the same pressure was DT_RELR bitmaps; Apple's answer is to stop treating fixup locations as data at all — the locations are the pointer slots, linked into chains.

This is also the structural break between the course's two sample eras: libasyncProfiler.dylib carries four separate streams and no chain command; prog64.macho carries chains and a trie and no LC_DYLD_INFO. One load command, LC_DYLD_CHAINED_FIXUPS, replaces rebase, bind, weak_bind, and lazy_bind.

A Simple Model

Three regions, all offsets from the start of one blob:

  1. Starts — per segment, per page: the file offset of the first fixup slot on that page.
  2. Imports — a table of (library ordinal, weak bit, symbol-name offset) — the bind opcodes' state columns, flattened into fixed-size records.
  3. Symbols — the string pool the import records point into.

Then the trick: every pointer slot packs a next distance to the following slot on its page plus a one-bit bind tag. Walk the page following next-hops; at each slot read bind — 0 means "this is a rebase: the other bits hold a target", 1 means "this is an import: the other bits hold an ordinal into the imports table". One pointer, two interpretations, selected by one bit.

The model's missing piece: next is a small distance, not an address — chains die at the end of the page (0 = last element), and slots that need no fixup are simply not linked.

The Actual Detail

The blob opens with dyld_chained_fixups_header — seven u32s, verbatim from the header: fixups_version (0), starts_offset, imports_offset, symbols_offset, imports_count, imports_format (1 = DYLD_CHAINED_IMPORT, 2 = +addend s32, 3 = +addend64), symbols_format (0 uncompressed, 1 zlib). Then dyld_chained_starts_in_image {seg_count, seg_info_offset[]}, where each non-zero entry points at a dyld_chained_starts_in_segment: size, page_size (u16, 0x1000 or 0x4000), pointer_format (u16), segment_offset (u64), max_valid_pointer, page_count, then page_start[] — each entry the first chain's offset within that page, or 0xFFFF (DYLD_CHAINED_PTR_START_NONE) for a page with no fixups.

pointer_format picks the slot bit layout. The header's enum includes DYLD_CHAINED_PTR_ARM64E = 1 (stride 8, arm64e pointer authentication), DYLD_CHAINED_PTR_64 = 2 ("target is vmaddr" — plain 64-bit userland, this course's samples' format family), DYLD_CHAINED_PTR_32 = 3, DYLD_CHAINED_PTR_64_OFFSET = 6 (target is a vm offset), and DYLD_CHAINED_PTR_ARM64E_USERLAND24 = 12 (24-bit bind ordinals). For PTR_64, the two overlay structs from fixup-chains.h:

dyld_chained_ptr_64_rebase: target:36, high8:8, reserved:7, next:12, bind:1 — bind == 0
dyld_chained_ptr_64_bind: ordinal:24, addend:8, reserved:19, next:12, bind:1 — bind == 1

Both carry "4-byte stride" next fields per the header's comments; the rebase target is a 36-bit vmaddr (64 GiB image ceiling), the bind ordinal indexes imports_format 1's record — dyld_chained_import {lib_ordinal:8, weak_import:1, name_offset:23} — the same ordinal space as the bind opcodes, now in a struct instead of a stream.

Common mistakes. Do not read this blob with Mach-O's little-endian habit on auto-pilot and then mis-attribute field order — the header's u32s are plain little-endian like the rest of the file; it is only the code-signature superblob (later module) that flips to big-endian. And an empty payload is legal: seg_info_offset 0 for every segment means "no chains here", not "corrupt" — header still required.

A Real Example

prog64.macho: LC_DYLD_CHAINED_FIXUPS at dataoff 12288, datasize 56 — the entire payload:

00003000: 0000 0000 2000 0000 3800 0000 3800 0000  .... ...8...8...
00003010: 0000 0000 0100 0000 0000 0000 0000 0000  ................
00003020: 0400 0000 0000 0000 0000 0000 0000 0000  ................
00003030: 0000 0000 0000 0000                      ........

Little-endian u32s: fixups_version 0, starts_offset 32, imports_offset 56, symbols_offset 56, imports_count 0, imports_format 1, symbols_format 0. At byte 32: seg_count 4, then seg_info_offset [0, 0, 0, 0] — every segment reports no chains, so no pointer_format or page_start arrays exist at all. imports_offset = symbols_offset = datasize = 56: both tables start exactly where the blob ends — zero records, zero strings. The four zero u32s past the starts table are alignment padding to the declared size.

Why empty? Look at prog.c: one initialized int, one zero-fill int, one static helper, arithmetic between them — every reference is RIP-relative or intra-image, zero undefined symbols (the header carries MH_NOUNDEFS), zero binds, and self-pointers that x86-64's PC-relative code never creates. ld64 still emits the command — modern toolchains always do — with a valid header describing nothing to fix. The arm64 twin repeats the trick: dataoff 32768, datasize 56, same skeleton. Contrast the dylib, which has 644 rebases and 120 binds worth of chains on a real application — its equivalent blob would be full of seg_info_offset values and per-page starts.

Try It

$ xxd -s 12288 -l 56 prog64.macho
00003000: 0000 0000 2000 0000 3800 0000 3800 0000  .... ...8...8...

$ python3 -c "import struct; d=open('prog64.macho','rb').read(); print(struct.unpack_from('<7I', d, 12288))"
(0, 32, 56, 56, 0, 1, 0)

What to look for: unpacking '<7I' gives the header in field order — three of the five middle values are the numbers 32/56/56 you can see repeated as 20 00 … 38 00 … 38 00 in the dump. Then repeat both commands against a dylib built with -fixup_chains on your own machine: imports_count will be non-zero, imports_offset will point inside the blob, and seg_info_offset entries will no longer all be zero.

Check Your Understanding

Without looking back: dyld walks a chain and finds a slot whose lowest bit is 1. What are the other bits?

Apply It

A chained-fixups header reports imports_offset = 56, symbols_offset = 56, and datasize = 56. What do you conclude?

The pattern: every offset in this blob is relative to the blob; equal offsets mean empty regions; imports_count is the count that makes them matter.

Connect

With fixups (rebase and bind) and exports (the trie) resolved, dyld's remaining job on a linked executable is administrative: find its own code path, decide where the process starts, and jump.

Next: The Dynamic Linker and Entry Point — LC_LOAD_DYLINKER names /usr/lib/dyld, LC_MAIN names the entry file offset, and the slide arithmetic turns both into runtime addresses.