41 Commits
Author SHA1 Message Date
zqxwce 87f796c62a resources: Update resources reference 2026-09-01 01:33:43 +03:00
zqxwceandClaude Fable 5 deabe6ffca catalog: Add iOS 27 beta 8 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 27.0 beta 8 (24A5430a) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 22 -> 23.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-09-01 01:33:43 +03:00
zqxwceandClaude Fable 5 2af884b56c resources: Bump storage submodule to fork main (drop ramdisk_input.tar.zst)
Fork main was force-pushed to ae7a0ac, orphaning the previously pinned
ee9a284. Advance the pin so fresh clones resolve against a live ref.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-29 03:44:16 +03:00
zqxwceandClaude Fable 5 977c17b56d resources: Point storage submodule to zqxwce fork
Retarget scripts/resources submodule URL from Lakr233 to the
zqxwce/vphone-cli-storage fork.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-29 03:24:32 +03:00
zqxwceandClaude Fable 5 53aa44dd57 catalog: Add iOS 27 beta 7 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 27.0 beta 7 (24A5424a) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 21 -> 22.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ej64m761KR33KaNYh3BuTo
2026-08-25 11:07:35 +03:00
zqxwceandClaude Opus 4.8 58988bd518 catalog: Replace iOS 26.6.1 RC with release build 23G83
The bundled 26.6.1 IPSW pointed at the 23G82 release candidate, which
differs from the shipped release. Point it at the 23G83 final restore
and update the tested-environment tables accordingly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-19 15:09:39 +03:00
Bent 72ccbde1de vphone-cli: Add VPHONE_ROOT env variable to override user data root 2026-08-18 11:48:47 +03:00
zqxwceandClaude Fable 5 39b6d04108 catalog: Add iOS 27 beta 6 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 27.0 beta 6 (24A5418b) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 20 -> 21.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-18 11:46:45 +03:00
zqxwceandClaude Fable 5 339719f34d catalog: Add iOS 26.6.1 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 26.6.1 (23G82) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 19 -> 20.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-13 17:58:15 +03:00
zqxwceandClaude Fable 5 421cb7aa7a docs: Update vm export/import examples for the reworked flags
Reflect fast-by-default compression (--max = xz -9), directory
auto-naming, and the positional import argument in the README and its
ja/ko/zh translations.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 16:19:33 +03:00
zqxwceandClaude Fable 5 dee895aeb7 cli: Rework vm export/import — presets, auto-naming, positional, progress
Export/import UX overhaul, wiring in the counting-pipe and progress-bar
primitives:

- Compression: replace `--compress {fast,balanced,max}` with a fast
  (zstd -3) default and a single `--max` (xz -9) flag; drop balanced.
- `vm export --out DIR`: when the destination is an existing directory,
  auto-name the archive `<vm>.tzst`/`.txz` and print the resolved path.
- `vm import ARCHIVE`: take the archive as a positional argument instead
  of `--in`.
- Progress bars: export runs a two-stage tar pipeline (uncompressed
  gnutar producer -> compressing consumer via bsdtar `@-`) so the bar is
  driven off the known uncompressed total; import extracts once (was
  decompressing twice) and counts the archive as it is fed into `tar -x`.
  gnutar avoids the pax-header `@-` mtree misbid on large members and,
  unlike ustar, carries files >8 GB.

Import validates the single top-level bundle after extracting into a
private staging dir, still failing fast on an explicit --name collision.

Tests: default is fast zstd; --max is xz; directory auto-naming yields
the right extensions; export/import invoke progress with monotonic,
correctly-totalled callbacks.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 16:19:33 +03:00
zqxwceandClaude Fable 5 673146ba0a cli: Add byte-counting tar pipeline + progress-bar primitives
Two building blocks for streaming progress on vm export/import, unused
until the export/import rework wires them in:

- VPhoneProcessRunner.runCountingTarPipe: drives a /usr/bin/tar consumer
  that reads its archive on stdin, invoking a callback with the running
  byte total. The source is either a producer tar (export) or a file read
  directly (import). SIGPIPE is ignored so a consumer that dies early
  surfaces as its exit status instead of killing this process.
- VPhoneProgressBar: a single-line redrawing byte bar that renders to
  stderr only when it is a TTY, so piped/--json/GUI-subprocess runs stay
  clean and it simply no-ops.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 16:19:33 +03:00
zqxwceandClaude Fable 5 988e5dc389 docs: Add Xcode + iOS SDK to host prerequisites
The build cross-compiles the guest daemon (vphoned) for iOS, which
requires the iOS SDK bundled with Xcode. Add it to the host
prerequisites in the English, Japanese, Korean, and Chinese READMEs.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 14:01:08 +03:00
zqxwceandClaude Fable 5 9f7e21402a cli: Add --compress preset to vm export (fast/balanced/max)
Export previously hardcoded xz -9 (the densest but slowest libarchive
compressor), forcing a multi-GB Disk.img through the most aggressive
setting on every export.

Add a `--compress {fast,balanced,max}` preset on `vphone vm export`,
default `balanced`:
  fast     zstd -3
  balanced zstd -19  (new default)
  max      xz -9     (previous behavior)

All presets go through the same system /usr/bin/tar (libarchive) already
used for xz, so no new dependency and no raised platform floor. Import is
unchanged: it already auto-detects the compressor via `tar -tf`/`-xf`, so
zstd and xz archives both import.

