Back to course

How dyld Loads a Mach-O

20 minadvancedModule 8: Loading & ExecutionContinues from macho-hardening
20 min · Module 8: Loading & Execution

Why This Matters

Between the shell's execve and the first line of your main() sits dyld, the dynamic linker — the only component that reads every load command you have studied and acts on them. Segments get mapped, dependencies chased, pointers fixed, exports published, initializers run, entry jumped to. A load command nobody reads is dead bytes; dyld is the reader.

It also explains most load-time mysteries: why a missing dylib fails before any of your code runs, why a function pointer is wrong until "fixups" happen, why constructors fire in an order you did not choose, and why an unknown command marked LC_REQ_DYLD can stop the image from loading at all while an unknown optional command is silently ignored.

A Simple Model

Seven stages, each backed by a load command we can point at in our samples:

  1. The kernel hands over. It maps the segments declared by LC_SEGMENT_64, reads LC_LOAD_DYLINKER, maps /usr/lib/dyld, and transfers control to it.
  2. dyld maps the executable at its preferred base (0x100000000) and picks the ASLR slide promised by MH_PIE — the same offset will be applied to every dependency.
  3. Closure. Walk LC_LOAD_DYLIB (plus weak and reexport variants) depth-first until the dependency set stops growing; LC_RPATH rows extend the run path used to resolve @rpath-prefixed names.
  4. Fixups. Modern files: one LC_DYLD_CHAINED_FIXUPS blob. Classic files: LC_DYLD_INFO_ONLY's rebase/bind/lazy/weak opcode streams. Either way: make every pointer name the right final address.
  5. Exports and symbols. Publish the image's exported names via LC_DYLD_EXPORTS_TRIE (or the export region inside dyld_info); LC_SYMTAB and LC_DYSYMTAB hold the full symbol view for tools.
  6. Initializers. Run S_MOD_INIT_FUNC_POINTERS sections, ObjC +load methods, Swift static init — the slot LC_ROUTINES ("image routines") reserved in the old format.
  7. Jump. Compute __TEXT.vmaddr + LC_MAIN.entryoff + slide and jump there. The startup sequence is the next concept; this one ends at the jump.

The model's missing piece: stages 4 through 6 leave no line in otool — their evidence is the offsets and formats you decoded in earlier concepts. And dyld's shared-cache bootstrap (how /usr/lib/libSystem.B.dylib materializes without a loose file) lives outside our samples: we can see the request for that dylib, never the cache itself.

The Actual Detail

Our two samples sit on opposite sides of a format-era split. Same pipeline, different evidence — every cell read from the files:

Stage evidenceprog64.macho (modern)libasyncProfiler.dylib (classic)
Image / flagsEXECUTE, ncmds 14 — flags include MH_PIE (random base)DYLIB, ncmds 18 — no MH_PIE; flags: NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK NO_REEXPORTED_DYLIBS
Dynamic linkerLC_LOAD_DYLINKER → /usr/lib/dyldnone — dylibs are loaded, not launched ("a file can have at most one of these")
EntryLC_MAIN, entryoff 1104none — LC_MAIN is "only used in MH_EXECUTE filetypes"
Dependenciesnone — no LC_LOAD_DYLIB at all/usr/lib/libSystem.B.dylib, /usr/lib/libc++.1.dylib
Run pathsnoneLC_RPATH: @executable_path/../lib and @executable_path/../lib/server
Pointer fixingLC_DYLD_CHAINED_FIXUPS, 56 bytes at 12288LC_DYLD_INFO_ONLY: rebase 573440/248, bind 573688/2664, lazy 577472/4656
ExportsLC_DYLD_EXPORTS_TRIE, 96 bytes at 12344export region 582128/1432 inside dyld_info
Full symbolsLC_SYMTAB, nsyms 6LC_SYMTAB, nsyms 2015 (strings at 619424, 87936 bytes)

