Run Apple's own macOS command line tools on iOS.
macOS ships a large collection of low-level diagnostic and introspection tools —
ioreg, lsmp, heap, vmmap, ... and dozens more.
Their iOS counterparts either do not exist, are stripped down,
or are simply not shipped on the device.
iOS and macOS are the same operating system underneath.
The binaries are the same architecture (arm64e), link against the same
frameworks, and call the same kernel. What actually stops a macOS binary from
launching on an iPhone is a handful of metadata fields: the Mach-O says
"I am for platform macOS", a few framework paths carry a macOS-only
Versions/A/ component, and the code signature is not one the device will
accept.
machomorph rewrites exactly those fields and re-signs the result.
It also brings the libraries along. Where a binary needs something iOS does not ship, machomorph works out its whole dependency closure, lifts each missing library out of the macOS dyld shared cache — where most of them exist as no file at all — repairs it so it actually runs standalone, and stages it beside the binary. One command, one binary, everything it needs:
./machomorph.py /usr/bin/csrutil -o out/csrutil -p ios -v 27.0That is the primary way to use it. The batch case is the same mechanism pointed
at a whole system: rebuild_cryptex.sh fills an SRD cryptex with every
portable macOS tool and every library they need. TL;DR for SRD users, point it
at the IPSW of the build on the device and enjoy the new tooling:
./scripts/rebuild_cryptex.sh --ipsw ~/Downloads/iPhone18,3_27.0_24A5424a_Restore.ipsw \
/path/to/cryptexPython 3.9+ and macOS, and no Python packages at all — machomorph.py is one
stdlib-only file.
machomorph.py itself shells out to exactly two things, both from macOS:
| tool | what for | if missing |
|---|---|---|
/usr/bin/codesign |
re-signing, since anything moved invalidates the signature | conversion fails (--no-sign skips it, leaving an invalid signature) |
xcrun --sdk … --show-sdk-path |
locating the SDK stubs the launch prediction reads | the prediction is skipped, with a note on stderr, and a binary that cannot launch is still ported |
Everything else the tool replaces — lipo, cbv, install_name_tool,
otool, ldid — is reimplemented, which is the point of the project.
Lifting a library out of the dyld shared cache needs more, because it is a different job from retargeting a file:
| tool | needed by | what for |
|---|---|---|
ipsw |
lifting a library out of the cache | reading the cache: dyld slide, dyld patches, dyld extract. brew install blacktop/tap/ipsw |
clang + Xcode |
lifting a library out of the cache | building native/dsc_extract against Apple's dsc_extractor.bundle, once |
srdtool |
scripts/rebuild_cryptex.sh, scripts/device_probe.sh |
installing the cryptex on a Security Research Device, and spawning on it |
idevicecrashreport |
reading crash reports after a probe | libimobiledevice; brew install libimobiledevice |
scripts/rebuild_cryptex.sh degrades rather than failing when ipsw is
absent: it skips the bundled libraries and says so. Converting a binary that
needs no library from the cache needs none of this.
machomorph.py INPUT... [-o OUTPUT] -p PLATFORM -v VERSION [options]
machomorph.py --scan [DIR...] --cryptex DIR -p PLATFORM -v VERSION [options]
A full run looks like this:
$ ./machomorph.py /usr/sbin/ioreg -o ioreg_ios -p ios -v 27.0
Thinned to arm64e (74816 bytes)
Original build version: macOS 26.6.0 (sdk 26.6.1)
Converted to: iOS 27.0.0 (sdk 27.0.0)
path: /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
-> /System/Library/Frameworks/CoreFoundation.framework/CoreFoundation
path: /System/Library/Frameworks/IOKit.framework/Versions/A/IOKit
-> /System/Library/Frameworks/IOKit.framework/IOKit
Rewrote 2 path(s)
Signed with identity '-' (no entitlements)
Output to ioreg_ios
In one invocation that: picked the arm64e slice out of the universal binary,
retargeted the Mach-O at iOS 27.0, fixed the arm64e pointer-authentication
cpusubtype, stripped the macOS-only Versions/A/ from both framework paths,
carried over the binary's existing entitlements, and re-signed ad hoc.
Platforms: macos ios tvos watchos bridgeos maccatalyst driverkit visionos
(and the matching simulators).
Useful options:
-
--no-libraries— do not bring along the libraries the target lacks. By default every conversion works out its dependency closure and lifts what is missing; see Bundling the libraries iOS lacks for--lib-layout,--max-libs,--dry-runand the rest. -
--info— dump arch, platform, linked libraries and entitlements, then exit. -
-a, --arch ARCH— which slice to take out of a fat binary. Defaults to arm64e, then arm64, then the only slice present. -
--change OLD NEW— rewrite one dylib install name or rpath. Repeatable, for the cases where a library genuinely lives somewhere else on iOS. -
--no-auto-paths— keep….framework/Versions/A/…paths as they are. -
--license-to-operate— addresearch.com.apple.license-to-operateto the entitlements before signing. Required for entitled binaries on the SRD. -
--entitlements FILE— sign with these entitlements instead of the ones already in the binary. -
--dump-entitlements FILE— also write out the entitlements actually used, so you can inspect or edit them. -
--sign-identity ID/--identifier ID— passed through tocodesign. Default identity is-(ad hoc). -
--no-sign— skip signing. The output will have an invalid signature. -
--no-cpusubtype-fix— leavecpusubtypealone. -
--dylib-index FILE— a list of the library paths the target can actually load, produced bydsc.indexfrom the target's dyld shared cache. With it, a library is resolved to wherever it really lives on the target (iOS demotes several public macOS frameworks toPrivateFrameworks, for instance), and anything absent there is reported before you ever copy the binary over. An iOS conversion usesdata/ios27_24A5424a_index.txtby default, and says which index it is using; this flag overrides it. Without an index there is no way to know that iOS keepsDiskArbitrationinPrivateFrameworks, and flatteningVersions/Aalone produces a plausible path that does not exist — reported as a successful rewrite, and failing on device as a missing library. It also blinds half the launch prediction, since a library can only be judged absent when there is a list to judge it against. -
--no-dylib-index— do not fall back to that shipped index. Paths are then rewritten by rule alone, and nothing can be said about what the target has.To build an index for another iOS build, hand
dsc.indexthe IPSW and it runsipsw extract --dylditself (the extraction is cached under/tmp/machomorph-ipsw, so only the first call pays for it):python3 -m dsc.index iPhone18,3_26.4_23E246_Restore.ipsw -o data/ios264_23E246_index.txt
It also accepts the directory
ipsw extract --dyldwrote, or the cache file itself../scripts/rebuild_cryptex.sh --ipsw FILE <cryptex>does the same thing for a whole build. -
--target-symbols FILE— what each of those libraries exports, produced bydsc.symindexfrom the same cache. The launch prediction otherwise reads the target's surface from the SDK's.tbdstubs, and the SDK ships stubs for/usr/liband the public frameworks only — no PrivateFrameworks. So a symbol from one is reportedunknown, which fails no binary and weakens nothing, and that is how aDiskManagementwith a hard import of_DAUnregisterApprovalCallbackshipped and tookcsrutildown. With the index the answer forDiskArbitrationis as good as the one forlibSystem.python3 -m dsc.symindex iPhone18,3_27.0_24A5424a_Restore.ipsw -o ios27_symbols.txt.gz
It is not shipped in
data/— 4.7 million symbols, 37 MB gzipped — sorebuild_cryptex.sh --ipsw FILEbuilds and caches it beside the dylib index, and a baremachomorph.pyrun says that it is working without one. -
--weak PATH/--weaken-missing— rewriteLC_LOAD_DYLIBtoLC_LOAD_WEAK_DYLIBso a binary launches even though a library is absent. The load command is rewritten, never deleted: chained-fixup binds address their library by ordinal, so removing one silently re-points every later import at the wrong library.
Three real tools, in increasing order of how much work they needed. Each one is a step further from "retarget the header" and closer to "supply what iOS does not have".
1. ioreg — nothing to fix. Every library it needs is already on iOS, at a
path one Versions/A strip away. This is the whole conversion, and it is the
run shown above:
./machomorph.py /usr/sbin/ioreg -o ioreg_ios -p ios -v 27.0Most of the ported tree is this case. Look before you convert — --info prints
the architecture, target platform, linked libraries (flagging macOS-only paths)
and entitlements:
./machomorph.py --info /usr/sbin/ioreg2. vmmap — needs a library iOS does not have, so lift it out of the cache.
--info shows the problem on the first line of its library list:
[dylib] /usr/lib/libxcselect.dylib
[dylib] .../CoreSymbolication.framework/Versions/A/CoreSymbolication <- macOS-only path
libxcselect is the "where is the active Xcode" resolver. iOS has no such
thing and ships no such library, and 106 macOS tools link it. It exists on disk
nowhere — Xcode ships only a .tbd text stub, which carries no code — so the
macOS shared cache is the only copy there is. Nothing has to be said about
that: converting vmmap lifts it.
./machomorph.py /usr/bin/vmmap -o out/vmmap -p ios -v 26.0The real library works unmodified on iOS: it looks for a developer directory,
does not find one, and returns false, which is the "carry on without Xcode"
path every caller already takes. This is what makes vmmap, heap, leaks,
atos and four more run on device.
3. tcpdump — needs several libraries, transitively. It wants
libcrypto.46.dylib and libssl.48.dylib, which iOS genuinely lacks, and
libcrypto in turn wants TrustEvaluationAgent. The closure is recursive and
so is the pass:
./machomorph.py /usr/sbin/tcpdump /usr/bin/openssl -p ios -v 26.0 \
--cryptex /path/to/cryptex --cryptex-libdir lib --loader-pathEach one is lifted, not extracted, and that is the difference that matters. A
raw cache extraction's code still reaches a cache-wide GOT that will not exist
in the process — enough for tcpdump to capture and dissect traffic, and not
enough for tcpdump --version, which reads a string out of a global. What a
lift repairs is
below.
--dry-run prints the closure and stops, which is how you find out what a tool
would cost before committing to it — curl needs 0.7 MB, system_profiler
needs 58.8 MB of AppKit (and is refused by --max-libs), and csrutil needs
five libraries that as lifted would each reserve 1.3–2.0 GB of contiguous
address space, which is what compaction exists for.
Other things you may need. Rewrite a library that really does live elsewhere on iOS:
./machomorph.py mytool -o mytool_ios -p ios -v 27.0 \
--change /usr/lib/libfoo.dylib /usr/lib/system/libfoo.dylibConvert an entitled tool, keeping its entitlements and adding the research one (required for entitled binaries on the SRD, and added automatically whenever the binary already carries entitlements):
./machomorph.py /usr/bin/vmmap -o vmmap_ios -p ios -v 27.0 \
--license-to-operate --dump-entitlements ents_vmmap.xmlGo the other way, running an iOS-only binary on macOS:
./machomorph.py lsdiagnose -o lsdiagnose_mac -p macos -v 15.0To find every portable tool and library on this Mac, convert them, and add them to a cryptex:
./scripts/rebuild_cryptex.sh --ipsw ~/Downloads/iPhone18,3_27.0_24A5424a_Restore.ipsw \
/path/to/cryptex
srdtool cryptex install /path/to/cryptex--ipsw is part of the shortest working command, not a refinement. It
names the iOS build actually on the device, and everything that decides what
can be ported is read out of that build's own shared cache: which libraries the
target has (the dylib index) and what each of them exports (the symbol index).
Without it the build falls back to the index shipped in data/, which is one
particular build, and to the SDK's .tbd stubs, which describe no
PrivateFramework — so a symbol from one comes back unknown, which fails no
binary and weakens nothing. That is exactly how a DiskManagement with a hard
import of _DAUnregisterApprovalCallback shipped and took csrutil down. The
extraction and both indexes are cached under /tmp/machomorph-ipsw, so only
the first build on a given IPSW pays for them.
That single command does the lot:
- Removes only what a previous run of this tool put there. Anything else in
the cryptex — binaries someone built natively for iOS — is left untouched,
tracked through a
.machomorph-manifestand enforced by--no-clobber. - Scans the macOS system directories and the Xcode toolchain, skipping the 95 xcrun shims, the binaries a device probe measured dying on a missing symbol, and everything the Xcode blocklist calls a compiler rather than an inspector. All three are machomorph's own defaults for a scan, not arguments this script passes. Symlink and hard-link aliases are reproduced as links. Every library iOS lacks is lifted out of the shared cache and staged as part of this step, because the binaries that need it say so — there is no list of libraries in the script.
- Stages the toolchain's own
@rpathdylibs (libcodedirectory,libLTO). - Copies the data trees no load command mentions: the perl module trees (core
and Extras, retargeting every XS
.bundle) and zsh's modules. zsh also needs itsfunctionstree and a shipped.zshenv, because itsmodule_pathandfpathdefaults are absolute macOS paths compiled into the binary — see "Running the ported tools on the device". - Stages a trust store, since iOS has no
/etc/sslat all. - Runs
cryptex.verifyand exits non-zero if it fails. An install cycle costs minutes and a reboot, and most of the mistakes this project has actually made are visible in the staged tree first.
Roughly 470 binaries plus 50 aliases, ~400 MB. The first run pays for the
lifts; they are cached in lifted/ and re-made only when the lifting code is
newer, so a second run is minutes.
Give it several inputs, or --scan to walk the system directories
(/usr/bin /usr/sbin /bin /sbin by default; pass your own, add
--scan-recursive to descend). --cryptex DIR stages the results into a
cryptex tree — binaries into DIR/bin, bundled libraries into DIR/usr/lib —
which is what srdtool cryptex install wants:
./machomorph.py --scan -p ios -v 26.0 --cryptex ~/srd/combined \
--weaken-missing --keep-goingA scan applies three things by default, because they are what makes the result usable rather than a matter of taste:
- the Xcode toolchain is scanned too —
/usr/bin/otool,nm,lipoandstripare one hard-linked xcrun stub, not tools, so a sweep that takes them and leaves the real llvm binaries behind has picked the wrong half; data/exclude_xcrun_shims.txtdrops those 95 stubs, by path;data/blocklist_symbols.txtdrops the binaries a device probe measured dying at launch on a symbol iOS does not export.
--no-scan-xcode and --no-exclude-defaults turn them off, --exclude-from
adds more lists, and each is announced on the way past. The lists apply only
to what a scan finds — a path named on the command line is always converted,
since silently dropping a binary someone asked for by name would be
indefensible.
That converts every executable Mach-O it finds (572, on a current macOS) and ends with a summary of what will not load anyway, ranked by how many binaries each absent library blocks — the list worth working down, because one replacement can unblock a whole group:
===== 733 binaries: 674 ready, 59 with libraries missing on the target, 0 failed
missing libraries, most-blocking first:
12 /System/Library/PrivateFrameworks/…/OpenDirectory
dscacheutil, dsconfigad, id, groups, …
The bin and lib directories are configurable with --cryptex-bindir and
--cryptex-libdir. --keep-going stops one bad binary from ending the batch.
On macOS, /usr/bin/otool, nm, lipo and strip are not tools — they are
the same 118640-byte stub, which looks up Xcode's copy of itself and execs it.
The real binaries live in the toolchain, and they port to iOS cleanly: 105 of
the 108 executables in XcodeDefault.xctoolchain/usr/bin resolve every
dependency against the iOS cache.
Any --scan takes them, skipping the ones that exist to build code rather
than inspect it:
./machomorph.py --scan -p ios -v 26.0 --cryptex ~/srd/combined \
--weaken-missing --keep-going--no-scan-xcode leaves the toolchain out; --scan-xcode on its own scans the
toolchain and nothing else. --list-skipped shows what a run would take and
what it would drop before you commit to it; --exclude GLOB drops more;
--no-xcode-blocklist takes everything, including clang (141 MB) and
swift-frontend (171 MB).
The blocklist is by purpose, not by name prefix — worth knowing if you edit
it, because the tools you want are themselves llvm binaries: otool is
llvm-otool, nm is llvm-nm, objdump is llvm-objdump. What
survives is the inspection set: the Mach-O dumpers and editors, strings, the
symbol and demangling tools (nm, c++filt, swift-demangle), dyld_info,
the DWARF tools, and lipo/size.
Two toolchain dylibs have to come along, libcodedirectory.dylib and
libLTO.dylib (which carries LLVM's disassembler — dyld_info calls into it).
Their rpath is @executable_path/../lib/, so they need no rewriting, just
staging in the right place:
./machomorph.py <toolchain>/usr/lib/lib{codedirectory,LTO}.dylib \
-p ios -v 26.0 --cryptex ~/srd/combined --cryptex-bindir lib \
--dylib-index ios27_index.txtmachomorph resolves @rpath/... against the binary's own LC_RPATH entries and
the staging directory, so once they are in place it stops reporting them as
missing.
This happens on its own, and it is most of what the tool does. For every binary it converts, machomorph walks the dependency closure, asks the target's dylib index which of those libraries the target actually has, and for each one it does not:
- gets a local copy — from
--prebuilt, from the macOS filesystem if the library is a real file, or by lifting it out of the shared cache, which is the only copy of most of them; - compacts it, so it reserves its own size rather than the 1.3–2.0 GB of contiguous address space the cache's layout implies;
- stages it into the output, rewriting its
LC_ID_DYLIBand every reference between bundled libraries; - repoints the binary at it with a
@loader_path-relative name, and weakens the reference so the binary still launches if the library somehow fails.
Where the libraries land is the output directory, mirroring the target's own spelling of each path, so the result reads as a small root filesystem.
--lib-layout flat puts them all beside the binary instead (shorter per
reference, which matters — the load-command area is fixed, and tcpdump has
16 bytes of slack for its two), --lib-subdir lib puts them in one
subdirectory, and --libs-into DIR moves the whole tree elsewhere.
--cryptex is always flat, into --cryptex-libdir.
Two things it refuses to guess at:
--max-libs N(default 7). It gates what to lift, not what a binary is allowed to see: a binary is always repointed at every library that ended up staged, whoever's closure paid for it. A larger closure means the binary is dragging in a whole macOS subsystem that cannot work on iOS anyway —system_profilerwants AppKit, SkyLight, HIToolbox and OpenGL, which is the macOS window server; it would load and have nothing to talk to. Such a binary is still converted, and simply reports its libraries as missing.--dry-runprints each closure with its size and address-space cost and stops.--weaken-unresolvable. A bundled library that imports a symbol the target does not export cannot load, and there is nothing to fix — iOS has no equivalent of Authorization Services or theSecTransformpipeline at all. The choice is between not shipping the library and binding those symbols NULL, so that it loads and only a path that reaches one of them crashes. That is a judgement about what the tool needs, so it is opt-in, and every symbol it weakens is named in the output. It applies to bundled libraries only: for one of the binaries being converted the same trade turns a clean skip into a crash later, and--forceis how you ask for that instead.
--no-libraries turns the whole pass off. --also PATH bundles a library even
though the target has one of the same name, for when the target's build has a
smaller export surface.
--provide-lib OLDPATH FILE is still there for a library you built yourself:
it copies FILE into the cryptex library directory, repoints every reference to
OLDPATH at it, and machomorph then checks the substitution actually holds up: it reads which
symbols the binary imports from OLDPATH (out of the two-level-namespace
library ordinal in the symbol table) and which the replacement exports (out of
its LC_DYLD_EXPORTS_TRIE), and warns about the gap — a symbol that is imported
but not exported means the binary loads and then crashes if it calls it. On a
batch it also summarises who needs what:
symbols used from /usr/lib/libxcselect.dylib:
94 _xcselect_invoke_xcrun
DeRez, GetFileInfo, ResMerger, Rez, SetFile, SplitForks, +88 more
9 _xcselect_get_developer_dir_path
atos, filtercalltree, heap, kmutil, leaks, malloc_history, +3 more
which is usually the fastest way to see which binaries a replacement genuinely serves.
If an input is a symlink to another input, it is reproduced as a relative
symlink rather than converted twice — otool -> llvm-otool stays a link, and
clang++ keeps the argv[0] that tells it to be a C++ driver.
The rewritten reference is @executable_path-relative, never absolute: the
cryptex mount point on the device carries a per-install random suffix, so an
absolute path would break on the next install. machomorph derives that name
from the cryptex layout and warns if the dylib's own LC_ID_DYLIB disagrees.
A library can only reach the device inside the cryptex — the trust cache is
keyed by cdhash and built at install time from the staged directory, so copying
a dylib over with scp does not work. Adding one means reinstalling the cryptex.
/usr/lib/libxcselect.dylib resolves "where is the active Xcode install". It is
the single most common missing library for ported macOS tools, and iOS has
neither the library nor the concept — nor does it exist as a file anywhere:
Xcode.app and the Command Line Tools ship only a .tbd text stub, which
carries no code. The shared cache is the only copy.
Converting any of the nine tools that need it lifts it out of there, which was impossible until the cache-uniqued GOT repair existed. To lift it on its own, name it as the input:
./machomorph.py /usr/lib/libxcselect.dylib -o lifted/libxcselect.dylib \
-p ios -v 26.0 --change /usr/lib/libxcselect.dylib \
@loader_path/../lib/libxcselect.dylibThe reason a macOS library helps at all on iOS is that its answer there is the
honest one. xcselect_get_developer_dir_path looks for DEVELOPER_DIR, then
the /var/select/developer_dir, /var/db/xcode_select_link and
/usr/share/xcode-select/* symlinks; on iOS there are none, so it returns
false, and every caller guards on exactly that:
bl _xcselect_get_developer_dir_path
cbz w0, <skip the respawn into Xcode's copy of this tool>
so the tools carry on and do their own work, with no environment variable
needed. DEVELOPER_DIR is honoured if you set one.
Lifting the real library is confirmed on the SRD — vmmap and heap both run
against it. That mattered to check, because the real library imports 63
libSystem symbols and a single unresolvable one would stop it loading, taking
all nine tools with it. Every one of them exists on iOS.
The cryptex is mounted at a path with a per-install random suffix, but the shell on the device already has it in the environment:
echo $CRYPTEX_MOUNT_PATH
# /private/var/run/com.apple.security.cryptexd/mnt/com.research.base-cryptex.ZcDlFSIts bin, sbin and usr/bin are already on PATH, so otool, nm and the
rest just work. Some tools need an environment variable first, because they look
for their own data at macOS paths that do not exist on iOS and are compiled
into the binary — C strings in __TEXT, not load commands, so machomorph
never sees them, and they could not be patched anyway because the mount point is
different every install.
Everything in one block, if you just want the tools to work:
export SSL_CERT_FILE=$CRYPTEX_MOUNT_PATH/share/ssl/cert.pem
export CURL_CA_BUNDLE=$SSL_CERT_FILE
export OPENSSL_CONF=$CRYPTEX_MOUNT_PATH/share/ssl/openssl.cnf
export PERL5LIB=$CRYPTEX_MOUNT_PATH/share/perl5:$CRYPTEX_MOUNT_PATH/share/perl5-extras
export RUBYLIB=$CRYPTEX_MOUNT_PATH/share/ruby:$CRYPTEX_MOUNT_PATH/share/ruby/universal-darwin25
export SDKROOT=/
export ZDOTDIR=$CRYPTEX_MOUNT_PATH/share/zsh/zdotdirThe cryptex ships etc/profile and etc/zshenv containing exactly that,
derived from $CRYPTEX_MOUNT_PATH at runtime — so rather than typing the block,
copy them to the device once:
ssh -p 2222 root@localhost 'cat > ~/.zshenv' < <cryptex>/etc/zshenv
ssh -p 2222 root@localhost 'cat > ~/.profile' < <cryptex>/etc/profileOnce per device, not per install: both locate the cryptex through
$CRYPTEX_MOUNT_PATH, which cryptex-run exports, so they survive every later
reinstall. rebuild_cryptex.sh prints these two lines beside the
srdtool cryptex install line.
They have to be copied because the cryptex is not path-overlaid onto /:
measured, <cryptex>/etc/profile is present while /etc/profile is absent (and
so is /bin/bash, though the cryptex ships bin/bash), while /etc/hosts —
iOS's own — is there. /private/preboot/Cryptexes/OS is a dyld search prefix
for dylibs, not a filesystem overlay, and holds only System and usr.
/private/etc is read-only, so nothing can be dropped there either.
Two files rather than one, because the choice is not arbitrary: zsh never reads
/etc/profile (that is sh and bash), and /etc/zshenv is the right zsh hook
rather than /etc/zshrc because it is read on every invocation including
zsh -c — the failing case is non-interactive — and because it is read before
~/.zshenv, which matters since ZDOTDIR must be set before zsh looks for
$ZDOTDIR/.zshenv. /etc/zprofile and /etc/zshrc are deliberately not
shipped: they are read after $ZDOTDIR/.zshenv has reassigned
ZDOTDIR=$HOME, so setting it again there would send zsh looking for your own
.zprofile and .zshrc inside the cryptex.
$HOME (/var/root) is writable and the login shell reads ~/.profile, so
that block in ~/.profile makes it permanent for interactive sessions —
verified on device, after which a bare curl https://apple.com works. Note it
applies to login shells only, so ssh <device> 'curl ...' still gets
nothing: that is a non-interactive shell, which reads neither ~/.profile nor
~/.bashrc. Export them in the command for scripted use.
There is no way to avoid the variables entirely. /private/etc is read-only, so
the files cannot be put where the tools already look, and the mount point's
random suffix means no absolute path can be compiled in.
| tool | needs | without it |
|---|---|---|
curl |
CURL_CA_BUNDLE |
curl: (77) error setting certificate verify locations: CAfile: /etc/ssl/cert.pem |
openssl |
SSL_CERT_FILE, OPENSSL_CONF |
handshake completes, chain verification fails |
perl |
PERL5LIB |
Can't locate POSIX.pm in @INC |
ruby |
RUBYLIB, SDKROOT |
cannot load such file -- rubygems.rb |
zsh |
ZDOTDIR |
failed to load module 'zsh/zle', no line editing |
expect |
nothing | — Tcl finds lib/tcl8.5 relative to the interpreter by itself |
curl and openssl — iOS has no /etc/ssl at all. macOS's curated bundle is
staged at share/ssl/cert.pem. Note the two fail differently, and curl's is the
more confusing: openssl completes a TLS handshake and then reports unable to get local issuer certificate, while curl refuses up front with error 77, which
reads like a broken build and is a missing file. SSL_CERT_FILE is not enough
for openssl s_client, which does not call SSL_CTX_set_default_verify_paths
unless given -CAfile — pass it explicitly there. curl honours
CURL_CA_BUNDLE on its own.
Ruby's Net::HTTP is a third case: it consults neither, so pass ca_file:
ruby -e 'require "net/https"; u=URI("https://apple.com/")
h=Net::HTTP.new(u.host,443); h.use_ssl=true; h.ca_file=ENV["SSL_CERT_FILE"]
puts h.get("/").code' # 200perl — the interpreter runs unaided, but @INC still points at
/System/Library/Perl, so anything that loads a module fails:
perl -e 'print "hi\n"' # works: no modules involved
perl -MPOSIX -e 'print POSIX::floor(3.7)'
# Can't locate POSIX.pm in @INC (@INC contains: /Library/Perl/5.34/... )
export PERL5LIB=$CRYPTEX_MOUNT_PATH/share/perl5
$CRYPTEX_MOUNT_PATH/bin/perl -MPOSIX -e \
'printf("uname=%s release=%s\n", (POSIX::uname())[0], (POSIX::uname())[2])'
# uname=Darwin release=27.0.0That also exercises the 51 XS .bundle modules, which are converted like any
other Mach-O.
ruby — the interpreter and its lifted libruby were always sound, but
nothing was staged for it, and ruby -v works without any of it, which is how
this went unnoticed for a while. ruby -e 'puts 1' needs three things:
export RUBYLIB=$CRYPTEX_MOUNT_PATH/share/ruby:$CRYPTEX_MOUNT_PATH/share/ruby/universal-darwin25
export SDKROOT=/
ruby -e 'require "digest"; puts Digest::MD5.hexdigest("abc")'
# 900150983cd24fb0d6963f7d28e17f72RUBYLIB must name both directories: rbconfig.rb lives in the arch
subdir, and naming only the top gives cannot load such file -- rbconfig. And
SDKROOT must be set to something, because Apple's rbconfig.rb computes
CONFIG["includedir"] by backticking xcode-select --print-path && xcrun --show-sdk-path at require time, and iOS has no /bin/sh, so the require
dies with Errno::ENOENT. The line short-circuits on ENV['SDKROOT'], so no
patch to Apple's file is needed.
All 96 native .bundle extensions are converted and load, which is not
optional: rubygems/specification.rb requires stringio, so without them even
ruby -e 'puts 1' fails. Only --disable-gems avoids it.
zsh — the shell runs, but three of its search paths are absolute macOS
paths compiled into the binary, so machomorph never sees them (they are C
strings in __TEXT, not load commands) and they cannot be patched either, since
the mount point is different every install:
/usr/lib/zsh/5.9 module_path -> failed to load module 'zsh/zle'
/usr/share/zsh/5.9/functions fpath -> compinit and autoloads fail
/usr/share/zsh/site-functions fpath
The cryptex ships both trees plus a startup file that corrects the two parameters at runtime, so one export is enough:
export ZDOTDIR=$CRYPTEX_MOUNT_PATH/share/zsh/zdotdir
zsh -c 'zmodload zsh/zle && echo ok'$ZDOTDIR/.zshenv is read on every invocation, before any module is wanted. It
sets module_path and fpath from $CRYPTEX_MOUNT_PATH and then reassigns
ZDOTDIR=$HOME, so shipping it does not hide your own .zshrc.
Two things worth knowing if you do it by hand instead. These are zsh
parameters, not environment variables — exporting MODULE_PATH has no effect,
because module_path is not one of the arrays tied to one (path/PATH is).
And without the modules you lose interactive line editing — arrow keys,
completion, history — while scripting is unaffected:
zsh -c "module_path=($CRYPTEX_MOUNT_PATH/share/zsh/5.9); zmodload zsh/zle"SRD iPhone18,3, iOS 27.0 24A5424a. Everything below was launched and observed
working, not merely converted. Raw probe data in measurements/.
| result | |
|---|---|
| Xcode reverse-engineering tools | run: otool -L, nm -mu, objdump, strings, lipo -info, size, strip, vtool -show-build, segedit, nmedit, install_name_tool, bitcode_strip, ctf_insert, codesign_allocate, dyld_info -platform, dyld_analyzer, dwarfdump, dsymutil, unwinddump, c++filt, swift-demangle, readtapi, and their llvm-* originals |
| memory and symbolication (8) | all run: atos, symbols, vmmap, heap, leaks, malloc_history, stringdups, filtercalltree. vmmap $$ gives real output against a live process |
| zsh | 5.9, all 37 modules load — needs ZDOTDIR (or module_path=(...), not MODULE_PATH). ztcp opens a real socket and reads dropbear's SSH banner |
| ruby | 2.6.10, all 96 native extensions load — socket, openssl, zlib, fiddle, ripper; net/https to apple.com returns 200 |
| expect | 5.45, drives openssl's interactive REPL over a pty |
| perl | 5.34, including XS modules from both the core and Extras trees (Digest::MD5, JSON::PP) with PERL5LIB set |
| openssl | LibreSSL 3.3.6; SHA-256, RSA and EC sign/verify, AES, TLSv1.3 to apple.com with Verify return code: 0 (ok) |
| curl | https://www.apple.com/ → 200, 254 KB, ssl_verify_result 0 |
| tcpdump | captures and fully decodes; --version works |
| dtrace | -l no longer crashes. It reaches its own initialisation and reports DTrace device not available on system — the iOS kernel has no DTrace, which is as far as this tool can go |
| bash | the ported macOS bash runs, forks, and serves as dropbear's login shell |
Every entry in the cryptex's bin/ launched under a timeout and classified.
| outcome | first sweep (2026-08-30) | final |
|---|---|---|
| loads and runs | 379 | 399 |
| fails: library missing | 243 | 0 |
| fails: symbol missing | 70 | 39 |
| SIGKILLed | 83 | 0 |
| crash | — | 1 |
| blocked (daemon/interactive) | 64 | 16 |
| not run (denylisted) | 124 | 64 |
Nothing fails on a library any more. The remaining 39 are iOS genuinely not
exporting a symbol — confirmed from the crash reports, where
termination.namespace is DYLD and indicator is Symbol missing — led by
_syslog$DARWIN_EXTSN (7; see next-session.md). The single crash is sntpd's
own __assert_rtn.
And the check that matters after all the shared-cache work: of the 40 crash
reports the final sweep produced, none is EXC_ARM_PAC_FAIL. Every lifted
library survives every path the sweep reaches.
- arm64 binaries run fine from a cryptex on an arm64e device. The Xcode toolchain ships arm64 only, and everything else here is arm64e.
- A locally built, non-platform arm64e dylib loads, as long as it is inside the cryptex and so covered by its trust cache.
- iOS ships no shell and no coreutils. The whole of
/bin,/sbin,/usr/binand/usr/sbinon the device is daemons plus a handful of diagnostics (data/ios_native_commands.txt) — nosh,ls,catorsleep. Everything a script uses has to come out of the cryptex. - A lifted library reserves 1–2 GB of address space, because it keeps the
cache's segment addresses (an ADRP immediate is a fixed PC-relative distance,
so nothing can move). dyld reserves the whole span, and what runs out is the
largest contiguous hole rather than a total: measured with perl's
DynaLoader, 1972 MB then 1659 MB then 881 MB all load, while 1765–1887 MB do not once 1972 MB is taken. Two or three lifted libraries per process. That is whysystemstats, which needs six, is blocklisted — though its real blocker is simpler:CoreDisplayandIOPresentmentneed symbols iOS does not export at all.
Both were found by dlopen-ing the staged libraries on the device and reading
the error, and both are now handled automatically.
incompatible platforms: iOS - macCatalyst. A macCatalyst-capable library
carries two LC_BUILD_VERSION load commands. Retargeting only the first
leaves the macCatalyst one behind and dyld rejects the image outright.
machomorph now drops the extras — nothing addresses LC_BUILD_VERSION by
ordinal, so removing them is safe.
__DATA_CONST segment missing SG_READ_ONLY flag. Images inside a dyld
shared cache do not carry SG_READ_ONLY on __DATA_CONST, because the cache
guarantees that protection itself. Pull one out with ipsw dyld extract and it
cannot be loaded as a standalone file until the flag is restored. machomorph now
restores it, on __AUTH_CONST too — a segment that only exists in cache images.
Neither is visible in otool -L output, and a weak reference hides both: dyld
skips the library silently and the process dies later with a confusing
Symbol not found ... Expected in: <no uuid> unknown. If you see that, the
library did not load, and dlopen-ing it directly is the fastest way to find
out why.
A library that exists only inside the cache is not a file, and what
ipsw dyld extract or Apple's dsc_extractor.bundle hand back is not a
loadable dylib either. machomorph repairs both reasons automatically — you only
need to notice it happening in the output:
Relaid out 6 segments for a standalone image, rebuilt 93 exports
The segments are unmappable. They keep the addresses they had in the cache, which are neither ascending nor page aligned — images share pages, so each segment begins wherever it happens to:
| segment | vmaddr | offset within a 16K page |
|---|---|---|
__TEXT |
0x19ce1e000 |
0x2000 |
__DATA_CONST |
0x1e712a5a8 |
0x25a8 |
__AUTH_CONST |
0x1f0987358 |
0x3358 |
__AUTH |
0x1eddd38f8 |
0x38f8 |
dyld walks you through this one error at a time: segment '__AUTH' vm address out of order, then file offset out of order once they are sorted, then a bare
mmap … errno=22 once both orderings are fixed.
The repair moves nothing. Addresses are baked into the image in places no
rewrite could reach — every ADRP/ADD pair in the code is a PC-relative distance
to data — so instead each segment is grown backwards to the page boundary
below it and the gap zero-filled. Every address stays exactly where it was. A
uniform shift first makes __TEXT page aligned, which keeps the mach header at
file offset 0; the symbol table's absolute addresses follow that shift.
The export trie is empty. A cache holds export information centrally, so a
per-image LC_DYLD_EXPORTS_TRIE is present but zero-sized: the library loads
and exports nothing, and every import against it fails. The symbol table
survives extraction intact, so machomorph rebuilds the trie from it — as a radix
tree, which matters, because a flat one-child-per-symbol trie silently breaks
whenever one name is a prefix of another (_foo and _foobar).
iOS checks __LINKEDIT alignment; macOS does not. Its sub-tables must be
8-byte aligned, and a page-aligned relocation shifts them by whatever the
original offset happened to be — mis-aligned LINKEDIT content 'symbol table'.
machomorph pads inside the segment so the shift is a multiple of 16, which
preserves the original alignment, and 8-aligns the trie it appends. Worth
knowing because a library that loads perfectly on the Mac can still be rejected
on device for this alone.
How far a raw extraction gets you. tcpdump works properly — tcpdump -i lo0 captures and fully decodes traffic (TCP/IP dissection, port names, TCP
options, timestamps), and tcpdump -D lists interfaces. dtrace prints its
usage and real runtime output, then crashes on -l and -n. curl and
openssl crashed on every path tried. The difference is which code paths a
tool takes, for the reason below — and the reason a plain extraction is never
what you want to ship.
The remaining limit, and how it was removed. A cache-extracted library maps
and resolves symbols, and then crashes when a particular path dereferences an
unrebased pointer (KERN_INVALID_ADDRESS at 0x7098993000100020 — a raw
chained-pointer bit pattern, not an address). Extracted images carry no
LC_DYLD_CHAINED_FIXUPS, because a shared cache does relocation centrally.
That is only half of it, and the other half is the half that matters: the
cache builder uniques GOT entries cache-wide. An image keeps its own __got /
__auth_got sections, but the builder zeroes them, clears their section type,
and rewrites the image's code to reach a shared GOT region that belongs to no
image at all. Extract the image and its every stub points outside its own
segments. That is a code problem, not just a metadata one, which is why no
extractor fixes it — Apple's and ipsw's both reproduce the rewritten code
verbatim.
Everything needed to undo it survives, though. The indirect symbol table is
intact, stub[i] and __auth_got[i] name the same symbol, and the dead GOT
sections are exactly the right size. Only two things have to come from the
cache: which words in the image's data are pointers (its slide info) and which
symbol each cache-wide GOT slot held (its patch table). So:
An input that is not a file anywhere is a lift, not an error:
./machomorph.py /usr/lib/libxcselect.dylib -o out/libxcselect.dylib \
-p ios -v 26.0 \
--change /usr/lib/libxcselect.dylib \
@loader_path/../lib/libxcselect.dylibextracts, collects the facts, retargets, repoints every stub at the image's own
GOT, synthesises LC_DYLD_CHAINED_FIXUPS, repairs the ObjC metadata, refuses to
hand back anything still reaching the cache, and compacts the result.
libxcselect (58 stubs, 86 fixups) and libdtrace (2259 fixups) both run
afterwards. It is the same pipeline the closure pass uses, so there is one
implementation of it.
Seven stages, each its own module in dsc/ with its own CLI — because when a
lift comes out wrong, the way to find out why is to run one stage by hand on the
intermediate:
| script | job |
|---|---|
dsc.gotscan |
diagnosis only — reports the damage and whether it is repairable |
dsc.facts |
pulls the slide-info and GOT-symbol facts out of the cache |
dsc.rebind |
repoints the code and synthesises the fixups |
dsc.objc |
rebases the ObjC selector, protocol and class references |
dsc.compact |
packs the segments, closing the 1.3–2.0 GB address-space hole |
That repair is what curl and openssl needed. Both were briefly rescued by
cross-compiling LibreSSL and curl for iOS instead; that route is gone.
Everything this project ships is now the Apple binary, rewritten — no
libraries compiled from source, no hand-written stubs. Lifting is strictly more
general: it works for libdtrace and libxcselect, which have no upstream to
build from and exist as a file nowhere, and it gives Apple's actual
implementation rather than a look-alike.
--dry-run applies it to a whole dependency closure and stops:
./machomorph.py /usr/bin/curl -o out/curl -p ios -v 26.0 --dry-runTwo ceilings it reports before you commit to anything. A lift only works if the
lifted library's own imports all exist on iOS — CoreDisplay needs
_DSBrightnessExternalConvertLinearToUser, which iOS does not export, so
systemstats can never work. And an uncompacted lift keeps the cache's
segment addresses, so dyld reserves its whole 1.3–2.0 GB span at load and what
runs out is the largest contiguous hole — two or three per process, never six.
Compaction is on by default and removes that ceiling; --no-compact puts it
back.
Use native/ (a wrapper around Apple's extractor) to get the images out;
it must be built -arch arm64e, since the bundle ships only x86_64 and arm64e
and dlopen needs a matching slice. It extracts the whole cache at once —
several GB — so do it once and keep the tree. machomorph picks it up from
/tmp/dsc_out automatically, and builds the wrapper itself if clang is
around.
native/dlopen_test, dlsym_test and dlcall_test are worth knowing
about: a converted macOS-targeted library can be loaded on the Mac itself,
which is a far faster way to find layout problems than reinstalling a cryptex.
Pass --no-cpusubtype-fix for that, or the macOS arm64e ptrauth version
(arm64e.v1) will make it unloadable by an ordinary process. Use dlcall_test
in preference to the other two: a damaged extraction loads and resolves symbols
perfectly, and only faults once you actually call into it.
Five edits to the Mach-O, and one call to codesign:
- Pick one architecture. iOS will not load a universal binary that still carries an x86_64 slice, so the target slice is extracted.
- Retarget the platform.
LC_BUILD_VERSION'splatform,minosandsdkfields are rewritten. Older binaries carryingLC_VERSION_MIN_*instead are converted or retargeted in place. - Fix the arm64e ptrauth ABI. macOS refuses to run an arm64e binary whose
pointer-authentication ABI version is 0, so
--platform macosforcescpusubtypeto0x81000002; for device platforms the version bits are cleared back to0x80000002. - Fix framework paths. macOS frameworks are versioned bundles
(
CoreFoundation.framework/Versions/A/CoreFoundation); on iOS they are flat (CoreFoundation.framework/CoreFoundation). TheVersions/X/component is stripped from everyLC_LOAD_DYLIB,LC_ID_DYLIBandLC_RPATH. - Re-sign. All of the above invalidate the signature. The binary's existing
entitlements are read straight out of the embedded
CS_SuperBlob, optionally extended, and handed back tocodesignfor an ad-hoc signature.
Some implementation notes:
- Load commands are rebuilt, not patched in place, so paths may grow as well as shrink. If the result no longer fits in the linker's header padding, the tool refuses to write rather than corrupt the binary, and tells you how many bytes short it is.
- Nothing else in the file moves. All file offsets are preserved, so chained
fixups, the symbol table and
__LINKEDITneed no adjustment. - The SDK version is set to
major << 16(minor and micro zeroed). Override it with--sdkif you need something exact. - 64-bit little-endian Mach-O only (arm64/arm64e/x86_64). No 32-bit, no PPC — those error out explicitly rather than misparsing.
./test_machomorph.py is a differential test suite: it runs the real lipo,
cbv, install_name_tool and ldid next to our implementations and compares
the results. Checks whose reference tool is missing are skipped.
./test_machomorph.py --cbv /path/to/cbvmachomorph.py the tool: convert a Mach-O for another Apple platform
and bring its libraries along -- the dependency
closure, the order of the lift and the output layouts
test_machomorph.py its tests, diffed against the real toolchain
dsc/ read and repair an image from a dyld shared cache
image.py just enough Mach-O to reason about stubs and GOTs
arm64.py the four instruction forms this project decodes
extract.py pull an image out of the cache, as the cache holds it
facts.py what the cache knows and an extraction does not
rebind.py repair the uniqued GOT, synthesise chained fixups
objc.py rebase the selector, protocol and class references
compact.py pack the segments, closing the address-space hole
gotscan.py judge a lifted library; modifies nothing
index.py the loadable-path list, out of a cache or an IPSW
cryptex/ build a cryptex, and check it before installing
verify.py pre-install gate over the staged tree
symbols.py the launch prediction, per binary
blocklist.py turn a probe result into the exclusion list below
restage.py superseded; kept for a tree built by an older version
scripts/ shell, because it drives other programs
rebuild_cryptex.sh scrape the system, convert it all, copy the data
trees, check the result. Nothing else
device_probe.sh launch every ported binary on the device, classify
native/ C, built by its own Makefile
dsc_extract.c wraps Apple's dsc_extractor.bundle
dlopen_test.c does it map?
dlsym_test.c does its export trie work?
dlcall_test.c does it RUN? The one that catches a damaged lift
data/ measured inputs the build reads
ios27_*_index.txt what the target's dyld cache can load
ios_native_commands.txt what iOS already ships
blocklist_symbols.txt what dies on a missing symbol, and WHICH symbol.
Generated by cryptex.blocklist -- not by hand
exclude_xcrun_shims.txt the 95 xcrun shims, excluded by path
no_compact.txt images compaction must not touch
ssl/openssl.cnf the trust store's config
lifted/ the lift cache: one library per basename, re-made
whenever any of the lifting code is newer (gitignored)
machomorph.py owns the conversion, the dependency closure and the order of
the lift; dsc/ owns everything that knows what a shared cache is; cryptex/
owns the checks; scripts/ drives other programs; native/ is C. Each of
dsc/ and cryptex/ is a package whose modules import each other properly, so
there is no sys.path juggling and no importing a library out of a CLI.
Run a stage directly with python3 -m, from the repository root:
python3 -m dsc.gotscan lifted/libcrypto.46.dylib
python3 -m dsc.compact in.dylib -o out.dylib
python3 -m dsc.index iPhone18,3_26.4_23E246_Restore.ipsw -o data/idx.txt
python3 -m dsc.symindex iPhone18,3_26.4_23E246_Restore.ipsw -o /tmp/syms.txt.gz
python3 -m cryptex.verify --cryptex DIR --dylib-index data/ios27_*.txt
python3 -m cryptex.symbols --all --cryptex DIRcryptex.verify and cryptex.symbols still print verify_cryptex and
symbol_check: as their own labels, so measurements recorded before the move
stay comparable.
A scan applies both by default (--no-exclude-defaults opts out). They are
kept apart, rather than being one list, because they are believed for different
reasons.
exclude_xcrun_shims.txt is 95 paths. /usr/bin hard-links one inode under
78 names whose whole body is xcselect_invoke_xcrun: look up the active Xcode
and re-exec into it. There is no Xcode on a phone, and on iOS they are SIGKILLed
rather than merely useless. It has to match by path, not by name, because those
names exist twice -- otool is both a shim and, in the Xcode toolchain, the
real llvm-otool. A bare otool line would drop the tool you want; the
/usr/bin scan runs first, so an earlier build shipped bin/otool -> DeRez.
blocklist_symbols.txt is generated from a device probe, and each line
carries the symbol that blocked it:
date # _syslog$DARWIN_EXTSN
jar # _OBJC_CLASS_$_JLRuntime
postconf # _sasl_client_init
Regenerate it after a probe rather than editing it:
cryptex/blocklist.py measurements/<latest>.tsv --cryptex <cryptex> \
> data/blocklist_symbols.txtTwo things follow from generating it. A missing symbol is a snapshot rather than
a verdict -- it can be answered by a rename, a forwarding shim, or a newer iOS
-- so the symbol is recorded to make the exclusion reversible: delete the lines
naming it and re-probe. And the generator holds back a binary whose missing
symbol is imported by a library this cryptex bundles, because that failure is
ours to fix rather than a limit of iOS. It distinguishes the two by measurement,
since the probe output is identical either way: date imports
_syslog$DARWIN_EXTSN itself from libSystem and is excluded, while openssl,
tcpdump and curl never import it and inherit it from the bundled
libcrypto, so they are held back with the reason written into the file.
Everything under data/ used to include a single hand-maintained
blocklist_ios.txt. It drifted from the measurements it claimed to encode and
excluded 480 binaries on reasoning that did not survive checking -- among other
things it blocked ioreg, whose native build is SIGKILLed on iOS while the port
dumps the whole registry. It is kept as evidence in measurements/, and
next-session-blocklist.md is the plan for rebuilding a defensible version.
machomorph was vibe-coded with Claude (Claude Code, Opus 5). That is worth
saying out loud, because "an LLM wrote a Mach-O rewriter" should make you want
to see evidence before you point it at anything you care about.
So the output is verified rather than trusted:
cbv's behaviour was reverse-engineered by byte-diffing its output against the input on real system binaries, not guessed at from the blog post. It turns out to mutate exactly three fields pluscpusubtype; everything else that differs iscodesign's doing.test_machomorph.pyis a differential suite — it runs the reallipo,cbv,install_name_tool,ldidandcodesignalongside our implementations and compares the results, rather than asserting against expectations we made up. Our thinning is byte-identical tolipo -thin; our entitlement parser agrees with bothldid -eandcodesign -d.- The tool was run across 1250 system binaries from
/usr/bin,/usr/sbin,/usr/libexec,/usr/liband the system frameworks, with no failures; a sample of the results was checked withcodesign --verifyandotool -l.
Verification only goes so far, of course: it shows we match the tools we replaced, on the binaries we tried. If something misbehaves, please open an issue with the binary in question.
The platform-conversion trick is Jonathan Levin's, described in
Merging macOS and iOS along with
his cbv tool — including the 0x81000002 pointer-authentication workaround,
which is not documented anywhere else as far as we know. machomorph
reimplements cbv's behaviour and is byte-for-byte verified against it.
Before this script, the workflow was:
lipo -thin arm64e /usr/sbin/ioreg -output /tmp/ioreg_thin
./cbv /tmp/ioreg_thin to ios 27.0
otool -L /tmp/ioreg_thin
install_name_tool -change \
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation \
/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation \
... /tmp/ioreg_thin # once per macOS-only path
ldid -e /tmp/ioreg_thin > ents.xml
# hand-edit ents.xml to add research.com.apple.license-to-operate
codesign --entitlements ents.xml -f -s - /tmp/ioreg_thinmachomorph folds all of that into one command:
| Step | Previously | Now |
|---|---|---|
| Pick one architecture out of a fat binary | lipo -thin arm64e |
built in (--arch) |
Rewrite LC_BUILD_VERSION platform/minos/sdk |
cbv <bin> to ios 27.0 |
-p ios -v 27.0 |
Fix the arm64e ptrauth cpusubtype |
cbv |
automatic |
Strip macOS-only Versions/A/ from paths |
install_name_tool -change × N |
automatic |
| Arbitrary path rewrites | install_name_tool -change |
--change OLD NEW |
| Inspect header, libraries, entitlements | otool -hv, otool -L, ldid -e |
--info |
| Read the existing entitlements | ldid -e |
automatic |
| Add the research entitlement | edit XML by hand | --license-to-operate |
| Re-sign ad hoc | codesign -f -s - |
automatic |
Thanks also to ldid (Jay Freeman / ProcursusTeam) — not required any more, but
it is the reference our entitlement parser was checked against.