Skip to content

Repository files navigation

Machotool for Mavericks

This README has not been read or edited by a human yet. Until it has, this project cannot cut its first release.

Mach-O surgery for hosts too old to have any. Builds with the stock 10.9 clang, no dependencies, and edits binaries produced by toolchains fifteen years newer.

tool job
patch_macho rewrite chained fixups as LC_DYLD_INFO_ONLY, so 10.9's dyld can load the image
change_dylib edit LC_LOAD_DYLIB / LC_RPATH — change, delete, add, insert, re-export — with library-ordinal renumbering; -strip-lc; -grow
add_version_min append LC_VERSION_MIN_MACOSX
fix_macho change install names and strip build version, including in fat binaries
rename_segment __DATA_CONST__DATA, so 10.9's libobjc finds the metadata
retag_swift_classes move the is-Swift tag from the stable-ABI bit to the legacy one

Layout

  • src/ — the shared toolkit library (machorewritecore): image parsing, ULEB, ordinals, fat-arch validation, export-trie rebuild, __LINKEDIT bumping, header growth, LC-kind tables, the atomic-write helper, the dylib/rpath load-command rewriter, the LC_VERSION_MIN_MACOSX appender, the segment rename, and the Swift class-record retag.

  • compat/ — these six tools' entry points. They predate machorewrite and keep their original names because install.sh fetches some of them by name. All six are now /bin/sh wrappers that print the machorewrite equivalent of what they were asked to do and then do it through machorewrite, so machorewrite is the only Mach-O rewriting binary this repo ships and compat/ contains no C at all. fix_macho was the last holdout: wrapping it changes what it does in five ways, and those changes were adopted deliberately rather than papered over — compat/README.md states each with its reason. Also here: translate.sh, the old-grammar-to-machorewrite translator the wrappers source, and machorewrite-compat.sh, the machinery they share. See compat/README.md.

    Packaging note: the six wrappers need machorewrite, machorewrite-compat.sh and machorewrite-translate.sh installed beside them. Anything that fetches patch_macho, change_dylib or add_version_min by name now has three more files to fetch. compat/README.md says what that means for mavericksforever.com/claude/install.sh, which has not been told.

  • cli/machorewrite, the multi-verb CLI built on src/.

  • tests/ — everything ctest runs, plus the fixtures it reads.

Building

cmake -S . -B build && cmake --build build && ctest --test-dir build

Needs shipyard, the family's shared CMake helpers — install it once and it self-registers, so find_package finds it with no CMAKE_PREFIX_PATH:

cmake -S ../mavericks-shipyard -B /tmp/sy -DCMAKE_INSTALL_PREFIX="$HOME/.local"
cmake --install /tmp/sy

Presets pick the build mode:

cmake --preset native   # on 10.9, with its own clang
cmake --preset cross    # on a modern host, against the pinned 10.9 SDK

Every tool is gated by shipyard's compat guard, which fails the build if a binary declares a floor above 10.9 or links a symbol 10.9 lacks. That matters more here than elsewhere in the family: these are the tools that make other binaries loadable on 10.9, so they had better load there themselves.

Build equivalence

There is no 10.9 runner in CI, so a cross-build has to be shown equivalent to a native one rather than assumed to be. Comparing the tool binaries is the wrong test — a 2014 clang and a 2026 one will never emit the same bytes.

These tools are deterministic file transformers, so what they produce is the invariant worth pinning. tests/characterize.sh runs the whole pipeline over a committed fixture and compares the output digest against tests/EXPECTED; ctest runs it. Build natively and cross, and the digests must match.

Two properties make that runnable in CI: the pipeline is deterministic (verified — same input, same flags, identical output), and a cross-built tool is x86_64 with a 10.9 floor, which still runs on a modern host, so the runner can execute what it just built.

Known gap: the committed fixture is a 10.9-built binary, so it has no chained fixups and does not exercise patch_macho's conversion — the heaviest transform in the pipeline. A fixture from a modern toolchain should be added; 10.9's clang cannot emit one.

Why not install_name_tool

On these binaries, 10.9's install_name_tool refuses to open the file:

install_name_tool: file not in an order that can be processed (dyld_info out of place)

It rebuilds __LINKEDIT and expects the 2013-era ordering of its pieces; modern linkers emit a different order, so it bails before touching anything.