Tests assert each preset's magic bytes (zstd vs xz), that fast/max
round-trip, and the default is zstd. README examples updated.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 13:45:31 +03:00
zqxwceandClaude Fable 5 3bb6f0fda4 frida: Add build.frida.re as a Sileo/apt source at first boot
The JB first-boot setup adds the Frida repo (deb https://build.frida.re/ ./)
next to the existing Havoc source, so Sileo/apt can install and update Frida
packages. Idempotent — skipped if a build.frida.re source is already present —
and picked up by the same insecure-repo `apt-get update` that follows.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 13:29:42 +03:00
zqxwceandClaude Fable 5 ceccc126c8 tests: Add Frida encoder/version-gate tests
- ARM64Encoder round-trips (TBZ/TBNZ bit retarget, movz flag clear) and the
  cloudOS 26.4 version-gate compare.
- Reveal procedures and static/runtime validation for both kernel patches;
  patch-comparison table rows JB-23b / JB-25c and the opt-in note.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 11:40:24 +03:00
zqxwceandClaude Fable 5 ae29e6de84 frida: Install re.frida.server via the extra-debs mechanism
On a --frida build, fetch_debs.sh resolves the latest frida iphoneos-arm64
release deb (== re.frida.server: no Depends, rootless /var/jb layout) into the
debs cache; the existing first-boot `dpkg -i` step installs it. VPHONE_FRIDA is
forwarded through the host CFW install. No APT source or dependency resolution.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 11:40:24 +03:00
zqxwceandClaude Fable 5 74b6076557 cli: Wire --frida through the pipeline, gated to cloudOS 26.4+
Expose --frida on patch-firmware, patch-component, fw patch, and vm create
(plus `make fw_patch_jb/exp FRIDA=1`), threading it into
KernelJBPatcher.applyFrida via FirmwarePipeline.

The kernel patches apply only when the cloudOS kernel is 26.4+ (the versions
where the shapes were validated); older kernels are left untouched. Baseline
JB/EXP output is byte-identical without --frida (26.4 emits 83 records, 87 with).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 11:40:24 +03:00
zqxwceandClaude Fable 5 fdf9487bfd kernel-jb: Add opt-in Frida Stalker kernel patches
Two narrowly-scoped patches, emitted only under the Frida opt-in:

- thread_set_state: clear TSSF_CHECK_ENTITLEMENT in the thread_set_state user
  setters (mov w6,#0x201 -> #0x1) so Frida can follow an existing thread without
  a GUARD_TYPE_MACH_PORT kill, while preserving TSSF_TRANSLATE_TO_USER and the
  TH_IN_MACH_EXCEPTION guard.
- vm_map_delete: retarget the immutable-code exception from current-protection
  execute (bit 9) to max-protection execute (bit 13) so a debugger-created
  RW/max-RWX permanent mapping survives repeated VM_PROT_COPY re-instrumentation
  instead of returning KERN_PROTECTION_FAILURE.

Both matchers are semantic (entitlement-string / developer-mode / call-flow
anchored, no hardcoded offsets/VAs/registers/bytes) and fail closed; replacement
bytes come from ARM64Encoder and are Capstone-verified.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 11:40:24 +03:00
ZomkaDEV d3b873c777 docs: note known ldid-procursus hang in FAQ 2026-08-11 15:05:35 +03:00
ZomkaDEV a5d624b613 fix: re-encode signcert.p12 as modern PKCS12 2026-08-11 15:05:35 +03:00
ZomkaDEV 334a558e32 fix: treat LSD embedded-reg gate as idempotent when already NOP'd 2026-08-11 15:05:35 +03:00
zqxwceandClaude Opus 4.8 f48fc29a27 cfw: Patch os_lockdown_mode_enabled to not crash on iOS 27b5
iOS 27's os_lockdown_mode_enabled() resolves Lockdown Mode via
sysctlbyname("security.mac.lockdown_mode_state_public", ...) and os_crashes
on a -1 return. The vphone base kernel (cloudOS 26.x) does not implement that
MAC sysctl, so the call returns -1/ENOENT and the first daemon to query
Lockdown Mode after "Continuing system boot" -- launchd (pid 1) -- aborts,
panicking the system (initproc exited, namespace 2 subcode 6).

Add cfw_patch_lockdown_mode.py: NOP the `cmn w0,#1; b.eq <os_crash>` gate so
the pre-zeroed output buffer path is taken (Lockdown Mode = disabled);
behavior-neutral on a kernel that implements the sysctl. Wire it into cfw.py
(patch-lockdown-mode) and the cfw_install.sh 27.* DSC-patch block. Also fixes
the 0_binary_patch_comparison.md LWCR note and adds row 16.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N2bwKrGJWY7o2ArdxibVPe
2026-08-11 14:50:22 +03:00
zqxwceandClaude Opus 4.8 ab456ac67e cfw: Apply the libxpc LWCR patch on iOS 27 (correct mangled symbol)
The patcher resolved `_xpc_token_satisfies_lwcr`, but libxpc's internal
routine carries the standard extra leading underscore in the DSC symbol
table (`__xpc_token_satisfies_lwcr`), so the lookup missed and the patch
silently no-op'd on every iOS 27 build. Resolve against the mangled name,
falling back to the source name.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N2bwKrGJWY7o2ArdxibVPe
2026-08-11 14:50:22 +03:00
zqxwceandClaude Opus 4.8 6d4855dca3 catalog: Add iOS 27 beta 5 (24A5408d) firmware pairing
Add the iPhone17,3 27.0 24A5408d + cloudOS 26.4 pairing to the firmware
catalog so it is selectable in `fw prepare` / `vm create`, and bump the two
hardcoded pairing counts in the picker tests (18 -> 19).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N2bwKrGJWY7o2ArdxibVPe
2026-08-11 14:50:22 +03:00
zqxwce 545fd35e0f deps: Add cmake
PyPI has no arm64 macOS wheel for keystone-engine, so pip builds it
from the sdist, whose make-share.sh invokes cmake directly. Without
it the build fails silently and installs bindings with no native
library, which is what the libkeystone repair recovers from.
2026-08-05 18:24:56 +03:00
zqxwceandClaude Fable 5 5d22d3ed23 vphone-cli: Verify and repair libkeystone in the managed venv
The unified tool provisions ~/.vphone/venv itself and never runs
setup_venv.sh, so it lost that script's libkeystone handling. It
pip-installs and then verifies with pythonIsUsable, which only probes
ipsw_parser — a venv with keystone bindings and no native library
passes, gets cached as good, and fails much later inside fw patch.

That state is reachable and silent. PyPI has no arm64 macOS wheel
(only macosx_10_14_x86_64), so pip builds keystone-engine from its
sdist, and the sdist's darwin path ignores the build's exit status
(subprocess.call plus a glob that matches nothing) — a failed native
build still installs bindings alone and pip reports success.

Probe that keystone can assemble rather than merely import, and on
failure install a loadable dylib next to the bindings: copy one from
Homebrew if the bottle ships it, else link one from libkeystone.a,
matching setup_venv.sh. The managed venv is repaired in place before
being rebuilt, since a missing dylib is not worth a full re-install.

Verified: repairs a venv whose dylibs were removed, and recovers a
fresh bootstrap that produced bindings-only (pip cache cleared, cmake
off PATH) — pip exits 0, the guard catches it, the venv is accepted.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-05 18:24:56 +03:00
zqxwceandClaude Fable 5 0491a9521a vphone-cli: Stream CFW install log live under --root-popup
`do shell script … with administrator privileges` runs the command under a
helper process that inherits none of our fds, so AppleScript can only hand
back its output once the command has EXITED — and it rewrites every \n to
\r on the way, which a reader applying terminal semantics (the GUI's log
view) takes for progress-bar overwrites, keeping only the final line. The
CFW install stage therefore sat silent for minutes and then showed one line.

Name our own terminal in the command instead: ttyname(stdout) is an absolute
device path the privileged shell can open, and under the GUI it is already
the pty the app is reading. Output then arrives as produced, with newlines
intact, on a tty the script's children line-buffer to. Gated on `echo` so
quiet verbosity still suppresses it, and skipped when stdout is not a tty.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-05 17:33:03 +03:00
zqxwceandClaude Fable 5 8de3c1c5c2 vphone-cli: Show fw prepare output during vm create
The fw prepare step sent the script's entire stdout/stderr to
/dev/null unless -v was passed, so the aria2c/curl/wget progress bar
was discarded with it. That left the longest phase of the pipeline —
a multi-GB IPSW download — printing nothing for minutes, which reads
as a hang.

Stream it unconditionally. Standalone `fw prepare` already does this
by flooring its verbosity at .info; this brings `vm create` in line.
The script itself is status-line based (~40 echoes, unzip -oq, no
per-file loops), so this adds progress rather than noise.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-05 17:01:19 +03:00
zqxwceandClaude Fable 5 d405b12d64 vphone-cli: Add --headless boot option
Boots without a VM window or menu bar. The guest keeps its display
device, so the boot chain is unchanged — only the AppKit window, menu
bar and Dock presence are skipped.

Available on `boot` and `vm launch`. `vm create` now uses it for both
setup boots (first boot and boot analysis); --interactive keeps the
window, since it asks the operator to press Enter once the VM has
booted and the window is their only progress cue.

Note: the vphone.sock host control socket does not start in headless
mode — its handler needs the VZVirtualMachineView capture view.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-05 16:47:34 +03:00
zqxwce 14e48bcf4b requirements: Add missing setuptools 2026-08-05 16:10:06 +03:00
zqxwceandClaude Fable 5 1ceef58106 vphone-cli: Add --disk-size to vm create
`vm create` built the orchestrator Options without setting diskSizeGB, so
the disk was pinned to the 64 GB default no matter what. The downstream
plumbing (Options.diskSizeGB -> NewBundleSpec -> sparse Disk.img truncate)
already existed; this just exposes `-d/--disk-size` on the command and
threads it through, matching the existing `vm new` convention.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-03 14:03:52 +03:00
zqxwceandClaude Fable 5 4397dc7d49 vphone-cli: Add fw catalog with recommended pairings and --json
Show the known iOS ↔ cloudOS firmware pairings, one recommended cloudOS
per iOS build, projected from VPhoneFirmwareCatalog.pairings. Human
output is an aligned table; `--json`/`-j` emits an object carrying the
device plus each pairing's download URLs.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-03 11:55:02 +03:00
zqxwceandClaude Fable 5 d37c06b61d vphone-cli: Add network to vm info/vm list JSON
`vm info --json` and `vm list --json` encode VPhoneBundleReport, which
carried no network field — so the network mode was absent from JSON even
though the human-readable output prints `net:`. Consumers parsing JSON had
no way to read a VM's network config.

Project the manifest's NetworkConfig into VPhoneBundleReport as `network`,
so both `info` and `list` emit it as structured JSON (mode, macAddress,
bridgeInterface). NetworkConfig gains Equatable (VPhoneBundleReport is
Equatable). The text `net:` line now reads from the same report projection.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-03 11:35:34 +03:00
zqxwceandClaude Fable 5 aeba01f13c vphone-cli: Add variant, udid, and device to vm info
`vm info` now reports the CFW variant the VM was last restored to, its
predicted UDID, and the product type the guest identifies as.

- udid: read from the bundle's udid-prediction.txt (VPhoneRestoreOps.resolveUDID)
- variant + device: recorded into restore-info.json at CFW-install time, in
  both the standalone `cfw install` and the `vm create` orchestrator. device is
  iPhone99,11 for every variant except exp, whose DeviceTree rewrite -> iPhone17,3.
- both new restore-info.json fields are optional, so pre-existing bundles decode
  unchanged and pick up variant/device on their next install.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-03 11:19:21 +03:00
zqxwceandClaude Fable 5 c6fa19efcb networking: Add vm config --network to edit VM network mode after creation
Adds `--network nat|bridged|none` and `--bridge-interface` to `vm config`,
and wires the boot path to honor the stored networkConfig (it previously
hardcoded NAT and ignored the manifest). `vm info` now shows the mode.

- New VPhoneNetworking: validates the mode, resolves/auto-picks the bridge
  interface, and builds the VZ network device — shared by config-time
  editing and boot.
- bridged uses VZBridgedNetworkDeviceAttachment (com.apple.vm.networking
  entitlement already present); hostOnly is rejected (no native VZ
  attachment); none yields no NIC.
- Rename NetworkMode.none -> .off (raw value kept "none") so a NetworkMode?
  literal `.none` can't silently bind to Optional.none.

The MAC is left framework-assigned; forcing a custom MAC breaks guest
networking, so no MAC override is exposed.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-02 13:13:50 +03:00
zqxwceandClaude Opus 4.8 6aef60bd9a vphone-cli: Add --root-popup to elevate CFW host-mount via macOS auth dialog
* feat: add --root-popup to elevate CFW host-mount via macOS auth dialog

Adds --root-popup to `cfw install` and `vm create`, elevating the CFW host-mount through macOS's native authentication dialog (osascript -> do shell script with administrator privileges) instead of the script's sudo re-exec. do shell script runs under a bare env, so the vars the bundled scripts read are forwarded inline, plus SUDO_USER so the script's chown-back still returns artifacts to the invoking user. On `vm create`, --sudo-password takes precedence.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* cfw: remove entire .cfw_temp on install cleanup

Replaces the selective `rm -f` of individual temp binaries with `rm -rf "$TEMP_DIR"`, dropping the cached Cryptex DMGs along with the temp files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-02 11:20:22 +03:00
zqxwceandClaude Fable 5 744090f694 setup: Remove artifacts when done to save disk space
Delete the three large regenerable intermediates once their consumers
finish, keeping the source archives so nothing needs re-downloading:

- built restore firmware (iPhone*_Restore/) after CFW install (its last
  consumer — it copies the SystemOS/AppOS cryptexes onto Disk.img)
- extracted base-IPSW dirs (iOS + cloudOS) at end of fw prepare; the
  downloaded .ipsw files are kept, so a re-run re-extracts, no re-download
- extracted CFW input dirs (cfw_input/, cfw_jb_input/) after cfw install;
  the resources .tar.zst archives are kept

Opt out with --keep-artifacts on `vm create` / `cfw install`, which
threads VPHONE_KEEP_ARTIFACTS to fw_prepare.sh and cfw_install_host.sh.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 15:06:45 +03:00
zqxwceandClaude Fable 5 966bddb62a setup: Record iOS + cloudOS versions on restore
Snapshot the restored iOS userland and cloudOS kernel versions to
restore-info.json at the bundle root so they are readable without booting
the VM, rewritten after every successful restore (vm create and vm restore).

Versions are read host-side from the bundle's iPhone*_Restore plists
(iPhone-BuildManifest.plist for iOS, the hybrid BuildManifest.plist for
cloudOS). vm list / vm info / --json surface them; the file lives at the
bundle root so vm export carries it even when the IPSW dir is excluded.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-30 13:27:18 +03:00
Kila2 25da0fa24a pymobiledevice3: Skip pairing refused devices 2026-07-30 11:08:25 +03:00
zqxwceandClaude Opus 4.8 ecf798e690 docs: streamline README and re-sync ko/ja/zh translations
Restructure the README (Prerequisites/Install/Build up top, a dedicated
SIP/AMFI Relaxation section, Tested Environments moved down) and trim prose.
Regenerate the Korean, Japanese, and Chinese translations to match the new
structure verbatim — same 13 sections in the same order, all commands, URLs,
tables, and identifiers preserved byte-for-byte, only prose and code comments
translated, docs/-relative links and in-language anchors applied.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y4VDqWf5pVakcFLqB23CKe
2026-07-28 18:04:45 +03:00
62 changed files with 2683 additions and 597 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[submodule "scripts/resources"]
path = scripts/resources
url = https://github.com/Lakr233/vphone-cli-storage.git
url = https://github.com/zqxwce/vphone-cli-storage.git
[submodule "vendor/libcapstone-spm"]
path = vendor/libcapstone-spm
url = https://github.com/Lakr233/libcapstone-spm.git
+9 -5
View File
@@ -112,8 +112,10 @@ help:
@echo " make fw_patch_dev Patch boot chain with Swift pipeline (dev mode TXM patches)"
@echo " make fw_patch_jb Patch boot chain with Swift pipeline (dev + JB extensions)"
@echo " Options: FORCE_EXC_GUARD=1 (see fw_patch above)"
@echo " FRIDA=1 Opt in to the Frida Stalker kernel relaxations"
@echo " make fw_patch_exp Patch boot chain with Swift pipeline (JB + EXP experimental)"
@echo " Options: FORCE_EXC_GUARD=1 (see fw_patch above)"
@echo " FRIDA=1 Opt in to the Frida Stalker kernel relaxations"
@echo ""
@echo "Testing:"
@echo " make test_jb_patches Run all JB kernel patches (incl. Sandbox) over every supported cloudOS kernel"
@@ -369,11 +371,13 @@ fw_patch_dev: patcher_build
fw_patch_jb: patcher_build
"$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(VM_DIR_ABS)" --variant jb \
$(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,)
$(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,) \
$(if $(filter 1 true yes YES TRUE,$(FRIDA)),--frida,)
fw_patch_exp: patcher_build
"$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(VM_DIR_ABS)" --variant exp \
$(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,)
$(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,) \
$(if $(filter 1 true yes YES TRUE,$(FRIDA)),--frida,)
.PHONY: test_jb_patches
@@ -481,13 +485,13 @@ cfw_install_dev:
$(MAKE) cfw_install_host VARIANT=dev
cfw_install_jb:
$(MAKE) cfw_install_host VARIANT=jb
$(MAKE) cfw_install_host VARIANT=jb FRIDA="$(FRIDA)"
cfw_install_exp:
$(MAKE) cfw_install_host VARIANT=exp SPOOF_BUILD="$(SPOOF_BUILD)"
$(MAKE) cfw_install_host VARIANT=exp SPOOF_BUILD="$(SPOOF_BUILD)" FRIDA="$(FRIDA)"
# CFW install: place files via host mount + flip the boot snapshot offline.
# VM must be off; re-execs under sudo.
# Options: VARIANT=regular|dev|jb|exp (default exp) SPOOF_BUILD=<id> (exp)
cfw_install_host:
$(if $(SPOOF_BUILD),SPOOF_BUILD="$(SPOOF_BUILD)") zsh "$(CURDIR)/$(SCRIPTS)/cfw_install_host.sh" --variant $(if $(VARIANT),$(VARIANT),exp) "$(VM_DIR_ABS)"
$(if $(SPOOF_BUILD),SPOOF_BUILD="$(SPOOF_BUILD)") $(if $(filter 1 true yes YES TRUE,$(FRIDA)),VPHONE_FRIDA=1) zsh "$(CURDIR)/$(SCRIPTS)/cfw_install_host.sh" --variant $(if $(VARIANT),$(VARIANT),exp) "$(VM_DIR_ABS)"
+90 -110
View File
@@ -4,99 +4,37 @@
Boot a virtual iPhone via Apple's Virtualization.framework using PCC research VM infrastructure.
Everything runs through the single `vphone-cli` binary — create, patch, restore, install, boot, and manage VMs. No `make` needed after building.
![poc](./docs/demo.jpeg)
## Tested Environments
| Host | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
iOS ≤ 26.0.1 use the 26.1 PCC vphone600 stack plus the CFW-time `IOMobileFramebuffer` SwapEnd payload-size patch. iOS 27.0 uses the 26.4 PCC vphone600 stack plus the CFW-time force-kern `IOMobileFramebuffer` present-path patch and the dyld shared-cache `maxSlide` fit.
> **Note:** GPU/Metal acceleration does not work on iOS 18.x — the 18.x Metal/IOGPU framework has no paravirtualized GPU implementation, so Metal-rendered content (web pages, images, wallpaper) does not render. Touch, networking, and apps work normally.
## Firmware Variants
Five patch variants with increasing security bypass — pass one to `--variant`:
| Variant | Boot Chain | CFW | Notes |
| ------------ | ----------- | --------- | ----------------------------------------------------------------- |
| `less` | 4 patches | 2 phases | Patchless — keeps iOS mitigations enabled |
| `regular` | 42 patches | 10 phases | AMFI/SSV/Img4/TXM bypass |
| `dev` | 53 patches | 12 phases | + TXM entitlement/debug bypass |
| `jb` | 113 patches | 14 phases | + full jailbreak (Sileo, TrollStore auto-install on first boot) |
| `exp` | 141 patches | 18 phases | JB superset + anti-VM-detection research patches |
See [`research/0_binary_patch_comparison.md`](./research/0_binary_patch_comparison.md) for the per-component breakdown.
## Prerequisites
**Host:** macOS 15+ (Sequoia), a non-nested Mac (Virtualization.framework can't nest). The private PV=3 entitlements + unsigned-binary workflow need SIP/AMFI relaxed. Pick **one** of these two paths — the SIP setting and the AMFI setting go together, don't mix them:
**Host:**
**Option A — fully disable SIP, then disable AMFI via boot-arg (most permissive).** In Recovery (long-press power → Terminal):
```bash
csrutil disable
csrutil allow-research-guests enable
```
Then reboot into macOS and set the AMFI boot-arg (needs SIP fully off to take effect):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # reboot after
```
**Option B — keep SIP on (debug-only relaxed), then allowlist the binary with amfidont** (leaves AMFI enabled system-wide). In Recovery:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
Then reboot into macOS and allowlist the repo with [`amfidont`](https://github.com/zqxwce/amfidont) (or [`amfree`](https://github.com/retX0/amfree)):
```bash
sudo amfidont --path <path_to_vphone-cli.app>
```
> The `less` (patchless) variant needs Option A, or Option B with `amfidont -S` (`sudo amfidont -S --path <path_to_vphone-cli.app>`).
- Apple Silicon
- macOS 15+ (Sequoia)
- Xcode + iOS SDK (cross-compiles the guest daemon)
- [SIP/AMFI relaxation to allow private PV=3 entitlements with unsigned-binary](#sipamfi-relaxation)
**Dependencies:**
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone libusb ipsw zstd
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone cmake libusb ipsw zstd
```
(A modern `python3` — 3.11+ — is required; the app builds its own Python environment from it, see [Python runtime](#python-runtime).)
## Install
```bash
brew install zqxwce/tap/vphone-cli
```
## Build
Two one-time bootstrap scripts (a compiled binary can't build itself), then everything is `vphone-cli`:
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
./scripts/setup_tools.sh # install deps, build toolchain submodules, create the Python venv
./scripts/build.sh # build + sign vphone-cli, bundle the .app, cross-compile vphoned
```
Put the binary on your `PATH` so the examples below work verbatim:
```bash
cd .build/vphone-cli.app/Contents/MacOS/
vphone-cli --help
```
@@ -107,25 +45,7 @@ One command creates a VM end-to-end (download → patch → DFU restore → CFW
```bash
vphone-cli vm create myphone -V jb # -V / --variant
```
The user is then prompted to choose iOS <-> cloudOS paring, you can specify either one by passing **`-i`/`--iphone-source`** and/or **`-c`/`--cloudos-source`**, i.e.:
```bash
# from local IPSWs
vphone-cli vm create myphone -V jb \
-i ~/ipsws/iPhone17,3_26.1_23B85_Restore.ipsw \
-c ~/ipsws/cloudOS_26.1-23B85.ipsw
# or from URLs — downloaded and cached under ~/.vphone/ipsws
vphone-cli vm create myphone -V jb \
-i "https://.../iPhone17,3_26.1_23B85_Restore.ipsw" \
-c "https://.../399b6..."
```
Then boot it:
```bash
vphone-cli vm launch myphone
```
@@ -141,8 +61,8 @@ vphone-cli vm info myphone # show one VM
vphone-cli vm new myphone # create an empty bundle (cpu/mem/disk options)
vphone-cli vm config myphone --cpu 8 --memory 8192
vphone-cli vm clone myphone myphone-2 # fast APFS clone, fresh device identity
vphone-cli vm export myphone --out myphone.tar.xz # xz -9; skips restore dir + staging files
vphone-cli vm import --in myphone.tar.xz --name restored
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default (--max = xz -9); --out may be a dir (auto-names <vm>.tzst/.txz); skips restore dir + staging files
vphone-cli vm import myphone.tzst --name restored
vphone-cli vm rename myphone iphone16
vphone-cli vm delete iphone16
```
@@ -165,38 +85,96 @@ vphone-cli vm launch myphone # 6. first boot
Update to a newer iOS by pointing `fw prepare` at an IPSW: `--iphone-source /path/to.ipsw --cloudos-source /path/to.ipsw`.
## Running & Connecting
## Firmware Variants
`vphone-cli vm launch <name>` opens the VM window; `vphone-cli vm stop <name>` shuts it down. The guest runs an SSH server (dropbear) on port `22222` and VNC on `5901`, reachable over the VM's NAT IP (find it with `arp -a` on `bridge100`):
Five patch variants with increasing security bypass — pass one to `--variant`:
| Variant | Boot Chain | CFW | Notes |
| ------------ | ----------- | --------- | ----------------------------------------------------------------- |
| `less` | 4 patches | 2 phases | Patchless — keeps iOS mitigations enabled |
| `regular` | 42 patches | 10 phases | AMFI/SSV/Img4/TXM bypass |
| `dev` | 53 patches | 12 phases | + TXM entitlement/debug bypass |
| `jb` | 113 patches | 14 phases | + full jailbreak (Sileo, TrollStore auto-install on first boot) |
| `exp` | 141 patches | 18 phases | JB superset + anti-VM-detection research patches |
See [`research/0_binary_patch_comparison.md`](./research/0_binary_patch_comparison.md) for the per-component breakdown.
## Running & Connecting
- **SSH (jailbreak):** `ssh -p 22222 mobile@<vm-ip>` (password `alpine`)
- **SSH (regular/dev):** `ssh -p 22222 root@<vm-ip>`
- **VNC:** `vnc://<vm-ip>:5901`
For the `jb`/`exp` variants, Sileo and TrollStore are installed automatically on first boot (monitor `/var/log/vphone_jb_setup.log`).
## Python runtime
A few steps (DFU restore, IPSW handling) run through Python. On first use,
vphone-cli provisions a self-contained venv at `~/.vphone/venv` from a modern
host `python3` (3.11+) using the bundled `requirements.txt` — so the signed
`.app` is **portable**: copy it anywhere (e.g. `/Applications`) and it runs
without the repo. Provisioning is automatic; run `vphone-cli setup` to do it
up front. Point at a specific interpreter with `VPHONE_PYTHON=/path/to/python3`,
or relocate the venv with `VPHONE_VENV_DIR=/path`.
## Locations
Everything vphone-cli creates lives under `~/.vphone/` — kept outside the repo and the `.app` so the signed bundle stays portable:
Everything vphone-cli creates lives under `~/.vphone/` — kept outside the repo and the `.app` so the signed bundle stays portable. Redirect the whole tree with `$VPHONE_ROOT`:
| Path | Contents |
| ----------------- | -------------------------------------------------------------------------------------------- |
| `~/.vphone/` | The per-user data root — override the entire location with `$VPHONE_ROOT`. |
| `~/.vphone/VMs/` | VM bundles — one directory per VM. This is the library; override with `$VPHONE_LIBRARY_ROOT`. |
| `~/.vphone/ipsws/`| Downloaded iPhone + cloudOS IPSWs, cached and reused across VMs. |
| `~/.vphone/tools/`| Cached APFS seal-volume artifacts (`apfs_sealvolume_<version>`) fetched during `fw prepare`. |
| `~/.vphone/debs/` | Cached `.deb` packages the `jb`/`exp` CFW install lays into the guest (Sileo, apt, …). |
| `~/.vphone/venv/` | Auto-provisioned Python environment (see [Python runtime](#python-runtime); override with `$VPHONE_VENV_DIR`). |
Precedence: the per-item overrides (`$VPHONE_LIBRARY_ROOT`, `$VPHONE_VENV_DIR`) win over `$VPHONE_ROOT`, which wins over the `~/.vphone` default. The `ipsws/`, `tools/`, and `debs/` caches always sit directly under whichever root is active.
## SIP/AMFI Relaxation
**Option A — fully disable SIP, then disable AMFI via boot-arg (most permissive).**
In Recovery (long-press power → Terminal):
```bash
csrutil disable
csrutil allow-research-guests enable
```
Then reboot into macOS and set the AMFI boot-arg (needs SIP fully off to take effect):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # reboot after
```
**Option B — keep SIP on (debug-only relaxed), then allowlist the binary with amfidont** (leaves AMFI enabled system-wide).
In Recovery:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
Then reboot into macOS and:
```bash
vphone-amfidont # .build/vphone-cli.app/Contents/Resources/vphone-amfidont for local builds
```
## Tested Environments
| Host | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
## FAQ
**`zsh: killed ./vphone-cli`** — AMFI/debug restrictions aren't bypassed; see [Prerequisites](#prerequisites) (`amfi_get_out_of_my_way=1` or `amfidont`).
@@ -211,6 +189,8 @@ Everything vphone-cli creates lives under `~/.vphone/` — kept outside the repo
**Install a `.ipa`/`.tipa`** — use the running VM's Install menu (drag-drop or file picker).
**`cfw install` hangs re-signing a system binary (e.g. `Campo`), memory climbing unbounded** — known bug in `ldid-procursus` up to `2.1.5-procursus7` (the current Homebrew `stable`): `bytes(uint64_t)` calls `__builtin_clzll(0)` with no zero-guard, which is undefined behavior, and on this build resolves to a `0`-length that underflows an unsigned loop counter — `ldid` spins writing one byte at a time into a growing buffer instead of terminating. Triggered by *any* entitlements plist containing an integer value of exactly `0` (some real Apple system binaries have these). Fixed upstream but not yet in a tagged release; rebuild from source: `brew install --HEAD ldid-procursus && brew link --overwrite ldid-procursus`. Kill the hung `ldid` process first (`sudo kill -9 <pid>`) if you already hit it.
## Automation
`vphone-cli` exposes a host control socket (`<bundle>/vphone.sock`) for programmatic control — screenshots, touch, swipes, hardware keys, clipboard — each action returning an inline screenshot for AI-driven E2E testing. See [vphone-mcp](https://github.com/pluginslab/vphone-mcp) for an MCP server wrapping it.
+89 -105
View File
@@ -4,99 +4,37 @@
PCC リサーチ VM インフラストラクチャを使用し、Apple の Virtualization.framework 経由で仮想 iPhone を起動します。
すべての処理は単一の `vphone-cli` バイナリを通じて実行されます — VM の作成、パッチ適用、復元、インストール、起動、管理。ビルド後は `make` は不要です。
![poc](./demo.jpeg)
## 動作確認済み環境
| ホスト | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
iOS ≤ 26.0.1 では 26.1 PCC vphone600 スタックと、CFW 適用時の `IOMobileFramebuffer` SwapEnd ペイロードサイズパッチを使用します。iOS 27.0 では 26.4 PCC vphone600 スタックと、CFW 適用時の force-kern `IOMobileFramebuffer` present-path パッチ、および dyld shared-cache の `maxSlide` フィットを使用します。
> **注意:** GPU/Metal アクセラレーションは iOS 18.x では動作しません — 18.x の Metal/IOGPU フレームワークには準仮想化 GPU 実装がないため、Metal でレンダリングされるコンテンツ(Web ページ、画像、壁紙)は描画されません。タッチ、ネットワーク、アプリは正常に動作します。
## ファームウェアバリアント
セキュリティバイパスの度合いが段階的に増す 5 つのパッチバリアント — いずれか 1 つを `--variant` に渡します:
| バリアント | ブートチェーン | CFW | 備考 |
| ------------ | ----------- | --------- | ----------------------------------------------------------------- |
| `less` | 4 patches | 2 phases | パッチなし — iOS の緩和策を有効なまま維持 |
| `regular` | 42 patches | 10 phases | AMFI/SSV/Img4/TXM バイパス |
| `dev` | 53 patches | 12 phases | + TXM エンタイトルメント/デバッグバイパス |
| `jb` | 113 patches | 14 phases | + 完全な脱獄(Sileo、TrollStore を初回起動時に自動インストール) |
| `exp` | 141 patches | 18 phases | JB のスーパーセット + VM 検出対策リサーチパッチ |
コンポーネントごとの内訳については [`research/0_binary_patch_comparison.md`](../research/0_binary_patch_comparison.md) を参照してください。
## 前提条件
**ホスト:** macOS 15+ (Sequoia)、ネストされていない MacVirtualization.framework はネストできません)。プライベートな PV=3 エンタイトルメント + 未署名バイナリのワークフローには SIP/AMFI の緩和が必要です。以下の 2 つの方法から **1 つ** を選んでください — SIP の設定と AMFI の設定はセットです。混在させないでください:
**ホスト:**
**オプション A — SIP を完全に無効化し、boot-arg で AMFI を無効化する(最も緩い)。** リカバリーモードで(電源ボタン長押し → ターミナル):
```bash
csrutil disable
csrutil allow-research-guests enable
```
その後 macOS で再起動し、AMFI の boot-arg を設定します(有効化には SIP を完全に無効化する必要があります):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # 後で再起動
```
**オプション B — SIP を有効なまま(デバッグのみ緩和)にし、amfidont でバイナリを許可リストに追加する**(AMFI はシステム全体で有効なまま)。リカバリーモードで:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
その後 macOS で再起動し、[`amfidont`](https://github.com/zqxwce/amfidont)(または [`amfree`](https://github.com/retX0/amfree))でリポジトリを許可リストに追加します:
```bash
sudo amfidont --path <path_to_vphone-cli.app>
```
> `less`(パッチなし)バリアントにはオプション A、またはオプション B に `amfidont -S` を組み合わせたもの(`sudo amfidont -S --path <path_to_vphone-cli.app>`)が必要です。
- Apple Silicon
- macOS 15+ (Sequoia)
- Xcode + iOS SDK(ゲストデーモンをクロスコンパイルするため)
- [未署名バイナリでプライベートな PV=3 エンタイトルメントを許可するための SIP/AMFI の緩和](#sipamfi-の緩和)
**依存関係:**
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone libusb ipsw zstd
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone cmake libusb ipsw zstd
```
(最新の `python3` — 3.11+ — が必要です。アプリはそこから独自の Python 環境を構築します。[Python ランタイム](#python-ランタイム) を参照してください。)
## インストール
```bash
brew install zqxwce/tap/vphone-cli
```
## ビルド
一度きりのブートストラップスクリプトが 2 つあります(コンパイル済みバイナリは自分自身をビルドできないため)。その後はすべて `vphone-cli` で行います:
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
./scripts/setup_tools.sh # 依存関係のインストール、ツールチェーンのサブモジュールのビルド、Python venv の作成
./scripts/build.sh # vphone-cli のビルド + 署名、.app のバンドル、vphoned のクロスコンパイル
```
以下の例をそのまま実行できるように、バイナリを `PATH` に追加します:
```bash
cd .build/vphone-cli.app/Contents/MacOS/
vphone-cli --help
```
@@ -107,25 +45,7 @@ vphone-cli --help
```bash
vphone-cli vm create myphone -V jb # -V / --variant
```
その後、iOS <-> cloudOS のペアリングを選ぶよう促されます。**`-i`/`--iphone-source`** および/または **`-c`/`--cloudos-source`** を渡して、どちらか(または両方)を指定することもできます。例:
```bash
# ローカルの IPSW から
vphone-cli vm create myphone -V jb \
-i ~/ipsws/iPhone17,3_26.1_23B85_Restore.ipsw \
-c ~/ipsws/cloudOS_26.1-23B85.ipsw
# または URL から — ~/.vphone/ipsws 以下にダウンロードしてキャッシュ
vphone-cli vm create myphone -V jb \
-i "https://.../iPhone17,3_26.1_23B85_Restore.ipsw" \
-c "https://.../399b6..."
```
その後、起動します:
```bash
vphone-cli vm launch myphone
```
@@ -141,8 +61,8 @@ vphone-cli vm info myphone # 1 つの VM を表示
vphone-cli vm new myphone # 空のバンドルを作成(cpu/mem/disk オプション)
vphone-cli vm config myphone --cpu 8 --memory 8192
vphone-cli vm clone myphone myphone-2 # 高速 APFS クローン、新しいデバイスアイデンティティ
vphone-cli vm export myphone --out myphone.tar.xz # xz -9; 復元ディレクトリ + ステージングファイルをスキップ
vphone-cli vm import --in myphone.tar.xz --name restored
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default (--max = xz -9); --out がディレクトリなら <vm>.tzst/.txz を自動命名; 復元ディレクトリ + ステージングファイルをスキップ
vphone-cli vm import myphone.tzst --name restored
vphone-cli vm rename myphone iphone16
vphone-cli vm delete iphone16
```
@@ -165,32 +85,96 @@ vphone-cli vm launch myphone # 6. 初回起動
新しい iOS に更新するには、`fw prepare` を IPSW に向けます: `--iphone-source /path/to.ipsw --cloudos-source /path/to.ipsw`
## 実行と接続
## ファームウェアバリアント
`vphone-cli vm launch <name>` は VM のウィンドウを開き、`vphone-cli vm stop <name>` はシャットダウンします。ゲストはポート `22222` で SSH サーバー(dropbear)を、`5901` で VNC を実行しており、VM の NAT IP 経由でアクセスできます(`bridge100` 上で `arp -a` を実行して確認):
セキュリティバイパスの度合いが段階的に増す 5 つのパッチバリアント — いずれか 1 つを `--variant` に渡します:
| バリアント | ブートチェーン | CFW | 備考 |
| ------------ | ----------- | --------- | ----------------------------------------------------------------- |
| `less` | 4 patches | 2 phases | パッチなし — iOS の緩和策を有効なまま維持 |
| `regular` | 42 patches | 10 phases | AMFI/SSV/Img4/TXM バイパス |
| `dev` | 53 patches | 12 phases | + TXM エンタイトルメント/デバッグバイパス |
| `jb` | 113 patches | 14 phases | + 完全な脱獄(Sileo、TrollStore を初回起動時に自動インストール) |
| `exp` | 141 patches | 18 phases | JB のスーパーセット + VM 検出対策リサーチパッチ |
コンポーネントごとの内訳については [`research/0_binary_patch_comparison.md`](../research/0_binary_patch_comparison.md) を参照してください。
## 実行と接続
- **SSH(脱獄):** `ssh -p 22222 mobile@<vm-ip>`(パスワード `alpine`
- **SSHregular/dev:** `ssh -p 22222 root@<vm-ip>`
- **VNC:** `vnc://<vm-ip>:5901`
`jb`/`exp` バリアントでは、Sileo と TrollStore が初回起動時に自動的にインストールされます(`/var/log/vphone_jb_setup.log` で監視)。
## Python ランタイム
いくつかのステップ(DFU 復元、IPSW 処理)は Python を通じて実行されます。初回使用時、vphone-cli はバンドルされた `requirements.txt` を使用して、最新のホスト `python3`3.11+)から `~/.vphone/venv` に自己完結型の venv をプロビジョニングします — そのため署名済みの `.app`**ポータブル** です。任意の場所(例: `/Applications`)にコピーすればリポジトリなしで動作します。プロビジョニングは自動ですが、事前に行うには `vphone-cli setup` を実行します。特定のインタプリタを指定するには `VPHONE_PYTHON=/path/to/python3`、venv の場所を変更するには `VPHONE_VENV_DIR=/path` を使用します。
## 場所
vphone-cli が生成するものはすべて `~/.vphone/` 以下に置かれます — 署名済みバンドルがポータブルであり続けるよう、リポジトリと `.app` の外に保管されます:
vphone-cli が生成するものはすべて `~/.vphone/` 以下に置かれます — 署名済みバンドルがポータブルであり続けるよう、リポジトリと `.app` の外に保管されます`$VPHONE_ROOT` でツリー全体をリダイレクトできます:
| パス | 内容 |
| ----------------- | ------------------------------------------------------------------------------------------ |
| `~/.vphone/VMs/` | VM バンドル — VM ごとに 1 ディレクトリ。ライブラリであり、`$VPHONE_LIBRARY_ROOT` で上書きできます。 |
| `~/.vphone/` | ユーザー別データルート — `$VPHONE_ROOT`場所全体を上書きます。 |
| `~/.vphone/VMs/` | VM バンドル — VM ごとに 1 ディレクトリ。これがライブラリです。`$VPHONE_LIBRARY_ROOT` で上書きできます。 |
| `~/.vphone/ipsws/`| ダウンロードされた iPhone + cloudOS の IPSW。キャッシュされ、複数の VM で再利用されます。 |
| `~/.vphone/tools/`| `fw prepare` 中に取得された APFS seal-volume アーティファクト(`apfs_sealvolume_<version>`)のキャッシュ。 |
| `~/.vphone/debs/` | `jb`/`exp` の CFW インストールがゲストに配置する `.deb` パッケージのキャッシュ(Sileo、apt など)。 |
| `~/.vphone/venv/` | 自動的にプロビジョニングされる Python 環境([Python ランタイム](#python-ランタイム) を参照。`$VPHONE_VENV_DIR` で上書き可能)。 |
優先順位: 項目ごとの上書き(`$VPHONE_LIBRARY_ROOT``$VPHONE_VENV_DIR`)が `$VPHONE_ROOT` より優先され、`$VPHONE_ROOT``~/.vphone` のデフォルトより優先されます。`ipsws/``tools/``debs/` キャッシュは、常に現在有効なルートの直下に置かれます。
## SIP/AMFI の緩和
**オプション A — SIP を完全に無効化し、boot-arg で AMFI を無効化する(最も緩い)。**
リカバリーモードで(電源ボタン長押し → ターミナル):
```bash
csrutil disable
csrutil allow-research-guests enable
```
その後 macOS で再起動し、AMFI の boot-arg を設定します(有効化には SIP を完全に無効化する必要があります):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # 後で再起動
```
**オプション B — SIP を有効なまま(デバッグのみ緩和)にし、amfidont でバイナリを許可リストに追加する**(AMFI はシステム全体で有効なまま)。
リカバリーモードで:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
その後 macOS で再起動し:
```bash
vphone-amfidont # ローカルビルドの場合は .build/vphone-cli.app/Contents/Resources/vphone-amfidont
```
## 動作確認済み環境
| ホスト | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
## FAQ
**`zsh: killed ./vphone-cli`** — AMFI/デバッグ制限がバイパスされていません。[前提条件](#前提条件) を参照してください(`amfi_get_out_of_my_way=1` または `amfidont`)。
+88 -110
View File
@@ -4,99 +4,37 @@
PCC 리서치 VM 인프라를 사용하여 Apple의 Virtualization.framework로 가상 iPhone을 부팅합니다.
모든 것은 단일 `vphone-cli` 바이너리를 통해 실행됩니다 — VM 생성, 패치, 복원, 설치, 부팅, 관리. 빌드 후에는 `make`가 필요하지 않습니다.
![poc](./demo.jpeg)
## 테스트 환경
| Host | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
iOS ≤ 26.0.1은 26.1 PCC vphone600 스택에 더해 CFW 단계의 `IOMobileFramebuffer` SwapEnd 페이로드 크기 패치를 사용합니다. iOS 27.0은 26.4 PCC vphone600 스택에 더해 CFW 단계의 force-kern `IOMobileFramebuffer` present-path 패치와 dyld 공유 캐시 `maxSlide` 조정을 사용합니다.
> **참고:** iOS 18.x에서는 GPU/Metal 가속이 작동하지 않습니다 — 18.x의 Metal/IOGPU 프레임워크에 반가상화 GPU 구현이 없기 때문에 Metal로 렌더링되는 콘텐츠(웹 페이지, 이미지, 배경화면)가 표시되지 않습니다. 터치, 네트워크, 앱은 정상적으로 작동합니다.
## 펌웨어 변형
보안 우회 수준이 점점 강해지는 5가지 패치 변형이 있습니다 — 하나를 `--variant`에 전달하세요:
| 변형 | 부트 체인 | CFW | 참고 |
| ------------ | ----------- | --------- | --------------------------------------------------------------- |
| `less` | 4 patches | 2 phases | Patchless — iOS 완화 기능을 활성 상태로 유지 |
| `regular` | 42 patches | 10 phases | AMFI/SSV/Img4/TXM 우회 |
| `dev` | 53 patches | 12 phases | + TXM 권한/디버그 우회 |
| `jb` | 113 patches | 14 phases | + 전체 탈옥 (Sileo, TrollStore가 첫 부팅 시 자동 설치) |
| `exp` | 141 patches | 18 phases | JB 상위 집합 + VM 탐지 방지 연구 패치 |
컴포넌트별 상세 분류는 [`research/0_binary_patch_comparison.md`](../research/0_binary_patch_comparison.md)를 참조하세요.
## 사전 요구 사항
**호스트:** macOS 15+ (Sequoia), 중첩되지 않은 Mac (Virtualization.framework는 중첩할 수 없습니다). Private PV=3 권한 + 서명되지 않은 바이너리 워크플로우에는 SIP/AMFI 완화가 필요합니다. 다음 두 가지 방법 중 **하나**를 선택하세요 — SIP 설정과 AMFI 설정은 함께 가야 하므로 섞지 마세요:
**호스트:**
**방법 A — SIP를 완전히 비활성화한 후, boot-arg로 AMFI를 비활성화 (가장 관대).** 복구 모드에서 (전원 버튼 길게 누르기 → 터미널):
```bash
csrutil disable
csrutil allow-research-guests enable
```
그런 다음 macOS로 재부팅하고 AMFI boot-arg를 설정합니다 (적용되려면 SIP가 완전히 꺼져 있어야 합니다):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # 이후 재부팅
```
**방법 B — SIP 유지 (디버그만 완화), 그런 다음 amfidont로 바이너리를 허용 목록에 추가** (AMFI는 시스템 전체에서 활성 상태 유지). 복구 모드에서:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
그런 다음 macOS로 재부팅하고 [`amfidont`](https://github.com/zqxwce/amfidont) (또는 [`amfree`](https://github.com/retX0/amfree))로 저장소를 허용 목록에 추가합니다:
```bash
sudo amfidont --path <path_to_vphone-cli.app>
```
> `less` (patchless) 변형은 방법 A, 또는 `amfidont -S`를 포함한 방법 B(`sudo amfidont -S --path <path_to_vphone-cli.app>`)가 필요합니다.
- Apple Silicon
- macOS 15+ (Sequoia)
- Xcode + iOS SDK (게스트 데몬 크로스 컴파일용)
- [서명되지 않은 바이너리로 private PV=3 권한을 허용하기 위한 SIP/AMFI 완화](#sipamfi-완화)
**의존성:**
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone libusb ipsw zstd
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone cmake libusb ipsw zstd
```
(최신 `python3` — 3.11+ — 이 필요합니다; 앱은 이를 사용하여 자체 Python 환경을 빌드합니다. [Python 런타임](#python-런타임)을 참조하세요.)
## 설치
```bash
brew install zqxwce/tap/vphone-cli
```
## 빌드
두 개의 일회성 부트스트랩 스크립트(컴파일된 바이너리는 스스로를 빌드할 수 없습니다)를 실행하면, 그 다음부터는 모든 것이 `vphone-cli`입니다:
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
./scripts/setup_tools.sh # 의존성 설치, 툴체인 서브모듈 빌드, Python venv 생성
./scripts/build.sh # vphone-cli 빌드 및 서명, .app 번들 생성, vphoned 크로스 컴파일
```
아래 예제가 그대로 작동하도록 바이너리를 `PATH`에 추가하세요:
```bash
cd .build/vphone-cli.app/Contents/MacOS/
vphone-cli --help
```
@@ -107,25 +45,7 @@ vphone-cli --help
```bash
vphone-cli vm create myphone -V jb # -V / --variant
```
그러면 iOS <-> cloudOS 페어링을 선택하라는 안내가 표시됩니다. **`-i`/`--iphone-source`** 및/또는 **`-c`/`--cloudos-source`**를 전달하여 둘 중 하나(또는 둘 다)를 직접 지정할 수도 있습니다. 예:
```bash
# 로컬 IPSW에서
vphone-cli vm create myphone -V jb \
-i ~/ipsws/iPhone17,3_26.1_23B85_Restore.ipsw \
-c ~/ipsws/cloudOS_26.1-23B85.ipsw
# 또는 URL에서 — 다운로드되어 ~/.vphone/ipsws 아래에 캐시됨
vphone-cli vm create myphone -V jb \
-i "https://.../iPhone17,3_26.1_23B85_Restore.ipsw" \
-c "https://.../399b6..."
```
그런 다음 부팅합니다:
```bash
vphone-cli vm launch myphone
```
@@ -141,8 +61,8 @@ vphone-cli vm info myphone # VM 하나 표시
vphone-cli vm new myphone # 빈 번들 생성 (cpu/mem/disk 옵션)
vphone-cli vm config myphone --cpu 8 --memory 8192
vphone-cli vm clone myphone myphone-2 # 빠른 APFS 복제, 새로운 기기 식별자
vphone-cli vm export myphone --out myphone.tar.xz # xz -9; restore 디렉토리 + 스테이징 파일 건너뜀
vphone-cli vm import --in myphone.tar.xz --name restored
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default (--max = xz -9); --out 이 디렉토리면 <vm>.tzst/.txz 자동 명명; restore 디렉토리 + 스테이징 파일 건너뜀
vphone-cli vm import myphone.tzst --name restored
vphone-cli vm rename myphone iphone16
vphone-cli vm delete iphone16
```
@@ -165,38 +85,96 @@ vphone-cli vm launch myphone # 6. 첫 부팅
최신 iOS로 업데이트하려면 `fw prepare`를 IPSW로 지정하세요: `--iphone-source /path/to.ipsw --cloudos-source /path/to.ipsw`.
## 실행 및 연결
## 펌웨어 변형
`vphone-cli vm launch <name>`은 VM 창을 엽니다; `vphone-cli vm stop <name>`은 종료합니다. 게스트는 포트 `22222`에서 SSH 서버(dropbear)를, `5901`에서 VNC를 실행하며, VM의 NAT IP로 접근할 수 있습니다 (`bridge100`에서 `arp -a`로 찾으세요):
보안 우회 수준이 점점 강해지는 5가지 패치 변형이 있습니다 — 하나를 `--variant`에 전달하세요:
| 변형 | 부트 체인 | CFW | 참고 |
| ------------ | ----------- | --------- | --------------------------------------------------------------- |
| `less` | 4 patches | 2 phases | Patchless — iOS 완화 기능을 활성 상태로 유지 |
| `regular` | 42 patches | 10 phases | AMFI/SSV/Img4/TXM 우회 |
| `dev` | 53 patches | 12 phases | + TXM 권한/디버그 우회 |
| `jb` | 113 patches | 14 phases | + 전체 탈옥 (Sileo, TrollStore가 첫 부팅 시 자동 설치) |
| `exp` | 141 patches | 18 phases | JB 상위 집합 + VM 탐지 방지 연구 패치 |
컴포넌트별 상세 분류는 [`research/0_binary_patch_comparison.md`](../research/0_binary_patch_comparison.md)를 참조하세요.
## 실행 및 연결
- **SSH (탈옥):** `ssh -p 22222 mobile@<vm-ip>` (비밀번호 `alpine`)
- **SSH (regular/dev):** `ssh -p 22222 root@<vm-ip>`
- **VNC:** `vnc://<vm-ip>:5901`
`jb`/`exp` 변형의 경우, Sileo와 TrollStore가 첫 부팅 시 자동으로 설치됩니다 (`/var/log/vphone_jb_setup.log`로 모니터링).
## Python 런타임
일부 단계(DFU 복원, IPSW 처리)는 Python을 통해 실행됩니다. 최초 사용 시,
vphone-cli는 번들된 `requirements.txt`를 사용하여 최신 호스트 `python3`(3.11+)로부터
`~/.vphone/venv`에 독립적인 venv를 프로비저닝합니다 — 따라서 서명된
`.app`은 **이식 가능**합니다: 어디든(예: `/Applications`) 복사하면 저장소
없이도 실행됩니다. 프로비저닝은 자동으로 이루어집니다; 미리 실행하려면 `vphone-cli setup`
실행하세요. 특정 인터프리터를 지정하려면 `VPHONE_PYTHON=/path/to/python3`을,
venv 위치를 변경하려면 `VPHONE_VENV_DIR=/path`를 사용하세요.
## 위치
vphone-cli가 생성하는 모든 것은 `~/.vphone/` 아래에 있습니다 — 서명된 번들이 이식 가능하도록 저장소와 `.app` 외부에 보관됩니다:
vphone-cli가 생성하는 모든 것은 `~/.vphone/` 아래에 있습니다 — 서명된 번들이 이식 가능하도록 저장소와 `.app` 외부에 보관됩니다. `$VPHONE_ROOT`로 전체 트리를 리디렉션할 수 있습니다:
| 경로 | 내용 |
| ----------------- | ------------------------------------------------------------------------------------------ |
| `~/.vphone/` | 사용자별 데이터 루트 — `$VPHONE_ROOT`로 전체 위치를 재정의합니다. |
| `~/.vphone/VMs/` | VM 번들 — VM마다 하나의 디렉터리. 라이브러리이며, `$VPHONE_LIBRARY_ROOT`로 재정의할 수 있습니다. |
| `~/.vphone/ipsws/`| 다운로드된 iPhone + cloudOS IPSW, 캐시되어 여러 VM에서 재사용됩니다. |
| `~/.vphone/tools/`| `fw prepare` 중에 가져온 APFS seal-volume 아티팩트(`apfs_sealvolume_<version>`) 캐시. |
| `~/.vphone/debs/` | `jb`/`exp` CFW 설치가 게스트에 넣는 `.deb` 패키지 캐시 (Sileo, apt 등). |
| `~/.vphone/venv/` | 자동으로 프로비저닝되는 Python 환경 ([Python 런타임](#python-런타임) 참조; `$VPHONE_VENV_DIR`로 재정의). |
우선순위: 항목별 재정의(`$VPHONE_LIBRARY_ROOT`, `$VPHONE_VENV_DIR`)가 `$VPHONE_ROOT`보다 우선하고, `$VPHONE_ROOT``~/.vphone` 기본값보다 우선합니다. `ipsws/`, `tools/`, `debs/` 캐시는 항상 현재 활성 루트 바로 아래에 위치합니다.
## SIP/AMFI 완화
**방법 A — SIP를 완전히 비활성화한 후, boot-arg로 AMFI를 비활성화 (가장 관대).**
복구 모드에서 (전원 버튼 길게 누르기 → 터미널):
```bash
csrutil disable
csrutil allow-research-guests enable
```
그런 다음 macOS로 재부팅하고 AMFI boot-arg를 설정합니다 (적용되려면 SIP가 완전히 꺼져 있어야 합니다):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # 이후 재부팅
```
**방법 B — SIP 유지 (디버그만 완화), 그런 다음 amfidont로 바이너리를 허용 목록에 추가** (AMFI는 시스템 전체에서 활성 상태 유지).
복구 모드에서:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
그런 다음 macOS로 재부팅하고:
```bash
vphone-amfidont # 로컬 빌드의 경우 .build/vphone-cli.app/Contents/Resources/vphone-amfidont
```
## 테스트 환경
| 호스트 | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
## FAQ
**`zsh: killed ./vphone-cli`** — AMFI/디버그 제한이 우회되지 않았습니다; [사전 요구 사항](#사전-요구-사항)을 참조하세요 (`amfi_get_out_of_my_way=1` 또는 `amfidont`).
+88 -104
View File
@@ -4,99 +4,37 @@
使用 PCC 研究虚拟机基础设施,通过 Apple 的 Virtualization.framework 启动一台虚拟 iPhone。
所有操作都通过单个 `vphone-cli` 二进制文件完成——创建、打补丁、恢复、安装、启动以及管理虚拟机。构建完成后无需再使用 `make`
![poc](./demo.jpeg)
## 测试环境
| 宿主机 | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 25.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
iOS ≤ 26.0.1 使用 26.1 PCC vphone600 栈,外加 CFW 阶段的 `IOMobileFramebuffer` SwapEnd 载荷大小补丁。iOS 27.0 使用 26.4 PCC vphone600 栈,外加 CFW 阶段的强制内核 `IOMobileFramebuffer` present-path 补丁以及 dyld 共享缓存 `maxSlide` 适配。
> **注意:** GPU/Metal 加速在 iOS 18.x 上无法工作——18.x 的 Metal/IOGPU 框架没有半虚拟化 GPU 实现,因此由 Metal 渲染的内容(网页、图片、壁纸)不会显示。触控、网络和应用可正常工作。
## 固件变体
五种补丁变体,安全绕过程度递增——将其中之一传给 `--variant`
| 变体 | 引导链 | CFW | 说明 |
| ------------ | ----------- | --------- | ------------------------------------------------- |
| `less` | 4 patches | 2 phases | 无补丁——保持 iOS 缓解措施启用 |
| `regular` | 42 patches | 10 phases | 绕过 AMFI/SSV/Img4/TXM |
| `dev` | 53 patches | 12 phases | + 绕过 TXM 授权/调试 |
| `jb` | 113 patches | 14 phases | + 完整越狱(首次启动时自动安装 Sileo、TrollStore|
| `exp` | 141 patches | 18 phases | JB 超集 + 反虚拟机检测研究补丁 |
各组件的详细拆解见 [`research/0_binary_patch_comparison.md`](../research/0_binary_patch_comparison.md)。
## 前置条件
**宿主机:** macOS 15+Sequoia),一台非嵌套的 MacVirtualization.framework 无法嵌套)。私有 PV=3 授权 + 未签名二进制的工作流需要放宽 SIP/AMFI。请从以下两条路径中选择**一条**——SIP 设置和 AMFI 设置是配套的,不要混用:
**宿主机:**
**方案 A——完全禁用 SIP,然后通过 boot-arg 禁用 AMFI(最宽松)。** 在恢复模式下(长按电源键 → 终端):
```bash
csrutil disable
csrutil allow-research-guests enable
```
然后重启进入 macOS 并设置 AMFI boot-arg(需要 SIP 完全关闭才能生效):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # 之后重启
```
**方案 B——保持 SIP 开启(仅放宽 debug),然后用 amfidont 将二进制加入白名单**(AMFI 在系统范围内保持启用)。在恢复模式下:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
然后重启进入 macOS,用 [`amfidont`](https://github.com/zqxwce/amfidont)(或 [`amfree`](https://github.com/retX0/amfree))将仓库加入白名单:
```bash
sudo amfidont --path <path_to_vphone-cli.app>
```
> `less`(无补丁)变体需要方案 A,或者搭配 `amfidont -S` 的方案 B`sudo amfidont -S --path <path_to_vphone-cli.app>`)。
- Apple Silicon
- macOS 15+Sequoia
- Xcode + iOS SDK(用于交叉编译访客守护进程)
- [放宽 SIP/AMFI,以允许未签名二进制使用私有 PV=3 授权](#放宽-sipamfi)
**依赖:**
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone libusb ipsw zstd
brew install [email protected] aria2 wget gnu-tar openssl@3 ldid-procursus sshpass keystone cmake libusb ipsw zstd
```
(需要一个较新的 `python3`——3.11+;应用会基于它构建自己的 Python 环境,见 [Python 运行时](#python-运行时)。)
## 安装
```bash
brew install zqxwce/tap/vphone-cli
```
## 构建
两个一次性的引导脚本(编译后的二进制无法自行构建),之后一切都通过 `vphone-cli` 完成:
```bash
git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git
./scripts/setup_tools.sh # 安装依赖、构建工具链子模块、创建 Python venv
./scripts/build.sh # 构建并签名 vphone-cli、打包 .app、交叉编译 vphoned
```
把二进制加入你的 `PATH`,这样下面的示例就能原样运行:
```bash
cd .build/vphone-cli.app/Contents/MacOS/
vphone-cli --help
```
@@ -107,25 +45,7 @@ vphone-cli --help
```bash
vphone-cli vm create myphone -V jb # -V / --variant
```
随后会提示你选择 iOS <-> cloudOS 配对;你也可以通过传入 **`-i`/`--iphone-source`** 和/或 **`-c`/`--cloudos-source`** 指定其中之一(或两者)。例如:
```bash
# 使用本地 IPSW
vphone-cli vm create myphone -V jb \
-i ~/ipsws/iPhone17,3_26.1_23B85_Restore.ipsw \
-c ~/ipsws/cloudOS_26.1-23B85.ipsw
# 或使用 URL——下载后缓存到 ~/.vphone/ipsws
vphone-cli vm create myphone -V jb \
-i "https://.../iPhone17,3_26.1_23B85_Restore.ipsw" \
-c "https://.../399b6..."
```
然后启动它:
```bash
vphone-cli vm launch myphone
```
@@ -141,8 +61,8 @@ vphone-cli vm info myphone # 显示某台虚拟机
vphone-cli vm new myphone # 创建一个空 bundle(cpu/内存/磁盘选项)
vphone-cli vm config myphone --cpu 8 --memory 8192
vphone-cli vm clone myphone myphone-2 # 快速 APFS 克隆,全新设备标识
vphone-cli vm export myphone --out myphone.tar.xz # xz -9;跳过 restore 目录 + 暂存文件
vphone-cli vm import --in myphone.tar.xz --name restored
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default--max = xz -9);--out 为目录时自动命名 <vm>.tzst/.txz;跳过 restore 目录 + 暂存文件
vphone-cli vm import myphone.tzst --name restored
vphone-cli vm rename myphone iphone16
vphone-cli vm delete iphone16
```
@@ -165,32 +85,96 @@ vphone-cli vm launch myphone # 6. 首次启动
要升级到更新的 iOS,把 `fw prepare` 指向一个 IPSW`--iphone-source /path/to.ipsw --cloudos-source /path/to.ipsw`
## 运行与连接
## 固件变体
`vphone-cli vm launch <name>` 会打开虚拟机窗口;`vphone-cli vm stop <name>` 会将其关闭。客户机在端口 `22222` 上运行 SSH 服务器(dropbear),在 `5901` 上运行 VNC,可通过虚拟机的 NAT IP 访问(在 `bridge100` 上用 `arp -a` 查找)
五种补丁变体,安全绕过程度递增——将其中之一传给 `--variant`
| 变体 | 引导链 | CFW | 说明 |
| --------- | ----------- | --------- | ------------------------------------------------- |
| `less` | 4 patches | 2 phases | 无补丁——保持 iOS 缓解措施启用 |
| `regular` | 42 patches | 10 phases | 绕过 AMFI/SSV/Img4/TXM |
| `dev` | 53 patches | 12 phases | + 绕过 TXM 授权/调试 |
| `jb` | 113 patches | 14 phases | + 完整越狱(首次启动时自动安装 Sileo、TrollStore|
| `exp` | 141 patches | 18 phases | JB 超集 + 反虚拟机检测研究补丁 |
各组件的详细拆解见 [`research/0_binary_patch_comparison.md`](../research/0_binary_patch_comparison.md)。
## 运行与连接
- **SSH(越狱):** `ssh -p 22222 mobile@<vm-ip>`(密码 `alpine`
- **SSHregular/dev):** `ssh -p 22222 root@<vm-ip>`
- **VNC** `vnc://<vm-ip>:5901`
对于 `jb`/`exp` 变体,Sileo 和 TrollStore 会在首次启动时自动安装(可监控 `/var/log/vphone_jb_setup.log`)。
## Python 运行时
有几个步骤(DFU 恢复、IPSW 处理)通过 Python 运行。首次使用时,vphone-cli 会基于宿主机上较新的 `python3`3.11+)并使用捆绑的 `requirements.txt`,在 `~/.vphone/venv` 处配置一个自包含的 venv——因此签名后的 `.app` 是**可移植的**:把它复制到任何地方(例如 `/Applications`),无需仓库即可运行。配置是自动进行的;运行 `vphone-cli setup` 可提前完成配置。用 `VPHONE_PYTHON=/path/to/python3` 指定特定的解释器,或用 `VPHONE_VENV_DIR=/path` 迁移 venv。
## 位置
vphone-cli 创建的所有内容都位于 `~/.vphone/` 下——保存在仓库和 `.app` 之外,以便签名后的包保持可移植:
vphone-cli 创建的所有内容都位于 `~/.vphone/` 下——保存在仓库和 `.app` 之外,以便签名后的包保持可移植。可用 `$VPHONE_ROOT` 重定向整个目录树
| 路径 | 内容 |
| ----------------- | -------------------------------------------------------------------------------- |
| `~/.vphone/` | 每用户数据根目录——用 `$VPHONE_ROOT` 覆盖整个位置。 |
| `~/.vphone/VMs/` | 虚拟机包——每个虚拟机一个目录。这是库;可用 `$VPHONE_LIBRARY_ROOT` 覆盖。 |
| `~/.vphone/ipsws/`| 已下载的 iPhone + cloudOS IPSW,缓存后在多个虚拟机间复用。 |
| `~/.vphone/tools/`| `fw prepare` 期间获取的 APFS seal-volume 制品(`apfs_sealvolume_<version>`)缓存。 |
| `~/.vphone/debs/` | `jb`/`exp` CFW 安装写入客户机的 `.deb` 包缓存(Sileo、apt 等)。 |
| `~/.vphone/venv/` | 自动配置的 Python 环境(见 [Python 运行时](#python-运行时);可用 `$VPHONE_VENV_DIR` 覆盖)。 |
优先级:单项覆盖(`$VPHONE_LIBRARY_ROOT``$VPHONE_VENV_DIR`)优先于 `$VPHONE_ROOT``$VPHONE_ROOT` 优先于 `~/.vphone` 默认值。`ipsws/``tools/``debs/` 缓存始终位于当前生效的根目录之下。
## 放宽 SIP/AMFI
**方案 A——完全禁用 SIP,然后通过 boot-arg 禁用 AMFI(最宽松)。**
在恢复模式下(长按电源键 → 终端):
```bash
csrutil disable
csrutil allow-research-guests enable
```
然后重启进入 macOS 并设置 AMFI boot-arg(需要 SIP 完全关闭才能生效):
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v" # 之后重启
```
**方案 B——保持 SIP 开启(仅放宽 debug),然后用 amfidont 将二进制加入白名单**AMFI 在系统范围内保持启用)。
在恢复模式下:
```bash
csrutil enable --without debug
csrutil allow-research-guests enable
```
然后重启进入 macOS 并执行:
```bash
vphone-amfidont # 本地构建见 .build/vphone-cli.app/Contents/Resources/vphone-amfidont
```
## 测试环境
| 宿主机 | iPhone | CloudOS |
| --------------- | --------------------- | --------------- |
| Mac16,11 27.0b2 | `17,3_18.6.2_22G100` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0_23A341` | `26.1-23B85` |
| Mac16,8 26.5.1 | `17,3_26.0.1_23A355` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
| Mac16,12 26.3 | `17,3_26.3.1_23D8133` | `26.3-23D128` |
| Mac16,11 26.2 | `17,3_26.4_23E246` | `26.4-23E5207q` |
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
## 常见问题
**`zsh: killed ./vphone-cli`** —— AMFI/debug 限制未被绕过;见[前置条件](#前置条件)`amfi_get_out_of_my_way=1``amfidont`)。
+1
View File
@@ -4,3 +4,4 @@ keystone-engine
pyimg4
pymobiledevice3>=9.5.0
ipsw-parser
setuptools
+7 -2
View File
@@ -124,6 +124,8 @@
> **⚠ iOS-27 hard-gate (2026-07-20).** Every patch below whose Purpose cites "iOS 27" / "27.0 on the 26.4 kernel" is **hard-gated to a 27.x base** via `KernelJBPatcher.applyIOS27` — set by `FirmwarePipeline` from the iPhone base `ProductVersion` (mirroring the `applyExcGuard`/iOS-18 mechanism at patch 27 above; standalone `patch-component` defaults it true, override with `--target-os`). Gated set: **JB-02b, JB-02d, JB-09 (the `ops[124]` add, plus on 27 the `ops[267]` removal — handed to JB-29; the other 201..316 hooks stay), JB-10b, JB-26, JB-27, JB-28, JB-29**. On an 18.x/26.x base **none** of them run. This supersedes the per-row "No-op-in-effect for version-matched userlands" notes: those describe the effect *if applied*, but **JB-27 is NOT a no-op on 26.x** — its `cmp 0x588→0x6e0` retarget makes the 26.x userclient reject its own native 0x588 SwapEnd → dead display (the 26.5 regression that motivated the hard-gate). **Verified 2026-07-20 (JB-29 addendum 2026-07-23):** a 26.5 base produces a **byte-identical** kernelcache to pre-branch `main` (`patch-component --component kernel-jb --target-os 26.5` vs main's output — `cmp` clean, 83 records each; re-confirmed unchanged after JB-29 by `sha256`, since `ops[267]` stays blanket-neutered on 26.5), and a 27.0 base emits **95** records on the `c0ecdb4b` deployment kernel: the 11 gated records above **plus JB-29's 2** (`jb.fpfs_scoped_open.{ops_retarget,cave}`), with `sandbox_ext_267` suppressed (JB-29 owns `ops[267]` on 27 → the FileProvider-scoped trampoline instead of the blanket allow-stub).
> **Frida Stalker support is opt-in (`--frida`).** JB-23b and JB-25c run only when firmware patching is invoked with `--frida` (`vphone-cli vm create … --frida`, `vphone-cli fw patch <vm> -V jb --frida`, `patch-firmware … --frida`, `patch-component --component kernel-jb --frida`, or `make fw_patch_jb FRIDA=1`), gated by `KernelJBPatcher.applyFrida` (set by `FirmwarePipeline` from `enableFrida`). Baseline JB/EXP output is **byte-identical** when off (26.4 emits 83 records without `--frida`, 87 with — the 4 being JB-23b's 2 thread_set_state setters and JB-25c's 2 vm_map_delete gates). Frida itself is installed through the existing extra-debs mechanism: on a `--frida` create the orchestrator sets `VPHONE_FRIDA=1`, `fetch_debs.sh` resolves the latest `frida_<ver>_iphoneos-arm64.deb` (== `re.frida.server`: no `Depends`, rootless `/var/jb` layout) from the Frida GitHub releases into the debs cache, `cfw_install_{jb,exp}.sh` stage it, and the first-boot "5b/8 INSTALL EXTRA DEBS" step `dpkg -i`'s it — no APT source, marker, or dependency resolution.
| # | Group | Method | Function | Purpose | JB Enabled |
| ----- | ----- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------: |
| JB-01 | A | `patch_amfi_cdhash_in_trustcache` | `AMFIIsCDHashInTrustCache` | Always return true + store hash | Y |
@@ -152,8 +154,10 @@
| JB-21 | B | `patch_spawn_validate_persona` | `_spawn_validate_persona` | Upstream dual-`cbz` persona helper bypass | Y |
| JB-22 | B | `patch_task_for_pid` | `_task_for_pid` | Allow task_for_pid via upstream early `pid == 0` gate NOP | Y |
| JB-23 | B | `patch_thid_should_crash` | `_thid_should_crash` | Prevent GUARD_TYPE_MACH_PORT crash | Y |
| JB-23b| B | `patchThreadSetStateEntitlementFlag` | `thread_set_state_from_user` / inlined `act_set_state_from_user` flags materialization | **Frida Stalker existing-thread support (opt-in `--frida`).** Stalker updates an existing thread's core registers via `thread_set_state_from_user`, which passes `flags = TSSF_TRANSLATE_TO_USER \| TSSF_CHECK_ENTITLEMENT` (0x201) into `thread_set_state_internal`; the inlined `thread_set_state_allowed()` then demands `com.apple.private.thread-set-state` (which the target lacks) → `GUARD_TYPE_MACH_PORT / THREAD_SET_STATE`. Rather than NOP the entitlement check, clear TSSF_CHECK_ENTITLEMENT (bit 9) in the flags the user setters pass: rewrite `mov w6, #0x201``mov w6, #0x1`. This preserves TSSF_TRANSLATE_TO_USER (user-pointer translation) and leaves the independent `TH_IN_MACH_EXCEPTION` guard enforced — it only stops user-initiated `thread_set_state` from being entitlement-gated. Anchor: entitlement-string xref cluster → the single containing function (thread_set_state_internal); then its direct `b`/`bl` callers that set `w6` (the 7th-arg = flags, a calling-convention anchor, not an allocation guess) to 0x201. Both setters (`thread_set_state_from_user` + inlined `act_set_state_from_user`) are patched. No offsets/VAs/registers/bytes hardcoded; replacement from the Keystone-backed `ARM64Encoder.encodeMovzW`, Capstone-verified. Kernels without the shape are skipped (fail-open no-op). Verified on the `c0ecdb4b` 26.4 kernel (UUID `BCD06230-CCBE-8E48-50FF-D9C166D83CD5`): exactly two records at file-off `0x1D95720`/`0x1D9594C`. | `--frida` |
| JB-24 | B | `patch_vm_fault_enter_prepare` | `_vm_fault_enter_prepare` | Force `cs_bypass` fast path in runtime fault validation | Y |
| JB-25 | B | `patch_vm_map_protect` | `_vm_map_protect` | Skip upstream write-downgrade gate. Shape A (26.126.4) active; **Shape B (26.5) disabled 2026-07-05** — widened the `~VM_PROT_WRITE` COW strip (vm_map.c:6202) instead of the RWX gate (vm_map.c:5997), breaking COW and crashing the debugger (SPTM `VIOLATION_ILLEGAL_MAP`). Retired on 26.5+: SPTM code-mod (debugger + Substrate tweaks) uses write-then-flip via `vm_protect(VM_PROT_COPY)``XNU_USER_DEBUG`, so no RWX patch is needed. | Y/N |
| JB-25c| B | `patchVmMapDeleteImmutableCode` | `_vm_map_delete` permanent-entry immutable-code exception (vm_map.c:8855) | **Frida Stalker repeated-`VM_PROT_COPY` fix (opt-in `--frida`).** Stalker's write-then-flip leaves a CSM-associated permanent entry at current `RW` / max `RWX`; XNU's "debugger may undo executable mappings" exception tests `entry->protection & VM_PROT_EXECUTE` (current, bit 9), which is clear, so the entry stays permanent and the next fixed overwrite returns `KERN_PROTECTION_FAILURE`. Retarget the execute test to the packed `max_protection` bit (bit 9 → bit 13; `protection:3`@7..9, `max_protection:4`@11..14 in the `[entry,#0x38]` flags word). Semantic matcher: packed-flags load + `vme_permanent` (bit 19) + the inlined `developer_mode_state()` byte-bit-0 read + the current-X test bound to the immutable-code cluster (Shape A: shares the remove-flags fallback target; Shape B: branches to the permanent-continuation target). The remove-flags bit is matched structurally (a test of a non-entry register), not by source constant (VM_MAP_REMOVE_* bit numbers drift across XNU versions). The later CSM current-X `#9` test in the same window is deliberately excluded (different branch target). Exactly two gates or fail closed; branch bytes from the Keystone-backed `ARM64Encoder.encodeTestBitBranch`, Capstone round-trip verified (sense/bit/target). Verified on the `c0ecdb4b` 26.4 kernel: two records at file-off `0x1DBE14C` (`tbz w8,#9→#0xd`) and `0x1DBE828` (`tbnz w8,#9→#0xd`). | `--frida` |
| JB-26 | B | `patch_iomfb_swapend_variable_size` | IOMFB userclient method-5 (SwapEnd) `__DATA_CONST` dispatch entry (`checkStructureInputSize`) | **iOS-27 VZ-view fix, kernel half — paired with DSC force-kern (DSC-patch item 11).** The 26.4 userclient's method-5 dispatch entry hard-checks `checkStructureInputSize == 0x588`; forced-kern iOS 27 sends its native `0x6e0`. Rewrite the size field to `kIOUCVariableStructureSize (0xFFFFFFFF)` so `IOUserClient::externalMethod` accepts 27's struct and reaches the handler. Anchor (structural): the sole `__DATA_CONST` entry `{ptr(ptrauth, top-byte≥0x80), scalarIn=0, structIn=0x588, scalarOut=0, structOut=0}` (verified unique; decompressed file-off 0x9c7228). No-op-in-effect for version-matched 26.x (sends 0x588). Re-enabled 2026-07-15 (was disabled when 27 present-path was still unknown). | Y |
| JB-27 | B | `patch_iomfb_swapend_handler_size` | method-5 handler internal size gate (`cmp w2,#0x588 ; b.ne <err>`) | Companion to JB-26: beyond the dispatch-table check the handler re-checks the struct size (`cmp w2,#0x588 ; b.ne <kIOReturnBadArgument>`; verified unique at decompressed file-off 0x16ae22c; success path forwards the raw struct ptr to a `vtable+0x590` paravirt swap method). Retarget the `cmp` immediate to `0x6e0` so forced-kern iOS 27's native SwapEnd reaches real swap processing (27's IOMFBSwapRec prefix matches 26.x → handler reads valid fields). Semantic anchor (`cmp w2,#imm` word + following `b.ne` decode). Enabled together with JB-26 + DSC force-kern for iOS 27. | Y |
| JB-28 | A | `patch_disk_images2_client_abi` | `com.apple.driver.AppleDiskImages2` kext: `DIDeviceCreatorUserClient::CreateDevice` + `DIDeviceIOUserClient::Connect` ABI-version gates, and the `AllocPortsArray`/`RegisterNotificationPort` notif-port sizing | **iOS-27 DDI (`/System/Developer`) auto-mount — attach layer.** `pymobiledevice3 mounter auto-mount` on the 26.4-kernel / 27-userland hybrid fails at attach: the kernel DiskImages2 driver is ABI v9, the 27 userland's DiskImages2 controller/daemon is ABI v11 (`DIDeviceCreatorUserClient::CreateDevice: Incompatible client: expected ABI version 9 actual 11`). Three layers: **GATE1** NOPs the CreateDevice controller-ABI `cmp #9 ; b.ne` reject; **GATE2b** NOPs the Connect daemon-ABI `cmp #9 ; b.ne` reject (both anchored on the C++ signature cstring → unique `cmp #9`/`b.ne`; version-robust; no-op-in-effect on version-matched userlands where ABI 9==9). **GATE2** (array + 2 bound checks) fixes a RegisterNotificationPort off-by-one (userland registers at index==maxPorts, one past the array) by widening the AllocPortsArray allocation (`lsl x1,xN,#3``mov x1,#0x4000`) AND both bound-check field loads (`ldrh [.,#0xd8]`/`ldr [.,#0xe8]``mov wD,#0x800`); applied **all-or-nothing** (widening the bound checks without the backing array would let RegisterNotificationPort write past it → corruption) and skipped/logged on builds whose notif-port codegen differs (the off-by-one is 26.4-hybrid-specific). Pairs with the sandbox ops[124] allow (JB-09) and the diskimagesiod userland patch (CFW binary-patch #13). Anchors structural (Capstone decode of the pinned function's instructions); replacement bytes from the Keystone-backed `ARM64Encoder`. **GATE2a anchor note:** the AllocPortsArray size-shift is matched on `lsl` mnemonic + destination `x1` (the unique size-writing lsl in the function) — NOT a 3-operand `lsl xd,xn,#imm` shape, because Capstone on this toolchain decodes the lsl-immediate (a UBFM alias) as **2 operands**; requiring 3 operands makes GATE2 silently skip (the all-or-nothing returns a harmless no-op). Verified on the `c0ecdb4b` 26.4 deployment kernel via `patch-component --component kernel-jb --records-out`: all five di2 records emit (`di2_createdevice_abi`, `di2_connect_abi`, `di2_allocports_size`, `di2_notif_boundcheck_d8`, `di2_notif_boundcheck_e8`) — always verify the di2 records EMIT, not merely that the JB suite reports "no failures". No-op-in-effect for version-matched userlands. | Y |
@@ -186,10 +190,11 @@ do NOT execute these).
| 9 | `mov w3,#<size>` -> `mov w3,#<base-size>` in `_kern_SwapEnd`**26.0/26.0.1 and 18.x** | DSC `IOMobileFramebuffer` | Fixes host VZ GUI black-screen with the available PCC vphone600 userclient: the userclient does an exact `checkStructureInputSize` check on external-method-5 (SwapEnd) input, so a userland whose `_kern_SwapEnd` sends a different-sized state gets `kIOReturnBadArgument` and the host display stays black (guest still renders — the Apple logo is visible over VNC, just not in the vphone-cli view). **The accepted size is a property of the base kernel, not the userland**: 26.1 base -> **0x560**, 26.4 base (xnu-12377) -> **0x588**. The 0x588 value is confirmed two ways: the sole dispatch-shaped entry in `kernelcache.*.vphone600` with `checkStructureInputSize==0x588` (scalarIn=0, scalarOut=0, structOut=0, preceded by a ptrauth code ptr, at decompressed file offset 0x9c7228), and empirically — native 26.5 userland sends 0x588 and displays correctly on this stack. Source (userland-sent) sizes observed: 18.6.2 = 0x514, 26.0/26.0.1 = 0x548, 27.0 (24A5380h) = 0x6e0. The patcher is semantic (anchors on `mov w1,#5` -> `mov w3,#imm` -> `mov x4,#0`/`mov x5,#0` -> `bl` inside `_kern_SwapEnd`) and idempotent — rewrites the size to `--target-size` regardless of source and re-attests the modified DSC page. Install gate: `26.0*` / `18.*` -> 0x560 (26.1 base). Validated after host install on `17,3_26.0_23A341`, `17,3_26.0.1_23A355`, and `17,3_18.6.2_22G100` (Apple logo renders) against the 26.1 base. **CORRECTION (2026-07-15): iOS 27.0 is NO LONGER handled here.** 27 presents the paravirt display via IOMFB's `_virt_*` callback path — external method 5 is NEVER called — so no SwapEnd *size* change can help 27 (confirmed by kernel trace + live AppleParavirtGPU idle scheduler). iOS 27 now uses **force-kern (item 11)** to route present back onto method 5; this row applies to 26.0/26.0.1/18.x only. | Y | Y | Y |
| 10 | Zero `maxSlide` in `dyld_cache_header` (`@0xF0`) — **iOS 27.0 / any userland whose cache overflows the 6 GiB region** | DSC `dyld_shared_cache_arm64e` header | Fixes pid-1 `launchd` panic at boot on the vphone600 26.x kernel. The kernel reserves `SHARED_REGION_SIZE_ARM64 = 0x180000000` (6 GiB) and, at map time, needs room for the cache's mapped span **plus** the header `maxSlide` (ASLR range). iOS 27.0's cache (span `0x17c830000` ≈ 5.95 GiB) + `maxSlide 0x20000000` = `0x19c830000` > 6 GiB, so `_shared_region_map_and_slide` returns `ENOMEM`, dyld cannot map `libSystem.B.dylib`, and `launchd` panics (`initproc failed to start`). Zeroing `maxSlide` (LE u64) in the main chunk maps the cache at slide 0 (fits with ~58 MiB spare). Install gate: **`27.*`** (hard-gated in `cfw_install.sh` as of 2026-07-20 — an 18.x/26.x base skips it entirely). The patcher additionally self-gates (`patch-dsc-maxslide`): no-op unless span + maxSlide > `0x180000000`, kept as defense-in-depth so 26.x / 18.x are untouched even if the install gate were removed. **Opt-in for non-27 bases** (added 2026-07-23): `FORCE_DSC_MAXSLIDE=1` in the `cfw_install.sh` environment runs `patch-dsc-maxslide --force` on 18.x/26.x too; `--force` bypasses the fits-check and zeroes `maxSlide` unconditionally (still idempotent — no-op if already 0). Default off, so non-27 bases keep their native ASLR slide unless explicitly requested; the `27.*` path is unchanged. **No** page re-attestation (header metadata, not a `cs_validate`'d code page — confirmed empirically). Validated on `17,3_27.0_24A5380h` + cloudOS 26.4 (`c0ecdb4b…`): `dyld cache mapped system-wide`, launchd reaches first unlock, vphoned connects as iOS 27.0.0, 0 panics. See `scripts/patchers/cfw_patch_dsc_maxslide.py`. | Y | Y | Y |
| 11 | Retarget public `_IOMobileFramebufferSwap*` trampolines -> `b _kern_Swap*` (force-kern) — **iOS 27.0** | DSC `IOMobileFramebuffer` | **iOS-27 VZ-view (host paravirt-GPU scanout) fix, userland half.** The host `VZVirtualMachineView` is fed by the guest `AppleParavirtGPU` scanout, which the 26.4 kernel drives ONLY from the IOMFB userclient SwapEnd (external method 5) — the `_kern_Swap*` path. iOS 27 defaults the paravirt display's present to IOMFB's parallel `_virt_Swap*` path (`_virt_SwapEnd` does no userclient call — it invokes an in-process callback `blraaz [conn+0xe68]` and hands the IOSurface to a virtual-display consumer), so the paravirt GPU never scans out → host VZ window black (guest still composites; GUI visible over in-guest TrollVNC; AppleParavirtGPU `SchedulerState` idle). The public `_IOMobileFramebufferSwap*` entrypoints are thin trampolines (`cbz x0; ldr xN,[x0,#slot]; cbz xN; braaz xN`) that tail-call the per-connection swap fp (kern or virt impl). This patch rewrites each trampoline's first insn to `b _kern_Swap<Name>`, forcing present onto method 5 regardless of how 27 classified the display (tail-call, args intact → behaviourally identical to selecting the kern fp). Fully dynamic: public + `_kern_` addrs resolved by name via `ipsw dyld symaddr`, trampoline shape verified by Capstone, branch bytes from Keystone `asm_at()`, modified DSC code pages re-attested. Requires ≥{SwapBegin,SwapEnd,SwapSetLayer} or raises (dry-run retargets 31 entrypoints on 24A5380h, skips 4 non-trampolines). **Pairs with the JB kernel patches (`patchIomfbSwapEndVariableSize` + `patchIomfbSwapEndHandlerSize`)** which relax the 26.4 userclient's two exact `0x588` size gates to accept 27's native `0x6e0` IOMFBSwapRec (prefix matches 26.x, so the paravirt swap handler reads valid fields). Install gate: `27.*`. See `scripts/patchers/cfw_patch_iomfb_force_kern.py`. **VALIDATED on-device (2026-07-15, `17,3_27.0_24A5380h` + cloudOS 26.4 `c0ecdb4b…`, JB): iOS 27 userland renders AND is interactive in the native VZ view (not just TrollVNC); clean boot — no `kIOReturnBadArgument`/SwapEnd rejection/panic.** Runtime confirmed 31 entrypoints retargeted (4 non-trampoline setters left on virt). | Y | Y | Y |
| 12 | NOP `-[_LSDModifyClient clientIsEntitledForEmbeddedRegistrationOperations]` entitlement gate + per-page re-attest — **iOS 27.0** | DSC `CoreServices` (LaunchServices) | **iOS-27 app-registration fix.** lsd gates `-[_LSDModifyClient performPostInstallationRegistration:operationUUID:reply:]` (and the containerized/rebuild registration paths) behind `clientIsEntitledForEmbeddedRegistrationOperations`, which does `xpc_connection_copy_entitlement_value` on the XPC peer for any of `com.apple.private.coreservices.lsaw` / `com.apple.private.installcoordinationd.daemon` / `com.apple.private.coreservices.can-register-install-results`. A client without one gets `NSOSStatusErrorDomain -54` (permErr, `LSDModifyService.mm:1639`), so `registerApplicationDictionary:` / `registerContainerizedApplicationWithInfoDictionaries:` fail and no app can (re)register — blocking vphoned's installer, TrollStore, and uicache/Sileo alike. The entitlement route is a dead end even for a launchd platform daemon (vphoned) whose validated csblob (`csops CS_OPS_ENTITLEMENTS_BLOB`) contains all three: LS registration is proxied, so the XPC peer lsd inspects is not the registering process. Fix: NOP the final `cbz w0, <not_entitled>` (the conditional branch whose fall-through sets the `mov w<reg>,#1` result) so the method always returns YES. Fully dynamic: method resolved via the DSC's own `.symbols` in-image local-symbol table (ipsw `symaddr -a`/`a2s` time out on this cache), gate located by control-flow shape in Capstone, NOP from Keystone `asm("nop")`, modified 16 KiB page re-attested (`cfw_dsc_codesign.py`; TXM enforces per-page). The resulting CDHash change is accepted by the JB always-true AMFI cdhash-trust patch. Install gate: **`27.*`** (hard-gated in `cfw_install.sh` as of 2026-07-20 — an 18.x/26.x base does not apply it). The patcher additionally self-gates (`patch-lsd-embedded-reg`): no-op on pre-iOS-27 userlands where the method is absent. **Pairs with vphoned's `vp_register_path` containerized-registration fallback** (`registerContainerizedApplicationWithInfoDictionaries:...:registrationError:`, treating a nil `registrationError` as success since it returns NO even when it registers). Also paired with **`/cores/vpregister`** (built + deployed by `cfw_install_jb.sh` / `cfw_install_exp.sh`, invoked by `vphone_jb_setup.sh` at first boot — 27-gated by DEPLOYMENT: `cfw_install_jb.sh`/`cfw_install_exp.sh` copy `/cores/vpregister` only when the mounted rootfs `SystemVersion.plist` is `27.*`, and the setup script's `[ -x /cores/vpregister ]` presence check is the runtime gate. Do NOT gate the invocation on a guest `sw_vers` check — the hybrid guest does not reliably report the 27 userland version at first boot, which silently skipped registration): it registers JB app bundles (Sileo) via the same containerized API, because `uicache -a`'s `registerApplicationDictionary:` is a deprecated no-op on iOS 27 (lsd logs *"you cannot use ... to register applications anymore. These interfaces have been deprecated for years."*). **VALIDATED (2026-07-17, `17,3_27.0_24A5380h` + cloudOS 26.4, JB): -54 gone; Sileo registers (`uicache -l` 0→1) via `vpregister`; vphoned installs+registers a test IPA (`com.vphone.vptest`) to `/var/containers/Bundle/Application/` end-to-end. Clean boot (re-attest correct; no CoreServices page rejection).** See `scripts/patchers/cfw_patch_lsd_embedded_reg.py` and `scripts/vphoned/vpregister.m`. | Y | Y | Y |
| 12 | NOP `-[_LSDModifyClient clientIsEntitledForEmbeddedRegistrationOperations]` entitlement gate + per-page re-attest — **iOS 27.0** | DSC `CoreServices` (LaunchServices) | **iOS-27 app-registration fix.** lsd gates `-[_LSDModifyClient performPostInstallationRegistration:operationUUID:reply:]` (and the containerized/rebuild registration paths) behind `clientIsEntitledForEmbeddedRegistrationOperations`, which does `xpc_connection_copy_entitlement_value` on the XPC peer for any of `com.apple.private.coreservices.lsaw` / `com.apple.private.installcoordinationd.daemon` / `com.apple.private.coreservices.can-register-install-results`. A client without one gets `NSOSStatusErrorDomain -54` (permErr, `LSDModifyService.mm:1639`), so `registerApplicationDictionary:` / `registerContainerizedApplicationWithInfoDictionaries:` fail and no app can (re)register — blocking vphoned's installer, TrollStore, and uicache/Sileo alike. The entitlement route is a dead end even for a launchd platform daemon (vphoned) whose validated csblob (`csops CS_OPS_ENTITLEMENTS_BLOB`) contains all three: LS registration is proxied, so the XPC peer lsd inspects is not the registering process. Fix: NOP the final `cbz w0, <not_entitled>` (the conditional branch whose fall-through sets the `mov w<reg>,#1` result) so the method always returns YES. Fully dynamic: method resolved via the DSC's own `.symbols` in-image local-symbol table (ipsw `symaddr -a`/`a2s` time out on this cache), gate located by control-flow shape in Capstone, NOP from Keystone `asm("nop")`, modified 16 KiB page re-attested (`cfw_dsc_codesign.py`; TXM enforces per-page). The resulting CDHash change is accepted by the JB always-true AMFI cdhash-trust patch. Install gate: **`27.*`** (hard-gated in `cfw_install.sh` as of 2026-07-20 — an 18.x/26.x base does not apply it). The patcher additionally self-gates (`patch-lsd-embedded-reg`): no-op on pre-iOS-27 userlands where the method is absent. **Pairs with vphoned's `vp_register_path` containerized-registration fallback** (`registerContainerizedApplicationWithInfoDictionaries:...:registrationError:`, treating a nil `registrationError` as success since it returns NO even when it registers). Also paired with **`/cores/vpregister`** (built + deployed by `cfw_install_jb.sh` / `cfw_install_exp.sh`, invoked by `vphone_jb_setup.sh` at first boot — 27-gated by DEPLOYMENT: `cfw_install_jb.sh`/`cfw_install_exp.sh` copy `/cores/vpregister` only when the mounted rootfs `SystemVersion.plist` is `27.*`, and the setup script's `[ -x /cores/vpregister ]` presence check is the runtime gate. Do NOT gate the invocation on a guest `sw_vers` check — the hybrid guest does not reliably report the 27 userland version at first boot, which silently skipped registration): it registers JB app bundles (Sileo) via the same containerized API, because `uicache -a`'s `registerApplicationDictionary:` is a deprecated no-op on iOS 27 (lsd logs *"you cannot use ... to register applications anymore. These interfaces have been deprecated for years."*). **VALIDATED (2026-07-17, `17,3_27.0_24A5380h` + cloudOS 26.4, JB): -54 gone; Sileo registers (`uicache -l` 0→1) via `vpregister`; vphoned installs+registers a test IPA (`com.vphone.vptest`) to `/var/containers/Bundle/Application/` end-to-end. Clean boot (re-attest correct; no CoreServices page rejection).** See `scripts/patchers/cfw_patch_lsd_embedded_reg.py` and `scripts/vphoned/vpregister.m`. **FIX (2026-08-10):** `_find_gate` only matched the live `cbz`/`cbnz` branch shape, so re-running `cfw install` (host-mount flow, `myphone` VM, `17,3_27.0_24A5390f`) against a cache where this gate was already NOP'd from a prior pass raised `ValueError: ... entitled-result gate ... not found` instead of recognizing the idempotent state (unlike the Cryptex/IOMFB steps, which log `already ... idempotent` and skip cleanly). Confirmed live via host-mount disassembly: the third check's `bl <check3>` is followed by a bare `nop` at the gate site (exact match against `asm("nop")` bytes) immediately before `mov w20, #1` — i.e. already patched. `_find_gate` now also matches `nop` immediately preceding `mov w<reg>,#1` as an already-patched gate, so a re-run just re-attests the page instead of erroring. | Y | Y | Y |
| 13 | `mov x0,#1; ret` on `-[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:]`**iOS 27.0** | `diskimagesiod` | **iOS-27 DDI (`/System/Developer`) auto-mount — mount-gate.** After the personalized DDI attaches (kernel side: JB-28 + JB-09), MobileStorageMounter waits on diskimagesiod's `-[DIDiskArb waitForDAMountWithExpectedCount:diskTracker:]` before it does the real (nobrowse) mount at `/System/Developer`. That wait loops until `isMountComplete` (= `callbackReached \|\| (appearedDiskCount>=expectedCount && mountedDiskCount>=mountableDiskCount)`) is YES; on the 26.4-kernel / 27-userland hybrid it never becomes true (not all of the DMG's IOMedia "appear" to diskimagesiod's DiskArbitration session, and diskarbitrationd never auto-mounts the volume), so the wait hangs and pmd3 times out. diskimagesiod itself does NOT mount the DDI (its `-[DIDiskArb mountWithDeviceName:...]` is dead code) — it only gates MobileStorageMounter. Forcing `isMountComplete` → YES lets the wait return so MobileStorageMounter proceeds. IMP resolved via LC_SYMTAB or ObjC metadata (selector → `__objc_selrefs``__TEXT,__objc_methlist` relative method list → IMP); prologue overwritten `mov x0,#1 ; ret` (safe — the method returns to the caller's unsigned LR without pushing a frame). **Gated to 27.\*** in `cfw_install.sh` (same `$IOS_VERSION` as the DSC patches): on a version-matched userland the native wait completes correctly and forcing it early could race the real mount, so it is NOT applied there. Embedded sandbox profile + private DA/apfs entitlements preserved on re-sign (`ldid_sign_ent`). **Validated on `c0ecdb4b` 26.4 + 27.0 userland: `pmd3 mounter auto-mount` → rc=0, DDI at `/System/Developer`, idempotent across fresh boots.** See `scripts/patchers/cfw_patch_diskimagesiod.py`. | Y | Y | Y |
| 14 | Merge backboard/frontboard launch mach-services into `com.apple.security.exception.mach-lookup.global-name` + `ldid_sign_ent` re-sign — **iOS 27.0** | `/Applications/Campo.app/Campo` | **iOS-27 Campo (wallpaper renderer) crash-loop fix — sandbox half.** Campo declares `com.apple.private.sandbox.profile:embedded = temporary-sandbox` + `no-container`, so it runs under the `temporary-sandbox` profile. On the 26.4 vphone600 kernel that builtin profile predates iOS 27 and DENIES the `mach-lookup` of the backboard/frontboard launch services, so `BKSDisplayServicesStart` (looks up `com.apple.backboard.display.services`) and then `+[BKSHIDEventDeliveryManager sharedInstance]` (HID) fail, log *"backboardd isn't running -- or we couldn't talk to it"*, and `brk #0` ~34 ms after launch → continuous crash-loop, no wallpaper (launchd eventually throttles it off). **JB-02d is NOT sufficient here:** it stops the exec-time container-manager-upcall autobox *KILL*, but Campo is still placed in `temporary-sandbox` (its own declared profile — confirmed the JB-02d `b`-flip is present in the running kernel yet Campo still traps), and that profile still denies the lookups. Fix: append the needed services to Campo's OWN `com.apple.security.exception.mach-lookup.global-name` array — the Apple-sanctioned escape hatch, which `temporary-sandbox` honors (Campo already ships ~20 such exceptions; these launch services simply aren't among them because 27's profile allows them directly). Services added (backboard names exact, from `com.apple.backboardd.plist` MachServices): `com.apple.backboard.display.services`, `com.apple.iohideventsystem`, `com.apple.CARenderServer`, `com.apple.backboard.hid.services`, `com.apple.backboard.hid-services.xpc`, `com.apple.backboard.TouchDeliveryPolicyServer`, `com.apple.backboard.system-app-server`, `com.apple.backboard.watchdog`, `com.apple.backboard.oswatchdog`, `com.apple.backboard.altsysapp`, `com.apple.AttentionAwareness`, `PurpleSystemEventPort`, `PurpleWorkspacePort`, `com.apple.frontboard.systemappservices`, `com.apple.frontboard.workspace`, `com.apple.frontboardservices.systemappmanager`, `com.apple.frontboard.watchdog`. Merge is done by the external helper `scripts/patchers/campo_mach_lookup_exceptions.py` (Python `plistlib`, not `plutil` — the entitlement key contains dots that `plutil` keypaths would mis-split; idempotent, preserves the existing array); re-signed with the JB `signcert.p12` via `ldid_sign_ent` (AMFI enforcement is relaxed on the research VM, so the re-signed cdhash loads and the entitlements are honored). Applied at host-mount build time in `cfw_install_jb.sh` / `cfw_install_exp.sh` step **[JB-3b]**, **hard-gated to `27.*`** via the mounted rootfs `SystemVersion.plist` `ProductVersion` (same gate as the vpregister/DSC patches) — skipped entirely on 26.x/18.x, which don't need it and where `Campo.app` also exists. **VALIDATED on-device (2026-07-21, `17,3_27.0_24A5390f` + cloudOS 26.4, JB): Campo launches and stays up (stable pid, no BKSDisplayServicesStart/BKSHIDEventDeliveryManager trap, no crash-loop); first-cut display-only exception advanced the trap from display→HID, confirming the mechanism, then the full service set cleared it.** See `scripts/cfw_install_jb.sh` / `scripts/cfw_install_exp.sh` step JB-3b and the merge helper `scripts/patchers/campo_mach_lookup_exceptions.py`. | - | - | Y |
| 15 | Derive `matched` from `error_code` + drop the `brk #1` in libxpc `_xpc_token_satisfies_lwcr` (`cset w8,ne; eor w8,w0,w8; tbz w8,#0``cset w0,eq; nop; nop`) + per-page re-attest — **iOS 27.0** | DSC `libxpc.dylib` | **iOS-27 daemon crash-loop fix (Lightweight Code Requirement).** iOS 27 lets an XPC server pin a "lightweight code requirement" (LWCR) on its listener — `xpc_connection_set_peer_lightweight_code_requirement`, or the Swift `XPCPeerRequirement.hasEntitlement(_:)` wrapper (→ `xpc_peer_requirement_create_entitlement_exists``_xpc_peer_requirement_create_lwcr_entitlement_requirement``xpc_peer_requirement_create_lwcr`). Creating the requirement runs a self-check, `_xpc_token_satisfies_lwcr`, which calls an internal matcher returning a `matched` bool (w0) plus a `match_result.error_code` (`AICMR_MATCH == 0`), then hard-asserts they agree (`matched == (error_code == 0)`) via `_os_crash_msg``brk #1`. On stock iOS the two always agree; under our JB code-signing environment the matcher's query writes `error_code = MATCH(0)` yet returns a failure status, so the matcher yields the forbidden `(matched=0, error_code=0)` pair and libxpc aborts. Because EVERY daemon that pins an entitlement peer-requirement at startup hits it, `intelligencetasksd` / `searchpartyd` / `transparencyd` / `bluetoothd` (and others) crash-loop continuously from boot (launchd re-spawn + ReportCrash churn). Fix: recompute `matched` from `error_code` and make the abort unreachable — `cset w8,ne; eor w8,w0,w8; tbz w8,#0,<abort>``cset w0,eq; nop; nop`; the function now returns `(error_code == 0)`, which reproduces stock's verdict when the two agree and resolves the contradiction toward "satisfied" when error_code says MATCH (real allow/deny for genuinely (un)satisfied peers is unchanged, since those set `error_code` != 0). Fully dynamic: `_xpc_token_satisfies_lwcr` resolved via the DSC's own `.symbols` in-image local-symbol table, the check located by control-flow shape in Capstone (`cset wC,ne; eor wE,w0,wC; tbz wE,#0`), replacements from Keystone, modified 16 KiB page re-attested (`cfw_dsc_codesign.py`; TXM enforces per-page; CDHash change accepted by the JB always-true AMFI cdhash-trust patch). Install gate: **`27.*`** (in `cfw_install.sh`, same block as maxSlide/lsd). Patcher additionally self-gates (`patch-xpc-lwcr`): no-op on pre-iOS-27 userlands where the symbol is absent. **VALIDATED on-device (2026-07-22, `17,3_27.0_24A5390f` + cloudOS 26.4, JB, host-mount deploy): patched bytes live (`e0179f1a 1f2003d5 1f2003d5`); the four LWCR crash-loopers disappear from the crash census after boot; launchd itself (which links libxpc) boots clean past first unlock — patch does not brick boot. NOTE: does NOT stop the resprings — those are a separate `FileProviderResolver` ResolverService memory-balloon → jetsam → backboardd kill (see investigation notes).** See `scripts/patchers/cfw_patch_xpc_lwcr.py`. | Y | Y | Y |
| 15 | Derive `matched` from `error_code` + drop the `brk #1` in libxpc `_xpc_token_satisfies_lwcr` (`cset w8,ne; eor w8,w0,w8; tbz w8,#0``cset w0,eq; nop; nop`) + per-page re-attest — **iOS 27.0** | DSC `libxpc.dylib` | **iOS-27 daemon crash-loop fix (Lightweight Code Requirement).** iOS 27 lets an XPC server pin a "lightweight code requirement" (LWCR) on its listener — `xpc_connection_set_peer_lightweight_code_requirement`, or the Swift `XPCPeerRequirement.hasEntitlement(_:)` wrapper (→ `xpc_peer_requirement_create_entitlement_exists``_xpc_peer_requirement_create_lwcr_entitlement_requirement``xpc_peer_requirement_create_lwcr`). Creating the requirement runs a self-check, `_xpc_token_satisfies_lwcr`, which calls an internal matcher returning a `matched` bool (w0) plus a `match_result.error_code` (`AICMR_MATCH == 0`), then hard-asserts they agree (`matched == (error_code == 0)`) via `_os_crash_msg``brk #1`. On stock iOS the two always agree; under our JB code-signing environment the matcher's query writes `error_code = MATCH(0)` yet returns a failure status, so the matcher yields the forbidden `(matched=0, error_code=0)` pair and libxpc aborts. Because EVERY daemon that pins an entitlement peer-requirement at startup hits it, `intelligencetasksd` / `searchpartyd` / `transparencyd` / `bluetoothd` (and others) crash-loop continuously from boot (launchd re-spawn + ReportCrash churn). Fix: recompute `matched` from `error_code` and make the abort unreachable — `cset w8,ne; eor w8,w0,w8; tbz w8,#0,<abort>``cset w0,eq; nop; nop`; the function now returns `(error_code == 0)`, which reproduces stock's verdict when the two agree and resolves the contradiction toward "satisfied" when error_code says MATCH (real allow/deny for genuinely (un)satisfied peers is unchanged, since those set `error_code` != 0). Fully dynamic: resolved via the DSC's own `.symbols` in-image local-symbol table as `__xpc_token_satisfies_lwcr` (the double-underscore mangled name; a single-underscore lookup silently skipped every iOS-27 build until fixed 2026-08-11), the check located by control-flow shape in Capstone (`cset wC,ne; eor wE,w0,wC; tbz wE,#0`), replacements from Keystone, modified 16 KiB page re-attested (`cfw_dsc_codesign.py`; TXM enforces per-page; CDHash change accepted by the JB always-true AMFI cdhash-trust patch). Install gate: **`27.*`** (in `cfw_install.sh`, same block as maxSlide/lsd). Patcher additionally self-gates (`patch-xpc-lwcr`): no-op on pre-iOS-27 userlands where the symbol is absent. **VALIDATED on-device (2026-07-22, `17,3_27.0_24A5390f` + cloudOS 26.4, JB, host-mount deploy): patched bytes live (`e0179f1a 1f2003d5 1f2003d5`); the four LWCR crash-loopers disappear from the crash census after boot; launchd itself (which links libxpc) boots clean past first unlock — patch does not brick boot. NOTE: does NOT stop the resprings — those are a separate `FileProviderResolver` ResolverService memory-balloon → jetsam → backboardd kill (see investigation notes).** See `scripts/patchers/cfw_patch_xpc_lwcr.py`. | Y | Y | Y |
| 16 | NOP the sysctl-error `b.eq <os_crash>` in libSystem `___os_lockdown_mode_enabled_block_invoke` (`cmn w0,#1; b.eq <crash>``nop`) + per-page re-attest — **iOS 27.0** | DSC `libSystem` (`lockdown_mode.c`) | **iOS-27 launchd (pid 1) boot-panic fix.** iOS 27's `os_lockdown_mode_enabled()` resolves Lockdown Mode once via `sysctlbyname("security.mac.lockdown_mode_state_public", &out, &len, 0, 0)`; on a -1 return it `os_crash`es (`lockdown_mode.c:os_lockdown_mode_enabled_block_invoke:47`). The vphone base kernel (cloudOS 26.x) does not implement that MAC sysctl, so the call returns -1/ENOENT and the first process to query Lockdown Mode after "Continuing system boot" aborts — that process is launchd (pid 1), so the kernel panics `initproc exited -- exit reason namespace 2 subcode 0x6 description: none`. b4 (24A5390f) boots on the same kernel; the sysctl query is new in b5 (24A5408d). The block pre-zeroes its output buffer (`stp x8, xzr, [sp]`), so NOPping the error branch falls through to the normal path, reads 0, records "Lockdown Mode disabled", and returns cleanly; on a kernel that implements the sysctl the branch is never taken (w0==0), so the patch is behavior-neutral. Dynamic: `___os_lockdown_mode_enabled_block_invoke` resolved via the DSC's own `.symbols` local-symbol table; the `cmn wR,#1; b.eq` sysctl-error idiom located by control-flow shape in Capstone; NOP from Keystone; modified 16 KiB page re-attested (`cfw_dsc_codesign.py`). Install gate: **`27.*`** (same block as maxSlide/lsd/lwcr). Self-gates: no-op where the symbol is absent (pre-iOS-27 userlands). **Root-caused + verified on-device 2026-08-11** (`17,3_27.0_24A5408d` + cloudOS 26.4, JB): abort message read live via the kernel GDB stub (patched `_abort``b .` to freeze launchd's spinning vCPU, then read its registers + the libSystem crash-info global) = `lockdown_mode.c:os_lockdown_mode_enabled_block_invoke:47: No such file or directory`; with the NOP applied the panic is gone and boot continues past "Got first unlock" into normal daemon startup. See `scripts/patchers/cfw_patch_lockdown_mode.py`. | Y | Y | Y |
### Installed Components
@@ -0,0 +1,93 @@
# JB-23b `patchThreadSetStateEntitlementFlag`
## Scope
Opt-in Frida Stalker patch. Emitted only when firmware patching uses `--frida`
(`KernelJBPatcher.applyFrida`). Baseline JB/EXP firmware is byte-identical when
off (26.4 emits 83 kernel-jb records without `--frida`, 85 with).
## Problem
Frida Stalker follows an existing thread by rewriting its core CPU registers via
the `thread_set_state` MIG routine, which lands in `thread_set_state_from_user()`.
That path passes:
```c
// osfmk/kern/thread_act.c
thread_set_state_from_user(...)
-> thread_set_state_internal(..., TSSF_TRANSLATE_TO_USER | TSSF_CHECK_ENTITLEMENT); // 0x1 | 0x200 = 0x201
```
`thread_set_state_internal()` (with `thread_set_state_allowed()` inlined) then, on
any flags carrying `TSSF_CHECK_ENTITLEMENT`, requires the caller task to hold
`com.apple.private.thread-set-state`. Frida's target does not, so the kernel
raises `GUARD_TYPE_MACH_PORT / THREAD_SET_STATE` and terminates it.
## Approach — clear the flag, don't NOP the check
Instead of NOPing an entitlement-failure branch inside `thread_set_state_allowed()`,
clear `TSSF_CHECK_ENTITLEMENT` (bit 9, 0x200) in the flags the *user setters* pass:
```asm
mov w6, #0x201 // before (TSSF_TRANSLATE_TO_USER | TSSF_CHECK_ENTITLEMENT)
mov w6, #0x1 // after (TSSF_TRANSLATE_TO_USER only)
```
`w6` is the 7th argument to `thread_set_state_internal` (`flags`) by the AArch64
calling convention. Clearing bit 9:
- `TSSF_TRANSLATE_TO_USER` (0x1) is preserved, so user-pointer translation on the
`from_user` path is unchanged.
- Both entitlement-gated branches in `thread_set_state_allowed()` (the
core-register and fatal-PAC-debug clauses, each `flags & TSSF_CHECK_ENTITLEMENT`)
fall through to "allowed" — the function's first test is `tbnz w6, #9`, which is
now not taken, so a non-mach-exception thread returns allowed immediately.
- The `TH_IN_MACH_EXCEPTION` guard (independent of this flag) stays enforced.
This is narrower and more source-faithful than editing the check: it disables the
entitlement requirement only for user-initiated `thread_set_state`, at the exact
call sites that request it.
## Reveal Procedure
1. `findString("com.apple.private.thread-set-state")`.
2. `findStringRefs` → all ADRP+ADD xrefs; group by `findFunctionStart`. Require
they resolve to a single function — `thread_set_state_internal` (the entitlement
checks are inlined there). Recover `[fnStart, fnEnd)` via `findFuncEnd`.
3. Scan code for direct `b`/`bl` whose target lands in `[fnStart - 0x10, fnEnd)`
(the internal function's entry, allowing a small landing-pad lead-in).
4. For each such call, scan back up to 8 instructions for `mov w6, #0x201`
(`w6` = flags; abandon if `w6` is otherwise written first).
5. Patch each such setter to `mov w6, #0x1` via `ARM64Encoder.encodeMovzW`,
Capstone-verifying the re-encode decodes to `mov/movz w6, #1`.
No file offsets, VAs, register numbers, or preassembled bytes are hardcoded.
Kernels without the shape are skipped without changing bytes (fail-open no-op),
and the patch only runs under `--frida`.
## Static Validation — 26.4
Kernel: `ipsws/c0ecdb4b…/kernelcache.research.vphone600`, UUID
`BCD06230-CCBE-8E48-50FF-D9C166D83CD5`.
`patch-component --component kernel-jb --target-os 26.4 --frida` emits exactly two
`kernelcache_frida.thread_set_state_entitlement_flag` records:
```text
0x01D95720: mov w6, #0x201 -> mov w6, #0x1
0x01D9594C: mov w6, #0x201 -> mov w6, #0x1
```
(VA `0xfffffe0008d99720` / `0xfffffe0008d9994c` — the `thread_set_state_from_user`
setter and the inlined `act_set_state_from_user` setter, both feeding the same
`thread_set_state_internal` at `0xfffffe0008d5c170`.) Without `--frida`, zero such
records are emitted.
## Notes
- The 26.4 research kernel has no `tss_should_crash` early-out in the compiled
`thread_set_state_allowed()` (it goes straight to `tbnz w6, #9`), so the
DEVELOPMENT boot-arg bypass is not available — a code patch is required.
- Companion: Frida Stalker's repeated `VM_PROT_COPY` overwrite also needs the
`vm_map_delete` immutable-code fix — JB-25c
(`patch_vm_map_delete_immutable_code.md`), applied together under `--frida`.
@@ -0,0 +1,95 @@
# JB-25c `patchVmMapDeleteImmutableCode`
## Scope
Opt-in Frida Stalker patch. Emitted only under `--frida`
(`KernelJBPatcher.applyFrida`). Companion to JB-23b (thread_set_state); together
they give Frida Stalker existing-thread following and repeated re-instrumentation.
## Problem
Frida Stalker instruments code by a write-then-flip: allocate, write RW, then
`vm_protect(VM_PROT_COPY)` to executable. On a CSM device this leaves a
CSM-associated **permanent** `vm_map_entry` at **current protection RW, maximum
protection RWX**. When Stalker later overwrites that region (re-instrumentation),
the fixed-overwrite path calls `vm_map_delete` on the old entry, whose
permanent-entry handler has a debugger exception (`osfmk/vm/vm_map.c:8855`):
```c
} else if ((flags & VM_MAP_REMOVE_IMMUTABLE_CODE) &&
(entry->protection & VM_PROT_EXECUTE) && // CURRENT protection
developer_mode_state()) {
entry->vme_permanent = FALSE; // allow the debugger to undo it
}
```
The entry is current-RW, so `entry->protection & VM_PROT_EXECUTE` is false, the
exception is skipped, the entry stays permanent, and the overwrite returns
`KERN_PROTECTION_FAILURE`.
## Approach — test max protection instead of current
Retarget the execute test from current protection to maximum protection. The
packed flags word at `[entry, #0x38]` (see `vm_map_xnu.h`: `VME_ALIAS_BITS=12` +
`VME_OFFSET_BITS=52` fill qword0, so `protection:3`/`max_protection:4` land in
qword1's low half) places:
- current protection EXECUTE = **bit 9**
- max protection EXECUTE = **bit 13**
So the fix is `#9 → #13` on the immutable-code execute test — "allow a debugger to
undo a mapping that is *capable of* execution," which is exactly Frida's RW/max-RWX
entry. This is strictly narrower than converting every `KERN_PROTECTION_FAILURE`
to success.
## Semantic Reveal Procedure
No offsets, VAs, registers, or bytes are hardcoded. For each candidate:
1. Pre-filter for `ldr wF, [xE, #0x38]` (the packed `vm_map_entry` flags word).
2. Require `tbz wF, #19` immediately after (`vme_permanent`).
3. Require the inlined `developer_mode_state()` read in the window: a byte load
whose bit 0 is then tested (`ldrb wD,[…] ; … ; tbz/tbnz wD,#0`). This ties the
match to the immutable-code gate rather than any packed-flags load.
4. Identify the current-X test (`wF`, bit 9) bound to the cluster:
- **Shape A**: `tbz wF,#9,T` immediately following a remove-flags argument test
`tbz wArg,#b,T` (different register, **same** fallback target `T`).
- **Shape B**: `tbnz wF,#9,P` (after the developer-mode gate) whose target `P`
equals the `vme_permanent` test's target (the permanent-continuation path).
5. Retarget bit 9 → 13, preserving sense (`tbz`/`tbnz`), source register, and
target, via `ARM64Encoder.encodeTestBitBranch`; Capstone round-trip verify the
re-encode's mnemonic, bit (13), and target before emitting.
Exactly two gates must be found (the compiler outlines the two source paths); any
other count fails closed. The **later CSM current-X `#9` test** in the same window
is excluded because its branch target is neither the remove-flags fallback nor the
permanent-continuation target.
Robustness notes vs. a naive scan:
- The remove-flags bit is matched **structurally** (a test of a register other than
the entry-flags register), never by a source constant — `VM_MAP_REMOVE_*` bit
numbers drift across XNU versions (this kernel tests bit 6; the reference source
defines `VM_MAP_REMOVE_IMMUTABLE_CODE = 0x080`).
- Bits 9/13/19 are protection/permanent **struct** bits, stable across versions.
## 26.4 Static Validation
Kernel: `ipsws/c0ecdb4b…/kernelcache.research.vphone600`.
`patch-component --component kernel-jb --target-os 26.4 --frida` emits exactly two
`kernelcache_frida.vm_map_delete_immutable_code` records:
```text
0x01DBE14C: tbz w8, #9, 0x1dbe16c -> tbz w8, #0xd, 0x1dbe16c (shape-A)
0x01DBE828: tbnz w8, #9, 0x1dbe958 -> tbnz w8, #0xd, 0x1dbe958 (shape-B)
```
Branch targets are unchanged; only the tested bit index differs. Without `--frida`,
zero such records are emitted (baseline 83; `--frida` 87 = 83 + 2 thread_set_state
+ 2 vm_map_delete).
## Validation Requirements
- `swift test --filter ARM64EncoderTests` passes (round-trip of the bit-13 encode).
- 26.4 dry-run emits exactly two `vm_map_delete_immutable_code` records.
- Before/after disassembly differs only in the tested bit index (9 → 13).
- If the semantic candidate count is not exactly two, the patch fails closed.
+4 -8
View File
@@ -371,6 +371,8 @@ case "$IOS_VERSION" in
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-lsd-embedded-reg "$DSC_DIR"
echo " [*] Patching libxpc LWCR self-check (iOS 27 daemon crash-loop)..."
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-xpc-lwcr "$DSC_DIR"
echo " [*] Patching os_lockdown_mode_enabled (missing MAC sysctl -> launchd abort)..."
"$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-lockdown-mode "$DSC_DIR"
fi
;;
*)
@@ -579,14 +581,8 @@ echo "[*] Unmounting image volumes..."
/sbin/umount $MNT1 2>/dev/null || true
/sbin/umount $MNT3 2>/dev/null || true
# Keep .cfw_temp/Cryptex*.dmg cached (slow to re-create)
# Only remove temp binaries
echo "[*] Cleaning up temp binaries..."
rm -f "$TEMP_DIR/seputil" \
"$TEMP_DIR/launchd_cache_loader" \
"$TEMP_DIR/mobileactivationd" \
"$TEMP_DIR/vphoned" \
"$TEMP_DIR/launchd.plist"
echo "[*] Cleaning up temp..."
rm -rf "$TEMP_DIR"
echo ""
echo "[+] CFW installation complete!"
+2 -8
View File
@@ -508,14 +508,8 @@ echo "[*] Unmounting image volumes..."
/sbin/umount $MNT1 2>/dev/null || true
/sbin/umount $MNT3 2>/dev/null || true
# Keep .cfw_temp/Cryptex*.dmg cached (slow to re-create)
# Only remove temp binaries
echo "[*] Cleaning up temp binaries..."
rm -f "$TEMP_DIR/seputil" \
"$TEMP_DIR/launchd_cache_loader" \
"$TEMP_DIR/mobileactivationd" \
"$TEMP_DIR/vphoned" \
"$TEMP_DIR/launchd.plist"
echo "[*] Cleaning up temp..."
rm -rf "$TEMP_DIR"
echo ""
echo "[+] CFW installation complete!"
+2 -3
View File
@@ -818,9 +818,8 @@ echo "[*] Unmounting image volumes..."
/sbin/umount $MNT3 2>/dev/null || true
/sbin/umount $MNT5 2>/dev/null || true
echo "[*] Cleaning up temp binaries..."
rm -f "$TEMP_DIR/launchd" \
"$TEMP_DIR/bootstrap-iphoneos-arm64.tar"
echo "[*] Cleaning up temp..."
rm -rf "$TEMP_DIR"
echo ""
echo "[+] CFW + JB + EXP installation complete!"
+6
View File
@@ -80,6 +80,7 @@ echo "[*] running $INSTALLER (files placed on host mounts)..."
( cd "$VM_DIR" && env CFW_HOST_CONTAINER="$CONT" _VPHONE_PATH="$P" \
${SPOOF_BUILD:+SPOOF_BUILD="$SPOOF_BUILD"} \
${FORCE_DSC_MAXSLIDE:+FORCE_DSC_MAXSLIDE="$FORCE_DSC_MAXSLIDE"} \
${VPHONE_FRIDA:+VPHONE_FRIDA="$VPHONE_FRIDA"} \
zsh "$SCRIPT_DIR/$INSTALLER" . )
cleanup
@@ -88,6 +89,11 @@ trap - EXIT
echo "[*] flipping boot snapshot offline (com.apple.os.update -> live volume)..."
"$PY" "$PROJ/tools/apfs_snap_rename.py" "$IMG"
# Drop the extracted CFW input dirs (source .tar.zst re-extracts). VPHONE_KEEP_ARTIFACTS opts out.
if [[ -z "${VPHONE_KEEP_ARTIFACTS:-}" ]]; then
rm -rf "${VM_DIR:?}/cfw_input" "${VM_DIR:?}/cfw_jb_input"
fi
# The whole install ran as root (owners-honored mounts / chown / cp). Hand the
# host-side artifacts it created (vm/.vphoned.signed, vm/.cfw_temp, extracted
# cfw_input/cfw_jb_input, the vphoned build) back to the invoking user, so the
+2 -3
View File
@@ -480,9 +480,8 @@ echo "[*] Unmounting image volumes..."
/sbin/umount $MNT3 2>/dev/null || true
/sbin/umount $MNT5 2>/dev/null || true
echo "[*] Cleaning up temp binaries..."
rm -f "$TEMP_DIR/launchd" \
"$TEMP_DIR/bootstrap-iphoneos-arm64.tar"
echo "[*] Cleaning up temp..."
rm -rf "$TEMP_DIR"
echo ""
echo "[+] CFW + JB installation complete!"
+38
View File
@@ -70,6 +70,44 @@ else
echo " [=] No manifest at $MANIFEST (skipping downloads)"
fi
# Opt-in (vm create --frida): fetch the latest Frida iOS release deb. It's
# re.frida.server (no deps, rootless layout), so the first-boot dpkg -i handles it.
if [[ "${VPHONE_FRIDA:-0}" == "1" ]]; then
echo " [>] Resolving latest Frida iOS release..."
frida_url="$(curl -fsSL --connect-timeout 20 \
https://api.github.com/repos/frida/frida/releases/latest 2>/dev/null \
| grep -o 'https://[^"]*/frida_[^"]*_iphoneos-arm64\.deb' | head -1)"
if [[ -n "$frida_url" ]]; then
frida_name="$(deb_filename_from_url "$frida_url")"
# Keep only one Frida deb in the cache so a newer "latest" fully replaces
# any previously-pinned version at install time.
for old in "$CACHE_DIR"/frida_*_iphoneos-arm64.deb(N); do
[[ "${old:t}" == "$frida_name" ]] || rm -f "$old"
done
dest="$CACHE_DIR/$frida_name"
if [[ -s "$dest" ]]; then
echo " [=] Cached: $frida_name"
cached=$((cached + 1))
else
echo " [>] Downloading: $frida_url"
tmp="$dest.download"
if curl -fL --retry 2 --connect-timeout 20 -o "$tmp" "$frida_url"; then
mv -f "$tmp" "$dest"
echo " [+] Downloaded: $frida_name"
downloaded=$((downloaded + 1))
else
rc=$?
rm -f "$tmp"
echo " [!] ERROR: Frida download failed (curl exit $rc), skipping" >&2
failed=$((failed + 1))
fi
fi
else
echo " [!] ERROR: could not resolve latest Frida iOS deb URL, skipping" >&2
failed=$((failed + 1))
fi
fi
total=0
for f in "$CACHE_DIR"/*.deb(N); do
total=$((total + 1))
+8
View File
@@ -687,5 +687,13 @@ echo "==> Generating hybrid plists ..."
echo "==> Cleaning up ..."
rm -rf "$CLOUDOS_DIR"
# Drop the extracted base-IPSW caches (kept .ipsw re-extracts). VPHONE_KEEP_ARTIFACTS opts out.
if [[ -z "${VPHONE_KEEP_ARTIFACTS:-}" ]]; then
hybrid="$(cd "$IPHONE_DIR" && pwd -P)"
for cache in "$IPHONE_CACHE" "$CLOUDOS_CACHE"; do
[[ -d "$cache" && "$(cd "$cache" && pwd -P)" != "$hybrid" ]] && rm -rf "$cache"
done
fi
echo "==> Done. Restore directory ready: $IPHONE_DIR/"
echo " Run 'make fw_patch' to patch boot-chain components."
+11 -1
View File
@@ -59,6 +59,7 @@ Commands:
installs. Self-gating (no-op on pre-iOS-27 userlands where the method is absent).
patch-xpc-lwcr <chunks_dir> [--dry-run]
patch-lockdown-mode <chunks_dir> [--dry-run]
Stop libxpc's Lightweight Code Requirement self-check (_xpc_token_satisfies_lwcr)
from brk-aborting on our JB. iOS 27's LWCR matcher returns the contradictory
(matched=0, error_code=MATCH) pair under our code-signing environment; the
@@ -127,6 +128,7 @@ if __name__ == "__main__":
from patchers.cfw_patch_dsc_maxslide import patch_dsc_maxslide
from patchers.cfw_patch_lsd_embedded_reg import patch_lsd_embedded_reg
from patchers.cfw_patch_xpc_lwcr import patch_xpc_lwcr
from patchers.cfw_patch_lockdown_mode import patch_lockdown_mode
from patchers.cfw_patch_camera_dsc import apply_all_camera_patches
from patchers.cfw_patch_watchdogd import patch_watchdogd
from patchers.cfw_patch_diskimagesiod import patch_diskimagesiod
@@ -142,6 +144,7 @@ else:
from .cfw_patch_dsc_maxslide import patch_dsc_maxslide
from .cfw_patch_lsd_embedded_reg import patch_lsd_embedded_reg
from .cfw_patch_xpc_lwcr import patch_xpc_lwcr
from .cfw_patch_lockdown_mode import patch_lockdown_mode
from .cfw_patch_camera_dsc import apply_all_camera_patches
from .cfw_patch_watchdogd import patch_watchdogd
from .cfw_patch_diskimagesiod import patch_diskimagesiod
@@ -251,6 +254,13 @@ def main():
sys.exit(1)
dry_run = "--dry-run" in sys.argv[3:]
patch_xpc_lwcr(sys.argv[2], dry_run=dry_run)
elif cmd == "patch-lockdown-mode":
if len(sys.argv) < 3:
print("Usage: patch_cfw.py patch-lockdown-mode <chunks_dir> [--dry-run]")
sys.exit(1)
dry_run = "--dry-run" in sys.argv[3:]
patch_lockdown_mode(sys.argv[2], dry_run=dry_run)
sys.exit(0)
elif cmd == "patch-camera-dsc":
@@ -321,7 +331,7 @@ def main():
print(f"Unknown command: {cmd}")
print("Commands: cryptex-paths, patch-seputil, patch-launchd-cache-loader, patch-camera-dsc,")
print(" patch-mobileactivationd, patch-launchd-jetsam,")
print(" patch-hv-vmm-dsc, patch-iomfb-swapend, patch-iomfb-force-kern, patch-dsc-maxslide, patch-lsd-embedded-reg, patch-xpc-lwcr, patch-watchdogd,")
print(" patch-hv-vmm-dsc, patch-iomfb-swapend, patch-iomfb-force-kern, patch-dsc-maxslide, patch-lsd-embedded-reg, patch-xpc-lwcr, patch-lockdown-mode, patch-watchdogd,")
print(" patch-diskimagesiod, inject-daemons, patch-dropbear-plist, inject-dylib")
sys.exit(1)
+117
View File
@@ -0,0 +1,117 @@
"""Stop libSystem's `os_lockdown_mode_enabled` from crashing on the vphone kernel.
iOS 27's `os_lockdown_mode_enabled()` resolves Lockdown Mode once via
`sysctlbyname("security.mac.lockdown_mode_state_public", ...)` and, if the
sysctl call returns -1, calls `os_crash` (lockdown_mode.c). The vphone base
kernel (cloudOS 26.x) does not implement that MAC sysctl, so the call fails
with ENOENT and every process that queries Lockdown Mode aborts including
launchd (pid 1), which panics the system right after "Continuing system boot".
The block pre-zeroes its output buffer (`stp x8, xzr, [sp]`), so dropping the
error branch makes the failure path fall through to the normal path, read 0,
and record "Lockdown Mode disabled". On a kernel that does implement the sysctl
the branch is never taken, so the patch is behavior-neutral there.
Shape (in `___os_lockdown_mode_enabled_block_invoke`):
bl <sysctlbyname>
cmn w0, #1 ; w0 == -1 ?
b.eq <os_crash> ; -> NOP
Anchored on the in-image local symbol; the sysctl-error idiom is located by
control-flow shape via Capstone; the NOP comes from Keystone; the modified page
is re-attested (`cfw_dsc_codesign.py`).
"""
from capstone.arm64_const import ARM64_OP_IMM
try:
from .cfw_asm import asm, _cs
from .cfw_dsc_chunks import DSCChunks
from .cfw_dsc_codesign import reattest_modified_pages
from .cfw_patch_xpc_lwcr import _resolve_local_symbol
except ImportError:
from cfw_asm import asm, _cs
from cfw_dsc_chunks import DSCChunks
from cfw_dsc_codesign import reattest_modified_pages
from cfw_patch_xpc_lwcr import _resolve_local_symbol
SYMBOL_CANDIDATES = (
"___os_lockdown_mode_enabled_block_invoke",
"__os_lockdown_mode_enabled_block_invoke",
)
def _imm(insn, idx):
ops = insn.operands
return ops[idx].imm if idx < len(ops) and ops[idx].type == ARM64_OP_IMM else None
def _disasm(chunks, vma, n=60):
buf = chunks.bytes_at_vma(vma, n * 4)
out = []
for insn in _cs.disasm(buf, vma):
out.append(insn)
if insn.mnemonic in ("ret", "retab"):
break
return out
def _find_error_gate(insns):
"""The `cmn wR, #1; b.eq` sysctl-error idiom, preceded by a bl."""
saw_bl = False
for i in range(len(insns) - 1):
if insns[i].mnemonic == "bl":
saw_bl = True
if not saw_bl:
continue
if insns[i].mnemonic == "cmn" and _imm(insns[i], 1) == 1:
beq = insns[i + 1]
if beq.mnemonic == "b.eq":
return beq
return None
def patch_lockdown_mode(chunks_dir, *, dry_run=False):
chunks = DSCChunks(chunks_dir)
print(f" [.] {chunks!r}")
fn_vma = None
for name in SYMBOL_CANDIDATES:
try:
fn_vma = _resolve_local_symbol(chunks_dir, name)
break
except RuntimeError:
continue
if fn_vma is None:
print(" [=] os_lockdown_mode_enabled not present (pre-iOS-27 userland); nothing to patch")
return 0
print(f" [.] {name} @ 0x{fn_vma:X}")
gate = _find_error_gate(_disasm(chunks, fn_vma))
if gate is None:
raise ValueError("lockdown_mode: `cmn wR,#1; b.eq <crash>` sysctl-error gate not found")
print(f" [.] gate @ 0x{gate.address:X}: {gate.mnemonic} {gate.op_str}")
nop = asm("nop")
cur = chunks.bytes_at_vma(gate.address, 4)
if cur == nop:
print(" [=] already patched")
return 1
action = "would write" if dry_run else "wrote"
print(f" [+] {action} nop at 0x{gate.address:X} ({cur.hex()} -> {nop.hex()})")
if not dry_run:
chunks.write_at_vma(gate.address, nop)
reattest_modified_pages(chunks, [gate.address], dry_run=False)
if chunks.bytes_at_vma(gate.address, 4) != nop:
raise RuntimeError(f"post-write verify failed at 0x{gate.address:X}")
print(" [+] lockdown-mode crash patch complete")
return 1
if __name__ == "__main__":
import sys
dry = "--apply" not in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]
d = args[0] if args else "/private/tmp/cryptex27/System/Library/Caches/com.apple.dyld"
patch_lockdown_mode(d, dry_run=dry)
+12 -5
View File
@@ -108,13 +108,20 @@ def _disasm_function(chunks, vma, max_insns=96):
def _find_gate(insns):
"""Locate the conditional branch that gates the entitled result: the
`cbz`/`cbnz` on w0 whose fall-through instruction is `mov w<reg>,#1`
(the YES value later moved to x0). Returns (insn, result_reg) or None."""
(the YES value later moved to x0). Returns (insn, result_reg) or None.
Also matches an already-patched site: a bare `nop` (this patch's own
output) immediately before the same `mov w<reg>,#1`, so a re-run against
an already-patched cache is recognized as idempotent instead of failing
to find the (now gone) conditional branch."""
for i in range(len(insns) - 1):
ins = insns[i]
if ins.mnemonic not in ("cbz", "cbnz"):
continue
ops = ins.operands
if not ops or ops[0].type != ARM64_OP_REG or ins.reg_name(ops[0].reg) != "w0":
is_branch = ins.mnemonic in ("cbz", "cbnz")
if is_branch:
ops = ins.operands
if not ops or ops[0].type != ARM64_OP_REG or ins.reg_name(ops[0].reg) != "w0":
continue
elif ins.mnemonic != "nop":
continue
nxt = _mov_reg_imm(insns[i + 1])
if nxt is not None and nxt[1] == 1 and nxt[0].startswith("w"):
+13 -4
View File
@@ -59,6 +59,8 @@ except ImportError: # direct self-test / standalone execution
SYMBOL = "_xpc_token_satisfies_lwcr"
# Mach-O mangles the leading-underscore source name to a double underscore.
SYMBOL_CANDIDATES = ("__xpc_token_satisfies_lwcr", SYMBOL)
def _resolve_local_symbol(chunks_dir, name):
@@ -153,12 +155,19 @@ def patch_xpc_lwcr(chunks_dir, *, dry_run=False):
# Self-gating: the LWCR path only exists on iOS 27+ libxpc. On older
# userlands the symbol is absent, so this is a no-op there.
try:
fn_vma = _resolve_local_symbol(chunks_dir, SYMBOL)
except RuntimeError:
fn_vma = None
resolved_name = None
for candidate in SYMBOL_CANDIDATES:
try:
fn_vma = _resolve_local_symbol(chunks_dir, candidate)
resolved_name = candidate
break
except RuntimeError:
continue
if fn_vma is None:
print(f" [=] {SYMBOL} not present (pre-iOS-27 userland); nothing to patch")
return 0
print(f" [.] {SYMBOL} @ 0x{fn_vma:X}")
print(f" [.] {resolved_name} @ 0x{fn_vma:X}")
insns = _disasm_function(chunks, fn_vma)
found = _find_consistency_check(insns)
+2 -1
View File
@@ -14,6 +14,7 @@ from pymobiledevice3.exceptions import (
ConnectionFailedToUsbmuxdError,
IRecvNoDeviceConnectedError,
IncorrectModeError,
PairingError,
)
from pymobiledevice3.irecv import IRecv
from pymobiledevice3.lockdown import create_using_usbmux
@@ -95,7 +96,7 @@ async def resolve_device(ecid: Optional[int], udid: Optional[str]) -> Device:
try:
lockdown = await create_using_usbmux(serial=usb_device.serial, connection_type="USB")
except (ConnectionFailedError, IncorrectModeError):
except (ConnectionFailedError, IncorrectModeError, PairingError):
continue
lockdown_ecid = int(str(lockdown.ecid), 0)
+1 -1
View File
@@ -27,7 +27,7 @@ ensure_repo_submodule() {
echo "[1/4] Checking brew packages..."
BREW_PACKAGES=(aria2 gnu-tar openssl@3 ldid-procursus sshpass zstd)
BREW_PACKAGES=(aria2 gnu-tar openssl@3 ldid-procursus sshpass zstd cmake)
BREW_MISSING=()
for pkg in "${BREW_PACKAGES[@]}"; do
+9
View File
@@ -229,6 +229,15 @@ else
log " Havoc source already present"
fi
FRIDA_LIST="$(dirname "$HAVOC_LIST")/frida.list"
if ! grep -rIl 'build.frida.re' /etc/apt /var/jb/etc/apt 2>/dev/null | grep -q .; then
mkdir -p "$(dirname "$FRIDA_LIST")"
printf '%s\n' 'deb https://build.frida.re/ ./' > "$FRIDA_LIST"
log " Frida source added: $FRIDA_LIST"
else
log " Frida source already present"
fi
apt-get -o Acquire::AllowInsecureRepositories=true \
-o Acquire::AllowDowngradeToInsecureRepositories=true \
update -qq 2>&1 || log " apt update exited with $?"
Binary file not shown.
@@ -34,6 +34,32 @@ public enum ARM64Encoder {
return ARM64.encodeU32(insn)
}
/// Encode TBZ/TBNZ (test bit and branch). Target must be 4-byte aligned and
/// within the signed 14-bit range (+/-32 KB).
///
/// Format: `[31] = b5`, `[30:24] = 0110110 (TBZ) / 0110111 (TBNZ)`,
/// `[23:19] = b40`, `[18:5] = imm14`, `[4:0] = Rt`
public static func encodeTestBitBranch(
nonzero: Bool,
register: UInt32,
bit: UInt32,
from pc: Int,
to target: Int
) -> Data? {
guard register < 32, bit < 64 else { return nil }
let delta = target - pc
guard delta & 0x3 == 0 else { return nil }
let imm14 = delta >> 2
guard imm14 >= -(1 << 13), imm14 < (1 << 13) else { return nil }
var insn: UInt32 = nonzero ? 0x3700_0000 : 0x3600_0000
insn |= (bit & 0x20) << 26
insn |= (bit & 0x1F) << 19
insn |= (UInt32(bitPattern: Int32(imm14)) & 0x3FFF) << 5
insn |= register & 0x1F
return ARM64.encodeU32(insn)
}
// MARK: - ADRP / ADD Encoding
/// Encode ADRP instruction.
@@ -0,0 +1,114 @@
// KernelJBPatchThreadSetState.swift optional Frida Stalker support (--frida).
//
// Frida follows an existing thread via thread_set_state_from_user, whose flags
// carry TSSF_CHECK_ENTITLEMENT and trip GUARD_TYPE_MACH_PORT. Clear that bit in the
// user setters (`mov w6,#0x201` `mov w6,#0x1`) rather than the check itself.
// Reveal + validation: research/kernel_patch_jb/patch_thread_set_state.md.
import Capstone
import Foundation
extension KernelJBPatcher {
private static let tssEntitlement = "com.apple.private.thread-set-state"
// TSSF_TRANSLATE_TO_USER (0x1) | TSSF_CHECK_ENTITLEMENT (0x200).
private static let tssFlagsFromUser: Int64 = 0x201
private static let tssFlagsCleared: UInt16 = 0x1
/// Clear TSSF_CHECK_ENTITLEMENT in the flags passed by the thread_set_state
/// user setters so Frida Stalker can update an existing thread's registers.
@discardableResult
func patchThreadSetStateEntitlementFlag() -> Bool {
log("\n[FRIDA] thread_set_state: clear TSSF_CHECK_ENTITLEMENT in user setters")
guard let strOff = buffer.findString(Self.tssEntitlement) else {
log(" [~] thread-set-state entitlement string absent; skipping")
return true
}
// All entitlement-string refs land in one function (thread_set_state_internal).
let refs = findStringRefs(strOff)
let starts = Set(refs.compactMap { findFunctionStart($0.adrpOff) })
guard starts.count == 1, let fnStart = starts.first else {
log(" [~] entitlement checks not in a single recovered function (\(starts.count)); skipping")
return true
}
let fnEnd = findFuncEnd(fnStart, maxSize: 0x1000)
// `mov w6,#0x201` (w6 = 7th arg = flags) feeding a direct branch into it.
var setterOffsets: [Int] = []
for range in codeRanges {
var off = range.start
while off + 4 <= min(range.end, buffer.count) {
defer { off += 4 }
guard let branch = disasAt(off),
branch.mnemonic == "b" || branch.mnemonic == "bl",
let target = branchTargetFileOffset(branch),
target >= fnStart - 0x10, target < fnEnd
else { continue }
if let setter = findFlagSetterBefore(off, funcFloor: range.start) {
setterOffsets.append(setter)
}
}
}
let unique = Array(Set(setterOffsets)).sorted()
guard !unique.isEmpty else {
log(" [~] no TSSF_CHECK_ENTITLEMENT setter reaches thread_set_state; skipping")
return true
}
for setterOff in unique {
guard let orig = disasAt(setterOff),
let rd = wRegisterNumber(orig),
let bytes = ARM64Encoder.encodeMovzW(rd: rd, imm16: Self.tssFlagsCleared),
let check = disasm.disassembleOne(bytes, at: UInt64(setterOff)),
(check.mnemonic == "mov" || check.mnemonic == "movz"),
let ops = check.aarch64?.operands, ops.count == 2,
ops[1].type == AARCH64_OP_IMM, ops[1].imm == Int64(Self.tssFlagsCleared)
else {
log(" [-] failed to assemble/verify cleared flags at 0x\(String(format: "%X", setterOff))")
return false
}
emit(setterOff, bytes,
patchID: "kernelcache_frida.thread_set_state_entitlement_flag",
virtualAddress: fileOffsetToVA(setterOff),
description: "clear TSSF_CHECK_ENTITLEMENT (0x201 -> 0x1) [thread_set_state user setter, --frida]")
}
return true
}
// MARK: - Helpers
/// Direct B/BL target (disassembly runs in file-offset space).
private func branchTargetFileOffset(_ insn: Instruction) -> Int? {
guard let ops = insn.aarch64?.operands, ops.count == 1,
ops[0].type == AARCH64_OP_IMM
else { return nil }
return Int(ops[0].imm)
}
/// Scan back up to 8 instructions for `mov w6, #0x201`, abandoning if w6 is
/// otherwise written first. Returns the setter's file offset.
private func findFlagSetterBefore(_ branchOff: Int, funcFloor: Int) -> Int? {
var off = branchOff - 4
var steps = 0
while off >= funcFloor, steps < 8 {
defer { off -= 4; steps += 1 }
guard let insn = disasAt(off) else { continue }
guard insn.mnemonic == "mov" || insn.mnemonic == "movz" else { continue }
guard let ops = insn.aarch64?.operands, ops.count == 2,
ops[0].type == AARCH64_OP_REG, ops[1].type == AARCH64_OP_IMM,
disasm.firstRegisterName(insn) == "w6"
else { continue }
return ops[1].imm == Self.tssFlagsFromUser ? off : nil
}
return nil
}
private func wRegisterNumber(_ insn: Instruction) -> UInt32? {
guard let name = disasm.firstRegisterName(insn), name.hasPrefix("w"),
let value = UInt32(name.dropFirst()), value < 32
else { return nil }
return value
}
}
@@ -0,0 +1,189 @@
// KernelJBPatchVmMapDelete.swift optional Frida Stalker support (--frida).
//
// Frida's write-then-flip leaves a permanent CSM mapping at current RW / max RWX;
// vm_map_delete's immutable-code exception tests current-protection EXECUTE, which
// is clear, so re-instrumentation fails with KERN_PROTECTION_FAILURE. Retarget the
// test from current-X (packed [entry,#0x38] bit 9) to max-X (bit 13).
// Reveal + validation: research/kernel_patch_jb/patch_vm_map_delete_immutable_code.md.
import Capstone
import Foundation
extension KernelJBPatcher {
private struct VmMapDeleteGate {
let offset: Int
let register: UInt32
let nonzero: Bool
let target: Int
let shape: String
}
@discardableResult
func patchVmMapDeleteImmutableCode() -> Bool {
log("\n[FRIDA] _vm_map_delete: allow debugger overwrite of RW/max-RWX permanent code")
let gates = findVmMapDeleteImmutableCodeGates()
if gates.isEmpty {
// Older kernels predate this compiled CSM/permanent-entry shape.
log(" [~] immutable-code current-protection gates not present; skipping")
return true
}
guard gates.count == 2 else {
log(" [-] expected 2 immutable-code execute gates, found \(gates.count); failing closed")
return false
}
// Each gate must live inside a recovered function (the compiler may outline
// the two source paths into separate local helpers).
for gate in gates where findFunctionStart(gate.offset) == nil {
log(" [-] could not recover function containing gate at 0x\(String(format: "%X", gate.offset))")
return false
}
var replacements: [(VmMapDeleteGate, Data)] = []
for gate in gates.sorted(by: { $0.offset < $1.offset }) {
guard let bytes = ARM64Encoder.encodeTestBitBranch(
nonzero: gate.nonzero, register: gate.register, bit: 13,
from: gate.offset, to: gate.target
),
let decoded = disasm.disassembleOne(bytes, at: UInt64(gate.offset)),
decoded.mnemonic == (gate.nonzero ? "tbnz" : "tbz"),
let ops = decoded.aarch64?.operands, ops.count == 3,
ops[1].type == AARCH64_OP_IMM, ops[1].imm == 13,
ops[2].type == AARCH64_OP_IMM, Int(ops[2].imm) == gate.target
else {
log(" [-] failed to assemble/verify max-X gate at 0x\(String(format: "%X", gate.offset))")
return false
}
replacements.append((gate, bytes))
}
for (gate, bytes) in replacements {
emit(gate.offset, bytes,
patchID: "kernelcache_frida.vm_map_delete_immutable_code",
virtualAddress: fileOffsetToVA(gate.offset),
description: "\(gate.nonzero ? "tbnz" : "tbz") entry max_protection.X [vm_map_delete immutable-code \(gate.shape), --frida]")
}
return true
}
// MARK: - Semantic matcher
private func findVmMapDeleteImmutableCodeGates() -> [VmMapDeleteGate] {
var hits: [VmMapDeleteGate] = []
for range in codeRanges {
var off = range.start
while off + 4 <= min(range.end, buffer.count) {
defer { off += 4 }
// Cheap pre-filter: 32-bit `ldr wRt, [xN, #0x38]`.
let word = buffer.readU32(at: off)
guard word & 0xFFC0_0000 == 0xB940_0000,
((word >> 10) & 0xFFF) * 4 == 0x38
else { continue }
if let gate = matchGate(at: off) {
hits.append(gate)
}
}
}
var seen = Set<Int>()
return hits.filter { seen.insert($0.offset).inserted }
}
/// The window rooted at `ldr wF,[entry,#0x38] ; tbz wF,#19` (vme_permanent),
/// carrying the inlined developer_mode_state() read and the immutable-code
/// current-X test to retarget.
private func matchGate(at ldrOff: Int) -> VmMapDeleteGate? {
let insns = disasm.disassemble(in: buffer.data, at: ldrOff, count: 16)
guard insns.count >= 8,
let flagsReg = destRegister(insns[0]),
bitBranch(insns[1], mnemonic: "tbz", register: flagsReg, bit: 19) != nil
else { return nil }
// Require the inlined developer_mode_state() read somewhere in the window:
// `ldrb wD,[...] ; ... ; tbz/tbnz wD,#0`. Gating anchor for these gates.
guard developerModeGatePresent(insns) else { return nil }
// Shape A: the current-X test immediately follows a remove-flags argument
// test and shares its fallback target.
// tbz wArg,#b, T
// tbz wF, #9, T <- retarget
for i in 2 ..< (insns.count - 1) {
guard let argTarget = bitBranchAnyBit(insns[i], mnemonic: "tbz"),
destRegister(insns[i]) != flagsReg,
let exec = bitBranch(insns[i + 1], mnemonic: "tbz", register: flagsReg, bit: 9),
exec == argTarget.target
else { continue }
return VmMapDeleteGate(
offset: Int(insns[i + 1].address), register: flagsReg,
nonzero: false, target: exec, shape: "shape-A")
}
// Shape B: developer mode is checked first, then the current-X test branches
// to the same permanent-continuation target as the vme_permanent test.
// tbz wF,#19, P
// ... developer-mode gate ...
// tbnz wF,#9, P <- retarget
let permTarget = bitBranch(insns[1], mnemonic: "tbz", register: flagsReg, bit: 19)!
for i in 3 ..< insns.count {
guard let exec = bitBranch(insns[i], mnemonic: "tbnz", register: flagsReg, bit: 9),
exec == permTarget
else { continue }
return VmMapDeleteGate(
offset: Int(insns[i].address), register: flagsReg,
nonzero: true, target: exec, shape: "shape-B")
}
return nil
}
// MARK: - Instruction helpers
/// The instruction's first operand as a W register number, if it is one.
private func destRegister(_ insn: Instruction) -> UInt32? {
guard let name = disasm.firstRegisterName(insn), name.hasPrefix("w"),
let value = UInt32(name.dropFirst()), value < 32
else { return nil }
return value
}
/// A `tbz`/`tbnz wReg,#bit,target` matching the given mnemonic, register, and
/// bit; returns the branch target file offset.
private func bitBranch(_ insn: Instruction, mnemonic: String, register: UInt32, bit: Int64) -> Int? {
guard insn.mnemonic == mnemonic,
let ops = insn.aarch64?.operands, ops.count == 3,
ops[0].type == AARCH64_OP_REG, destRegister(insn) == register,
ops[1].type == AARCH64_OP_IMM, ops[1].imm == bit,
ops[2].type == AARCH64_OP_IMM
else { return nil }
return Int(ops[2].imm)
}
/// Any `tbz`/`tbnz wReg,#bit,target` of the given mnemonic; returns bit + target.
private func bitBranchAnyBit(_ insn: Instruction, mnemonic: String) -> (bit: Int64, target: Int)? {
guard insn.mnemonic == mnemonic,
let ops = insn.aarch64?.operands, ops.count == 3,
ops[0].type == AARCH64_OP_REG,
ops[1].type == AARCH64_OP_IMM, ops[2].type == AARCH64_OP_IMM
else { return nil }
return (ops[1].imm, Int(ops[2].imm))
}
/// The inlined `developer_mode_state()`: a byte load whose bit 0 is then tested
/// (`ldrb wD,[...] ; ; tbz/tbnz wD,#0`).
private func developerModeGatePresent(_ insns: [Instruction]) -> Bool {
for i in 0 ..< insns.count {
guard insns[i].mnemonic == "ldrb", let devReg = destRegister(insns[i]) else { continue }
for j in (i + 1) ..< min(insns.count, i + 4) {
let m = insns[j].mnemonic
if (m == "tbz" || m == "tbnz"),
bitBranch(insns[j], mnemonic: m, register: devReg, bit: 0) != nil {
return true
}
}
}
return false
}
}
@@ -4,11 +4,13 @@
import Foundation
/// JB kernel patcher: 84 patches across 3 groups.
/// JB kernel patcher across 3 groups. Variant- and feature-gated methods can
/// change the emitted record count; iOS-27-only patches are gated by `applyIOS27`
/// and Frida Stalker relaxations by `applyFrida` (opt-in `--frida`).
///
/// Group A: Core gate-bypass methods (5 patches)
/// Group B: Pattern/string anchored methods (16 patches)
/// Group C: Shellcode/trampoline heavy methods (4 patches)
/// Group A: Core gate-bypass methods
/// Group B: Pattern/string anchored methods
/// Group C: Shellcode/trampoline heavy methods
public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
public let component = "kernelcache_jb"
@@ -21,6 +23,10 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
/// set (override with --target-os).
public var applyIOS27 = false
/// Opt-in Frida Stalker kernel relaxations (exposed as `--frida`). Baseline
/// JB/EXP firmware is byte-identical when false.
public var applyFrida = false
public func findAll() throws -> [PatchRecord] {
try parseMachO()
buildADRPIndex()
@@ -67,6 +73,13 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
patchVmFaultEnterPrepare()
patchVmMapProtect()
// Opt-in Frida Stalker support (--frida): existing-thread follow
// (thread_set_state) + repeated VM_PROT_COPY overwrite (vm_map_delete).
if applyFrida {
patchThreadSetStateEntitlementFlag()
patchVmMapDeleteImmutableCode()
}
// Group C
patchCredLabelUpdateExecve()
patchHookCredLabelUpdateExecve()
@@ -81,6 +81,7 @@ public final class FirmwarePipeline {
let noBinpack: Bool
let noVphoned: Bool
let forceExcGuard: Bool
let enableFrida: Bool
let loader: any FirmwareLoader
/// Set when the iPhone base is iOS 18.x (read from iPhone-BuildManifest.plist).
@@ -93,6 +94,9 @@ public final class FirmwarePipeline {
/// byte-identical to pre-branch. Computed in `patchAll()` alongside iosBaseIs18.
private var iosBaseIs27 = false
/// Set when the cloudOS kernel is 26.4+; gates the opt-in Frida kernel patches.
private var cloudOSIsFridaCapable = false
// MARK: - Init
public init(
@@ -102,6 +106,7 @@ public final class FirmwarePipeline {
noBinpack: Bool = false,
noVphoned: Bool = false,
forceExcGuard: Bool = false,
enableFrida: Bool = false,
loader: (any FirmwareLoader)? = nil
) {
self.vmDirectory = vmDirectory
@@ -110,6 +115,7 @@ public final class FirmwarePipeline {
self.noBinpack = noBinpack
self.noVphoned = noVphoned
self.forceExcGuard = forceExcGuard
self.enableFrida = enableFrida
self.loader = loader ?? ContainerFirmwareLoader()
}
@@ -135,6 +141,17 @@ public final class FirmwarePipeline {
: iosBaseIs27 ? " (enabling iOS-27 JB kernel patches)" : ""
log("[*] iPhone base iOS: \(baseVersion ?? "unknown")\(baseGateNote)")
// Frida Stalker kernel patches only apply on cloudOS 26.4+ (where the shapes
// were validated); older kernels are left untouched. The Frida deb install is
// separate and version-independent.
let cloudOSVersion = Self.readCloudOSProductVersion(restoreDir)
cloudOSIsFridaCapable = Self.productVersionAtLeast(cloudOSVersion, 26, 4)
if enableFrida {
log("[*] cloudOS kernel: \(cloudOSVersion ?? "unknown")"
+ (cloudOSIsFridaCapable ? " (Frida kernel patches enabled)"
: " (< 26.4 — Frida kernel patches skipped)"))
}
let components = buildComponentList()
log("[*] Patching \(components.count) boot-chain components ...")
@@ -220,6 +237,9 @@ public final class FirmwarePipeline {
// JB kernel patches so 18.x/26.x bases apply none of them.
let applyIOS27 = iosBaseIs27
// Opt-in Frida Stalker kernel relaxations (--frida), gated to cloudOS 26.4+.
let applyFrida = enableFrida && cloudOSIsFridaCapable
// iOS 18 bases: disable the skywalk flowswitch netagents via boot-arg so
// Network.framework uses the BSD path (the 26.1-kernel skywalk
// channel-create traps in the 18.x Network.framework and crash-loops
@@ -341,6 +361,7 @@ public final class FirmwarePipeline {
{ data, verbose in
let p = KernelJBPatcher(data: data, verbose: verbose)
p.applyIOS27 = applyIOS27
p.applyFrida = applyFrida
return p
},
]
@@ -352,6 +373,7 @@ public final class FirmwarePipeline {
{ data, verbose in
let p = KernelJBPatcher(data: data, verbose: verbose)
p.applyIOS27 = applyIOS27
p.applyFrida = applyFrida
return p
},
{ data, verbose in
@@ -436,12 +458,9 @@ public final class FirmwarePipeline {
return restoreDir
}
/// Read the iPhone base `ProductVersion` from `iPhone-BuildManifest.plist`
/// (preserved by fw_prepare before the hybrid manifest overwrites
/// BuildManifest.plist). Returns nil if absent/unreadable callers then
/// treat the base as non-iOS-18 (conservative).
static func readBaseProductVersion(_ restoreDir: URL) -> String? {
let url = restoreDir.appendingPathComponent("iPhone-BuildManifest.plist")
/// `ProductVersion` from a manifest in `restoreDir`, or nil if absent/unreadable.
static func readProductVersion(_ restoreDir: URL, manifest: String) -> String? {
let url = restoreDir.appendingPathComponent(manifest)
guard let data = try? Data(contentsOf: url),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
let dict = plist as? [String: Any],
@@ -450,6 +469,23 @@ public final class FirmwarePipeline {
return version
}
/// iPhone base version (`iPhone-BuildManifest.plist`, preserved by fw_prepare).
static func readBaseProductVersion(_ restoreDir: URL) -> String? {
readProductVersion(restoreDir, manifest: "iPhone-BuildManifest.plist")
}
/// cloudOS/kernel version (the live `BuildManifest.plist`).
static func readCloudOSProductVersion(_ restoreDir: URL) -> String? {
readProductVersion(restoreDir, manifest: "BuildManifest.plist")
}
/// Dotted `ProductVersion` >= major.minor, compared numerically. nil is false.
static func productVersionAtLeast(_ version: String?, _ major: Int, _ minor: Int) -> Bool {
guard let parts = version?.split(separator: ".").compactMap({ Int($0) }),
let vMajor = parts.first else { return false }
return vMajor != major ? vMajor > major : (parts.count > 1 ? parts[1] : 0) >= minor
}
private func compareRestoreDirectories(_ lhs: URL, _ rhs: URL) -> Bool {
let leftName = lhs.lastPathComponent
let rightName = rhs.lastPathComponent
+121 -31
View File
@@ -88,13 +88,22 @@ public enum VPhoneBundleOps {
public static func updateConfig(
bundleNamed name: String, in library: VPhoneLibrary,
cpuCount: UInt?, memoryMB: UInt64?
cpuCount: UInt?, memoryMB: UInt64?,
networkMode: VPhoneVirtualMachineManifest.NetworkConfig.NetworkMode? = nil,
bridgeInterface: String? = nil
) throws -> VPhoneBundle {
let bundle = try library.bundle(named: name)
let editsNetwork = networkMode != nil || bridgeInterface != nil
let network = editsNetwork
? try VPhoneNetworking.merge(
into: bundle.manifest.networkConfig,
mode: networkMode, bridgeInterface: bridgeInterface)
: nil
let updated = bundle.manifest.updating(
cpuCount: cpuCount,
memorySize: memoryMB.map { $0 * 1024 * 1024 },
screenConfig: nil)
screenConfig: nil,
networkConfig: network)
try updated.write(to: bundle.configURL)
return VPhoneBundle(url: bundle.url, manifest: updated)
}
@@ -158,46 +167,110 @@ public enum VPhoneBundleOps {
// MARK: - Export / Import
/// Compression preset for `export`. Both import transparently `importArchive`
/// auto-detects the compressor when it extracts. `threads=0` all cores.
public enum ExportCompression: String, CaseIterable, Sendable {
case fast, max
var tarArgs: [String] {
switch self {
case .fast: ["--zstd", "--options", "zstd:compression-level=3,zstd:threads=0"]
case .max: ["-J", "--options", "xz:compression-level=9,xz:threads=0"]
}
}
/// Extension for auto-named output when `export`'s destination is a directory.
public var fileExtension: String {
switch self {
case .fast: "tzst"
case .max: "txz"
}
}
}
/// Regenerable staging artifacts that never need to travel in an export:
/// `.vphoned.signed` is re-staged on the next launch, and the CFW install
/// inputs/temp are consumed at install time (the result already lives in
/// `Disk.img`). Always excluded.
static let exportExcludePatterns = ["*.vphoned.signed", "*cfw_input*", "*cfw_jb_input*", "*.cfw_temp*"]
/// When `to` is an existing directory, the archive is written inside it as
/// `<name>.<compression.fileExtension>`. Returns the resolved output URL.
///
/// Runs as a two-stage `tar` pipeline (uncompressed producer compressing
/// consumer via bsdtar's `@-`) so `progress` can be driven off the
/// uncompressed byte stream: it is called with `(bytesDone, totalBytes)`,
/// where `totalBytes` is the bundle's on-disk logical size (minus excludes).
@discardableResult
public static func export(
bundleNamed name: String, to outFile: URL, includeIPSW: Bool, in library: VPhoneLibrary
) throws {
bundleNamed name: String, to outFile: URL, includeIPSW: Bool,
compression: ExportCompression = .fast, in library: VPhoneLibrary,
progress: ((Int64, Int64) -> Void)? = nil
) throws -> URL {
_ = try library.bundle(named: name) // validate it exists
// xz at max level, multithreaded (threads=0 all cores) the densest
// compressor libarchive offers, for a multi-GB Disk.img.
var args = ["-cf", outFile.path, "-J", "--options", "xz:compression-level=9,xz:threads=0"]
if !includeIPSW { args += ["--exclude", "*_Restore*"] }
for pattern in exportExcludePatterns { args += ["--exclude", pattern] }
args += ["-C", library.root.path, name]
let r = try VPhoneProcessRunner.runCapturing(URL(fileURLWithPath: "/usr/bin/tar"), args)
guard r.succeeded else { throw VPhoneBundleOpsError.tarFailed(r.stderr) }
var isDir: ObjCBool = false
let outFile = FileManager.default.fileExists(atPath: outFile.path, isDirectory: &isDir) && isDir.boolValue
? outFile.appendingPathComponent("\(name).\(compression.fileExtension)")
: outFile
// gnutar (not the bsdtar-default pax): pax extended headers make the
// consumer's `@-` reader misbid the stream as mtree ("Line too long")
// on large members; gnutar also carries files >8 GB (ustar cannot).
var producer = ["--format", "gnutar", "-cf", "-"]
if !includeIPSW { producer += ["--exclude", "*_Restore*"] }
for pattern in exportExcludePatterns { producer += ["--exclude", pattern] }
producer += ["-C", library.root.path, name]
let consumer = ["-cf", outFile.path] + compression.tarArgs + ["@-"]
let total = progress != nil
? archivedLogicalSize(bundleDir: library.url(forName: name), libraryRoot: library.root, includeIPSW: includeIPSW)
: 0
let err = try VPhoneProcessRunner.runCountingTarPipe(
producerArgs: producer, sourceFile: nil, consumerArgs: consumer
) { done in progress?(done, total) }
if let err { throw VPhoneBundleOpsError.tarFailed(err) }
return outFile
}
/// On-disk logical size of the members `export` will archive, mirroring the
/// tar `--exclude` patterns so the progress total matches the streamed bytes.
private static func archivedLogicalSize(
bundleDir: URL, libraryRoot: URL, includeIPSW: Bool
) -> Int64 {
let fm = FileManager.default
guard let en = fm.enumerator(
at: bundleDir,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else { return 0 }
let prefix = libraryRoot.path.count + 1 // members are "<name>/..."
var total: Int64 = 0
for case let url as URL in en {
let rel = String(url.path.dropFirst(prefix))
if !includeIPSW, rel.contains("_Restore") { en.skipDescendants(); continue }
if exportExcludePatterns.contains(where: { fnmatch($0, rel, 0) == 0 }) { continue }
guard let vals = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
vals.isRegularFile == true else { continue }
total += Int64(vals.fileSize ?? 0)
}
return total
}
/// Extracts (auto-detecting gzip/zstd/xz) into a private staging dir, then
/// promotes the single top-level bundle to the library. Extracting first
/// means the archive is decompressed once; `progress` is called with
/// `(bytesDone, totalBytes)` as the compressed file is fed into `tar -x`,
/// where `totalBytes` is the archive's size on disk.
public static func importArchive(
from inFile: URL, name: String?, in library: VPhoneLibrary
from inFile: URL, name: String?, in library: VPhoneLibrary,
progress: ((Int64, Int64) -> Void)? = nil
) throws -> VPhoneBundle {
let fm = FileManager.default
// Auto-detect the compression (-tf, not -tzf) so both legacy gzip and
// current xz archives import.
let listing = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", inFile.path])
guard listing.succeeded else { throw VPhoneBundleOpsError.tarFailed(listing.stderr) }
let topDirs = Set(listing.stdout.split(whereSeparator: \.isNewline).compactMap {
$0.split(separator: "/").first.map(String.init)
})
guard topDirs.count == 1, let archived = topDirs.first else {
throw VPhoneBundleOpsError.badArchive(
"expected a single top-level bundle directory, found \(topDirs.sorted())")
// Fail fast when the destination name is already known (explicit rename).
if let name {
try requireValidName(name)
if fm.fileExists(atPath: library.url(forName: name).path) {
throw VPhoneLibraryError.alreadyExists(name: name)
}
}
let finalName = name ?? archived
try requireValidName(finalName)
let dst = library.url(forName: finalName)
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: finalName) }
// Extract into a private staging dir so the archive's OWN top-level name
// can never clobber/merge into an existing bundle of that name; only the
@@ -207,9 +280,21 @@ public enum VPhoneBundleOps {
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: staging) }
let extract = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/tar"), ["-xf", inFile.path, "-C", staging.path])
guard extract.succeeded else { throw VPhoneBundleOpsError.tarFailed(extract.stderr) }
let total = progress != nil ? fileByteSize(inFile) : 0
let err = try VPhoneProcessRunner.runCountingTarPipe(
producerArgs: nil, sourceFile: inFile, consumerArgs: ["-xf", "-", "-C", staging.path]
) { done in progress?(done, total) }
if let err { throw VPhoneBundleOpsError.tarFailed(err) }
let entries = try fm.contentsOfDirectory(atPath: staging.path)
guard entries.count == 1, let archived = entries.first else {
throw VPhoneBundleOpsError.badArchive(
"expected a single top-level bundle directory, found \(entries.sorted())")
}
let finalName = name ?? archived
try requireValidName(finalName)
let dst = library.url(forName: finalName)
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: finalName) }
let extracted = staging.appendingPathComponent(archived)
guard fm.fileExists(atPath: extracted.appendingPathComponent("config.plist").path) else {
throw VPhoneBundleOpsError.badArchive(
@@ -218,4 +303,9 @@ public enum VPhoneBundleOps {
try fm.moveItem(at: extracted, to: dst)
return try VPhoneBundle.load(at: dst)
}
private static func fileByteSize(_ url: URL) -> Int64 {
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
return Int64(size ?? 0)
}
}
@@ -5,11 +5,17 @@ public struct VPhoneBundleReport: Codable, Equatable, Sendable {
public let cpuCount: Int
public let memoryMB: Int
public let diskSizeBytes: Int64
public let network: VPhoneVirtualMachineManifest.NetworkConfig
public let restoreInfo: VPhoneRestoreInfo?
public let udid: String?
public init(bundle: VPhoneBundle) {
self.name = bundle.name
self.cpuCount = Int(bundle.manifest.cpuCount)
self.memoryMB = Int(bundle.manifest.memorySize / (1024 * 1024))
self.diskSizeBytes = bundle.diskSizeBytes
self.network = bundle.manifest.networkConfig
self.restoreInfo = VPhoneRestoreInfo.load(fromBundle: bundle)
self.udid = VPhoneRestoreOps.resolveUDID(bundle: bundle)
}
}
@@ -31,6 +31,9 @@ public struct VPhoneCloudOSOption: Sendable, Equatable {
/// The known downloadable iPhone/cloudOS pairings. Prompts show the friendly
/// `iosName`/`cloudosName`; selection resolves to the URLs.
public enum VPhoneFirmwareCatalog {
/// The iPhone model every catalog IPSW targets.
public static let device = "iPhone17,3"
// cloudOS images (one per major); referenced by multiple iPhone builds.
static let cloud261 = "https://updates.cdn-apple.com/private-cloud-compute/399b664dd623358c3de118ffc114e42dcd51c9309e751d43bc949b98f4e31349"
static let cloud262 = "https://updates.cdn-apple.com/private-cloud-compute/0cb00f22e0f7a8b33995b49b2bdca77f781ed6093a09c570ac21b0f012bab908"
@@ -52,10 +55,15 @@ public enum VPhoneFirmwareCatalog {
.init(iosName: "iOS 26.5", iosURL: "https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-63074/5E6B4A05-BDBC-45FE-9606-22B8F4315989/iPhone17,3_26.5_23F77_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 26.5.2", iosURL: "https://updates.cdn-apple.com/2026SpringFCS/fullrestores/140-25549/1AFB1F72-E48E-476A-9C21-42B27C846C01/iPhone17,3_26.5.2_23F84_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 26.6", iosURL: "https://updates.cdn-apple.com/2026SummerFCS/fullrestores/140-58193/1F477C3E-934B-43C0-B428-753B9E005EC0/iPhone17,3_26.6_23G71_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 26.6.1", iosURL: "https://updates.cdn-apple.com/2026SummerFCS/fullrestores/140-93817/B5362BAA-F3EE-49C8-BA43-309F0DAD1362/iPhone17,3_26.6.1_23G83_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 1", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/122-99394/32118457-A80B-4953-BF2A-11F74FD7D375/iPhone17,3_27.0_24A5355q_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 2", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-21207/F0510574-F649-48C5-B535-0A477E342BFB/iPhone17,3_27.0_24A5370h_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 3", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-35950/D135F5B5-C2BE-4630-8AE9-C78A6F0E8381/iPhone17,3_27.0_24A5380h_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 4", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-57108/5E816D0E-89BB-4B95-8825-6A3EDF22E509/iPhone17,3_27.0_24A5390f_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 5", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-86338/57B34BF9-3BF5-4B47-BCCA-81B282175957/iPhone17,3_27.0_24A5408d_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 6", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/ad5b3026-b03e-4b21-8bcb-96d6ea527e09/iPhone17,3_27.0_24A5418b_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 7", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/ad5a4f9d-f005-466b-bbcf-3b466040074b/iPhone17,3_27.0_24A5424a_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
.init(iosName: "iOS 27 beta 8", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/2d03d580-843b-4b2a-b09d-976b31c10744/iPhone17,3_27.0_24A5430a_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
]
/// Distinct cloudOS images (first-seen order) for the "choose the cloudOS" prompt.
@@ -67,4 +75,43 @@ public enum VPhoneFirmwareCatalog {
}
return out
}
/// JSON-friendly projection of the catalog: each iOS build with its recommended cloudOS.
public static var report: VPhoneFirmwareCatalogReport {
VPhoneFirmwareCatalogReport(
device: device,
pairings: pairings.map {
.init(
ios: .init(name: $0.iosName, url: $0.iosURL),
recommendedCloudOS: .init(name: $0.cloudosName, url: $0.cloudosURL))
})
}
}
// MARK: - VPhoneFirmwareCatalogReport
/// Codable view of the firmware catalog for `fw catalog --json`.
public struct VPhoneFirmwareCatalogReport: Codable, Equatable, Sendable {
public struct Firmware: Codable, Equatable, Sendable {
public let name: String
public let url: String
public init(name: String, url: String) { self.name = name; self.url = url }
}
public struct Entry: Codable, Equatable, Sendable {
public let ios: Firmware
public let recommendedCloudOS: Firmware
public init(ios: Firmware, recommendedCloudOS: Firmware) {
self.ios = ios
self.recommendedCloudOS = recommendedCloudOS
}
}
public let device: String
public let pairings: [Entry]
public init(device: String, pairings: [Entry]) {
self.device = device
self.pairings = pairings
}
}
+2 -2
View File
@@ -41,8 +41,8 @@ public struct VPhoneLibrary: Sendable {
// `~/.vphone/VMs` deliberately space-free: bundle paths flow into the
// shell/make firmware pipeline, and "Application Support" (a space) breaks
// any unquoted expansion there. Keep the default path shell-safe.
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".vphone/VMs", isDirectory: true)
return VPhoneResources.userDataRoot()
.appendingPathComponent("VMs", isDirectory: true)
}
public func url(forName name: String) -> URL {
+118
View File
@@ -0,0 +1,118 @@
import Foundation
import Virtualization
// MARK: - Errors
public enum VPhoneNetworkingError: Error, Equatable {
/// hostOnly has no native Virtualization.framework attachment.
case hostOnlyUnsupported
/// A bridge interface was requested but no such interface exists on the host.
case bridgeInterfaceNotFound(requested: String, available: [String])
/// bridged mode was selected but the host exposes no bridgeable interfaces.
case noBridgeInterfaces
/// `--bridge-interface` was given without selecting bridged mode.
case bridgeInterfaceWithoutBridgedMode
}
extension VPhoneNetworkingError: CustomStringConvertible, LocalizedError {
public var description: String {
switch self {
case .hostOnlyUnsupported:
"network mode 'hostOnly' is not supported (Virtualization.framework has no host-only attachment); use nat, bridged, or none"
case let .bridgeInterfaceNotFound(requested, available):
"bridge interface '\(requested)' not found; available: \(available.isEmpty ? "(none)" : available.joined(separator: ", "))"
case .noBridgeInterfaces:
"bridged mode requires a host interface, but none are available for bridging"
case .bridgeInterfaceWithoutBridgedMode:
"--bridge-interface is only valid with --network bridged"
}
}
public var errorDescription: String? { description }
}
// MARK: - Networking helpers
/// Host-side helpers for validating and realizing a VM's `NetworkConfig`.
/// Shared between config-time editing (`VPhoneBundleOps.updateConfig`) and boot-time
/// device construction so both agree on validation and interface resolution.
public enum VPhoneNetworking {
public typealias NetworkConfig = VPhoneVirtualMachineManifest.NetworkConfig
public typealias NetworkMode = NetworkConfig.NetworkMode
/// Identifiers of host interfaces available for bridging (empty without the
/// `com.apple.vm.networking` entitlement, e.g. in unsigned test binaries).
public static func availableBridgeInterfaces() -> [String] {
VZBridgedNetworkInterface.networkInterfaces.map(\.identifier)
}
/// Resolve the concrete bridge interface to persist for bridged mode.
/// - `requested`: an explicit `--bridge-interface`, validated against the host.
/// - `current`: the interface already stored on the bundle, kept if still present.
/// - otherwise the first available interface is auto-picked.
public static func resolveBridgeInterface(requested: String?, current: String?) throws -> String {
let available = availableBridgeInterfaces()
if let requested {
guard available.contains(requested) else {
throw VPhoneNetworkingError.bridgeInterfaceNotFound(requested: requested, available: available)
}
return requested
}
if let current, available.contains(current) {
return current
}
guard let first = available.first else {
throw VPhoneNetworkingError.noBridgeInterfaces
}
return first
}
/// Merge partial edits onto an existing config, validating the result.
/// A nil argument leaves that field unchanged.
public static func merge(
into current: NetworkConfig,
mode: NetworkMode?,
bridgeInterface: String?
) throws -> NetworkConfig {
let newMode = mode ?? current.mode
if newMode == .hostOnly {
throw VPhoneNetworkingError.hostOnlyUnsupported
}
let newBridge: String?
if newMode == .bridged {
newBridge = try resolveBridgeInterface(requested: bridgeInterface, current: current.bridgeInterface)
} else {
if bridgeInterface != nil {
throw VPhoneNetworkingError.bridgeInterfaceWithoutBridgedMode
}
newBridge = current.bridgeInterface
}
return NetworkConfig(mode: newMode, macAddress: current.macAddress, bridgeInterface: newBridge)
}
/// Build the VZ network device for a config, or nil for `.off` (no NIC).
/// The MAC is left framework-assigned; a forced MAC breaks guest networking.
/// Throws if the config cannot be realized (missing bridge interface, hostOnly).
public static func makeNetworkDevice(_ cfg: NetworkConfig) throws -> VZVirtioNetworkDeviceConfiguration? {
switch cfg.mode {
case .off:
return nil
case .hostOnly:
throw VPhoneNetworkingError.hostOnlyUnsupported
case .nat:
let net = VZVirtioNetworkDeviceConfiguration()
net.attachment = VZNATNetworkDeviceAttachment()
return net
case .bridged:
guard let id = cfg.bridgeInterface else {
throw VPhoneNetworkingError.noBridgeInterfaces
}
guard let iface = VZBridgedNetworkInterface.networkInterfaces.first(where: { $0.identifier == id }) else {
throw VPhoneNetworkingError.bridgeInterfaceNotFound(
requested: id, available: availableBridgeInterfaces())
}
let net = VZVirtioNetworkDeviceConfiguration()
net.attachment = VZBridgedNetworkDeviceAttachment(interface: iface)
return net
}
}
}
@@ -79,6 +79,97 @@ public enum VPhoneProcessRunner {
stderr: String(decoding: errBox.take(), as: UTF8.self))
}
/// Stream an archive through a `/usr/bin/tar` consumer that reads on stdin,
/// invoking `onBytes` with the running byte total so a caller can drive a
/// progress bar off the *uncompressed* (export) or *compressed* (import)
/// stream. The byte source is exactly one of:
/// - `producerArgs`: a `tar` producing an uncompressed archive to a pipe
/// (export count uncompressed input; the consumer compresses via `@-`).
/// - `sourceFile`: the archive file read directly (import count the file
/// as it is fed into `tar -x`).
/// Returns the stderr of whichever stage exited nonzero (consumer first), or
/// `nil` on success. SIGPIPE is ignored for the duration so a consumer that
/// dies early surfaces as its exit status rather than killing this process.
public static func runCountingTarPipe(
producerArgs: [String]?,
sourceFile: URL?,
consumerArgs: [String],
onBytes: ((Int64) -> Void)? = nil
) throws -> String? {
let tar = URL(fileURLWithPath: "/usr/bin/tar")
let prevPIPE = signal(SIGPIPE, SIG_IGN)
defer { signal(SIGPIPE, prevPIPE) }
let group = DispatchGroup()
let consumer = Process()
consumer.executableURL = tar
consumer.arguments = consumerArgs
let cIn = Pipe()
consumer.standardInput = cIn
consumer.standardOutput = FileHandle.nullDevice
let cErr = Pipe()
consumer.standardError = cErr
let cErrBox = DataBox()
group.enter()
cErr.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
else { cErrBox.append(chunk) }
}
var producer: Process?
let source: FileHandle
let pErrBox = DataBox()
if let producerArgs {
let p = Process()
p.executableURL = tar
p.arguments = producerArgs
let pOut = Pipe()
p.standardOutput = pOut
let pErr = Pipe()
p.standardError = pErr
group.enter()
pErr.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
else { pErrBox.append(chunk) }
}
producer = p
source = pOut.fileHandleForReading
} else if let sourceFile {
source = try FileHandle(forReadingFrom: sourceFile)
} else {
preconditionFailure("runCountingTarPipe: producerArgs or sourceFile required")
}
try consumer.run()
try producer?.run()
let sink = cIn.fileHandleForWriting
var total: Int64 = 0
while true {
guard let chunk = try? source.read(upToCount: 1 << 20), !chunk.isEmpty else { break }
do { try sink.write(contentsOf: chunk) } catch { break } // consumer died; status below
total += Int64(chunk.count)
onBytes?(total)
}
try? sink.close()
try? source.close()
producer?.waitUntilExit()
consumer.waitUntilExit()
group.wait()
if consumer.terminationStatus != 0 {
return String(decoding: cErrBox.take(), as: UTF8.self)
}
if let producer, producer.terminationStatus != 0 {
return String(decoding: pErrBox.take(), as: UTF8.self)
}
return nil
}
/// Run `executable args`, inheriting the parent's stdout/stderr so output
/// streams live to the terminal (for long-running tools: downloads, restore,
/// CFW install). Returns the child's exit status; throws only on spawn failure.
@@ -167,4 +258,27 @@ public enum VPhoneProcessRunner {
if savedFg > 0 { _ = tcsetpgrp(ttyFD, savedFg) } // take it back
return process.terminationStatus
}
/// Run `executable args` as root via macOS's native auth dialog (`osascript`
/// `do shell script with administrator privileges`). `do shell script` runs
/// under a bare env, so `env` is passed inline as `KEY=value` nothing else
/// reaches the command. Returns the command's exit status.
public static func runWithAdminPrivileges(
_ executable: URL, _ args: [String], env: [String: String] = [:], echo: Bool = true
) throws -> Int32 {
func shQuote(_ s: String) -> String { "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" }
var tokens = env.sorted { $0.key < $1.key }.map { "\($0.key)=\(shQuote($0.value))" }
tokens.append(shQuote(executable.path))
tokens += args.map(shQuote)
var command = tokens.joined(separator: " ")
if echo, isatty(STDOUT_FILENO) != 0, let tty = ttyname(STDOUT_FILENO) {
command += " > \(shQuote(String(cString: tty))) 2>&1"
}
// Escape the /bin/sh command for the AppleScript string literal (\ then ").
let appleEscaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let source = "do shell script \"\(appleEscaped)\" with administrator privileges"
return try runStreaming(URL(fileURLWithPath: "/usr/bin/osascript"), ["-e", source], echo: echo)
}
}
+87 -4
View File
@@ -70,9 +70,17 @@ public struct VPhoneResources: Sendable {
// MARK: - Cache dirs
public var userCacheDir: URL {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vphone")
/// The per-user data root: `$VPHONE_ROOT` when set, else `~/.vphone`. Both
/// `VPhoneResources` (ipsws/tools/debs/venv) and `VPhoneLibrary` (VMs)
/// derive from this so one variable redirects everything vphone-cli creates.
public static func userDataRoot() -> URL {
if let root = ProcessInfo.processInfo.environment["VPHONE_ROOT"], !root.isEmpty {
return URL(fileURLWithPath: root, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vphone")
}
public var userCacheDir: URL { Self.userDataRoot() }
public var ipswCacheDir: URL { userCacheDir.appendingPathComponent("ipsws") }
public var sealVolumeCacheDir: URL { userCacheDir.appendingPathComponent("tools") }
public var debsCacheDir: URL { userCacheDir.appendingPathComponent("debs") }
@@ -109,6 +117,71 @@ public struct VPhoneResources: Sendable {
return (try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]))?.succeeded == true
}
/// Must assemble, not just import a bindings-only install imports fine
/// and then fails inside `fw patch`.
func keystoneIsUsable(_ python: URL) -> Bool {
let probe = "from keystone import Ks, KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN; import sys; "
+ "sys.exit(0 if bytes(Ks(KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN).asm('nop')[0]) else 1)"
return (try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]))?.succeeded == true
}
func venvIsUsable(_ python: URL) -> Bool {
pythonIsUsable(python) && keystoneIsUsable(python)
}
// MARK: - keystone native library
/// Older bottles ship only the static archive.
private func homebrewKeystoneLibs() -> (dylib: URL?, archive: URL?) {
for prefix in ["/opt/homebrew/opt/keystone/lib", "/usr/local/opt/keystone/lib"] {
let dir = URL(fileURLWithPath: prefix)
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { continue }
let dylib = names.first { $0.hasPrefix("libkeystone") && $0.hasSuffix(".dylib") }
let archive = names.first { $0 == "libkeystone.a" }
if dylib != nil || archive != nil {
return (dylib.map(dir.appendingPathComponent), archive.map(dir.appendingPathComponent))
}
}
return (nil, nil)
}
/// Asked of the interpreter: `import keystone` is what's broken here.
private func keystonePackageDir(_ python: URL) -> URL? {
let probe = "import sysconfig; print(sysconfig.get_paths()['purelib'])"
guard let r = try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]), r.succeeded else { return nil }
let purelib = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
guard !purelib.isEmpty else { return nil }
return URL(fileURLWithPath: purelib).appendingPathComponent("keystone")
}
/// PyPI has no arm64 macOS wheel and the sdist ignores its build's exit
/// status, so a failed native build still installs bindings alone and pip
/// reports success. Same recovery as scripts/setup_venv.sh.
func repairKeystone(_ python: URL) -> Bool {
guard let pkgDir = keystonePackageDir(python),
FileManager.default.fileExists(atPath: pkgDir.path) else { return false }
let dest = pkgDir.appendingPathComponent("libkeystone.dylib")
let libs = homebrewKeystoneLibs()
if let dylib = libs.dylib {
try? FileManager.default.removeItem(at: dest)
guard (try? FileManager.default.copyItem(at: dylib, to: dest)) != nil else { return false }
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dest.path)
} else if let archive = libs.archive {
let r = try? VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/bin/clang"),
["-shared", "-o", dest.path, "-Wl,-all_load", archive.path,
"-lc++", "-install_name", "@rpath/libkeystone.dylib"])
guard r?.succeeded == true else { return false }
} else {
return false
}
guard keystoneIsUsable(python) else { return false }
FileHandle.standardError.write(Data("[+] Repaired keystone native library: \(dest.path)\n".utf8))
return true
}
/// Resolve a python with working deps: an explicit `VPHONE_PYTHON`, the dev
/// repo `.venv`, the managed per-user venv, else provision the managed venv
/// on this machine. Never silently falls back to a stale system python.
@@ -118,8 +191,13 @@ public struct VPhoneResources: Sendable {
if pythonIsUsable(u) { return u }
}
let devVenv = base.appendingPathComponent(".venv/bin/python3")
if pythonIsUsable(devVenv) { return devVenv }
if pythonIsUsable(managedVenvPython) { return managedVenvPython }
if venvIsUsable(devVenv) { return devVenv }
// Repair in place before rebuilding a missing dylib is not worth a
// full re-install.
if pythonIsUsable(managedVenvPython),
keystoneIsUsable(managedVenvPython) || repairKeystone(managedVenvPython) {
return managedVenvPython
}
return try bootstrapManagedVenv()
}
@@ -156,6 +234,11 @@ public struct VPhoneResources: Sendable {
guard pythonIsUsable(py) else {
lastError = "venv from \(host.path) still lacks a usable ipsw_parser (too old?)"; continue
}
guard keystoneIsUsable(py) || repairKeystone(py) else {
lastError = "venv from \(host.path) has no working libkeystone — "
+ "`brew install keystone`, or install cmake so pip can build it"
continue
}
log("[+] Python environment ready: \(py.path)")
return py
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
/// iOS-userland and cloudOS-kernel versions a bundle was restored with, recorded
/// host-side so they're readable without booting the VM. Persisted as
/// `restore-info.json` at the bundle root and rewritten on every restore.
public struct VPhoneRestoreInfo: Codable, Equatable, Sendable {
public struct OSVersion: Codable, Equatable, Sendable {
public let version: String
public let build: String
public init(version: String, build: String) {
self.version = version
self.build = build
}
}
public let ios: OSVersion
public let cloudOS: OSVersion
public let variant: String?
public let device: String?
public init(ios: OSVersion, cloudOS: OSVersion, variant: String? = nil, device: String? = nil) {
self.ios = ios
self.cloudOS = cloudOS
self.variant = variant
self.device = device
}
static let fileName = "restore-info.json"
public static let baseDevice = "iPhone99,11"
public static let experimentalDevice = "iPhone17,3"
/// Only `exp` rewrites the DeviceTree identity; others keep the base type.
public static func device(forVariant variant: String) -> String {
variant == "exp" ? experimentalDevice : baseDevice
}
public static func url(forBundle bundle: VPhoneBundle) -> URL {
bundle.url.appendingPathComponent(fileName)
}
/// The `restore-info.json` snapshot if present, else derived live from the
/// bundle's restore-directory plists so bundles restored before this file
/// existed still report their versions. `nil` when neither is available.
public static func load(fromBundle bundle: VPhoneBundle) -> VPhoneRestoreInfo? {
if let data = try? Data(contentsOf: url(forBundle: bundle)),
let info = try? JSONDecoder().decode(VPhoneRestoreInfo.self, from: data) {
return info
}
return derive(fromBundle: bundle)
}
/// Read both versions from the bundle's `iPhone*_Restore` plists:
/// `iPhone-BuildManifest.plist` (iOS userland) and the hybrid
/// `BuildManifest.plist` (cloudOS kernel). `nil` if the restore directory or
/// either version is missing.
public static func derive(fromBundle bundle: VPhoneBundle) -> VPhoneRestoreInfo? {
guard let restoreDir = findRestoreDirectory(inBundle: bundle),
let ios = readVersion(restoreDir.appendingPathComponent("iPhone-BuildManifest.plist")),
let cloudOS = readVersion(restoreDir.appendingPathComponent("BuildManifest.plist"))
else { return nil }
return VPhoneRestoreInfo(ios: ios, cloudOS: cloudOS)
}
public func write(toBundle bundle: VPhoneBundle) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(self).write(to: Self.url(forBundle: bundle))
}
/// Set `variant` (and its device) on the bundle's restore-info.json, keeping
/// the recorded versions. nil if no versions exist yet to preserve.
@discardableResult
public static func recordVariant(_ variant: String, toBundle bundle: VPhoneBundle) throws
-> VPhoneRestoreInfo?
{
guard let base = load(fromBundle: bundle) else { return nil }
let merged = VPhoneRestoreInfo(
ios: base.ios, cloudOS: base.cloudOS,
variant: variant, device: device(forVariant: variant))
try merged.write(toBundle: bundle)
return merged
}
/// Remove the `iPhone*_Restore/` tree from the bundle; returns its name, or
/// nil if absent. Record versions (`derive`) first it reads this directory.
@discardableResult
public static func removeBuiltFirmware(fromBundle bundle: VPhoneBundle) throws -> String? {
guard let dir = findRestoreDirectory(inBundle: bundle) else { return nil }
try FileManager.default.removeItem(at: dir)
return dir.lastPathComponent
}
// MARK: - Restore-directory reads
static func findRestoreDirectory(inBundle bundle: VPhoneBundle) -> URL? {
let entries = (try? FileManager.default.contentsOfDirectory(
at: bundle.url, includingPropertiesForKeys: nil)) ?? []
return entries
.filter { $0.lastPathComponent.hasPrefix("iPhone") && $0.lastPathComponent.hasSuffix("_Restore") }
.max { $0.lastPathComponent < $1.lastPathComponent }
}
private static func readVersion(_ plist: URL) -> OSVersion? {
guard let data = try? Data(contentsOf: plist),
let root = (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any],
let version = root["ProductVersion"] as? String,
let build = root["ProductBuildVersion"] as? String
else { return nil }
return OSVersion(version: version, build: build)
}
}
+13
View File
@@ -23,6 +23,19 @@ public enum VPhoneRestoreOps {
return nil
}
// MARK: - UDID
/// UDID from the `UDID=` line of the bundle's udid-prediction.txt, or nil.
public static func resolveUDID(bundle: VPhoneBundle) -> String? {
let pred = bundle.url.appendingPathComponent("udid-prediction.txt")
guard let text = try? String(contentsOf: pred, encoding: .utf8) else { return nil }
for line in text.split(whereSeparator: \.isNewline) where line.hasPrefix("UDID=") {
let value = line.dropFirst("UDID=".count).trimmingCharacters(in: .whitespaces)
return value.isEmpty ? nil : value
}
return nil
}
// MARK: - AEA
/// True if the file begins with the AEA1 magic (`41 45 41 31`).
@@ -102,22 +102,27 @@ public struct VPhoneVirtualMachineManifest: Codable, Sendable {
}
}
public struct NetworkConfig: Codable, Sendable {
public struct NetworkConfig: Codable, Equatable, Sendable {
public let mode: NetworkMode
public let macAddress: String
/// Host interface identifier to bridge (bridged mode only); nil otherwise.
public let bridgeInterface: String?
public enum NetworkMode: String, Codable, Sendable {
case nat
case bridged
case hostOnly
case none
/// No network device. Named `off` (not `none`) so a `NetworkMode?`
/// literal `.none` can't silently bind to `Optional.none`.
case off = "none"
}
public static let `default` = NetworkConfig(mode: .nat, macAddress: "")
public init(mode: NetworkMode, macAddress: String) {
public init(mode: NetworkMode, macAddress: String, bridgeInterface: String? = nil) {
self.mode = mode
self.macAddress = macAddress
self.bridgeInterface = bridgeInterface
}
}
@@ -220,7 +225,8 @@ public struct VPhoneVirtualMachineManifest: Codable, Sendable {
cpuCount: UInt? = nil,
memorySize: UInt64? = nil,
screenConfig: ScreenConfig? = nil,
machineIdentifier: Data? = nil
machineIdentifier: Data? = nil,
networkConfig: NetworkConfig? = nil
) -> VPhoneVirtualMachineManifest {
VPhoneVirtualMachineManifest(
platformType: platformType,
@@ -229,7 +235,7 @@ public struct VPhoneVirtualMachineManifest: Codable, Sendable {
cpuCount: cpuCount ?? self.cpuCount,
memorySize: memorySize ?? self.memorySize,
screenConfig: screenConfig ?? self.screenConfig,
networkConfig: networkConfig,
networkConfig: networkConfig ?? self.networkConfig,
diskImage: diskImage,
nvramStorage: nvramStorage,
romImages: romImages,
+20 -3
View File
@@ -43,6 +43,9 @@ struct VPhoneBootCLI: ParsableCommand {
@Flag(name: .shortAndLong, help: "Boot into DFU mode")
var dfu: Bool = false
@Flag(name: .customLong("headless"), help: "Boot without a VM window or menu bar")
var headless: Bool = false
@Option(help: "Kernel GDB debug stub port on host (omit for system-assigned port; valid: 6000...65535)")
var kernelDebugPort: Int?
@@ -61,9 +64,9 @@ struct VPhoneBootCLI: ParsableCommand {
@Flag(name: .customLong("no-vphoned"), help: "Exclude vphoned usage (patchless-only).")
var noVphoned: Bool = false
/// DFU mode runs headless (no GUI).
/// DFU mode is always headless.
var noGraphics: Bool {
dfu
dfu || headless
}
var installPackageURL: URL? {
@@ -184,6 +187,12 @@ struct PatchFirmwareCLI: ParsableCommand {
)
var forceExcGuard: Bool = false
@Flag(
name: .customLong("frida"),
help: "Opt in to Frida Stalker kernel relaxations (existing-thread follow + repeated VM_PROT_COPY). jb/exp only."
)
var frida: Bool = false
mutating func run() throws {
let pipeline = FirmwarePipeline(
vmDirectory: vmDirectory,
@@ -191,7 +200,8 @@ struct PatchFirmwareCLI: ParsableCommand {
verbose: !quiet,
noBinpack: noBinpack,
noVphoned: noVphoned,
forceExcGuard: forceExcGuard
forceExcGuard: forceExcGuard,
enableFrida: frida
)
let records = try pipeline.patchAll()
@@ -256,6 +266,12 @@ struct PatchComponentCLI: ParsableCommand {
)
var targetOS: String?
@Flag(
name: .customLong("frida"),
help: "kernel-jb only: opt in to the Frida Stalker kernel relaxations."
)
var frida: Bool = false
mutating func run() throws {
let payload = try IM4PHandler.load(contentsOf: input).payload
let count: Int
@@ -285,6 +301,7 @@ struct PatchComponentCLI: ParsableCommand {
// --target-os, default to applying them so the dev/test tool exercises the
// full set.
patcher.applyIOS27 = targetOS.map { $0.hasPrefix("27.") } ?? true
patcher.applyFrida = frida
count = try patcher.apply()
patchedData = patcher.buffer.data
records = patcher.patches
@@ -99,11 +99,14 @@ public struct VPhoneCreateOrchestrator {
public var sudoPassword: String?
public var spoofBuild: String?
public var forceDSCMaxSlide: Bool
public var enableFrida: Bool
public var rootPopup: Bool
public var interactive: Bool
public var cpuCount: UInt
public var memoryMB: UInt64
public var diskSizeGB: UInt64
public var verbosity: VPhoneVerbosity
public var keepArtifacts: Bool
public init(
name: String,
@@ -113,11 +116,14 @@ public struct VPhoneCreateOrchestrator {
sudoPassword: String? = nil,
spoofBuild: String? = nil,
forceDSCMaxSlide: Bool = false,
enableFrida: Bool = false,
rootPopup: Bool = false,
interactive: Bool = false,
cpuCount: UInt = 8,
memoryMB: UInt64 = 8192,
diskSizeGB: UInt64 = 64,
verbosity: VPhoneVerbosity = .quiet
verbosity: VPhoneVerbosity = .quiet,
keepArtifacts: Bool = false
) {
self.name = name
self.variant = variant
@@ -126,11 +132,14 @@ public struct VPhoneCreateOrchestrator {
self.sudoPassword = sudoPassword
self.spoofBuild = spoofBuild
self.forceDSCMaxSlide = forceDSCMaxSlide
self.enableFrida = enableFrida
self.rootPopup = rootPopup
self.interactive = interactive
self.cpuCount = cpuCount
self.memoryMB = memoryMB
self.diskSizeGB = diskSizeGB
self.verbosity = verbosity
self.keepArtifacts = keepArtifacts
}
}
@@ -184,9 +193,9 @@ public struct VPhoneCreateOrchestrator {
} else {
print("[!] --sudo-password failed validation; will still try at CFW-install time")
}
} else if isatty(FileHandle.standardInput.fileDescriptor) == 0 {
// No password and no terminal for sudo to prompt on fail before
// the long download/restore, not at the eventual sudo prompt.
} else if !options.rootPopup && isatty(FileHandle.standardInput.fileDescriptor) == 0 {
// No password, no popup, no terminal for sudo to prompt on fail
// before the long download/restore, not at the eventual sudo prompt.
throw VPhoneCreateError.sudoPasswordRequired
}
}
@@ -203,7 +212,9 @@ public struct VPhoneCreateOrchestrator {
try runFWPrepare(options: options, isLess: isLess, bundleURL: bundleURL)
print("\n=== fw patch ===")
try runFWPatch(variant: variantOption, isLess: isLess, bundleURL: bundleURL, verbosity: v)
try runFWPatch(
variant: variantOption, isLess: isLess, enableFrida: options.enableFrida,
bundleURL: bundleURL, verbosity: v)
print("\n=== Restore phase ===")
try runRestorePhase(bundleURL: bundleURL, verbosity: v)
@@ -215,6 +226,13 @@ public struct VPhoneCreateOrchestrator {
try runCFWInstall(options: options, bundleURL: bundleURL, sudoEnvExtras: sudoEnvExtras)
}
// CFW install is the last consumer of the built restore tree (it copies
// the SystemOS/AppOS cryptexes from it onto Disk.img); reclaim it now.
if !options.keepArtifacts, let bundle = try? VPhoneBundle.load(at: bundleURL),
let removed = try? VPhoneRestoreInfo.removeBuiltFirmware(fromBundle: bundle) {
print("[+] Removed built firmware \(removed)/ to save space (--keep-artifacts to keep)")
}
print("\n=== First boot ===")
try runFirstBoot(options: options, isLess: isLess, bundleURL: bundleURL)
@@ -301,17 +319,20 @@ public struct VPhoneCreateOrchestrator {
env["IPSW_DIR"] = resources.ipswCacheDir.path
env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path
if isLess { env["VARIANT"] = "less" }
if options.keepArtifacts { env["VPHONE_KEEP_ARTIFACTS"] = "1" }
trace("spawn /bin/bash \(resources.fwPrepareScript.path) (env keys: VPHONE_PYTHON, IPSW_DIR, VPHONE_SEAL_DIR)", v)
// Always streamed silence during a multi-GB download reads as a hang.
let code = try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/bash"), [resources.fwPrepareScript.path], cwd: bundleURL, env: env,
echo: v.showsToolDetail)
echo: true)
guard code == 0 else { throw VPhoneCreateError.fwPrepareFailed(code) }
print("[+] Firmware prepared (iPhone + cloudOS merged into bundle).")
}
private func runFWPatch(
variant: PatchFirmwareCLI.VariantOption, isLess: Bool, bundleURL: URL, verbosity v: VPhoneVerbosity
variant: PatchFirmwareCLI.VariantOption, isLess: Bool, enableFrida: Bool,
bundleURL: URL, verbosity v: VPhoneVerbosity
) throws {
// Mirrors the Makefile's `ifeq ($(UID),0)` gate on `fw_patch_less`
// only the `less` variant requires root.
@@ -328,7 +349,8 @@ public struct VPhoneCreateOrchestrator {
trace("in-process FirmwarePipeline.patchAll variant=\(variant.rawValue)", v)
let pipeline = FirmwarePipeline(
vmDirectory: bundleURL, variant: variant.pipelineVariant, verbose: v.showsToolDetail,
noBinpack: false, noVphoned: false, forceExcGuard: false)
noBinpack: false, noVphoned: false, forceExcGuard: false,
enableFrida: enableFrida)
let records = try pipeline.patchAll()
print("[fw patch] applied \(records.count) patches for \(variant.rawValue)")
}
@@ -370,6 +392,8 @@ public struct VPhoneCreateOrchestrator {
python, restoreArgs, cwd: bundleURL, echo: v.showsToolDetail)
guard restoreCode == 0 else { throw VPhoneCreateError.restoreUpdateFailed(restoreCode) }
recordRestoreVersions(bundleURL: bundleURL)
// wait_for_post_restore_reboot: a plain case-insensitive 'panic' grep
// distinct from (narrower than) BOOT_PANIC_REGEX used elsewhere.
print("[*] Restore complete; waiting up to 30s for reboot/panic before stopping DFU...")
@@ -386,6 +410,25 @@ public struct VPhoneCreateOrchestrator {
// `defer` above terminates the DFU process on every exit path.
}
/// Snapshot the just-restored iOS + cloudOS versions to `restore-info.json`,
/// read host-side from the bundle's restore-dir plists. Best-effort: the
/// restore already succeeded, so a metadata miss is a warning, not a failure.
private func recordRestoreVersions(bundleURL: URL) {
guard let bundle = try? VPhoneBundle.load(at: bundleURL),
let info = VPhoneRestoreInfo.derive(fromBundle: bundle)
else {
print("[!] Could not record restore versions (metadata not found)")
return
}
do {
try info.write(toBundle: bundle)
print("[+] Recorded versions: iOS \(info.ios.version) (\(info.ios.build)), "
+ "cloudOS \(info.cloudOS.version) (\(info.cloudOS.build))")
} catch {
print("[!] Could not write restore-info.json: \(error)")
}
}
private func loadDeviceIdentity(bundleURL: URL) throws -> (udid: String, ecid: String) {
let predictionFile = bundleURL.appendingPathComponent("udid-prediction.txt")
let deadline = Date().addingTimeInterval(30)
@@ -443,37 +486,54 @@ public struct VPhoneCreateOrchestrator {
private func runCFWInstall(options: Options, bundleURL: URL, sudoEnvExtras: [String: String]) throws {
let v = options.verbosity
var env = ProcessInfo.processInfo.environment
if let spoofBuild = options.spoofBuild { env["SPOOF_BUILD"] = spoofBuild }
if options.forceDSCMaxSlide { env["FORCE_DSC_MAXSLIDE"] = "1" }
try FileManager.default.createDirectory(at: resources.ipswCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.debsCacheDir, withIntermediateDirectories: true)
env["VPHONE_PYTHON"] = try resources.pythonExecutable().path
env["IPSW_DIR"] = resources.ipswCacheDir.path
env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path
env["VPHONE_DEBS_DIR"] = resources.debsCacheDir.path
for (key, value) in sudoEnvExtras { env[key] = value }
var scriptEnv: [String: String] = [
"VPHONE_PYTHON": try resources.pythonExecutable().path,
"IPSW_DIR": resources.ipswCacheDir.path,
"VPHONE_SEAL_DIR": resources.sealVolumeCacheDir.path,
"VPHONE_DEBS_DIR": resources.debsCacheDir.path,
]
if let spoofBuild = options.spoofBuild { scriptEnv["SPOOF_BUILD"] = spoofBuild }
if options.forceDSCMaxSlide { scriptEnv["FORCE_DSC_MAXSLIDE"] = "1" }
if options.enableFrida { scriptEnv["VPHONE_FRIDA"] = "1" }
if options.keepArtifacts { scriptEnv["VPHONE_KEEP_ARTIFACTS"] = "1" }
let args = [resources.cfwInstallHostScript.path, "--variant", options.variant, bundleURL.path]
let envKeys = (["VPHONE_PYTHON", "IPSW_DIR", "VPHONE_SEAL_DIR"] + sudoEnvExtras.keys.sorted()).joined(separator: ", ")
trace("spawn /bin/zsh \(args.joined(separator: " ")) (env keys: \(envKeys))", v)
// With an askpass credential sudo is non-interactive honor verbosity.
// Without one, sudo must prompt on the terminal run as a foreground job
// so its process group owns the tty (see runForeground).
// --sudo-password (askpass) wins over --root-popup.
let usePopup = options.rootPopup && sudoEnvExtras["SUDO_ASKPASS"] == nil
let code: Int32
if sudoEnvExtras["SUDO_ASKPASS"] != nil {
code = try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/zsh"), args, env: env, echo: v.showsToolDetail)
if usePopup {
// Forward SUDO_USER (sudo would set it) so the script's chown-back runs.
scriptEnv["SUDO_USER"] = NSUserName()
trace("osascript admin-privileges /bin/zsh \(args.joined(separator: " "))", v)
code = try VPhoneProcessRunner.runWithAdminPrivileges(
URL(fileURLWithPath: "/bin/zsh"), args, env: scriptEnv, echo: v.showsToolDetail)
} else {
print("[*] CFW install needs root — sudo will prompt for your macOS password.")
// Foreground so sudo can own the tty; echo honors verbosity (quiet
// suppresses the install's own output, sudo's /dev/tty prompt stays).
code = try VPhoneProcessRunner.runForeground(
URL(fileURLWithPath: "/bin/zsh"), args, env: env, echo: v.showsToolDetail)
var env = ProcessInfo.processInfo.environment
for (key, value) in scriptEnv { env[key] = value }
for (key, value) in sudoEnvExtras { env[key] = value }
let envKeys = (["VPHONE_PYTHON", "IPSW_DIR", "VPHONE_SEAL_DIR"] + sudoEnvExtras.keys.sorted()).joined(separator: ", ")
trace("spawn /bin/zsh \(args.joined(separator: " ")) (env keys: \(envKeys))", v)
// With an askpass credential sudo is non-interactive honor verbosity.
// Without one, sudo must prompt on the terminal run as a foreground
// job so its process group owns the tty (see runForeground).
if sudoEnvExtras["SUDO_ASKPASS"] != nil {
code = try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/zsh"), args, env: env, echo: v.showsToolDetail)
} else {
print("[*] CFW install needs root — sudo will prompt for your macOS password.")
code = try VPhoneProcessRunner.runForeground(
URL(fileURLWithPath: "/bin/zsh"), args, env: env, echo: v.showsToolDetail)
}
}
guard code == 0 else { throw VPhoneCreateError.cfwInstallFailed(code) }
print("[+] CFW installed (\(options.variant)).")
if let bundle = try? VPhoneBundle.load(at: bundleURL),
let info = try? VPhoneRestoreInfo.recordVariant(options.variant, toBundle: bundle), info.variant != nil {
print("[+] Recorded variant \(options.variant), device \(info.device ?? "?")")
}
}
// MARK: - first boot
@@ -483,6 +543,8 @@ public struct VPhoneCreateOrchestrator {
let configURL = bundleURL.appendingPathComponent("config.plist")
var args = ["--config", configURL.path]
if isLess { args += ["--variant", "less"] }
// --interactive keeps the window: it is the operator's only boot-progress cue.
if !options.interactive { args.append("--headless") }
if options.interactive {
print("[*] press Enter to start VM, after the VM has finished booting, press Enter again to finish last stage")
@@ -529,9 +591,9 @@ public struct VPhoneCreateOrchestrator {
private func runBootAnalysis(bundleURL: URL, verbosity v: VPhoneVerbosity) throws {
let configURL = bundleURL.appendingPathComponent("config.plist")
trace("spawn \(selfExecutable.path) --config \(configURL.path) (guest serial: off)", v)
trace("spawn \(selfExecutable.path) --config \(configURL.path) --headless (guest serial: off)", v)
let vm = VPhoneManagedProcess(
selfExecutable, ["--config", configURL.path], cwd: bundleURL, echo: false)
selfExecutable, ["--config", configURL.path, "--headless"], cwd: bundleURL, echo: false)
try vm.start()
defer { vm.terminate() }
+30 -2
View File
@@ -7,7 +7,33 @@ struct VPhoneFWCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "fw",
abstract: "Firmware pipeline: prepare (download/merge IPSWs) and patch",
subcommands: [VPhoneFWPrepareCommand.self, VPhoneFWPatchCommand.self])
subcommands: [VPhoneFWCatalogCommand.self, VPhoneFWPrepareCommand.self, VPhoneFWPatchCommand.self])
}
// MARK: - catalog
struct VPhoneFWCatalogCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "catalog",
abstract: "Show the known iOS ↔ cloudOS firmware pairings (recommended per iOS build)")
@Flag(name: .shortAndLong, help: "Emit JSON") var json = false
func run() throws {
let report = VPhoneFirmwareCatalog.report
if json {
print(String(decoding: try JSONEncoder().encode(report), as: UTF8.self))
return
}
print("Firmware catalog (\(report.device))")
let width = report.pairings.map(\.ios.name.count).max() ?? 0
let header = "iOS".padding(toLength: width, withPad: " ", startingAt: 0)
print("\(header) recommended cloudOS")
for e in report.pairings {
let ios = e.ios.name.padding(toLength: width, withPad: " ", startingAt: 0)
print("\(ios) \(e.recommendedCloudOS.name)")
}
}
}
// MARK: - prepare
@@ -69,6 +95,7 @@ struct VPhoneFWPatchCommand: ParsableCommand {
@Argument(help: "VM name") var name: String?
@Option(name: [.customShort("V"), .long], help: "variant: regular | dev | jb | exp | less") var variant: PatchFirmwareCLI.VariantOption = .regular
@Flag(name: .customLong("force-exc-guard"), help: "Force the EXC_GUARD disable patch") var forceExcGuard = false
@Flag(name: .customLong("frida"), help: "Opt in to Frida Stalker kernel relaxations (jb/exp only)") var frida = false
@Flag(name: .shortAndLong, help: "Suppress per-component progress") var quiet = false
func run() throws {
@@ -88,7 +115,8 @@ struct VPhoneFWPatchCommand: ParsableCommand {
verbose: !quiet,
noBinpack: false,
noVphoned: false,
forceExcGuard: forceExcGuard)
forceExcGuard: forceExcGuard,
enableFrida: frida)
let records = try pipeline.patchAll()
print("[fw patch] applied \(records.count) patches for \(variant.rawValue)")
}
@@ -0,0 +1,64 @@
import Foundation
// MARK: - VPhoneProgressBar
/// A single-line, redrawing byte progress bar for long transfers. Renders to
/// stderr only when it is a TTY, so piped/`--json`/GUI-subprocess invocations
/// (stdout consumed elsewhere) stay clean and the bar simply no-ops.
final class VPhoneProgressBar {
private let label: String
private let enabled: Bool
private let start = Date()
private let width = 28
private var lastRender = Date.distantPast
private var total: Int64 = 0
init(label: String) {
self.label = label
self.enabled = isatty(FileHandle.standardError.fileDescriptor) != 0
}
func update(done: Int64, total: Int64) {
guard enabled else { return }
self.total = total
let now = Date()
if done < total, now.timeIntervalSince(lastRender) < 0.066 { return } // ~15 fps
lastRender = now
render(done: done, now: now)
}
func finish() {
guard enabled else { return }
render(done: total, now: Date())
FileHandle.standardError.write(Data("\n".utf8))
}
private func render(done: Int64, now: Date) {
let frac = total > 0 ? min(1.0, Double(done) / Double(total)) : 0
let filled = Int(frac * Double(width))
let bar = String(repeating: "", count: filled) + String(repeating: "", count: width - filled)
let elapsed = now.timeIntervalSince(start)
let rate = elapsed > 0 ? Double(done) / elapsed : 0
var line = "\r\(label) [\(bar)] \(Int(frac * 100))% \(Self.bytes(done))"
if total > 0 { line += "/\(Self.bytes(total))" }
if rate > 0 { line += " \(Self.bytes(Int64(rate)))/s" }
if total > 0, rate > 0, done < total { line += " eta \(Self.clock(Double(total - done) / rate))" }
line += "\u{1B}[K" // clear to end of line
FileHandle.standardError.write(Data(line.utf8))
}
static func bytes(_ n: Int64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var value = Double(n), i = 0
while value >= 1024, i < units.count - 1 { value /= 1024; i += 1 }
return i == 0 ? "\(n) B" : String(format: "%.1f %@", value, units[i])
}
static func clock(_ seconds: Double) -> String {
let s = Int(seconds.rounded())
return s >= 3600
? String(format: "%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60)
: String(format: "%02d:%02d", s / 60, s % 60)
}
}
+60 -18
View File
@@ -45,6 +45,8 @@ struct VPhoneRestoreCommand: ParsableCommand {
if getShsh {
throw ExitCode(try pmd3("restore-get-shsh", extra: []))
}
let code: Int32
if offline {
let fm = FileManager.default
let shshes = ((try? fm.contentsOfDirectory(at: bundle.url, includingPropertiesForKeys: nil)) ?? [])
@@ -57,9 +59,30 @@ struct VPhoneRestoreCommand: ParsableCommand {
guard let restoreDir else { throw VPhoneRestoreError.noRestoreDir }
print("[restore] decrypting AEA images in \(restoreDir.lastPathComponent)...")
try VPhoneRestoreOps.decryptAEAImages(inRestoreDir: restoreDir)
throw ExitCode(try pmd3("restore-update", extra: ["--tss", shsh.path]))
code = try pmd3("restore-update", extra: ["--tss", shsh.path])
} else {
code = try pmd3("restore-update", extra: [])
}
if code == 0 { recordRestoreVersions(bundle: bundle) }
throw ExitCode(code)
}
/// Snapshot the just-restored iOS + cloudOS versions to `restore-info.json`,
/// read host-side from the bundle's restore-dir plists. Best-effort: the
/// restore already succeeded, so a metadata miss is only a warning.
private func recordRestoreVersions(bundle: VPhoneBundle) {
guard let info = VPhoneRestoreInfo.derive(fromBundle: bundle) else {
FileHandle.standardError.write(Data("warning: could not record restore versions (metadata not found)\n".utf8))
return
}
do {
try info.write(toBundle: bundle)
print("[restore] recorded iOS \(info.ios.version) (\(info.ios.build)) / "
+ "cloudOS \(info.cloudOS.version) (\(info.cloudOS.build))")
} catch {
FileHandle.standardError.write(Data("warning: could not write restore-info.json: \(error)\n".utf8))
}
throw ExitCode(try pmd3("restore-update", extra: []))
}
}
@@ -81,6 +104,8 @@ struct VPhoneCFWInstallCommand: ParsableCommand {
@Option(name: [.customShort("V"), .long], help: "variant: regular | dev | jb | exp") var variant: String = "exp"
@Option(name: [.customShort("b"), .long], help: "(exp only) rewrite ProductBuildVersion to this build id") var spoofBuild: String?
@Flag(name: .customLong("force-dsc-maxslide"), help: "Zero the dyld cache maxSlide on non-27 bases (opt-in DSC-map fit)") var forceDSCMaxSlide = false
@Flag(name: .customLong("root-popup"), help: "Elevate via macOS's native authentication dialog (osascript) instead of the sudo re-exec") var rootPopup = false
@Flag(name: .customLong("keep-artifacts"), help: "Keep the extracted CFW input dirs (cfw_input/, cfw_jb_input/) after install (default: removed to save space)") var keepArtifacts = false
@Option(name: .shortAndLong, help: "Resource base override (default: inferred from the running binary path)")
var projectRoot: String?
@Flag(name: .customShort("v"), help: "Increase verbosity: -v tool detail, -vv guest serial, -vvv internal trace")
@@ -95,28 +120,45 @@ struct VPhoneCFWInstallCommand: ParsableCommand {
let bundle = try lib.library.bundle(named: name)
let resources = projectRoot.map { VPhoneResources(base: URL(fileURLWithPath: $0)) } ?? .resolve()
var env = ProcessInfo.processInfo.environment
if let spoofBuild { env["SPOOF_BUILD"] = spoofBuild }
if forceDSCMaxSlide { env["FORCE_DSC_MAXSLIDE"] = "1" }
// Same redirect as `fw prepare`: VPHONE_PYTHON/IPSW_DIR/VPHONE_SEAL_DIR are
// exported for the bundled scripts (cfw_install_host.sh's PY/P lines honor
// VPHONE_PYTHON); the apfs_sealvolume read itself only happens on the
// `fw patch` path (CryptexFilesystemPatcher).
// Env the bundled scripts read: rides along via `sudo -E` by default;
// --root-popup forwards it inline (do shell script's bare env).
try FileManager.default.createDirectory(at: resources.ipswCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: resources.debsCacheDir, withIntermediateDirectories: true)
env["VPHONE_PYTHON"] = try resources.pythonExecutable().path
env["IPSW_DIR"] = resources.ipswCacheDir.path
env["VPHONE_SEAL_DIR"] = resources.sealVolumeCacheDir.path
env["VPHONE_DEBS_DIR"] = resources.debsCacheDir.path
var scriptEnv: [String: String] = [
"VPHONE_PYTHON": try resources.pythonExecutable().path,
"IPSW_DIR": resources.ipswCacheDir.path,
"VPHONE_SEAL_DIR": resources.sealVolumeCacheDir.path,
"VPHONE_DEBS_DIR": resources.debsCacheDir.path,
]
if let spoofBuild { scriptEnv["SPOOF_BUILD"] = spoofBuild }
if forceDSCMaxSlide { scriptEnv["FORCE_DSC_MAXSLIDE"] = "1" }
if keepArtifacts { scriptEnv["VPHONE_KEEP_ARTIFACTS"] = "1" }
let args = [resources.cfwInstallHostScript.path, "--variant", variant, bundle.url.path]
if v.tracesInternals {
print("[trace] spawning: /bin/zsh \(args.joined(separator: " ")) (env keys: VPHONE_PYTHON, IPSW_DIR, VPHONE_SEAL_DIR)")
let code: Int32
if rootPopup {
// Forward SUDO_USER (sudo would set it) so the script's chown-back runs.
scriptEnv["SUDO_USER"] = NSUserName()
code = try VPhoneProcessRunner.runWithAdminPrivileges(
URL(fileURLWithPath: "/bin/zsh"), args, env: scriptEnv, echo: v.showsToolDetail)
} else {
var env = ProcessInfo.processInfo.environment
for (key, value) in scriptEnv { env[key] = value }
if v.tracesInternals {
print("[trace] spawning: /bin/zsh \(args.joined(separator: " ")) (env keys: VPHONE_PYTHON, IPSW_DIR, VPHONE_SEAL_DIR)")
}
code = try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/zsh"), args, env: env, echo: v.showsToolDetail)
}
if code == 0 {
if let info = try? VPhoneRestoreInfo.recordVariant(variant, toBundle: bundle), info.variant != nil {
print("[cfw] recorded variant \(variant), device \(info.device ?? "?")")
}
if !keepArtifacts, let removed = try? VPhoneRestoreInfo.removeBuiltFirmware(fromBundle: bundle) {
print("[cfw] removed built firmware \(removed)/ to save space (--keep-artifacts to keep)")
}
}
let code = try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/zsh"), args, env: env, echo: v.showsToolDetail)
throw ExitCode(code)
}
}
+45 -5
View File
@@ -60,7 +60,11 @@ struct VPhoneVMListCommand: ParsableCommand {
print("(no VMs in \(library.root.path))")
} else {
for r in reports {
print("\(r.name) \(r.cpuCount) CPU \(r.memoryMB) MB \(r.diskSizeBytes / (1024*1024*1024)) GB disk")
var line = "\(r.name) \(r.cpuCount) CPU \(r.memoryMB) MB \(r.diskSizeBytes / (1024*1024*1024)) GB disk"
if let info = r.restoreInfo {
line += " iOS \(info.ios.version) / cloudOS \(info.cloudOS.version)"
}
print(line)
}
}
}
@@ -77,7 +81,8 @@ struct VPhoneVMInfoCommand: ParsableCommand {
func run() throws {
let name = try VPhoneVMSelection.resolveExisting(name, in: lib.library)
let report = VPhoneBundleReport(bundle: try lib.library.bundle(named: name))
let bundle = try lib.library.bundle(named: name)
let report = VPhoneBundleReport(bundle: bundle)
if json {
print(String(decoding: try JSONEncoder().encode(report), as: UTF8.self))
} else {
@@ -85,6 +90,14 @@ struct VPhoneVMInfoCommand: ParsableCommand {
print("cpu: \(report.cpuCount)")
print("mem: \(report.memoryMB) MB")
print("disk: \(report.diskSizeBytes) bytes")
print("net: \(describeNetwork(report.network))")
if let udid = report.udid { print("udid: \(udid)") }
if let info = report.restoreInfo {
print("iOS: \(info.ios.version) (\(info.ios.build))")
print("cloudOS: \(info.cloudOS.version) (\(info.cloudOS.build))")
if let variant = info.variant { print("variant: \(variant)") }
if let device = info.device { print("device: \(device)") }
}
}
}
}
@@ -116,19 +129,46 @@ struct VPhoneVMNewCommand: ParsableCommand {
struct VPhoneVMConfigCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "config", abstract: "Edit VM manifest fields (cpu/memory)")
commandName: "config", abstract: "Edit VM manifest fields (cpu/memory/network)")
@OptionGroup var lib: VPhoneLibraryOption
@Argument(help: "VM name") var name: String?
@Option(name: .shortAndLong, help: "CPU cores") var cpu: UInt?
@Option(name: .shortAndLong, help: "Memory (MB)") var memory: UInt64?
@Option(name: [.customShort("n"), .long], help: "Network mode: nat | bridged | none") var network: String?
@Option(name: .long, help: "Host interface to bridge (bridged mode; auto-picks first if omitted)")
var bridgeInterface: String?
func run() throws {
let mode = try network.map(Self.parseMode)
let name = try VPhoneVMSelection.resolveExisting(name, in: lib.library)
let updated = try VPhoneBundleOps.updateConfig(
bundleNamed: name, in: lib.library, cpuCount: cpu, memoryMB: memory)
print("updated \(updated.name): \(updated.manifest.cpuCount) CPU, \(updated.manifest.memorySize / (1024*1024)) MB")
bundleNamed: name, in: lib.library, cpuCount: cpu, memoryMB: memory,
networkMode: mode, bridgeInterface: bridgeInterface)
let m = updated.manifest
print("updated \(updated.name): \(m.cpuCount) CPU, \(m.memorySize / (1024*1024)) MB, "
+ "net=\(describeNetwork(m.networkConfig))")
}
private static func parseMode(_ s: String)
throws -> VPhoneVirtualMachineManifest.NetworkConfig.NetworkMode
{
switch s.lowercased() {
case "nat": return .nat
case "bridged": return .bridged
case "none", "off": return .off
case "hostonly", "host-only":
throw ValidationError("network mode 'hostOnly' is not supported; use nat, bridged, or none")
default:
throw ValidationError("unknown network mode '\(s)'; expected nat, bridged, or none")
}
}
}
private func describeNetwork(_ net: VPhoneVirtualMachineManifest.NetworkConfig) -> String {
var s = net.mode.rawValue
if net.mode == .bridged, let iface = net.bridgeInterface { s += "(\(iface))" }
return s
}
// MARK: - rename
+9 -1
View File
@@ -16,11 +16,16 @@ struct VPhoneVMCreateCommand: ParsableCommand {
@Option(name: [.customShort("V"), .long], help: "variant: regular | dev | jb | exp | less") var variant: String = "regular"
@Option(name: .shortAndLong, help: "iPhone IPSW URL or local path") var iphoneSource: String?
@Option(name: .shortAndLong, help: "cloudOS IPSW URL or local path") var cloudosSource: String?
@Option(name: .shortAndLong, help: "Disk size (GB)") var diskSize: UInt64 = 64
@Option(name: .shortAndLong, help: "sudo password for the CFW host-mount install (via askpass; never logged)")
var sudoPassword: String?
@Option(name: [.customShort("b"), .long], help: "(exp only) rewrite ProductBuildVersion to this build id") var spoofBuild: String?
@Flag(name: .customLong("force-dsc-maxslide"), help: "Zero the dyld cache maxSlide on non-27 bases (opt-in DSC-map fit)") var forceDSCMaxSlide = false
@Flag(name: .customLong("frida"), help: "Opt in to Frida Stalker support: install re.frida.server (latest GitHub release) + jb/exp kernel relaxations") var frida = false
@Flag(name: .customLong("root-popup"), help: "Elevate the CFW host-mount via macOS's native authentication dialog (osascript) instead of a sudo prompt") var rootPopup = false
@Flag(help: "Prompt at first-boot stages instead of running non-interactively") var interactive = false
@Flag(name: .customLong("keep-artifacts"), help: "Keep intermediate build artifacts (built restore firmware, extracted base-IPSW caches, extracted CFW input dirs) instead of removing them after use. Source archives (.ipsw / .tar.zst) are always kept.")
var keepArtifacts = false
@Option(name: .shortAndLong, help: "Resource base override (default: inferred from the running binary path)")
var projectRoot: String?
@Flag(name: .customShort("v"), help: "Increase verbosity: -v tool detail, -vv guest serial, -vvv internal trace")
@@ -37,6 +42,9 @@ struct VPhoneVMCreateCommand: ParsableCommand {
name: name, variant: variant,
iphoneSource: sources.iphoneSource, cloudosSource: sources.cloudosSource,
sudoPassword: sudoPassword, spoofBuild: spoofBuild, forceDSCMaxSlide: forceDSCMaxSlide,
interactive: interactive, verbosity: VPhoneVerbosity(count: verboseCount)))
enableFrida: frida, rootPopup: rootPopup,
interactive: interactive, diskSizeGB: diskSize,
verbosity: VPhoneVerbosity(count: verboseCount),
keepArtifacts: keepArtifacts))
}
}
@@ -9,6 +9,7 @@ struct VPhoneVMLaunchCommand: ParsableCommand {
@OptionGroup var lib: VPhoneLibraryOption
@Argument(help: "VM name") var name: String?
@Flag(name: .shortAndLong, help: "Boot into DFU mode (headless)") var dfu = false
@Flag(name: .customLong("headless"), help: "Boot without a VM window or menu bar") var headless = false
@Option(name: [.customShort("V"), .long], help: "Firmware variant") var variant: String?
@Flag(name: .customLong("no-vphoned"), help: "Do not stage/use vphoned") var noVphoned = false
@Option(help: "Kernel GDB debug stub port on host (omit for system-assigned; valid: 6000...65535)")
@@ -61,6 +62,7 @@ struct VPhoneVMLaunchCommand: ParsableCommand {
var args = ["--config", bundle.configURL.path]
if dfu { args.append("--dfu") }
if headless { args.append("--headless") }
if let variant { args += ["--variant", variant] }
if noVphoned { args.append("--no-vphoned") }
if let kernelDebugPort { args += ["--kernel-debug-port", String(kernelDebugPort)] }
+14 -5
View File
@@ -28,13 +28,19 @@ struct VPhoneVMExportCommand: ParsableCommand {
@OptionGroup var lib: VPhoneLibraryOption
@Argument(help: "VM name") var name: String?
@Option(name: .shortAndLong, help: "output archive path") var out: String
@Flag(help: "densest compression (xz -9) instead of the default fast (zstd -3)") var max = false
@Flag(help: "include the *_Restore* IPSW directory") var includeIpsw = false
func run() throws {
let name = try VPhoneVMSelection.resolveExisting(name, in: lib.library)
try VPhoneBundleOps.export(
bundleNamed: name, to: URL(fileURLWithPath: out), includeIPSW: includeIpsw, in: lib.library)
print("exported \(name)\(out)")
let compression: VPhoneBundleOps.ExportCompression = max ? .max : .fast
let bar = VPhoneProgressBar(label: "exporting \(name)")
let outURL = try VPhoneBundleOps.export(
bundleNamed: name, to: URL(fileURLWithPath: out), includeIPSW: includeIpsw,
compression: compression, in: lib.library,
progress: { done, total in bar.update(done: done, total: total) })
bar.finish()
print("exported \(name)\(outURL.path)")
}
}
@@ -43,12 +49,15 @@ struct VPhoneVMImportCommand: ParsableCommand {
commandName: "import", abstract: "Import a VM bundle from a .tgz archive")
@OptionGroup var lib: VPhoneLibraryOption
@Option(name: [.customShort("i"), .customLong("in")], help: "input archive path") var input: String
@Argument(help: "input archive path") var input: String
@Option(name: .shortAndLong, help: "name for the imported VM (default: the archive's own name)") var name: String?
func run() throws {
let bar = VPhoneProgressBar(label: "importing")
let bundle = try VPhoneBundleOps.importArchive(
from: URL(fileURLWithPath: input), name: name, in: lib.library)
from: URL(fileURLWithPath: input), name: name, in: lib.library,
progress: { done, total in bar.update(done: done, total: total) })
bar.finish()
print("imported → \(bundle.name)")
}
}
@@ -184,10 +184,12 @@ class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate {
let attachment = try VZDiskImageStorageDeviceAttachment(url: options.diskURL, readOnly: false)
config.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
// Network (shared NAT)
let net = VZVirtioNetworkDeviceConfiguration()
net.attachment = VZNATNetworkDeviceAttachment()
config.networkDevices = [net]
// Network (mode + MAC from the bundle manifest; nat/bridged/none)
if let net = try VPhoneNetworking.makeNetworkDevice(manifest.networkConfig) {
config.networkDevices = [net]
} else {
config.networkDevices = []
}
// Serial port (PL011 UART - pipes for input/output with boot detection)
if let serialPort = Dynamic._VZPL011SerialPortConfiguration().asObject
@@ -156,6 +156,41 @@ struct ARM64EncoderTests {
// `mov x0, x20` matches the project's preverified ARM64.movX0X20 constant.
#expect(ARM64Encoder.encodeMovX(rd: 0, rm: 20) == ARM64.movX0X20)
}
@Test func encodeTestBitBranchRoundTrips() throws {
// The vm_map_delete --frida patch retargets `tbz/tbnz w8,#9` to bit 13
// (current-protection.X max_protection.X), preserving sense and target.
let tbz = try #require(ARM64Encoder.encodeTestBitBranch(
nonzero: false, register: 8, bit: 13, from: 0x1000, to: 0x1020))
let tbzI = try #require(disasm.disassembleOne(tbz, at: 0x1000))
#expect(tbzI.mnemonic == "tbz")
#expect(tbzI.operandString.contains("w8"))
#expect(tbzI.operandString.contains("#0xd"))
#expect(tbzI.operandString.contains("0x1020"))
let tbnz = try #require(ARM64Encoder.encodeTestBitBranch(
nonzero: true, register: 8, bit: 13, from: 0x2000, to: 0x1f00))
let tbnzI = try #require(disasm.disassembleOne(tbnz, at: 0x2000))
#expect(tbnzI.mnemonic == "tbnz")
#expect(tbnzI.operandString.contains("#0xd"))
#expect(tbnzI.operandString.contains("0x1f00"))
// Rejects bad register / bit / out-of-range target.
#expect(ARM64Encoder.encodeTestBitBranch(nonzero: false, register: 32, bit: 13, from: 0, to: 4) == nil)
#expect(ARM64Encoder.encodeTestBitBranch(nonzero: false, register: 8, bit: 64, from: 0, to: 4) == nil)
#expect(ARM64Encoder.encodeTestBitBranch(nonzero: false, register: 8, bit: 13, from: 0, to: 0x8000) == nil)
}
@Test func encodeMovzWClearsTSSFCheckEntitlement() throws {
// The thread_set_state --frida patch rewrites `mov w6, #0x201`
// (TSSF_TRANSLATE_TO_USER | TSSF_CHECK_ENTITLEMENT) to `mov w6, #0x1`,
// clearing only the entitlement bit while preserving user translation.
let bytes = try #require(ARM64Encoder.encodeMovzW(rd: 6, imm16: 0x1))
let insn = try #require(disasm.disassembleOne(bytes, at: 0))
#expect(insn.mnemonic == "mov" || insn.mnemonic == "movz")
#expect(insn.operandString.contains("w6"))
#expect(insn.operandString.contains("#1") || insn.operandString.contains("#0x1"))
}
}
/// Round-trip coverage for the shared raw-instruction predicates in `ARM64Inst`,
@@ -476,3 +511,16 @@ struct FirmwarePipelineTests {
#expect(found == target)
}
}
struct FridaGatingTests {
@Test func cloudOSVersionGate() {
// Frida kernel patches apply on cloudOS 26.4+ only.
#expect(FirmwarePipeline.productVersionAtLeast("26.4", 26, 4))
#expect(FirmwarePipeline.productVersionAtLeast("26.5", 26, 4))
#expect(FirmwarePipeline.productVersionAtLeast("26.10", 26, 4))
#expect(FirmwarePipeline.productVersionAtLeast("27.0", 26, 4))
#expect(!FirmwarePipeline.productVersionAtLeast("26.3", 26, 4))
#expect(!FirmwarePipeline.productVersionAtLeast("18.5", 26, 4))
#expect(!FirmwarePipeline.productVersionAtLeast(nil, 26, 4))
}
}
+150
View File
@@ -97,6 +97,45 @@ struct BundleOpsTests {
// Persisted: a fresh load sees the change.
#expect(try lib.bundle(named: "cfg").manifest.cpuCount == 4)
// Untouched network stays at the default.
#expect(updated.manifest.networkConfig.mode == .nat)
}
@Test func updateConfigPersistsNetwork() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "net", cpuCount: 8, memoryMB: 8192, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let updated = try VPhoneBundleOps.updateConfig(
bundleNamed: "net", in: lib, cpuCount: nil, memoryMB: nil, networkMode: .off)
#expect(updated.manifest.networkConfig.mode == .off)
// Persisted across a fresh load, and cpu/memory untouched.
let reloaded = try lib.bundle(named: "net").manifest
#expect(reloaded.networkConfig.mode == .off)
#expect(reloaded.cpuCount == 8)
}
@Test func updateConfigRejectsBadNetwork() throws {
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "bad", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
#expect(throws: VPhoneNetworkingError.hostOnlyUnsupported) {
_ = try VPhoneBundleOps.updateConfig(
bundleNamed: "bad", in: lib, cpuCount: nil, memoryMB: nil, networkMode: .hostOnly)
}
// A rejected edit must not have mutated the on-disk manifest.
#expect(try lib.bundle(named: "bad").manifest.networkConfig.mode == .nat)
}
@Test func renameThenDelete() throws {
@@ -322,4 +361,115 @@ struct BundleOpsTests {
_ = try VPhoneBundleOps.importArchive(from: archive, name: nil, in: VPhoneLibrary(root: root))
}
}
// MARK: - Compression presets
private static let zstdMagic: [UInt8] = [0x28, 0xB5, 0x2F, 0xFD]
private static let xzMagic: [UInt8] = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]
private func magic(_ url: URL, _ n: Int) throws -> [UInt8] {
Array(try Data(contentsOf: url).prefix(n))
}
private func exportAndImport(
_ compression: VPhoneBundleOps.ExportCompression?
) throws -> (archive: URL, imported: VPhoneBundle) {
let root = try makeRoot()
let rom = try fakeROM(); let seprom = try fakeROM()
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 6, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let archive = root.appendingPathComponent("orig.archive")
if let compression {
try VPhoneBundleOps.export(
bundleNamed: "orig", to: archive, includeIPSW: false, compression: compression, in: lib)
} else {
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
}
let dstRoot = try makeRoot()
let imported = try VPhoneBundleOps.importArchive(
from: archive, name: "copy", in: VPhoneLibrary(root: dstRoot))
return (archive, imported)
}
@Test func exportDefaultsToFastZstd() throws {
let (archive, imported) = try exportAndImport(nil)
#expect(try magic(archive, 4) == Self.zstdMagic)
#expect(imported.manifest.cpuCount == 6)
}
@Test func exportFastProducesZstdAndRoundTrips() throws {
let (archive, imported) = try exportAndImport(.fast)
#expect(try magic(archive, 4) == Self.zstdMagic)
#expect(imported.manifest.cpuCount == 6)
}
@Test func exportMaxProducesXzAndRoundTrips() throws {
let (archive, imported) = try exportAndImport(.max)
#expect(try magic(archive, 6) == Self.xzMagic)
#expect(imported.manifest.cpuCount == 6)
}
@Test func exportToDirectoryAutoNamesWithExtension() throws {
let root = try makeRoot()
let rom = try fakeROM(); let seprom = try fakeROM()
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 6, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let outDir = try makeRoot()
let zstdOut = try VPhoneBundleOps.export(
bundleNamed: "orig", to: outDir, includeIPSW: false, in: lib)
#expect(zstdOut == outDir.appendingPathComponent("orig.tzst"))
#expect(FileManager.default.fileExists(atPath: zstdOut.path))
let xzOut = try VPhoneBundleOps.export(
bundleNamed: "orig", to: outDir, includeIPSW: false, compression: .max, in: lib)
#expect(xzOut == outDir.appendingPathComponent("orig.txz"))
#expect(FileManager.default.fileExists(atPath: xzOut.path))
}
@Test func exportAndImportReportProgress() throws {
final class Collector {
private(set) var dones: [Int64] = []
private(set) var total: Int64 = 0
func add(_ done: Int64, _ total: Int64) { dones.append(done); self.total = total }
}
let root = try makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let rom = try fakeROM(); let seprom = try fakeROM()
let lib = VPhoneLibrary(root: root)
_ = try VPhoneBundleOps.create(
.init(name: "orig", cpuCount: 6, memoryMB: 2048, diskSizeGB: 1,
romSource: rom, sepromSource: seprom), in: lib)
let exp = Collector()
let archive = root.appendingPathComponent("orig.tzst")
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib) {
exp.add($0, $1)
}
#expect(!exp.dones.isEmpty)
#expect(exp.total > 0) // bundle logical size
#expect(exp.dones.last! > 0)
#expect(exp.dones == exp.dones.sorted()) // monotonically non-decreasing
let imp = Collector()
let dstRoot = try makeRoot()
defer { try? FileManager.default.removeItem(at: dstRoot) }
_ = try VPhoneBundleOps.importArchive(from: archive, name: "copy", in: VPhoneLibrary(root: dstRoot)) {
imp.add($0, $1)
}
let archiveSize = try Data(contentsOf: archive).count
#expect(!imp.dones.isEmpty)
#expect(imp.total == Int64(archiveSize)) // total == compressed file size
#expect(imp.dones.last! == Int64(archiveSize)) // whole archive fed
#expect(imp.dones == imp.dones.sorted())
}
@Test func compressionPresetTarArgs() {
#expect(VPhoneBundleOps.ExportCompression.fast.tarArgs
== ["--zstd", "--options", "zstd:compression-level=3,zstd:threads=0"])
#expect(VPhoneBundleOps.ExportCompression.max.tarArgs
== ["-J", "--options", "xz:compression-level=9,xz:threads=0"])
}
}
@@ -13,6 +13,24 @@ struct BundleReportTests {
#expect(report.name == "myvm")
#expect(report.cpuCount == 6)
#expect(report.memoryMB == 4096)
#expect(report.network.mode == .nat)
}
@Test func carriesNetworkConfig() throws {
let net = VPhoneVirtualMachineManifest.NetworkConfig(
mode: .bridged, macAddress: "00:11:22:33:44:55", bridgeInterface: "en0")
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 2 * 1024 * 1024 * 1024,
networkConfig: net,
romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
let report = VPhoneBundleReport(
bundle: VPhoneBundle(url: URL(fileURLWithPath: "/tmp/b"), manifest: manifest))
#expect(report.network.mode == .bridged)
#expect(report.network.bridgeInterface == "en0")
let back = try JSONDecoder().decode(
VPhoneBundleReport.self, from: try JSONEncoder().encode(report))
#expect(back.network == net)
}
@Test func encodesToJSON() throws {
@@ -0,0 +1,25 @@
@testable import VPhoneCore
import Foundation
import Testing
struct FirmwareCatalogReportTests {
@Test func mapsEveryPairing() throws {
let report = VPhoneFirmwareCatalog.report
#expect(report.device == VPhoneFirmwareCatalog.device)
#expect(report.pairings.count == VPhoneFirmwareCatalog.pairings.count)
for (entry, pairing) in zip(report.pairings, VPhoneFirmwareCatalog.pairings) {
#expect(entry.ios.name == pairing.iosName)
#expect(entry.ios.url == pairing.iosURL)
#expect(entry.recommendedCloudOS.name == pairing.cloudosName)
#expect(entry.recommendedCloudOS.url == pairing.cloudosURL)
}
}
@Test func roundTripsJSON() throws {
let report = VPhoneFirmwareCatalog.report
let back = try JSONDecoder().decode(
VPhoneFirmwareCatalogReport.self, from: try JSONEncoder().encode(report))
#expect(back == report)
}
}
@@ -10,8 +10,8 @@ struct FirmwarePickerTests {
// MARK: - Catalog integrity
@Test func catalogHasEighteenPairings() {
#expect(VPhoneFirmwareCatalog.pairings.count == 18)
@Test func catalogHasTwentyThreePairings() {
#expect(VPhoneFirmwareCatalog.pairings.count == 23)
}
@Test func everyPairingIsPopulated() {
@@ -106,7 +106,7 @@ struct FirmwarePickerTests {
iphone: nil, cloudos: nil, isInteractive: true,
read: reader(["1"]), write: { lines.append($0) })
let menu = lines.filter { $0.hasPrefix(" [") }
#expect(menu.count == 18)
#expect(menu.count == 23)
// Label text starts in one column regardless of 1- vs 2-digit index.
let labelStarts = Set(menu.map { $0.range(of: "] ")!.upperBound.utf16Offset(in: $0) })
#expect(labelStarts.count == 1)
+8
View File
@@ -50,10 +50,18 @@ struct LibraryTests {
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root")
}
@Test func defaultRootHonorsVPHONERoot() {
unsetenv("VPHONE_LIBRARY_ROOT")
setenv("VPHONE_ROOT", "/tmp/vphone-test-root", 1)
defer { unsetenv("VPHONE_ROOT") }
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root/VMs")
}
@Test func defaultRootIsShellSafe() {
// The default root feeds the shell/make firmware pipeline; a space in it
// (e.g. "Application Support") breaks unquoted expansion. Must stay space-free.
unsetenv("VPHONE_LIBRARY_ROOT")
unsetenv("VPHONE_ROOT")
#expect(!VPhoneLibrary.defaultRoot().path.contains(" "))
}
+35
View File
@@ -31,5 +31,40 @@ struct ManifestTests {
let updated = sampleManifest().updating(cpuCount: 4, memorySize: nil, screenConfig: nil)
#expect(updated.cpuCount == 4)
#expect(updated.memorySize == 8 * 1024 * 1024 * 1024)
// networkConfig is preserved when not passed.
#expect(updated.networkConfig.mode == .nat)
}
@Test func networkConfigRoundTripsThroughPlist() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let net = VPhoneVirtualMachineManifest.NetworkConfig(
mode: .bridged, macAddress: "", bridgeInterface: "en0")
let url = dir.appendingPathComponent("config.plist")
try sampleManifest().updating(networkConfig: net).write(to: url)
let loaded = try VPhoneVirtualMachineManifest.load(from: url)
#expect(loaded.networkConfig.mode == .bridged)
#expect(loaded.networkConfig.bridgeInterface == "en0")
}
// Manifests written before bridgeInterface existed omit that key; they must
// still decode, with bridgeInterface defaulting to nil.
@Test func decodesManifestWithoutBridgeInterfaceKey() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("config.plist")
try sampleManifest().write(to: url) // default network bridgeInterface nil
let text = try String(contentsOf: url, encoding: .utf8)
#expect(!text.contains("bridgeInterface")) // nil optional is omitted from the plist
let loaded = try VPhoneVirtualMachineManifest.load(from: url)
#expect(loaded.networkConfig.bridgeInterface == nil)
}
}
@@ -0,0 +1,55 @@
@testable import VPhoneCore
import Foundation
import Testing
import Virtualization
struct NetworkingTests {
typealias NetworkConfig = VPhoneVirtualMachineManifest.NetworkConfig
@Test func mergeSetsMode() throws {
let out = try VPhoneNetworking.merge(into: .default, mode: .off, bridgeInterface: nil)
#expect(out.mode == .off)
#expect(out.bridgeInterface == nil)
}
@Test func mergeWithoutModePreservesCurrent() throws {
let current = NetworkConfig(mode: .off, macAddress: "")
let out = try VPhoneNetworking.merge(into: current, mode: nil, bridgeInterface: nil)
#expect(out.mode == .off)
}
@Test func mergeRejectsHostOnly() {
#expect(throws: VPhoneNetworkingError.hostOnlyUnsupported) {
_ = try VPhoneNetworking.merge(into: .default, mode: .hostOnly, bridgeInterface: nil)
}
}
@Test func mergeRejectsBridgeInterfaceWithoutBridgedMode() {
#expect(throws: VPhoneNetworkingError.bridgeInterfaceWithoutBridgedMode) {
_ = try VPhoneNetworking.merge(into: .default, mode: .nat, bridgeInterface: "en0")
}
}
// The test binary is unsigned, so no interfaces are available for bridging:
// selecting bridged must fail loudly rather than silently produce a dead NIC.
@Test func bridgedWithoutAvailableInterfacesThrows() {
guard VPhoneNetworking.availableBridgeInterfaces().isEmpty else { return }
#expect(throws: VPhoneNetworkingError.self) {
_ = try VPhoneNetworking.merge(into: .default, mode: .bridged, bridgeInterface: nil)
}
#expect(throws: VPhoneNetworkingError.self) {
_ = try VPhoneNetworking.merge(into: .default, mode: .bridged, bridgeInterface: "en0")
}
}
@Test func makeNetworkDeviceOffIsNil() throws {
let dev = try VPhoneNetworking.makeNetworkDevice(NetworkConfig(mode: .off, macAddress: ""))
#expect(dev == nil)
}
@Test func makeNetworkDeviceNATHasAttachment() throws {
let dev = try VPhoneNetworking.makeNetworkDevice(NetworkConfig(mode: .nat, macAddress: ""))
#expect(dev != nil)
#expect(dev?.attachment is VZNATNetworkDeviceAttachment)
}
}
+37 -1
View File
@@ -27,18 +27,54 @@ struct ResourcesTests {
}
@Test func cacheDirsAreHomeRelativeAndToolsBinIsBaseRelative() {
// The VPHONE_ROOT override would relocate the cache; only assert the default.
if ProcessInfo.processInfo.environment["VPHONE_ROOT"] != nil { return }
let r = VPhoneResources(base: URL(fileURLWithPath: "/Applications/vphone-cli.app/Contents/Resources"))
#expect(r.userCacheDir.path.hasSuffix("/.vphone"))
#expect(r.toolsBinDir.path == r.base.appendingPathComponent(".tools/bin").path)
}
/// These all shell out; a missing interpreter must return false, not throw.
@Test func venvProbesAreTotalForAMissingInterpreter() {
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
let missing = URL(fileURLWithPath: "/nonexistent/bin/python3")
#expect(r.pythonIsUsable(missing) == false)
#expect(r.keystoneIsUsable(missing) == false)
#expect(r.venvIsUsable(missing) == false)
#expect(r.repairKeystone(missing) == false)
}
@Test func managedVenvDefaultsUnderDotVphone() {
// The override env var would change this; only assert the default.
// The override env vars would change this; only assert the default.
if ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"] != nil { return }
if ProcessInfo.processInfo.environment["VPHONE_ROOT"] != nil { return }
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
#expect(r.managedVenvDir.path.hasSuffix("/.vphone/venv"))
}
@Test func userCacheDirHonorsVPHONERoot() {
unsetenv("VPHONE_VENV_DIR")
setenv("VPHONE_ROOT", "/tmp/vphone-test-root", 1)
defer { unsetenv("VPHONE_ROOT") }
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
#expect(r.userCacheDir.path == "/tmp/vphone-test-root")
#expect(r.ipswCacheDir.path == "/tmp/vphone-test-root/ipsws")
#expect(r.sealVolumeCacheDir.path == "/tmp/vphone-test-root/tools")
#expect(r.debsCacheDir.path == "/tmp/vphone-test-root/debs")
#expect(r.managedVenvDir.path == "/tmp/vphone-test-root/venv")
}
@Test func managedVenvOverrideBeatsVPHONERoot() {
setenv("VPHONE_ROOT", "/tmp/vphone-test-root", 1)
setenv("VPHONE_VENV_DIR", "/tmp/custom-venv", 1)
defer {
unsetenv("VPHONE_ROOT")
unsetenv("VPHONE_VENV_DIR")
}
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
#expect(r.managedVenvDir.path == "/tmp/custom-venv")
}
@Test func pythonUsabilityProbeRejectsMissingAcceptsDevVenv() {
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let r = VPhoneResources(base: cwd)
@@ -0,0 +1,136 @@
@testable import VPhoneCore
import Foundation
import Testing
struct RestoreInfoTests {
private func makeBundle() throws -> VPhoneBundle {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 1024 * 1024, romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
return VPhoneBundle(url: root, manifest: manifest)
}
/// Write a restore dir with the two BuildManifest plists. Omit a key by
/// passing nil for its value to exercise the missing-key path.
private func makeRestoreDir(
in bundle: VPhoneBundle, iosVersion: String?, iosBuild: String?,
cloudVersion: String?, cloudBuild: String?
) throws {
let dir = bundle.url.appendingPathComponent("iPhone17,3_27.0_24A5390f_Restore")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
func write(_ name: String, _ version: String?, _ build: String?) throws {
var dict: [String: Any] = [:]
if let version { dict["ProductVersion"] = version }
if let build { dict["ProductBuildVersion"] = build }
let data = try PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0)
try data.write(to: dir.appendingPathComponent(name))
}
try write("iPhone-BuildManifest.plist", iosVersion, iosBuild)
try write("BuildManifest.plist", cloudVersion, cloudBuild)
}
@Test func derivesBothVersionsFromPlists() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: "26.4", cloudBuild: "23E5207q")
let info = VPhoneRestoreInfo.derive(fromBundle: b)
#expect(info?.ios == .init(version: "27.0", build: "24A5390f"))
#expect(info?.cloudOS == .init(version: "26.4", build: "23E5207q"))
}
@Test func deriveNilWhenNoRestoreDir() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
#expect(VPhoneRestoreInfo.derive(fromBundle: b) == nil)
}
@Test func deriveNilWhenVersionKeyMissing() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: nil, cloudBuild: "23E5207q")
#expect(VPhoneRestoreInfo.derive(fromBundle: b) == nil)
}
@Test func writeThenLoadRoundTrips() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
let info = VPhoneRestoreInfo(
ios: .init(version: "18.6.2", build: "22G100"),
cloudOS: .init(version: "26.1", build: "23B85"))
try info.write(toBundle: b)
#expect(VPhoneRestoreInfo.load(fromBundle: b) == info)
}
@Test func loadFallsBackToDeriveWhenNoJSON() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: "26.4", cloudBuild: "23E5207q")
// No restore-info.json written load() must derive from the plists.
let info = VPhoneRestoreInfo.load(fromBundle: b)
#expect(info?.ios.version == "27.0")
#expect(info?.cloudOS.version == "26.4")
}
@Test func bundleReportCarriesRestoreInfo() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: "26.4", cloudBuild: "23E5207q")
let report = VPhoneBundleReport(bundle: b)
#expect(report.restoreInfo?.ios.build == "24A5390f")
#expect(report.restoreInfo?.cloudOS.build == "23E5207q")
}
@Test func deviceForVariant() {
#expect(VPhoneRestoreInfo.device(forVariant: "exp") == "iPhone17,3")
for v in ["regular", "dev", "jb"] {
#expect(VPhoneRestoreInfo.device(forVariant: v) == "iPhone99,11")
}
}
@Test func recordVariantMergesIntoVersions() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try VPhoneRestoreInfo(
ios: .init(version: "18.6.2", build: "22G100"),
cloudOS: .init(version: "26.1", build: "23B85")).write(toBundle: b)
let merged = try VPhoneRestoreInfo.recordVariant("exp", toBundle: b)
#expect(merged?.variant == "exp")
#expect(merged?.device == "iPhone17,3")
let loaded = VPhoneRestoreInfo.load(fromBundle: b)
#expect(loaded?.ios.build == "22G100")
#expect(loaded?.variant == "exp")
#expect(loaded?.device == "iPhone17,3")
}
@Test func recordVariantNilWithoutVersions() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
#expect(try VPhoneRestoreInfo.recordVariant("jb", toBundle: b) == nil)
}
@Test func bundleReportCarriesUDID() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try "UDID=AAAABBBB-1122334455667788\n"
.write(to: b.url.appendingPathComponent("udid-prediction.txt"), atomically: true, encoding: .utf8)
#expect(VPhoneBundleReport(bundle: b).udid == "AAAABBBB-1122334455667788")
}
/// The snapshot lives at the bundle root, so `vm export` must not strip it:
/// it is matched by neither the `*_Restore*` exclude nor any regenerable-
/// artifact pattern. Guards against a future exclude edit dropping it.
@Test func notExcludedFromExport() throws {
let name = VPhoneRestoreInfo.fileName
#expect(fnmatch("*_Restore*", name, 0) != 0)
for pattern in VPhoneBundleOps.exportExcludePatterns {
#expect(fnmatch(pattern, name, 0) != 0)
}
}
}
@@ -33,6 +33,22 @@ struct RestoreOpsTests {
#expect(VPhoneRestoreOps.resolveECID(explicit: nil, bundle: b) == nil)
}
@Test func resolveUDIDFromPredictionFile() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let b = try bundle(in: root)
try "UDID=AAAABBBB-1122334455667788\nECID=1122334455667788\n"
.write(to: root.appendingPathComponent("udid-prediction.txt"), atomically: true, encoding: .utf8)
#expect(VPhoneRestoreOps.resolveUDID(bundle: b) == "AAAABBBB-1122334455667788")
}
@Test func resolveUDIDNilWhenMissing() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let b = try bundle(in: root)
#expect(VPhoneRestoreOps.resolveUDID(bundle: b) == nil)
}
@Test func isAEAEncryptedDetectsMagic() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)