mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87f796c62a | ||
|
|
deabe6ffca | ||
|
|
2af884b56c | ||
|
|
977c17b56d | ||
|
|
53aa44dd57 | ||
|
|
58988bd518 | ||
|
|
72ccbde1de | ||
|
|
39b6d04108 | ||
|
|
339719f34d | ||
|
|
421cb7aa7a | ||
|
|
dee895aeb7 | ||
|
|
673146ba0a | ||
|
|
988e5dc389 | ||
|
|
9f7e21402a | ||
|
|
3bb6f0fda4 | ||
|
|
ceccc126c8 | ||
|
|
ae29e6de84 | ||
|
|
74b6076557 | ||
|
|
fdf9487bfd | ||
|
|
d3b873c777 | ||
|
|
a5d624b613 | ||
|
|
334a558e32 | ||
|
|
f48fc29a27 | ||
|
|
ab456ac67e | ||
|
|
6d4855dca3 | ||
|
|
545fd35e0f | ||
|
|
5d22d3ed23 | ||
|
|
0491a9521a | ||
|
|
8de3c1c5c2 | ||
|
|
d405b12d64 | ||
|
|
14e48bcf4b |
+1
-1
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ keystone-engine
|
||||
pyimg4
|
||||
pymobiledevice3>=9.5.0
|
||||
ipsw-parser
|
||||
setuptools
|
||||
|
||||
@@ -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.1–26.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.
|
||||
@@ -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
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
Submodule scripts/resources updated: ee9a2845b3...2ef6b06a10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)] }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(" "))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user