machoedit
The Apple counterpart of elfedit: rewrite the linking metadata of a
Mach-O binary so it can be relocated into a Nix-like store, then re-sign
it so arm64 will actually run it.
Why this is not just “patch some bytes”
On elfedit the job is PT_INTERP + DT_RUNPATH + DT_NEEDED. The
Mach-O map is:
elfedit (ELF) | machoedit (Mach-O) |
|---|---|
set_interpreter | — (dyld is not relocatable, always system) |
set_runpath | add_rpath (LC_RPATH) |
add_needed (DT_NEEDED) | change_dylib (LC_LOAD_DYLIB install name) |
| — (n/a on ELF) | set_id (LC_ID_DYLIB) |
| — (n/a on ELF) | ad-hoc re-signing (LC_CODE_SIGNATURE) |
The last row is the whole point. On Apple Silicon, every Mach-O must
carry a valid code signature or the kernel SIGKILLs it at exec.
Editing a single byte invalidates the signature, so every edit is
followed by a full ad-hoc re-sign: a CS_ADHOC CodeDirectory whose
code slots are SHA-256 hashes of each 4 KiB page. Ad-hoc signing needs
no certificate and no Apple tooling, so it is implemented here in pure
Rust and works cross-platform (you can relocate arm64 macOS binaries
from a Linux builder).
Design
Every edit fits inside the header padding the linker leaves between the
load commands and the first section (build with
-headerpad_max_install_names). Section data never moves, and the code
signature keeps its file offset (codeLimit is constant), so the file
keeps its length: we rewrite the load-command region in place, zero the
freed padding, and recompute the signature. Time/space cost is
independent of the binary size, like elfedit.
Performance
Size-independent, like elfedit. A full edit + re-sign (open → set_id
→ add_rpath → update) is flat from a 16 KiB to a 129 MiB dylib
(release, Apple M1, warm pages):
| dylib size | edit + re-sign |
|---|---|
| 16 KiB | ~90 µs |
| 4 MiB | ~155 µs |
| 32 MiB | ~240 µs |
| 129 MiB | ~230 µs |
The mild rise is open’s mmap+parse setup, not the edit or the signing,
both of which are constant. Two subtleties make this work:
- We hash only the pages the edit dirtied (the header pad) and copy every other CodeDirectory slot untouched — never a whole-file re-hash.
- macOS caches code-signature validation per vnode, so an in-place mmap
write leaves it stale and the kernel
SIGKILLs whoever maps the “tainted” page (even thoughcodesign --verifypasses on disk!). Wemsync(MS_INVALIDATE)only the dirtied pages to force re-validation, instead of copying the file to a fresh vnode (which would be O(size)). This syscall is the bulk of the fixed ~200 µs cost — the macOS signing tax that ELF simply does not pay.
Reproduce: cargo run --release --example time -- <dylib> (clean
per-op numbers) or MACHOEDIT_FIXTURES=<dir> cargo bench (criterion).
Status
Working and validated on real Apple Silicon (macOS 26, arm64): edits are
accepted by otool, the re-signed binary passes Apple’s own
codesign --verify (“valid on disk / satisfies its Designated
Requirement”) and dlopens and runs. See tests/macos_codesign.rs
(gated on target_os = "macos"); the pure-computation tests run
everywhere.
Real-world coverage
Swept over a representative sample of the ecosystem — ~12 Homebrew bottles
(openssl, python, git, gettext, coreutils, ripgrep, …), 252 Mach-O files —
with each relocated, re-signed and then re-checked by Apple’s codesign
(examples/scan.rs). 249/249 non-bundle Mach-O pass. The sweep forced
two fixes the toy fixture had hidden:
- Special slots. Every real binary is ad-hoc-signed with
nSpecialSlots=2(Info.plist + Requirements); v1 only handled 0. Since we only ever rewrite code slots, preserving the (untouched) special slots is enough — we just stopped rejecting them. - Page size is per-binary. Apple Silicon signs in 16 KiB pages, not
4 KiB; the CodeDirectory declares it (
pageSize= log2). We now read it from each file instead of assuming.sample_signed.dylib+real_world_signature_verifies_and_survives_editlock this in.
The 3 remaining rejects are app/framework bundles (“code has no resources but signature indicates they must be present”) — see the scope note below.
Not yet
- App/framework bundles. This tool signs a Mach-O, not a bundle.
Re-sealing a bundle’s resource envelope (
CodeResources,Info.plist) is a bundle-level operation (codesign --deep); handle it separately. - Fat/universal binaries (
lipo -thin arm64first). Note thecafebabemagic is shared with Java.classfiles — disambiguate onnfat_arch. - Adding an
LC_CODE_SIGNATUREto a binary that lacks one (all arm64 binaries already have one). - Growing beyond the header pad (would require relocating section data,
as
install_name_tooldoes without-headerpad_max_install_names). - Certificate-based signing (a Designated Requirement that survives even a version change). Deliberately not done: for a content-addressed store, a content change should produce a new identity, and a cert would be a trust anchor to manage — an impurity. See “Stable identity” below.
Stable identity, without a certificate
Consent (TCC, Gatekeeper, library validation) is keyed on the code identity — the cdhash — not the file path. Ad-hoc signing that changes the cdhash on every build is what creates the AWS EC2 Mac consent-fatigue problem (root is restored each boot, so a grant baked against a churning identity never sticks).
machoedit’s ad-hoc signing is deterministic: code slots are plain
SHA-256 of the file’s pages, with no timestamp or nonce. So identical
store content yields an identical cdhash across rebuilds — and
cdhash() returns exactly the value Apple computes (CandidateCDHashFull;
cdhash_short() is the 20-byte form used in cdhash H"…" requirements).
Elpe can bake that hash into a PPPC profile shipped in the AMI, and the
grant persists as long as the package content does — no certificate, no
per-build churn. A version update changes the content and hence the
hash, which is correct: it is genuinely a different binary. Verified
against Apple’s tooling in tests/macos_codesign.rs.