The distinction runs deeper than convenience. install_name_tool rewrites the file; these tools never move a byte of data, editing only within existing header padding. That is why -strip-lc and -grow exist, and why a replacement path that is too long is an error here and a non-event with Apple's tool.

machorewrite never writes its input

machorewrite FILE OUT takes its statements on stdin, and it is the only way to modify a binary: FILE is opened read-only and never touched, and the result goes to OUT. (verify FILE and info FILE are read-only and take no OUT.) An OUT that names FILE — the same path, a symlink to it, or a hard link to it — is refused before any work is done. machorewrite --capabilities' output positional=2 never-writes-input line tells a caller to expect this shape rather than assume it.

A successful write gives OUT FILE's permission bits, FILE's owner (best-effort — changing owner needs privilege), and every extended attribute FILE carries (quarantine and the like), then renames a temp file onto OUT: OUT ends up either its previous content or the whole new file, never a partial one, and a symlink at OUT is followed to its target rather than replaced.

The six compat/ wrappers still look like they edit in place, the way the retired C tools did: each writes to a temp file beside FILE and moves it over FILE once the run succeeds. A FILE with more than one hard link is refused up front instead — moving the temp over one name would leave every other name for that inode on the old content, and there is no atomic way to update every name for an inode at once.

Prove it or refuse

-grow makes header room by lowering the image base, which invalidates every structure storing an offset from that base: the LC_FUNCTION_STARTS leading delta, __TEXT,__init_offsets, the export trie, LC_DATA_IN_CODE, and compact unwind. All five are re-based. Anything unrecognised — an unclassified load command, an unknown section type — is refused rather than grown past.

