31 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
43 changed files with 1490 additions and 110 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)"
+17 -6
View File
@@ -12,12 +12,13 @@ Boot a virtual iPhone via Apple's Virtualization.framework using PCC research VM
- 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
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
```
## Install
@@ -60,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
```
@@ -106,16 +107,19 @@ See [`research/0_binary_patch_comparison.md`](./research/0_binary_patch_comparis
## 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).**
@@ -162,9 +166,14 @@ vphone-amfidont # .build/vphone-cli.app/Contents/Resources/vphone-amfido
| 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,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 25.4.1 | `17,3_27.0_24A5390f` | `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
@@ -180,6 +189,8 @@ vphone-amfidont # .build/vphone-cli.app/Contents/Resources/vphone-amfido
**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.
+15 -6
View File
@@ -12,12 +12,13 @@ PCC リサーチ VM インフラストラクチャを使用し、Apple の Virtu
- Apple Silicon
- macOS 15+ (Sequoia)
- Xcode + iOS SDK(ゲストデーモンをクロスコンパイルするため)
- [未署名バイナリでプライベートな PV=3 エンタイトルメントを許可するための SIP/AMFI の緩和](#sipamfi-の緩和)
**依存関係:**
```bash
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
```
## インストール
@@ -60,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
```
@@ -106,16 +107,19 @@ vphone-cli vm launch myphone # 6. 初回起動
## 場所
vphone-cli が生成するものはすべて `~/.vphone/` 以下に置かれます — 署名済みバンドルがポータブルであり続けるよう、リポジトリと `.app` の外に保管されます:
vphone-cli が生成するものはすべて `~/.vphone/` 以下に置かれます — 署名済みバンドルがポータブルであり続けるよう、リポジトリと `.app` の外に保管されます`$VPHONE_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 を無効化する(最も緩い)。**
@@ -162,9 +166,14 @@ vphone-amfidont # ローカルビルドの場合は .build/vphone-cli.ap
| 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,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 25.4.1 | `17,3_27.0_24A5390f` | `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
+15 -6
View File
@@ -12,12 +12,13 @@ PCC 리서치 VM 인프라를 사용하여 Apple의 Virtualization.framework로
- Apple Silicon
- macOS 15+ (Sequoia)
- Xcode + iOS SDK (게스트 데몬 크로스 컴파일용)
- [서명되지 않은 바이너리로 private PV=3 권한을 허용하기 위한 SIP/AMFI 완화](#sipamfi-완화)
**의존성:**
```bash
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
```
## 설치
@@ -60,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
```
@@ -106,16 +107,19 @@ vphone-cli vm launch myphone # 6. 첫 부팅
## 위치
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를 비활성화 (가장 관대).**
@@ -162,9 +166,14 @@ vphone-amfidont # 로컬 빌드의 경우 .build/vphone-cli.app/Contents
| 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,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 25.4.1 | `17,3_27.0_24A5390f` | `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
+15 -6
View File
@@ -12,12 +12,13 @@
- Apple Silicon
- macOS 15+Sequoia
- Xcode + iOS SDK(用于交叉编译访客守护进程)
- [放宽 SIP/AMFI,以允许未签名二进制使用私有 PV=3 授权](#放宽-sipamfi)
**依赖:**
```bash
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
```
## 安装
@@ -60,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
```
@@ -106,16 +107,19 @@ vphone-cli vm launch myphone # 6. 首次启动
## 位置
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(最宽松)。**
@@ -162,9 +166,14 @@ vphone-amfidont # 本地构建见 .build/vphone-cli.app/Contents/Resourc
| 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,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 25.4.1 | `17,3_27.0_24A5390f` | `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` |
## 常见问题
+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.
+2
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
;;
*)
+1
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
+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))
+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)
+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
+110 -29
View File
@@ -167,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
@@ -216,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(
@@ -227,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)
}
}
@@ -55,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.
+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 {
+96 -1
View File
@@ -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.
@@ -179,8 +270,12 @@ public enum VPhoneProcessRunner {
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 = tokens.joined(separator: " ")
let appleEscaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let source = "do shell script \"\(appleEscaped)\" with administrator privileges"
+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
}
+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,6 +99,7 @@ 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
@@ -115,6 +116,7 @@ public struct VPhoneCreateOrchestrator {
sudoPassword: String? = nil,
spoofBuild: String? = nil,
forceDSCMaxSlide: Bool = false,
enableFrida: Bool = false,
rootPopup: Bool = false,
interactive: Bool = false,
cpuCount: UInt = 8,
@@ -130,6 +132,7 @@ public struct VPhoneCreateOrchestrator {
self.sudoPassword = sudoPassword
self.spoofBuild = spoofBuild
self.forceDSCMaxSlide = forceDSCMaxSlide
self.enableFrida = enableFrida
self.rootPopup = rootPopup
self.interactive = interactive
self.cpuCount = cpuCount
@@ -209,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)
@@ -317,15 +322,17 @@ public struct VPhoneCreateOrchestrator {
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.
@@ -342,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)")
}
@@ -489,6 +497,7 @@ public struct VPhoneCreateOrchestrator {
]
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]
@@ -534,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")
@@ -580,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() }
+3 -1
View File
@@ -95,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 {
@@ -114,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)
}
}
+2 -1
View File
@@ -21,6 +21,7 @@ struct VPhoneVMCreateCommand: ParsableCommand {
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.")
@@ -41,7 +42,7 @@ struct VPhoneVMCreateCommand: ParsableCommand {
name: name, variant: variant,
iphoneSource: sources.iphoneSource, cloudosSource: sources.cloudosSource,
sudoPassword: sudoPassword, spoofBuild: spoofBuild, forceDSCMaxSlide: forceDSCMaxSlide,
rootPopup: rootPopup,
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)")
}
}
@@ -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))
}
}
+111
View File
@@ -361,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"])
}
}
@@ -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(" "))
}
+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)