The dylib also shows the install-name machinery the closure stage consumes: LC_ID_DYLIB names itself build/lib/libasyncProfiler.dylib, and each LC_LOAD_DYLIB carries an absolute path — no rpaths needed for those two, but the two LC_RPATH commands are there for any @rpath-prefixed dependency. rpath_command's comment is exact: "a path which at runtime should be added to the current run path used to find @rpath prefixed dylibs."

LC_REQ_DYLD, the stage gate. loader.h: if dyld meets a load command it does not understand that has the LC_REQ_DYLD bit, it errors "unknown load command required for execution" and refuses the image; unknown commands without the bit are ignored. Both era-marks above — LC_DYLD_INFO_ONLY and LC_DYLD_CHAINED_FIXUPS — carry the bit: neither generation can half-load.

A Real Example

The modern fixup stage, in full: LC_DYLD_CHAINED_FIXUPS's 56-byte header at file offset 12288, decoded field by field against fixup-chains.h:

00003000: 0000 0000 2000 0000 3800 0000 3800 0000  .... ...8...8...
00003010: 0000 0000 0100 0000 0000 0000 0000 0000  ................

fixups_version 0, starts_offset 0x20, imports_offset 0x38, symbols_offset 0x38, imports_count 0, imports_format 1, symbols_format 0. That imports_count of zero is the whole file in one number: this program links no libraries (no LC_LOAD_DYLIB), so it imports no symbols — only rebase chains, relocating code and data within its own image. The header's two length fields even agree: starts begin at 0x20, imports would begin at 0x38, and 56 bytes of payload end exactly at 0x38 with nothing between to count.

Compare the classic side of the same stage — five offset/size pairs dyld walks in order (rebase, bind, weak_bind, lazy_bind, export), then LC_SYMTAB's nsyms 2015 for everything else. Two eras, one pipeline stage.

Try It

Watch both halves of the pipeline declare themselves — on macOS use otool, on Linux llvm-otool:

$ llvm-otool -l prog64.macho | grep -A2 LC_LOAD_DYLINKER
          cmd LC_LOAD_DYLINKER
      cmdsize 32
         name /usr/lib/dyld (offset 12)

$ llvm-otool -L libasyncProfiler.dylib
libasyncProfiler.dylib:
	build/lib/libasyncProfiler.dylib (compatibility version 0.0.0, current version 0.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.100.3)
	/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1500.65.0)

$ llvm-otool -l libasyncProfiler.dylib | grep -A11 LC_DYLD_INFO_ONLY
            cmd LC_DYLD_INFO_ONLY
        cmdsize 48
     rebase_off 573440
    rebase_size 248
       bind_off 573688
      bind_size 2664
  weak_bind_off 576352
 weak_bind_size 1120
  lazy_bind_off 577472
 lazy_bind_size 4656
     export_off 582128
    export_size 1432

What to look for: the executable names its dynamic linker and nothing else to load; otool -L's first line is always the dylib's own install name (its LC_ID_DYLIB), the rest are stage-3 closure edges; and the five dyld_info pairs are the entire classic fixup-plus-exports stage in twelve lines. Every offset points into __LINKEDIT — where the map lesson said this metadata lives.

Check Your Understanding

Without looking back: which load command tells the kernel and dyld where the dynamic linker itself lives, and what does prog64.macho name?

Apply It

Suppose an executable declares LC_RPATH @executable_path/../lib and links a dependency named @rpath/libFoo.dylib. How does dyld resolve it?

The pattern: every stage of the pipeline has a load command as its receipt — dylinker, dylibs, rpaths, fixups, exports, init sections, entry. Read the commands and you have read the algorithm.

Connect

dyld has now consumed the entire format: the protections it mapped, the rebase and bind streams or chained fixups it executed, the export trie it published, and the LC_MAIN entry it is about to jump to. But "the image is loaded" still hides a question every crash report depends on: at which addresses does all this actually exist right now?

Next: Process Memory Layout — preferred addresses, the slide, and the three coordinate systems (file offset, vmaddr, runtime address) that every bug report mixes together.