[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-96009":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":8,"htmlUrl":8,"language":9,"languages":8,"totalLinesOfCode":8,"stars":10,"forks":11,"watchers":12,"openIssues":13,"contributorsCount":13,"subscribersCount":13,"size":13,"stars1d":13,"stars7d":13,"stars30d":11,"stars90d":13,"forks30d":13,"starsTrendScore":13,"compositeScore":14,"rankGlobal":8,"rankLanguage":8,"license":15,"archived":16,"fork":16,"defaultBranch":17,"hasWiki":18,"hasPages":16,"topics":19,"createdAt":8,"pushedAt":8,"updatedAt":20,"readmeContent":21,"aiSummary":22,"trendingCount":13,"starSnapshotCount":13,"syncStatus":23,"lastSyncTime":24,"discoverSource":25},96009,"macos-to-ios","mowisec\u002Fmacos-to-ios","mowisec",null,"Python",116,9,1,0,40.9,"Apache License 2.0",false,"main",true,[],"2026-09-20 04:01:32","# machomorph\n\n**Run Apple's own macOS command line tools on iOS.**\n\nmacOS ships a large collection of low-level diagnostic and introspection tools —\n`ioreg`, `lsmp`, `heap`, `vmmap`, ... and dozens more.\nTheir iOS counterparts either do not exist, are stripped down,\nor are simply not shipped on the device.\n\niOS and macOS are the same operating system underneath.\nThe binaries are the same architecture (arm64e), link against the same\nframeworks, and call the same kernel. What actually stops a macOS binary from\nlaunching on an iPhone is a handful of metadata fields: the Mach-O says\n\"I am for platform macOS\", a few framework paths carry a macOS-only\n`Versions\u002FA\u002F` component, and the code signature is not one the device will\naccept.\n\n`machomorph` rewrites exactly those fields and re-signs the result.\n\nIt also brings the libraries along. Where a binary needs something iOS does not\nship, machomorph works out its whole dependency closure, **lifts each missing\nlibrary out of the macOS dyld shared cache** — where most of them exist as no\nfile at all — repairs it so it actually runs standalone, and stages it beside\nthe binary. One command, one binary, everything it needs:\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Fbin\u002Fcsrutil -o out\u002Fcsrutil -p ios -v 27.0\n```\n\n\nThat is the primary way to use it. The batch case is the same mechanism pointed\nat a whole system: `rebuild_cryptex.sh` fills an **SRD cryptex** with every\nportable macOS tool and every library they need. TL;DR for SRD users, point it\nat the IPSW of the build on the device and enjoy the new tooling:\n\n```sh\n.\u002Fscripts\u002Frebuild_cryptex.sh --ipsw ~\u002FDownloads\u002FiPhone18,3_27.0_24A5424a_Restore.ipsw \\\n    \u002Fpath\u002Fto\u002Fcryptex\n```\n\n\n## Requirements\n\nPython 3.9+ and macOS, and no Python packages at all — `machomorph.py` is one\nstdlib-only file.\n\n**`machomorph.py` itself shells out to exactly two things, both from macOS:**\n\n| tool | what for | if missing |\n|---|---|---|\n| `\u002Fusr\u002Fbin\u002Fcodesign` | re-signing, since anything moved invalidates the signature | conversion fails (`--no-sign` skips it, leaving an invalid signature) |\n| `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 |\n\nEverything else the tool replaces — `lipo`, `cbv`, `install_name_tool`,\n`otool`, `ldid` — is reimplemented, which is the point of the project.\n\n**Lifting a library out of the dyld shared cache needs more**, because it is a\ndifferent job from retargeting a file:\n\n| tool | needed by | what for |\n|---|---|---|\n| [`ipsw`](https:\u002F\u002Fgithub.com\u002Fblacktop\u002Fipsw) | lifting a library out of the cache | reading the cache: `dyld slide`, `dyld patches`, `dyld extract`. `brew install blacktop\u002Ftap\u002Fipsw` |\n| `clang` + Xcode | lifting a library out of the cache | building `native\u002Fdsc_extract` against Apple's `dsc_extractor.bundle`, once |\n| `srdtool` | `scripts\u002Frebuild_cryptex.sh`, `scripts\u002Fdevice_probe.sh` | installing the cryptex on a Security Research Device, and spawning on it |\n| `idevicecrashreport` | reading crash reports after a probe | libimobiledevice; `brew install libimobiledevice` |\n\n`scripts\u002Frebuild_cryptex.sh` degrades rather than failing when `ipsw` is\nabsent: it skips the bundled libraries and says so. Converting a binary that\nneeds no library from the cache needs none of this.\n\n## Usage\n\n```\nmachomorph.py INPUT... [-o OUTPUT] -p PLATFORM -v VERSION [options]\nmachomorph.py --scan [DIR...] --cryptex DIR -p PLATFORM -v VERSION [options]\n```\n\nA full run looks like this:\n\n```\n$ .\u002Fmachomorph.py \u002Fusr\u002Fsbin\u002Fioreg -o ioreg_ios -p ios -v 27.0\nThinned to arm64e (74816 bytes)\nOriginal build version:   macOS 26.6.0 (sdk 26.6.1)\nConverted to:             iOS 27.0.0 (sdk 27.0.0)\n  path: \u002FSystem\u002FLibrary\u002FFrameworks\u002FCoreFoundation.framework\u002FVersions\u002FA\u002FCoreFoundation\n     -> \u002FSystem\u002FLibrary\u002FFrameworks\u002FCoreFoundation.framework\u002FCoreFoundation\n  path: \u002FSystem\u002FLibrary\u002FFrameworks\u002FIOKit.framework\u002FVersions\u002FA\u002FIOKit\n     -> \u002FSystem\u002FLibrary\u002FFrameworks\u002FIOKit.framework\u002FIOKit\nRewrote 2 path(s)\nSigned with identity      '-' (no entitlements)\nOutput to                 ioreg_ios\n```\n\nIn one invocation that: picked the arm64e slice out of the universal binary,\nretargeted the Mach-O at iOS 27.0, fixed the arm64e pointer-authentication\n`cpusubtype`, stripped the macOS-only `Versions\u002FA\u002F` from both framework paths,\ncarried over the binary's existing entitlements, and re-signed ad hoc.\n\nPlatforms: `macos ios tvos watchos bridgeos maccatalyst driverkit visionos`\n(and the matching simulators).\n\nUseful options:\n\n* `--no-libraries` — do not bring along the libraries the target lacks. By\n  default every conversion works out its dependency closure and lifts what is\n  missing; see [Bundling the libraries iOS\n  lacks](#bundling-the-libraries-ios-lacks) for `--lib-layout`, `--max-libs`,\n  `--dry-run` and the rest.\n* `--info` — dump arch, platform, linked libraries and entitlements, then exit.\n* `-a, --arch ARCH` — which slice to take out of a fat binary. Defaults to\n  arm64e, then arm64, then the only slice present.\n* `--change OLD NEW` — rewrite one dylib install name or rpath. Repeatable, for\n  the cases where a library genuinely lives somewhere else on iOS.\n* `--no-auto-paths` — keep `….framework\u002FVersions\u002FA\u002F…` paths as they are.\n* `--license-to-operate` — add `research.com.apple.license-to-operate` to the\n  entitlements before signing. Required for entitled binaries on the SRD.\n* `--entitlements FILE` — sign with these entitlements instead of the ones\n  already in the binary.\n* `--dump-entitlements FILE` — also write out the entitlements actually used, so\n  you can inspect or edit them.\n* `--sign-identity ID` \u002F `--identifier ID` — passed through to `codesign`.\n  Default identity is `-` (ad hoc).\n* `--no-sign` — skip signing. The output will have an invalid signature.\n* `--no-cpusubtype-fix` — leave `cpusubtype` alone.\n* `--dylib-index FILE` — a list of the library paths the target can actually\n  load, produced by `dsc.index` from the target's dyld shared cache. With it,\n  a library is resolved to wherever it really lives on the target (iOS demotes\n  several public macOS frameworks to `PrivateFrameworks`, for instance), and\n  anything absent there is reported before you ever copy the binary over.\n  **An iOS conversion uses `data\u002Fios27_24A5424a_index.txt` by default**, and\n  says which index it is using; this flag overrides it. Without an index there\n  is no way to know that iOS keeps `DiskArbitration` in `PrivateFrameworks`,\n  and flattening `Versions\u002FA` alone produces a plausible path that does not\n  exist — reported as a successful rewrite, and failing on device as a missing\n  library. It also blinds half the launch prediction, since a library can only\n  be judged absent when there is a list to judge it against.\n* `--no-dylib-index` — do not fall back to that shipped index. Paths are then\n  rewritten by rule alone, and nothing can be said about what the target has.\n\n  To build an index for another iOS build, hand `dsc.index` the IPSW and it\n  runs `ipsw extract --dyld` itself (the extraction is cached under\n  `\u002Ftmp\u002Fmachomorph-ipsw`, so only the first call pays for it):\n\n  ```sh\n  python3 -m dsc.index iPhone18,3_26.4_23E246_Restore.ipsw -o data\u002Fios264_23E246_index.txt\n  ```\n\n  It also accepts the directory `ipsw extract --dyld` wrote, or the cache file\n  itself. `.\u002Fscripts\u002Frebuild_cryptex.sh --ipsw FILE \u003Ccryptex>` does the same thing\n  for a whole build.\n* `--target-symbols FILE` — what each of those libraries *exports*, produced by\n  `dsc.symindex` from the same cache. The launch prediction otherwise reads the\n  target's surface from the SDK's `.tbd` stubs, and the SDK ships stubs for\n  `\u002Fusr\u002Flib` and the public frameworks only — **no PrivateFrameworks**. So a\n  symbol from one is reported `unknown`, which fails no binary and weakens\n  nothing, and that is how a `DiskManagement` with a hard import of\n  `_DAUnregisterApprovalCallback` shipped and took `csrutil` down. With the\n  index the answer for `DiskArbitration` is as good as the one for `libSystem`.\n\n  ```sh\n  python3 -m dsc.symindex iPhone18,3_27.0_24A5424a_Restore.ipsw -o ios27_symbols.txt.gz\n  ```\n\n  It is not shipped in `data\u002F` — 4.7 million symbols, 37 MB gzipped — so\n  `rebuild_cryptex.sh --ipsw FILE` builds and caches it beside the dylib index,\n  and a bare `machomorph.py` run says that it is working without one.\n* `--weak PATH` \u002F `--weaken-missing` — rewrite `LC_LOAD_DYLIB` to\n  `LC_LOAD_WEAK_DYLIB` so a binary launches even though a library is absent.\n  The load command is rewritten, never deleted: chained-fixup binds address\n  their library by ordinal, so removing one silently re-points every later\n  import at the wrong library.\n\n### Examples\n\nThree real tools, in increasing order of how much work they needed. Each one is\na step further from \"retarget the header\" and closer to \"supply what iOS does\nnot have\".\n\n**1. `ioreg` — nothing to fix.** Every library it needs is already on iOS, at a\npath one `Versions\u002FA` strip away. This is the whole conversion, and it is the\nrun shown above:\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Fsbin\u002Fioreg -o ioreg_ios -p ios -v 27.0\n```\n\nMost of the ported tree is this case. Look before you convert — `--info` prints\nthe architecture, target platform, linked libraries (flagging macOS-only paths)\nand entitlements:\n\n```sh\n.\u002Fmachomorph.py --info \u002Fusr\u002Fsbin\u002Fioreg\n```\n\n**2. `vmmap` — needs a library iOS does not have, so lift it out of the cache.**\n`--info` shows the problem on the first line of its library list:\n\n```\n    [dylib] \u002Fusr\u002Flib\u002Flibxcselect.dylib\n    [dylib] ...\u002FCoreSymbolication.framework\u002FVersions\u002FA\u002FCoreSymbolication \u003C- macOS-only path\n```\n\n`libxcselect` is the \"where is the active Xcode\" resolver. iOS has no such\nthing and ships no such library, and 106 macOS tools link it. It exists on disk\nnowhere — Xcode ships only a `.tbd` text stub, which carries no code — so the\nmacOS shared cache is the only copy there is. Nothing has to be said about\nthat: converting `vmmap` lifts it.\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Fbin\u002Fvmmap -o out\u002Fvmmap -p ios -v 26.0\n```\n\nThe real library works unmodified on iOS: it looks for a developer directory,\ndoes not find one, and returns false, which is the \"carry on without Xcode\"\npath every caller already takes. This is what makes `vmmap`, `heap`, `leaks`,\n`atos` and four more run on device.\n\n**3. `tcpdump` — needs several libraries, transitively.** It wants\n`libcrypto.46.dylib` and `libssl.48.dylib`, which iOS genuinely lacks, and\n`libcrypto` in turn wants `TrustEvaluationAgent`. The closure is recursive and\nso is the pass:\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Fsbin\u002Ftcpdump \u002Fusr\u002Fbin\u002Fopenssl -p ios -v 26.0 \\\n    --cryptex \u002Fpath\u002Fto\u002Fcryptex --cryptex-libdir lib --loader-path\n```\n\nEach one is *lifted*, not extracted, and that is the difference that matters. A\nraw cache extraction's code still reaches a cache-wide GOT that will not exist\nin the process — enough for `tcpdump` to capture and dissect traffic, and not\nenough for `tcpdump --version`, which reads a string out of a global. What a\nlift repairs is\n[below](#lifting-a-library-out-of-the-dyld-shared-cache).\n\n`--dry-run` prints the closure and stops, which is how you find out what a tool\nwould cost before committing to it — `curl` needs 0.7 MB, `system_profiler`\nneeds 58.8 MB of AppKit (and is refused by `--max-libs`), and `csrutil` needs\nfive libraries that as lifted would each reserve 1.3–2.0 GB of contiguous\naddress space, which is what compaction exists for.\n\n**Other things you may need.** Rewrite a library that really does live\nelsewhere on iOS:\n\n```sh\n.\u002Fmachomorph.py mytool -o mytool_ios -p ios -v 27.0 \\\n    --change \u002Fusr\u002Flib\u002Flibfoo.dylib \u002Fusr\u002Flib\u002Fsystem\u002Flibfoo.dylib\n```\n\nConvert an entitled tool, keeping its entitlements and adding the research one\n(required for entitled binaries on the SRD, and added automatically whenever\nthe binary already carries entitlements):\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Fbin\u002Fvmmap -o vmmap_ios -p ios -v 27.0 \\\n    --license-to-operate --dump-entitlements ents_vmmap.xml\n```\n\nGo the other way, running an iOS-only binary on macOS:\n\n```sh\n.\u002Fmachomorph.py lsdiagnose -o lsdiagnose_mac -p macos -v 15.0\n```\n\n## The one command\n\nTo find every portable tool and library on this Mac, convert them, and add them\nto a cryptex:\n\n```sh\n.\u002Fscripts\u002Frebuild_cryptex.sh --ipsw ~\u002FDownloads\u002FiPhone18,3_27.0_24A5424a_Restore.ipsw \\\n    \u002Fpath\u002Fto\u002Fcryptex\nsrdtool cryptex install \u002Fpath\u002Fto\u002Fcryptex\n```\n\n**`--ipsw` is part of the shortest working command, not a refinement.** It\nnames the iOS build actually on the device, and everything that decides what\ncan be ported is read out of that build's own shared cache: which libraries the\ntarget has (the dylib index) and what each of them exports (the symbol index).\nWithout it the build falls back to the index shipped in `data\u002F`, which is one\nparticular build, and to the SDK's `.tbd` stubs, which describe **no\nPrivateFramework** — so a symbol from one comes back `unknown`, which fails no\nbinary and weakens nothing. That is exactly how a `DiskManagement` with a hard\nimport of `_DAUnregisterApprovalCallback` shipped and took `csrutil` down. The\nextraction and both indexes are cached under `\u002Ftmp\u002Fmachomorph-ipsw`, so only\nthe first build on a given IPSW pays for them.\n\nThat single command does the lot:\n\n1. Removes only what a previous run of *this* tool put there. Anything else in\n   the cryptex — binaries someone built natively for iOS — is left untouched,\n   tracked through a `.machomorph-manifest` and enforced by `--no-clobber`.\n2. Scans the macOS system directories **and** the Xcode toolchain, skipping\n   the 95 xcrun shims, the binaries a device probe measured dying on a missing\n   symbol, and everything the Xcode blocklist calls a compiler rather than an\n   inspector. All three are machomorph's own defaults for a scan, not\n   arguments this script passes. Symlink and hard-link aliases are reproduced\n   as links.\n   Every library iOS lacks is lifted out of the shared cache and staged as\n   part of this step, because the binaries that need it say so — there is no\n   list of libraries in the script.\n3. Stages the toolchain's own `@rpath` dylibs (`libcodedirectory`, `libLTO`).\n4. Copies the data trees no load command mentions: the perl module trees (core\n   **and** Extras, retargeting every XS `.bundle`) and zsh's modules. zsh also\n   needs its `functions` tree and a shipped `.zshenv`, because its\n   `module_path` and `fpath` defaults are absolute macOS paths compiled into\n   the binary — see \"Running the ported tools on the device\".\n5. Stages a trust store, since iOS has no `\u002Fetc\u002Fssl` at all.\n6. Runs `cryptex.verify` and **exits non-zero if it fails**. An install cycle\n   costs minutes and a reboot, and most of the mistakes this project has\n   actually made are visible in the staged tree first.\n\nRoughly 470 binaries plus 50 aliases, ~400 MB. The first run pays for the\nlifts; they are cached in `lifted\u002F` and re-made only when the lifting code is\nnewer, so a second run is minutes.\n\n## Converting a whole system into a cryptex\n\nGive it several inputs, or `--scan` to walk the system directories\n(`\u002Fusr\u002Fbin \u002Fusr\u002Fsbin \u002Fbin \u002Fsbin` by default; pass your own, add\n`--scan-recursive` to descend). `--cryptex DIR` stages the results into a\ncryptex tree — binaries into `DIR\u002Fbin`, bundled libraries into `DIR\u002Fusr\u002Flib` —\nwhich is what `srdtool cryptex install` wants:\n\n```sh\n.\u002Fmachomorph.py --scan -p ios -v 26.0 --cryptex ~\u002Fsrd\u002Fcombined \\\n    --weaken-missing --keep-going\n```\n\n**A scan applies three things by default**, because they are what makes the\nresult usable rather than a matter of taste:\n\n* the Xcode toolchain is scanned too — `\u002Fusr\u002Fbin\u002Fotool`, `nm`, `lipo` and\n  `strip` are one hard-linked xcrun stub, not tools, so a sweep that takes them\n  and leaves the real llvm binaries behind has picked the wrong half;\n* `data\u002Fexclude_xcrun_shims.txt` drops those 95 stubs, by path;\n* `data\u002Fblocklist_symbols.txt` drops the binaries a device probe measured dying\n  at launch on a symbol iOS does not export.\n\n`--no-scan-xcode` and `--no-exclude-defaults` turn them off, `--exclude-from`\nadds more lists, and each is announced on the way past. **The lists apply only\nto what a scan finds** — a path named on the command line is always converted,\nsince silently dropping a binary someone asked for by name would be\nindefensible.\n\nThat converts every executable Mach-O it finds (572, on a current macOS) and\nends with a summary of what will not load anyway, ranked by how many binaries\neach absent library blocks — the list worth working down, because one\nreplacement can unblock a whole group:\n\n```\n===== 733 binaries: 674 ready, 59 with libraries missing on the target, 0 failed\n\nmissing libraries, most-blocking first:\n    12  \u002FSystem\u002FLibrary\u002FPrivateFrameworks\u002F…\u002FOpenDirectory\n          dscacheutil, dsconfigad, id, groups, …\n```\n\nThe bin and lib directories are configurable with `--cryptex-bindir` and\n`--cryptex-libdir`. `--keep-going` stops one bad binary from ending the batch.\n\n## Taking the real tools out of Xcode\n\nOn macOS, `\u002Fusr\u002Fbin\u002Fotool`, `nm`, `lipo` and `strip` are not tools — they are\nthe same 118640-byte stub, which looks up Xcode's copy of itself and `exec`s it.\nThe real binaries live in the toolchain, and they port to iOS cleanly: 105 of\nthe 108 executables in `XcodeDefault.xctoolchain\u002Fusr\u002Fbin` resolve every\ndependency against the iOS cache.\n\nAny `--scan` takes them, skipping the ones that exist to *build* code rather\nthan inspect it:\n\n```sh\n.\u002Fmachomorph.py --scan -p ios -v 26.0 --cryptex ~\u002Fsrd\u002Fcombined \\\n    --weaken-missing --keep-going\n```\n\n`--no-scan-xcode` leaves the toolchain out; `--scan-xcode` on its own scans the\ntoolchain and nothing else. `--list-skipped` shows what a run would take and\nwhat it would drop before you commit to it; `--exclude GLOB` drops more;\n`--no-xcode-blocklist` takes everything, including `clang` (141 MB) and\n`swift-frontend` (171 MB).\n\nThe blocklist is by *purpose*, not by name prefix — worth knowing if you edit\nit, because the tools you want are themselves llvm binaries: `otool` **is**\n`llvm-otool`, `nm` **is** `llvm-nm`, `objdump` **is** `llvm-objdump`. What\nsurvives is the inspection set: the Mach-O dumpers and editors, `strings`, the\nsymbol and demangling tools (`nm`, `c++filt`, `swift-demangle`), `dyld_info`,\nthe DWARF tools, and `lipo`\u002F`size`.\n\nTwo toolchain dylibs have to come along, `libcodedirectory.dylib` and\n`libLTO.dylib` (which carries LLVM's disassembler — `dyld_info` calls into it).\nTheir rpath is `@executable_path\u002F..\u002Flib\u002F`, so they need no rewriting, just\nstaging in the right place:\n\n```sh\n.\u002Fmachomorph.py \u003Ctoolchain>\u002Fusr\u002Flib\u002Flib{codedirectory,LTO}.dylib \\\n    -p ios -v 26.0 --cryptex ~\u002Fsrd\u002Fcombined --cryptex-bindir lib \\\n    --dylib-index ios27_index.txt\n```\n\nmachomorph resolves `@rpath\u002F...` against the binary's own `LC_RPATH` entries and\nthe staging directory, so once they are in place it stops reporting them as\nmissing.\n\n## Bundling the libraries iOS lacks\n\nThis happens on its own, and it is most of what the tool does. For every binary\nit converts, machomorph walks the dependency closure, asks the target's dylib\nindex which of those libraries the target actually has, and for each one it does\nnot:\n\n1. gets a local copy — from `--prebuilt`, from the macOS filesystem if the\n   library is a real file, or by **lifting it out of the shared cache**, which\n   is the only copy of most of them;\n2. compacts it, so it reserves its own size rather than the 1.3–2.0 GB of\n   contiguous address space the cache's layout implies;\n3. stages it into the output, rewriting its `LC_ID_DYLIB` and every reference\n   *between* bundled libraries;\n4. repoints the binary at it with a `@loader_path`-relative name, and weakens\n   the reference so the binary still launches if the library somehow fails.\n\nWhere the libraries land is the output directory, mirroring the target's own\nspelling of each path, so the result reads as a small root filesystem.\n\n`--lib-layout flat` puts them all beside the binary instead (shorter per\nreference, which matters — the load-command area is fixed, and `tcpdump` has\n16 bytes of slack for its two), `--lib-subdir lib` puts them in one\nsubdirectory, and `--libs-into DIR` moves the whole tree elsewhere.\n`--cryptex` is always flat, into `--cryptex-libdir`.\n\nTwo things it refuses to guess at:\n\n* **`--max-libs N`** (default 7). It gates what to *lift*, not what a binary is\n  allowed to see: a binary is always repointed at every library that ended up\n  staged, whoever's closure paid for it. A larger closure means the binary is\n  dragging in a whole macOS subsystem that cannot work on iOS anyway — `system_profiler`\n  wants AppKit, SkyLight, HIToolbox and OpenGL, which is the macOS window\n  server; it would load and have nothing to talk to. Such a binary is still\n  converted, and simply reports its libraries as missing. `--dry-run` prints\n  each closure with its size and address-space cost and stops.\n* **`--weaken-unresolvable`**. A bundled library that imports a symbol the\n  target does not export cannot load, and there is nothing to fix — iOS has no\n  equivalent of Authorization Services or the `SecTransform` pipeline at all.\n  The choice is between not shipping the library and binding those symbols\n  NULL, so that it loads and only a path that reaches one of them crashes. That\n  is a judgement about what the tool needs, so it is opt-in, and every symbol\n  it weakens is named in the output. It applies to **bundled libraries only**:\n  for one of the binaries being converted the same trade turns a clean skip into\n  a crash later, and `--force` is how you ask for that instead.\n\n`--no-libraries` turns the whole pass off. `--also PATH` bundles a library even\nthough the target has one of the same name, for when the target's build has a\nsmaller export surface.\n\n`--provide-lib OLDPATH FILE` is still there for a library you built yourself:\nit copies `FILE` into the cryptex library directory, repoints every reference to\n`OLDPATH` at it, and machomorph then checks the substitution actually holds up: it reads which\nsymbols the binary imports from `OLDPATH` (out of the two-level-namespace\nlibrary ordinal in the symbol table) and which the replacement exports (out of\nits `LC_DYLD_EXPORTS_TRIE`), and warns about the gap — a symbol that is imported\nbut not exported means the binary loads and then crashes if it calls it. On a\nbatch it also summarises who needs what:\n\n```\nsymbols used from \u002Fusr\u002Flib\u002Flibxcselect.dylib:\n    94  _xcselect_invoke_xcrun\n          DeRez, GetFileInfo, ResMerger, Rez, SetFile, SplitForks, +88 more\n     9  _xcselect_get_developer_dir_path\n          atos, filtercalltree, heap, kmutil, leaks, malloc_history, +3 more\n```\n\nwhich is usually the fastest way to see which binaries a replacement genuinely\nserves.\n\nIf an input is a symlink to another input, it is reproduced as a relative\nsymlink rather than converted twice — `otool -> llvm-otool` stays a link, and\n`clang++` keeps the `argv[0]` that tells it to be a C++ driver.\n\nThe rewritten reference is `@executable_path`-relative, never absolute: the\ncryptex mount point on the device carries a per-install random suffix, so an\nabsolute path would break on the next install. machomorph derives that name\nfrom the cryptex layout and warns if the dylib's own `LC_ID_DYLIB` disagrees.\n\nA library can only reach the device *inside* the cryptex — the trust cache is\nkeyed by cdhash and built at install time from the staged directory, so copying\na dylib over with `scp` does not work. Adding one means reinstalling the cryptex.\n\n### `libxcselect`: the real library, lifted from the cache\n\n`\u002Fusr\u002Flib\u002Flibxcselect.dylib` resolves \"where is the active Xcode install\". It is\nthe single most common missing library for ported macOS tools, and iOS has\nneither the library nor the concept — nor does it exist as a file anywhere:\n`Xcode.app` and the Command Line Tools ship only a `.tbd` text stub, which\ncarries no code. The shared cache is the only copy.\n\nConverting any of the nine tools that need it\n[lifts it out of there](#lifting-a-library-out-of-the-dyld-shared-cache), which\nwas impossible until the cache-uniqued GOT repair existed. To lift it on its\nown, name it as the input:\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Flib\u002Flibxcselect.dylib -o lifted\u002Flibxcselect.dylib \\\n    -p ios -v 26.0 --change \u002Fusr\u002Flib\u002Flibxcselect.dylib \\\n                            @loader_path\u002F..\u002Flib\u002Flibxcselect.dylib\n```\n\nThe reason a macOS library helps at all on iOS is that its answer there is the\nhonest one. `xcselect_get_developer_dir_path` looks for `DEVELOPER_DIR`, then\nthe `\u002Fvar\u002Fselect\u002Fdeveloper_dir`, `\u002Fvar\u002Fdb\u002Fxcode_select_link` and\n`\u002Fusr\u002Fshare\u002Fxcode-select\u002F*` symlinks; on iOS there are none, so it returns\nfalse, and every caller guards on exactly that:\n\n```\nbl _xcselect_get_developer_dir_path\ncbz w0, \u003Cskip the respawn into Xcode's copy of this tool>\n```\n\nso the tools carry on and do their own work, with no environment variable\nneeded. `DEVELOPER_DIR` is honoured if you set one.\n\nLifting the real library is confirmed on the SRD — `vmmap` and `heap` both run\nagainst it. That mattered to check, because the real library imports 63\nlibSystem symbols and a single unresolvable one would stop it loading, taking\nall nine tools with it. Every one of them exists on iOS.\n\n## Running the ported tools on the device\n\nThe cryptex is mounted at a path with a per-install random suffix, but the shell\non the device already has it in the environment:\n\n```sh\necho $CRYPTEX_MOUNT_PATH\n# \u002Fprivate\u002Fvar\u002Frun\u002Fcom.apple.security.cryptexd\u002Fmnt\u002Fcom.research.base-cryptex.ZcDlFS\n```\n\nIts `bin`, `sbin` and `usr\u002Fbin` are already on `PATH`, so `otool`, `nm` and the\nrest just work. Some tools need an environment variable first, because they look\nfor their own data at macOS paths that do not exist on iOS and are **compiled\ninto the binary** — C strings in `__TEXT`, not load commands, so machomorph\nnever sees them, and they could not be patched anyway because the mount point is\ndifferent every install.\n\nEverything in one block, if you just want the tools to work:\n\n```sh\nexport SSL_CERT_FILE=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fssl\u002Fcert.pem\nexport CURL_CA_BUNDLE=$SSL_CERT_FILE\nexport OPENSSL_CONF=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fssl\u002Fopenssl.cnf\nexport PERL5LIB=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fperl5:$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fperl5-extras\nexport RUBYLIB=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fruby:$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fruby\u002Funiversal-darwin25\nexport SDKROOT=\u002F\nexport ZDOTDIR=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fzsh\u002Fzdotdir\n```\n\nThe cryptex ships `etc\u002Fprofile` and `etc\u002Fzshenv` containing exactly that,\nderived from `$CRYPTEX_MOUNT_PATH` at runtime — so rather than typing the block,\ncopy them to the device **once**:\n\n```sh\nssh -p 2222 root@localhost 'cat > ~\u002F.zshenv'  \u003C \u003Ccryptex>\u002Fetc\u002Fzshenv\nssh -p 2222 root@localhost 'cat > ~\u002F.profile' \u003C \u003Ccryptex>\u002Fetc\u002Fprofile\n```\n\nOnce per *device*, not per install: both locate the cryptex through\n`$CRYPTEX_MOUNT_PATH`, which `cryptex-run` exports, so they survive every later\nreinstall. `rebuild_cryptex.sh` prints these two lines beside the\n`srdtool cryptex install` line.\n\nThey have to be copied because the cryptex is **not** path-overlaid onto `\u002F`:\nmeasured, `\u003Ccryptex>\u002Fetc\u002Fprofile` is present while `\u002Fetc\u002Fprofile` is absent (and\nso is `\u002Fbin\u002Fbash`, though the cryptex ships `bin\u002Fbash`), while `\u002Fetc\u002Fhosts` —\niOS's own — is there. `\u002Fprivate\u002Fpreboot\u002FCryptexes\u002FOS` is a *dyld* search prefix\nfor dylibs, not a filesystem overlay, and holds only `System` and `usr`.\n`\u002Fprivate\u002Fetc` is read-only, so nothing can be dropped there either.\n\nTwo files rather than one, because the choice is not arbitrary: zsh never reads\n`\u002Fetc\u002Fprofile` (that is sh and bash), and `\u002Fetc\u002Fzshenv` is the right zsh hook\nrather than `\u002Fetc\u002Fzshrc` because it is read on **every** invocation including\n`zsh -c` — the failing case is non-interactive — and because it is read before\n`~\u002F.zshenv`, which matters since `ZDOTDIR` must be set before zsh looks for\n`$ZDOTDIR\u002F.zshenv`. `\u002Fetc\u002Fzprofile` and `\u002Fetc\u002Fzshrc` are deliberately not\nshipped: they are read *after* `$ZDOTDIR\u002F.zshenv` has reassigned\n`ZDOTDIR=$HOME`, so setting it again there would send zsh looking for your own\n`.zprofile` and `.zshrc` inside the cryptex.\n\n`$HOME` (`\u002Fvar\u002Froot`) is writable and the login shell reads `~\u002F.profile`, so\nthat block in `~\u002F.profile` makes it permanent for interactive sessions —\nverified on device, after which a bare `curl https:\u002F\u002Fapple.com` works. Note it\napplies to **login** shells only, so `ssh \u003Cdevice> 'curl ...'` still gets\nnothing: that is a non-interactive shell, which reads neither `~\u002F.profile` nor\n`~\u002F.bashrc`. Export them in the command for scripted use.\n\nThere is no way to avoid the variables entirely. `\u002Fprivate\u002Fetc` is read-only, so\nthe files cannot be put where the tools already look, and the mount point's\nrandom suffix means no absolute path can be compiled in.\n\n| tool | needs | without it |\n|---|---|---|\n| `curl` | `CURL_CA_BUNDLE` | `curl: (77) error setting certificate verify locations: CAfile: \u002Fetc\u002Fssl\u002Fcert.pem` |\n| `openssl` | `SSL_CERT_FILE`, `OPENSSL_CONF` | handshake completes, chain verification fails |\n| `perl` | `PERL5LIB` | `Can't locate POSIX.pm in @INC` |\n| `ruby` | `RUBYLIB`, `SDKROOT` | `cannot load such file -- rubygems.rb` |\n| `zsh` | `ZDOTDIR` | `failed to load module 'zsh\u002Fzle'`, no line editing |\n| `expect` | nothing | — Tcl finds `lib\u002Ftcl8.5` relative to the interpreter by itself |\n\n**curl and openssl** — iOS has no `\u002Fetc\u002Fssl` at all. macOS's curated bundle is\nstaged at `share\u002Fssl\u002Fcert.pem`. Note the two fail differently, and curl's is the\nmore confusing: openssl completes a TLS handshake and then reports `unable to\nget local issuer certificate`, while curl refuses up front with error 77, which\nreads like a broken build and is a missing file. `SSL_CERT_FILE` is not enough\nfor `openssl s_client`, which does not call `SSL_CTX_set_default_verify_paths`\nunless given `-CAfile` — pass it explicitly there. `curl` honours\n`CURL_CA_BUNDLE` on its own.\n\nRuby's `Net::HTTP` is a third case: it consults neither, so pass `ca_file`:\n\n```sh\nruby -e 'require \"net\u002Fhttps\"; u=URI(\"https:\u002F\u002Fapple.com\u002F\")\n  h=Net::HTTP.new(u.host,443); h.use_ssl=true; h.ca_file=ENV[\"SSL_CERT_FILE\"]\n  puts h.get(\"\u002F\").code'                                          # 200\n```\n\n**perl** — the interpreter runs unaided, but `@INC` still points at\n`\u002FSystem\u002FLibrary\u002FPerl`, so anything that loads a module fails:\n\n```sh\nperl -e 'print \"hi\\n\"'                 # works: no modules involved\nperl -MPOSIX -e 'print POSIX::floor(3.7)'\n#   Can't locate POSIX.pm in @INC (@INC contains: \u002FLibrary\u002FPerl\u002F5.34\u002F... )\n\nexport PERL5LIB=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fperl5\n$CRYPTEX_MOUNT_PATH\u002Fbin\u002Fperl -MPOSIX -e \\\n    'printf(\"uname=%s release=%s\\n\", (POSIX::uname())[0], (POSIX::uname())[2])'\n#   uname=Darwin release=27.0.0\n```\n\nThat also exercises the 51 XS `.bundle` modules, which are converted like any\nother Mach-O.\n\n**ruby** — the interpreter and its lifted `libruby` were always sound, but\nnothing was staged for it, and `ruby -v` works without any of it, which is how\nthis went unnoticed for a while. `ruby -e 'puts 1'` needs three things:\n\n```sh\nexport RUBYLIB=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fruby:$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fruby\u002Funiversal-darwin25\nexport SDKROOT=\u002F\nruby -e 'require \"digest\"; puts Digest::MD5.hexdigest(\"abc\")'\n#   900150983cd24fb0d6963f7d28e17f72\n```\n\n`RUBYLIB` must name **both** directories: `rbconfig.rb` lives in the arch\nsubdir, and naming only the top gives `cannot load such file -- rbconfig`. And\n`SDKROOT` must be set to *something*, because Apple's `rbconfig.rb` computes\n`CONFIG[\"includedir\"]` by backticking `xcode-select --print-path && xcrun\n--show-sdk-path` at require time, and **iOS has no `\u002Fbin\u002Fsh`**, so the require\ndies with `Errno::ENOENT`. The line short-circuits on `ENV['SDKROOT']`, so no\npatch to Apple's file is needed.\n\nAll 96 native `.bundle` extensions are converted and load, which is not\noptional: `rubygems\u002Fspecification.rb` requires `stringio`, so without them even\n`ruby -e 'puts 1'` fails. Only `--disable-gems` avoids it.\n\n**zsh** — the shell runs, but three of its search paths are absolute macOS\npaths *compiled into the binary*, so machomorph never sees them (they are C\nstrings in `__TEXT`, not load commands) and they cannot be patched either, since\nthe mount point is different every install:\n\n```\n\u002Fusr\u002Flib\u002Fzsh\u002F5.9              module_path   -> failed to load module 'zsh\u002Fzle'\n\u002Fusr\u002Fshare\u002Fzsh\u002F5.9\u002Ffunctions  fpath         -> compinit and autoloads fail\n\u002Fusr\u002Fshare\u002Fzsh\u002Fsite-functions fpath\n```\n\nThe cryptex ships both trees plus a startup file that corrects the two\nparameters at runtime, so one export is enough:\n\n```sh\nexport ZDOTDIR=$CRYPTEX_MOUNT_PATH\u002Fshare\u002Fzsh\u002Fzdotdir\nzsh -c 'zmodload zsh\u002Fzle && echo ok'\n```\n\n`$ZDOTDIR\u002F.zshenv` is read on every invocation, before any module is wanted. It\nsets `module_path` and `fpath` from `$CRYPTEX_MOUNT_PATH` and then reassigns\n`ZDOTDIR=$HOME`, so shipping it does not hide your own `.zshrc`.\n\nTwo things worth knowing if you do it by hand instead. These are zsh\n*parameters*, not environment variables — exporting `MODULE_PATH` has no effect,\nbecause `module_path` is not one of the arrays tied to one (`path`\u002F`PATH` is).\nAnd without the modules you lose interactive line editing — arrow keys,\ncompletion, history — while scripting is unaffected:\n\n```sh\nzsh -c \"module_path=($CRYPTEX_MOUNT_PATH\u002Fshare\u002Fzsh\u002F5.9); zmodload zsh\u002Fzle\"\n```\n\n## What has been tested on device\n\nSRD iPhone18,3, iOS 27.0 `24A5424a`. Everything below was launched and observed\nworking, not merely converted. Raw probe data in `measurements\u002F`.\n\n| | result |\n|---|---|\n| **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 |\n| **memory and symbolication** (8) | all run: `atos`, `symbols`, `vmmap`, `heap`, `leaks`, `malloc_history`, `stringdups`, `filtercalltree`. `vmmap $$` gives real output against a live process |\n| **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 |\n| **ruby** | 2.6.10, all 96 native extensions load — `socket`, `openssl`, `zlib`, `fiddle`, `ripper`; `net\u002Fhttps` to apple.com returns 200 |\n| **expect** | 5.45, drives `openssl`'s interactive REPL over a pty |\n| **perl** | 5.34, including XS modules from both the core and Extras trees (`Digest::MD5`, `JSON::PP`) with `PERL5LIB` set |\n| **openssl** | LibreSSL 3.3.6; SHA-256, RSA and EC sign\u002Fverify, AES, TLSv1.3 to apple.com with `Verify return code: 0 (ok)` |\n| **curl** | `https:\u002F\u002Fwww.apple.com\u002F` → **200**, 254 KB, `ssl_verify_result 0` |\n| **tcpdump** | captures and fully decodes; `--version` works |\n| **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 |\n| **bash** | the ported macOS bash runs, forks, and serves as dropbear's login shell |\n\n### The whole-tree sweep\n\nEvery entry in the cryptex's `bin\u002F` launched under a timeout and classified.\n\n| outcome | first sweep (2026-08-30) | final |\n|---|---|---|\n| loads and runs | 379 | **399** |\n| **fails: library missing** | **243** | **0** |\n| fails: symbol missing | 70 | 39 |\n| SIGKILLed | 83 | **0** |\n| crash | — | 1 |\n| blocked (daemon\u002Finteractive) | 64 | 16 |\n| not run (denylisted) | 124 | 64 |\n\n**Nothing fails on a library any more.** The remaining 39 are iOS genuinely not\nexporting a symbol — confirmed from the crash reports, where\n`termination.namespace` is `DYLD` and `indicator` is `Symbol missing` — led by\n`_syslog$DARWIN_EXTSN` (7; see `next-session.md`). The single crash is `sntpd`'s\nown `__assert_rtn`.\n\nAnd the check that matters after all the shared-cache work: of the 40 crash\nreports the final sweep produced, **none is `EXC_ARM_PAC_FAIL`**. Every lifted\nlibrary survives every path the sweep reaches.\n\n### Four facts worth recording, because all were open questions\n\n* **arm64 binaries run fine from a cryptex on an arm64e device.** The Xcode\n  toolchain ships arm64 only, and everything else here is arm64e.\n* **A locally built, non-platform arm64e dylib loads**, as long as it is inside\n  the cryptex and so covered by its trust cache.\n* **iOS ships no shell and no coreutils.** The whole of `\u002Fbin`, `\u002Fsbin`,\n  `\u002Fusr\u002Fbin` and `\u002Fusr\u002Fsbin` on the device is daemons plus a handful of\n  diagnostics (`data\u002Fios_native_commands.txt`) — no `sh`, `ls`, `cat` or\n  `sleep`. Everything a script uses has to come out of the cryptex.\n* **A lifted library reserves 1–2 GB of address space**, because it keeps the\n  cache's segment addresses (an ADRP immediate is a fixed PC-relative distance,\n  so nothing can move). dyld reserves the whole span, and what runs out is the\n  largest *contiguous* hole rather than a total: measured with perl's\n  `DynaLoader`, 1972 MB then 1659 MB then 881 MB all load, while 1765–1887 MB\n  do not once 1972 MB is taken. **Two or three lifted libraries per process.**\n  That is why `systemstats`, which needs six, is blocklisted — though its real\n  blocker is simpler: `CoreDisplay` and `IOPresentment` need symbols iOS does\n  not export at all.\n\n## Two things that break a library taken out of the shared cache\n\nBoth were found by `dlopen`-ing the staged libraries on the device and reading\nthe error, and both are now handled automatically.\n\n**`incompatible platforms: iOS - macCatalyst`.** A macCatalyst-capable library\ncarries *two* `LC_BUILD_VERSION` load commands. Retargeting only the first\nleaves the macCatalyst one behind and dyld rejects the image outright.\nmachomorph now drops the extras — nothing addresses `LC_BUILD_VERSION` by\nordinal, so removing them is safe.\n\n**`__DATA_CONST segment missing SG_READ_ONLY flag`.** Images inside a dyld\nshared cache do not carry `SG_READ_ONLY` on `__DATA_CONST`, because the cache\nguarantees that protection itself. Pull one out with `ipsw dyld extract` and it\ncannot be loaded as a standalone file until the flag is restored. machomorph now\nrestores it, on `__AUTH_CONST` too — a segment that only exists in cache images.\n\nNeither is visible in `otool -L` output, and a weak reference hides both: dyld\nskips the library silently and the process dies later with a confusing\n`Symbol not found ... Expected in: \u003Cno uuid> unknown`. If you see that, the\nlibrary did not load, and `dlopen`-ing it directly is the fastest way to find\nout why.\n\n## Lifting a library out of the dyld shared cache\n\nA library that exists only inside the cache is not a file, and what\n`ipsw dyld extract` or Apple's `dsc_extractor.bundle` hand back is not a\nloadable dylib either. machomorph repairs both reasons automatically — you only\nneed to notice it happening in the output:\n\n    Relaid out 6 segments for a standalone image, rebuilt 93 exports\n\n**The segments are unmappable.** They keep the addresses they had in the cache,\nwhich are neither ascending nor page aligned — images share pages, so each\nsegment begins wherever it happens to:\n\n| segment | vmaddr | offset within a 16K page |\n|---|---|---|\n| `__TEXT` | `0x19ce1e000` | `0x2000` |\n| `__DATA_CONST` | `0x1e712a5a8` | `0x25a8` |\n| `__AUTH_CONST` | `0x1f0987358` | `0x3358` |\n| `__AUTH` | `0x1eddd38f8` | `0x38f8` |\n\ndyld walks you through this one error at a time: `segment '__AUTH' vm address\nout of order`, then `file offset out of order` once they are sorted, then a bare\n`mmap … errno=22` once both orderings are fixed.\n\nThe repair moves **nothing**. Addresses are baked into the image in places no\nrewrite could reach — every ADRP\u002FADD pair in the code is a PC-relative distance\nto data — so instead each segment is grown *backwards* to the page boundary\nbelow it and the gap zero-filled. Every address stays exactly where it was. A\nuniform shift first makes `__TEXT` page aligned, which keeps the mach header at\nfile offset 0; the symbol table's absolute addresses follow that shift.\n\n**The export trie is empty.** A cache holds export information centrally, so a\nper-image `LC_DYLD_EXPORTS_TRIE` is present but zero-sized: the library loads\nand exports nothing, and every import against it fails. The symbol table\nsurvives extraction intact, so machomorph rebuilds the trie from it — as a radix\ntree, which matters, because a flat one-child-per-symbol trie silently breaks\nwhenever one name is a prefix of another (`_foo` and `_foobar`).\n\n**iOS checks `__LINKEDIT` alignment; macOS does not.** Its sub-tables must be\n8-byte aligned, and a page-aligned relocation shifts them by whatever the\noriginal offset happened to be — `mis-aligned LINKEDIT content 'symbol table'`.\nmachomorph pads *inside* the segment so the shift is a multiple of 16, which\npreserves the original alignment, and 8-aligns the trie it appends. Worth\nknowing because a library that loads perfectly on the Mac can still be rejected\non device for this alone.\n\n**How far a raw extraction gets you.** `tcpdump` works properly — `tcpdump -i\nlo0` captures and fully decodes traffic (TCP\u002FIP dissection, port names, TCP\noptions, timestamps), and `tcpdump -D` lists interfaces. `dtrace` prints its\nusage and real runtime output, then crashes on `-l` and `-n`. `curl` and\n`openssl` crashed on every path tried. The difference is which code paths a\ntool takes, for the reason below — and the reason a plain extraction is never\nwhat you want to ship.\n\n**The remaining limit, and how it was removed.** A cache-extracted library maps\nand resolves symbols, and then crashes when a particular path dereferences an\nunrebased pointer (`KERN_INVALID_ADDRESS at 0x7098993000100020` — a raw\nchained-pointer bit pattern, not an address). Extracted images carry **no**\n`LC_DYLD_CHAINED_FIXUPS`, because a shared cache does relocation centrally.\n\nThat is only half of it, and the other half is the half that matters: **the\ncache builder uniques GOT entries cache-wide.** An image keeps its own `__got` \u002F\n`__auth_got` sections, but the builder zeroes them, clears their section type,\nand rewrites the image's code to reach a shared GOT region that belongs to no\nimage at all. Extract the image and its every stub points outside its own\nsegments. That is a *code* problem, not just a metadata one, which is why no\nextractor fixes it — Apple's and `ipsw`'s both reproduce the rewritten code\nverbatim.\n\nEverything needed to undo it survives, though. The indirect symbol table is\nintact, `stub[i]` and `__auth_got[i]` name the same symbol, and the dead GOT\nsections are exactly the right size. Only two things have to come from the\ncache: which words in the image's data are pointers (its slide info) and which\nsymbol each cache-wide GOT slot held (its patch table). So:\n\nAn input that is not a file anywhere is a lift, not an error:\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Flib\u002Flibxcselect.dylib -o out\u002Flibxcselect.dylib \\\n    -p ios -v 26.0 \\\n    --change \u002Fusr\u002Flib\u002Flibxcselect.dylib \\\n             @loader_path\u002F..\u002Flib\u002Flibxcselect.dylib\n```\n\nextracts, collects the facts, retargets, repoints every stub at the image's own\nGOT, synthesises `LC_DYLD_CHAINED_FIXUPS`, repairs the ObjC metadata, refuses to\nhand back anything still reaching the cache, and compacts the result.\n`libxcselect` (58 stubs, 86 fixups) and `libdtrace` (2259 fixups) both run\nafterwards. It is the same pipeline the closure pass uses, so there is one\nimplementation of it.\n\nSeven stages, each its own module in `dsc\u002F` with its own CLI — because when a\nlift comes out wrong, the way to find out why is to run one stage by hand on the\nintermediate:\n\n| script | job |\n|---|---|\n| `dsc.gotscan` | diagnosis only — reports the damage and whether it is repairable |\n| `dsc.facts` | pulls the slide-info and GOT-symbol facts out of the cache |\n| `dsc.rebind` | repoints the code and synthesises the fixups |\n| `dsc.objc` | rebases the ObjC selector, protocol and class references |\n| `dsc.compact` | packs the segments, closing the 1.3–2.0 GB address-space hole |\n\nThat repair is what `curl` and `openssl` needed. Both were briefly rescued by\ncross-compiling LibreSSL and curl for iOS instead; that route is gone.\n**Everything this project ships is now the Apple binary, rewritten** — no\nlibraries compiled from source, no hand-written stubs. Lifting is strictly more\ngeneral: it works for `libdtrace` and `libxcselect`, which have no upstream to\nbuild from and exist as a file nowhere, and it gives Apple's actual\nimplementation rather than a look-alike.\n\n`--dry-run` applies it to a whole dependency closure and stops:\n\n```sh\n.\u002Fmachomorph.py \u002Fusr\u002Fbin\u002Fcurl -o out\u002Fcurl -p ios -v 26.0 --dry-run\n```\n\nTwo ceilings it reports before you commit to anything. A lift only works if the\nlifted library's *own* imports all exist on iOS — `CoreDisplay` needs\n`_DSBrightnessExternalConvertLinearToUser`, which iOS does not export, so\n`systemstats` can never work. And an *uncompacted* lift keeps the cache's\nsegment addresses, so dyld reserves its whole 1.3–2.0 GB span at load and what\nruns out is the largest contiguous hole — two or three per process, never six.\nCompaction is on by default and removes that ceiling; `--no-compact` puts it\nback.\n\nUse `native\u002F` (a wrapper around Apple's extractor) to get the images out;\nit must be built `-arch arm64e`, since the bundle ships only x86_64 and arm64e\nand `dlopen` needs a matching slice. It extracts the whole cache at once —\nseveral GB — so do it once and keep the tree. machomorph picks it up from\n`\u002Ftmp\u002Fdsc_out` automatically, and builds the wrapper itself if `clang` is\naround.\n\n`native\u002Fdlopen_test`, `dlsym_test` and `dlcall_test` are worth knowing\nabout: a converted **macOS**-targeted library can be loaded on the Mac itself,\nwhich is a far faster way to find layout problems than reinstalling a cryptex.\nPass `--no-cpusubtype-fix` for that, or the macOS arm64e ptrauth version\n(`arm64e.v1`) will make it unloadable by an ordinary process. Use `dlcall_test`\nin preference to the other two: a damaged extraction loads and resolves symbols\nperfectly, and only faults once you actually call into it.\n\n\n## How it works\n\nFive edits to the Mach-O, and one call to `codesign`:\n\n1. **Pick one architecture.** iOS will not load a universal binary that still\n   carries an x86_64 slice, so the target slice is extracted.\n2. **Retarget the platform.** `LC_BUILD_VERSION`'s `platform`, `minos` and `sdk`\n   fields are rewritten. Older binaries carrying `LC_VERSION_MIN_*` instead are\n   converted or retargeted in place.\n3. **Fix the arm64e ptrauth ABI.** macOS refuses to run an arm64e binary whose\n   pointer-authentication ABI version is 0, so `--platform macos` forces\n   `cpusubtype` to `0x81000002`; for device platforms the version bits are\n   cleared back to `0x80000002`.\n4. **Fix framework paths.** macOS frameworks are versioned bundles\n   (`CoreFoundation.framework\u002FVersions\u002FA\u002FCoreFoundation`); on iOS they are flat\n   (`CoreFoundation.framework\u002FCoreFoundation`). The `Versions\u002FX\u002F` component is\n   stripped from every `LC_LOAD_DYLIB`, `LC_ID_DYLIB` and `LC_RPATH`.\n5. **Re-sign.** All of the above invalidate the signature. The binary's existing\n   entitlements are read straight out of the embedded `CS_SuperBlob`, optionally\n   extended, and handed back to `codesign` for an ad-hoc signature.\n\nSome implementation notes:\n\n* Load commands are **rebuilt**, not patched in place, so paths may grow as well\n  as shrink. If the result no longer fits in the linker's header padding, the\n  tool refuses to write rather than corrupt the binary, and tells you how many\n  bytes short it is.\n* **Nothing else in the file moves.** All file offsets are preserved, so chained\n  fixups, the symbol table and `__LINKEDIT` need no adjustment.\n* The SDK version is set to `major \u003C\u003C 16` (minor and micro zeroed). Override it\n  with `--sdk` if you need something exact.\n* 64-bit little-endian Mach-O only (arm64\u002Farm64e\u002Fx86_64). No 32-bit, no PPC —\n  those error out explicitly rather than misparsing.\n\n## Tests\n\n`.\u002Ftest_machomorph.py` is a differential test suite: it runs the real `lipo`,\n`cbv`, `install_name_tool` and `ldid` next to our implementations and compares\nthe results. Checks whose reference tool is missing are skipped.\n\n```sh\n.\u002Ftest_machomorph.py --cbv \u002Fpath\u002Fto\u002Fcbv\n```\n\n\n## Repository layout\n\n```\nmachomorph.py            the tool: convert a Mach-O for another Apple platform\n                         and bring its libraries along -- the dependency\n                         closure, the order of the lift and the output layouts\ntest_machomorph.py       its tests, diffed against the real toolchain\n\ndsc\u002F                     read and repair an image from a dyld shared cache\n  image.py                 just enough Mach-O to reason about stubs and GOTs\n  arm64.py                 the four instruction forms this project decodes\n  extract.py               pull an image out of the cache, as the cache holds it\n  facts.py                 what the cache knows and an extraction does not\n  rebind.py                repair the uniqued GOT, synthesise chained fixups\n  objc.py                  rebase the selector, protocol and class references\n  compact.py               pack the segments, closing the address-space hole\n  gotscan.py               judge a lifted library; modifies nothing\n  index.py                 the loadable-path list, out of a cache or an IPSW\n\ncryptex\u002F                 build a cryptex, and check it before installing\n  verify.py                pre-install gate over the staged tree\n  symbols.py               the launch prediction, per binary\n  blocklist.py             turn a probe result into the exclusion list below\n  restage.py               superseded; kept for a tree built by an older version\n\nscripts\u002F                 shell, because it drives other programs\n  rebuild_cryptex.sh       scrape the system, convert it all, copy the data\n                           trees, check the result. Nothing else\n  device_probe.sh          launch every ported binary on the device, classify\n\nnative\u002F                  C, built by its own Makefile\n  dsc_extract.c            wraps Apple's dsc_extractor.bundle\n  dlopen_test.c            does it map?\n  dlsym_test.c             does its export trie work?\n  dlcall_test.c            does it RUN? The one that catches a damaged lift\n\ndata\u002F                    measured inputs the build reads\n  ios27_*_index.txt        what the target's dyld cache can load\n  ios_native_commands.txt  what iOS already ships\n  blocklist_symbols.txt    what dies on a missing symbol, and WHICH symbol.\n                           Generated by cryptex.blocklist -- not by hand\n  exclude_xcrun_shims.txt  the 95 xcrun shims, excluded by path\n  no_compact.txt           images compaction must not touch\n  ssl\u002Fopenssl.cnf          the trust store's config\n\nlifted\u002F                  the lift cache: one library per basename, re-made\n                         whenever any of the lifting code is newer (gitignored)\n```\n\n`machomorph.py` owns the conversion, the dependency closure and the order of\nthe lift; `dsc\u002F` owns everything that knows what a shared cache is; `cryptex\u002F`\nowns the checks; `scripts\u002F` drives other programs; `native\u002F` is C. Each of\n`dsc\u002F` and `cryptex\u002F` is a package whose modules import each other properly, so\nthere is no `sys.path` juggling and no importing a library out of a CLI.\n\nRun a stage directly with `python3 -m`, from the repository root:\n\n```sh\npython3 -m dsc.gotscan  lifted\u002Flibcrypto.46.dylib\npython3 -m dsc.compact  in.dylib -o out.dylib\npython3 -m dsc.index    iPhone18,3_26.4_23E246_Restore.ipsw -o data\u002Fidx.txt\npython3 -m dsc.symindex iPhone18,3_26.4_23E246_Restore.ipsw -o \u002Ftmp\u002Fsyms.txt.gz\npython3 -m cryptex.verify  --cryptex DIR --dylib-index data\u002Fios27_*.txt\npython3 -m cryptex.symbols --all --cryptex DIR\n```\n\n`cryptex.verify` and `cryptex.symbols` still print `verify_cryptex` and\n`symbol_check:` as their own labels, so measurements recorded before the move\nstay comparable.\n\n### The two exclusion lists, and why they are separate\n\nA scan applies both by default (`--no-exclude-defaults` opts out). They are\nkept apart, rather than being one list, because they are believed for different\nreasons.\n\n`exclude_xcrun_shims.txt` is 95 **paths**. `\u002Fusr\u002Fbin` hard-links one inode under\n78 names whose whole body is `xcselect_invoke_xcrun`: look up the active Xcode\nand re-exec into it. There is no Xcode on a phone, and on iOS they are SIGKILLed\nrather than merely useless. It has to match by path, not by name, because those\nnames exist twice -- `otool` is both a shim and, in the Xcode toolchain, the\nreal `llvm-otool`. A bare `otool` line would drop the tool you want; the\n\u002Fusr\u002Fbin scan runs first, so an earlier build shipped `bin\u002Fotool -> DeRez`.\n\n`blocklist_symbols.txt` is **generated** from a device probe, and each line\ncarries the symbol that blocked it:\n\n```\ndate              # _syslog$DARWIN_EXTSN\njar               # _OBJC_CLASS_$_JLRuntime\npostconf          # _sasl_client_init\n```\n\nRegenerate it after a probe rather than editing it:\n\n```sh\ncryptex\u002Fblocklist.py measurements\u002F\u003Clatest>.tsv --cryptex \u003Ccryptex> \\\n    > data\u002Fblocklist_symbols.txt\n```\n\nTwo things follow from generating it. A missing symbol is a snapshot rather than\na verdict -- it can be answered by a rename, a forwarding shim, or a newer iOS\n-- so the symbol is recorded to make the exclusion reversible: delete the lines\nnaming it and re-probe. And the generator **holds back** a binary whose missing\nsymbol is imported by a library *this cryptex bundles*, because that failure is\nours to fix rather than a limit of iOS. It distinguishes the two by measurement,\nsince the probe output is identical either way: `date` imports\n`_syslog$DARWIN_EXTSN` itself from libSystem and is excluded, while `openssl`,\n`tcpdump` and `curl` never import it and inherit it from the bundled\n`libcrypto`, so they are held back with the reason written into the file.\n\nEverything under `data\u002F` used to include a single hand-maintained\n`blocklist_ios.txt`. It drifted from the measurements it claimed to encode and\nexcluded 480 binaries on reasoning that did not survive checking -- among other\nthings it blocked `ioreg`, whose native build is SIGKILLed on iOS while the port\ndumps the whole registry. It is kept as evidence in `measurements\u002F`, and\n`next-session-blocklist.md` is the plan for rebuilding a defensible version.\n\n\n## A note on how this was built\n\n`machomorph` was vibe-coded with Claude (Claude Code, Opus 5). That is worth\nsaying out loud, because \"an LLM wrote a Mach-O rewriter\" should make you want\nto see evidence before you point it at anything you care about.\n\nSo the output is verified rather than trusted:\n\n* `cbv`'s behaviour was **reverse-engineered by byte-diffing** its output against\n  the input on real system binaries, not guessed at from the blog post. It turns\n  out to mutate exactly three fields plus `cpusubtype`; everything else that\n  differs is `codesign`'s doing.\n* [`test_machomorph.py`](test_machomorph.py) is a **differential** suite — it runs\n  the real `lipo`, `cbv`, `install_name_tool`, `ldid` and `codesign` alongside our\n  implementations and compares the results, rather than asserting against\n  expectations we made up. Our thinning is byte-identical to `lipo -thin`; our\n  entitlement parser agrees with both `ldid -e` and `codesign -d`.\n* The tool was run across **1250 system binaries** from `\u002Fusr\u002Fbin`, `\u002Fusr\u002Fsbin`,\n  `\u002Fusr\u002Flibexec`, `\u002Fusr\u002Flib` and the system frameworks, with no failures; a\n  sample of the results was checked with `codesign --verify` and `otool -l`.\n\nVerification only goes so far, of course: it shows we match the tools we\nreplaced, on the binaries we tried. If something misbehaves, please open an\nissue with the binary in question.\n\n## Inspiration and acknowledgments\n\nThe platform-conversion trick is Jonathan Levin's, described in\n[*Merging macOS and iOS*](https:\u002F\u002Fwww.df-f.com\u002Fblog\u002Fmacosandiosmerge) along with\nhis `cbv` tool — including the `0x81000002` pointer-authentication workaround,\nwhich is not documented anywhere else as far as we know. `machomorph`\nreimplements `cbv`'s behaviour and is byte-for-byte verified against it.\n\nBefore this script, the workflow was:\n\n```sh\nlipo -thin arm64e \u002Fusr\u002Fsbin\u002Fioreg -output \u002Ftmp\u002Fioreg_thin\n.\u002Fcbv \u002Ftmp\u002Fioreg_thin to ios 27.0\notool -L \u002Ftmp\u002Fioreg_thin\ninstall_name_tool -change \\\n    \u002FSystem\u002FLibrary\u002FFrameworks\u002FCoreFoundation.framework\u002FVersions\u002FA\u002FCoreFoundation \\\n    \u002FSystem\u002FLibrary\u002FFrameworks\u002FCoreFoundation.framework\u002FCoreFoundation \\\n    ... \u002Ftmp\u002Fioreg_thin      # once per macOS-only path\nldid -e \u002Ftmp\u002Fioreg_thin > ents.xml\n# hand-edit ents.xml to add research.com.apple.license-to-operate\ncodesign --entitlements ents.xml -f -s - \u002Ftmp\u002Fioreg_thin\n```\n\n`machomorph` folds all of that into one command:\n\n| Step | Previously | Now |\n|---|---|---|\n| Pick one architecture out of a fat binary | `lipo -thin arm64e` | built in (`--arch`) |\n| Rewrite `LC_BUILD_VERSION` platform\u002Fminos\u002Fsdk | `cbv \u003Cbin> to ios 27.0` | `-p ios -v 27.0` |\n| Fix the arm64e ptrauth `cpusubtype` | `cbv` | automatic |\n| Strip macOS-only `Versions\u002FA\u002F` from paths | `install_name_tool -change` × N | automatic |\n| Arbitrary path rewrites | `install_name_tool -change` | `--change OLD NEW` |\n| Inspect header, libraries, entitlements | `otool -hv`, `otool -L`, `ldid -e` | `--info` |\n| Read the existing entitlements | `ldid -e` | automatic |\n| Add the research entitlement | edit XML by hand | `--license-to-operate` |\n| Re-sign ad hoc | `codesign -f -s -` | automatic |\n\nThanks also to `ldid` (Jay Freeman \u002F ProcursusTeam) — not required any more, but\nit is the reference our entitlement parser was checked against.\n","machomorph 是一个将 macOS 命令行工具（如 ioreg、vmmap、csrutil）移植到 iOS 设备运行的工具。它通过修改 Mach-O 二进制文件的平台标识、修复框架路径、重签名，并自动从 macOS dyld 共享缓存中提取并重构缺失依赖库，生成可在 iOS（arm64e）上独立运行的可执行文件。项目纯 Python 实现（仅依赖标准库），无需第三方包，核心逻辑自包含，支持单工具移植与批量构建 SRD cryptex。适用于 iOS 系统级调试、越狱\u002F安全研究、设备内核与驱动分析等需要 macOS 级诊断能力的场景。",2,"2026-09-08 02:30:07","CREATED_QUERY"]