Two independent checks back that up:

  • mg_verify proves every base-relative structure resolves to the same address after the grow as before. A handler that never ran, ran twice, or ran with the wrong delta all look the same to it: a moved address.

  • mg_plausible asks a different question of the finished file — do initializers and unwind entries still land on an address LC_FUNCTION_STARTS lists? It needs no "before" image, so it can be run on a file nothing kept a snapshot of. It runs when there is something for it to re-check: the image carries an LC_FUNCTION_STARTS and the run disturbed the base-relative values those offsets are (src/relations.h's mrel_verify_applies — one expression, shared by every gate site). A grow is such a run, and mg_grow_header runs this check on every image it grows before handing it back.

    What that leaves out is worth stating plainly, because it is easy to read a guarantee into the paragraph above. An edit that fits the existing header pad — a dylib, rpath, load-command or segment statement — disturbs nothing this gate examines, so it is not refused for a property its input already had. Inside one run, fixups set classic (and a target 10.9 that expands to it) does disturb the image base, and such a run is refused rather than written. Across the patch_machoadd_version_minchange_dylib chain it is not: each tool is its own process and carries no record of what the previous one disturbed, so the change_dylib step no longer re-checks the conversion patch_macho performed. src/rewrite.c's comment at the gate has the reasoning and the measurement.

    What no caller can do is switch off a gate that applies: what decides is the image and the operations, never an environment variable. MACHO_NO_VERIFY=1 is what remains of an older, caller-controlled opt-out, and on a machorewrite verb run it no longer changes anything — the one gate that consults it is reached only by a rewrite that grew the header, and mg_grow_header has already run the same check on that image unconditionally.

That gate exists because every defect ever found in this code has been a silent success: the tool reported OK and the binary died in the loader — or worse, didn't.

machorewrite FILE OUT — edit scripts

install.sh-style porting runs several rewrites in sequence — strip a load command, then repoint a handful of dylibs — each of which used to be its own invocation and its own full write of the file. machorewrite takes a script naming every statement instead, applies them all to one in-memory copy, and writes once:

printf 'load-command delete uuid\n' | machorewrite FILE OUT   # statements on stdin
machorewrite FILE OUT < script                                # a script that lives in a file

There is no second spelling. machorewrite edit FILE OUT SCRIPT was one until the edit verb was deleted; the shell's < does what its SCRIPT argument did, and the design's claim that one form is the only way to change a binary is worth more than saving a caller four characters.

FILE is only read, and OUT must not be it — the same file twice, or a symlink or hard link to it, is refused before the script is even read. A successful run always leaves OUT there, even when no statement changed anything: OUT is the answer. To see what a script would do without disturbing anything, give it a scratch OUT — that is the same run, and the result is a file you can inspect rather than a prediction.

It takes no flags at all — its two arguments are FILE and OUT, in that order. There is no -- to end flag parsing, so a FILE whose real name starts with -- is refused as an unknown flag; reference it through a path that doesn't, e.g. ./--name. One leading dash is a file name there, as it was for every historical tool. Not for OUT, though: an OUT beginning with - is refused and says so, because OUT is a file this command creates, so a flag-looking one is a mistake rather than a name. A FILE named like one of the two surviving verbs (verify, info) is spelled ./verify, for the same reason.

Nothing is written unless every statement succeeded. The whole script is parsed before FILE is opened at all, so a typo in the last line of a long script costs nothing. Each statement then runs against the image in memory, in the order written; if any statement is refused, OUT is not written and FILE is exactly as it was found. The finished image is verified after the last statement and before the write — whenever the run disturbed anything that check covers, and with no opt-out when it applies — and only then written, once. A run that disturbed nothing it checks says so instead: nothing this run disturbed is re-checked.

Every run logs, on stderr, what it did — there is no quiet mode, so there is no flag. A tool whose job is to make edits nobody can see afterwards should not have an option to say nothing about them, and anyone who wants silence has 2>/dev/null, which needs no cooperation from machorewrite. The report is each statement as it starts; beneath it, indented, the follow-up work it did that its line does not name — for dylib insert and dylib delete the ordinal renumbering (the command inserted or removed and its ordinal, the old-to-new map, and how many nlist entries and SET_DYLIB_ORDINAL opcodes changed), for fixups set classic the conversion's figures (rebases and binds emitted, the bytes of opcodes and the bytes appended, the commands stripped, how far __LINKEDIT grew) or that an already-classic image passed through, for swift-abi set legacy how many class records it retagged or that there was nothing to retag, for version-min set the LC_VERSION_MIN_MACOSX it appended, and for target 10.9 its whole expansion, line by line, each with the finding that produced it — or that this binary already targets 10.9; then FILE: verified (or FILE: nothing this run disturbed is re-checked, when the run moved nothing the check looks at) and OUT: written (N bytes).

Stderr, not stdout, and that division is load-bearing: the operations' own progress lines go to stdout, where the compat wrappers' callers have always read them, so the report can be unconditional without changing a byte of what any wrapper prints.

On a fat file, each slice is accounted for too. slice NAME: before an edited slice's statements and slice NAME: verified after; slice NAME: not selected by arch; passed through unchanged, slice NAME: 32-bit; passed through unchanged or slice NAME: not a 64-bit Mach-O; passed through unchanged for the rest — whether a slice is a 64-bit Mach-O is decided by its own bytes, never by the cputype its fat_arch entry declares; and, once the slices are laid out again, slice NAME: moved from offset 0x… to 0x… for any slice an earlier slice's growth moved.

The refusal line on stderr and the exit code are what tell you whether the file was written. The operations still print their own progress to stdout as each statement runs — FILE: updated (sizeofcmds=...) and the like — but during an edit run such a line describes the image in memory, not the file. A run refused at a later statement, or at verification, writes nothing, even after printing it. On a fat file the refusal line names the slice too — or, for a statement's own miss (see fatal-warnings, below), says it matched nothing in any selected slice.

The write never touches FILE. machorewrite takes FILE OUT and writes only OUT, by way of a temp file and a rename — see "machorewrite never writes its input", above, for what that guarantees. So whether FILE is writable is not a question it asks either; a read-only (0444) FILE in a writable directory is read just fine, and the run exits 0.

File format

One statement per line. Fields are whitespace-separated, with single- and double-quote grouping and backslash escapes — shell word rules. # begins a comment only at the start of an unquoted field, matching shell: a#b is the literal field a#b, not a followed by a comment. Blank lines are ignored. A CR (or any other control byte except tab) anywhere in a line is a parse error, not a silently-accepted character — so a script saved with CRLF line endings will not parse; use LF.

Statements

load-command  delete    KIND        uuid | codesig | source-version
                                    | build-version | code-sign-drs
segment       rename    OLD NEW
version-min   set       10.9
swift-abi     set       legacy
fixups        set       classic
dylib         replace   OLD NEW
dylib         append    PATH
dylib         insert    PATH
dylib         delete    PATH
dylib         reexport  PATH
rpath         replace   OLD NEW
rpath         delete    PATH
rpath         append    PATH
rpath         insert    PATH

These statements are the whole mutating surface. machorewrite used to carry a second spelling of them — a CLI verb per operation, machorewrite dylib FILE OUT -replace A B beside the line dylib replace A B — and the two had different semantics: a verb applied all its operations in one pass against the original image, while statements apply in sequence, each seeing what the one before left. Maintaining both is what the verbs cost, so they are gone.

grow went with them. It enlarged the header pad by an exact byte count, which no statement expresses; allow-grow, below, is the directive that lets a dylib, rpath or version-min set statement grow the pad as a side effect, which is a different thing from naming a count.

There is one more line, target 10.9, which is neither of those: see "The target statement", below.

Statements run one at a time, in the order written, so each insert goes to the front of the image as the statement before it left it: the lines dylib insert A then dylib insert B leave B at ordinal 1 and A at ordinal 2. rpath insert works the same way, so dyld searches B before A.

Directives

Three, and each must precede every operation in the script — a directive after any operation, not only the one it would have governed, is a parse error:

arch NAME           apply the script only to the slice named NAME (lipo's
                    names: x86_64, x86_64h, arm64, arm64e, i386); repeatable.
                    Without it, every 64-bit slice of a fat file is edited
allow-grow          permission to enlarge the header pad by lowering the image
                    base if new load commands do not fit; opt-in, and refused
                    by default. MH_EXECUTE + MH_PIE only -- the image-base
                    trick needs a __PAGEZERO and no absolute relocations to fix.
                    Covers dylib, rpath and version-min set; not fixups set
                    classic -- nothing grows while the image still has chained
                    fixups, so put fixups set classic first
fatal-warnings      an operation that matched nothing refuses the whole run
                    (exit 1, nothing written) instead of only being reported

The target statement

target        10.9

Every other statement means the same thing for every input — dylib replace A B may match nothing, but what it asks for is fixed. target 10.9 asks a different question of every binary and answers it differently: it is the intent level, arriving as a named line rather than as hidden behaviour.

It expands, where it is written, into statements the language already has — the ones this binary actually needs — and those run in its place:

detected expands to
LC_DYLD_CHAINED_FIXUPS present fixups set classic
LC_BUILD_VERSION present load-command delete build-version
no LC_VERSION_MIN_MACOSX version-min set 10.9
__DATA_CONST carrying __objc_* sections segment rename __DATA_CONST __DATA
class records carrying the stable-ABI Swift tag swift-abi set legacy

Each detection is exact rather than a guess: a load command is present or it is not, a section name begins with __objc_ or it does not, a tag bit is set or it is not. Never dylib or rpath work — no tool can guess which stub dylib you meant, and that is the dominant real workload, so a profile stops where the guessing would start.

Position is not cosmetic, which is why this is a statement and not a flag. fixups set classic rewrites __LINKEDIT and strips load commands, which changes the header pad available to every dylib replace after it, and nothing reorders your statements — the script is the plan. Where you write the line is where the expansion lands:

allow-grow

target 10.9

dylib replace /System/Library/Frameworks/Metal.framework/Versions/A/Metal  @loader_path/libMetalStub.dylib
rpath  insert  @loader_path/../Frameworks

The report lists the expansion line by line, with the finding that produced each, since the same line does different things to different binaries:

  target 10.9
    fixups set classic  (LC_DYLD_CHAINED_FIXUPS present)
    version-min set 10.9  (no LC_VERSION_MIN_MACOSX)

and says nothing to do: this binary already targets 10.9 when the expansion is empty.

The rest of the rules:

  • One target per script. A second is a parse error.
  • An unknown target is a refusal. target 10.10 errors, naming what this build does know, rather than silently doing 10.9's work.
  • The directives still govern the expansionallow-grow reaches a derived version-min set 10.9 exactly as it reaches one you wrote — and if a derived statement is refused, the refusal names the target line, which is the line you wrote.
  • target never counts as unmatched under fatal-warnings, and neither does anything it derived: "this binary already targets 10.9 correctly" is a correct answer for a profile, unlike for an explicit operation. (It happens for real: fixups set classic strips LC_BUILD_VERSION itself, so the load-command delete build-version the same expansion derived finds nothing left to do.)
  • Writing target 10.9 and an explicit statement it would have derived makes the explicit one redundant, and fatal-warnings will flag it. That is right, and is documented rather than special-cased.

Worked example

What install.sh does today as three tools and three full writes of a ~200MB binary:

patch_macho     "$REAL" "$T"
add_version_min "$T"
change_dylib    "$T" -strip-lc uuid -strip-lc codesig \
    -change "/usr/lib/libSystem.B.dylib"  "@loader_path/../S.dylib" \
    -change "/usr/lib/libicucore.A.dylib" "@loader_path/../I.dylib" \
    -change "/usr/lib/libc++.1.dylib"     "@loader_path/../c++.1.dylib"

becomes one script, claude.edits:

# Claude Code -> 10.9
fixups        set      classic
version-min   set      10.9
load-command  delete   uuid
load-command  delete   codesig
dylib         replace  /usr/lib/libSystem.B.dylib   @loader_path/../S.dylib
dylib         replace  /usr/lib/libicucore.A.dylib  @loader_path/../I.dylib
dylib         replace  /usr/lib/libc++.1.dylib      @loader_path/../c++.1.dylib

and one invocation:

machorewrite "$REAL" "$T" < claude.edits

Limits

  • A fat (universal) file is edited slice by slice, and kept whole. With no arch directive every 64-bit slice is edited and everything else passes through; with arch directives, exactly the named slices. Whether a slice is a 64-bit Mach-O is read from the slice's own bytes, not from the cputype its fat_arch entry declares — a container where the two disagree is malformed, and the bytes are the thing being edited. Naming a slice the file lacks, or one that is not a 64-bit Mach-O, is refused. Nothing ever drops a slice — thin a file with lipo if you want one. A 64-bit fat container (fat_arch_64) is refused.
  • allow-grow reaches dylib, rpath and version-min set — the statements whose load commands can outgrow the header pad — and only on a 64-bit PIE executable. It does not reach fixups set classic: growth refuses an image that still has chained fixups. Nor does the conversion need it: it removes whichever of LC_DYLD_EXPORTS_TRIE, LC_DYLD_CHAINED_FIXUPS and LC_BUILD_VERSION are present before adding its 48-byte LC_DYLD_INFO_ONLY, so on a modern chained binary, which carries all three, it frees at least 56 bytes before using 48. On a chained image nothing can grow until fixups set classic has run; put it first. segment rename and load-command delete never need it, since neither adds bytes to the load commands.
  • fatal-warnings covers the statements that can match nothing: load-command delete (no command of that kind), dylib replace/delete/ reexport and rpath replace/delete (no command naming that path), and segment rename (no segment of that name). append and insert always act, and the three set statements treat "already so" as success, so none of those can miss. Neither can target, nor anything its expansion derived (see "The target statement", above). On a fat file, a statement has matched if it matched in any selected slice.
  • MACHO_NO_VERIFY changes nothing about a script run. The check that runs after the last statement, before the single write, has never consulted it and still does not. A dylib/rpath/load-command statement also runs the same per-step plausibility check the shared rewriter runs (see "Prove it or refuse" above); that per-step check is the one place in the tools that reads the variable, and it now applies only to a step that grew the header — which mg_grow_header has already checked, unconditionally, on the grown image. What decides whether a check runs is the image and the operations, never the caller: a run that disturbed nothing it examines skips it, and nothing a caller can set will suppress one that applies.

Notes

  • Not yet a drop-in replacement for insert_dylib on 32-bit input, refused deliberately. See docs/prior-art.md.
  • This repo is its own upstream: the tools are not a port of somebody else's project. UPSTREAM_VERSION is still the family's file, but the version is X.Y.Z with no -mavericks.N suffix — there is no external thing to repackage, so there is no repackaging axis to carry. No Renovate customManager watches it either, because nothing external releases it. See INGREDIENTS.md.

Provenance

Extracted with full history from Wowfunhappy/Mavericks-Porting-Resources. patch_macho and fix_macho are substantially Wowfunhappy's; header growth, the re-basers, ordinal renumbering and verification are Amitai Schleier's. The commit log is the accurate record. Details, including what was extracted and from where: PROVENANCE.md.

Four commits in that history also touched files that stayed behind, so their messages mention unrelated work. The changes are correct; only the messages are wider than their diffs.

About

Mach-O binary editing tools for Mac OS X 10.9 Mavericks

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages