Add Git LFS instructions and fix Makefile help alignment

- Add git-lfs to brew deps and document git lfs install/pull steps
  in both English and Chinese READMEs
- Fix continuation line alignment in make help (off by one)
- Add missing blank line before VM management section
This commit is contained in:
Lakr
2026-03-03 13:09:31 +08:00
parent 6e3ecc6879
commit fa054ca719
39 changed files with 4023 additions and 1396 deletions
+1
View File
@@ -311,6 +311,7 @@ __marimo__/
/VM
/ipsws/
.limd/
.tools/
/.swiftpm
*.ipsw
/updates-cdn
+83 -74
View File
@@ -146,6 +146,7 @@ The firmware is a **PCC/iPhone hybrid** — PCC boot infrastructure wrapping iPh
### Component Origins
The firmware merges two Apple IPSWs:
- **iPhone IPSW:** `iPhone17,3_26.1_23B85_Restore.ipsw` (d47ap)
- **cloudOS IPSW:** PCC vresearch101ap IPSW (CDN hash URL)
@@ -155,28 +156,28 @@ iPhone restore directory (`kernelcache.*`, `Firmware/{agx,all_flash,ane,dfu,pmp}
#### Boot Chain — from PCC (cloudOS / vresearch101ap)
| Component | File | Patched | Patch Purpose |
|-----------|------|---------|---------------|
| AVPBooter | `AVPBooter.vresearch1.bin` | Yes (1) | DGST signature validation bypass |
| LLB | `Firmware/all_flash/LLB.vresearch101.RELEASE.im4p` | Yes (6) | Serial + image4 bypass + boot-args + rootfs + panic |
| iBSS | `Firmware/dfu/iBSS.vresearch101.RELEASE.im4p` | Yes (2) | Serial labels + image4 callback bypass |
| iBEC | `Firmware/dfu/iBEC.vresearch101.RELEASE.im4p` | Yes (3) | Serial + image4 bypass + boot-args |
| SPTM | `Firmware/all_flash/sptm.vresearch1.release.im4p` | No | — |
| TXM | `Firmware/txm.iphoneos.research.im4p` | Yes (1) | Trustcache validation bypass |
| SEP Firmware | `Firmware/all_flash/sep-firmware.vresearch101.RELEASE.im4p` | No | — |
| DeviceTree | `Firmware/all_flash/DeviceTree.vphone600ap.im4p` | No | — |
| KernelCache | `kernelcache.release.vphone600` | Yes (25) | APFS, MAC, debugger, launch constraints, etc. |
| GPU/ANE/PMP | `Firmware/{agx,ane,pmp}/*` | No | — |
| Component | File | Patched | Patch Purpose |
| ------------ | ----------------------------------------------------------- | -------- | --------------------------------------------------- |
| AVPBooter | `AVPBooter.vresearch1.bin` | Yes (1) | DGST signature validation bypass |
| LLB | `Firmware/all_flash/LLB.vresearch101.RELEASE.im4p` | Yes (6) | Serial + image4 bypass + boot-args + rootfs + panic |
| iBSS | `Firmware/dfu/iBSS.vresearch101.RELEASE.im4p` | Yes (2) | Serial labels + image4 callback bypass |
| iBEC | `Firmware/dfu/iBEC.vresearch101.RELEASE.im4p` | Yes (3) | Serial + image4 bypass + boot-args |
| SPTM | `Firmware/all_flash/sptm.vresearch1.release.im4p` | No | — |
| TXM | `Firmware/txm.iphoneos.research.im4p` | Yes (1) | Trustcache validation bypass |
| SEP Firmware | `Firmware/all_flash/sep-firmware.vresearch101.RELEASE.im4p` | No | — |
| DeviceTree | `Firmware/all_flash/DeviceTree.vphone600ap.im4p` | No | — |
| KernelCache | `kernelcache.release.vphone600` | Yes (25) | APFS, MAC, debugger, launch constraints, etc. |
| GPU/ANE/PMP | `Firmware/{agx,ane,pmp}/*` | No | — |
> TXM filename says "iphoneos" but is copied from cloudOS IPSW (`fw_prepare.sh` line 81).
#### OS / Filesystem — from iPhone (iPhone17,3)
| Component | Notes |
|-----------|-------|
| OS | iPhone OS image |
| SystemVolume | System partition |
| StaticTrustCache | Static trust cache |
| Component | Notes |
| -------------------------------- | ---------------------- |
| OS | iPhone OS image |
| SystemVolume | System partition |
| StaticTrustCache | Static trust cache |
| Ap,SystemVolumeCanonicalMetadata | System volume metadata |
> Cryptex1 components (SystemOS/AppOS DMGs) are **not** included in the BuildManifest.
@@ -187,8 +188,8 @@ iPhone restore directory (`kernelcache.*`, `Firmware/{agx,all_flash,ane,dfu,pmp}
`fw_manifest.py` generates a **single** DFU erase-install identity (20 components).
The VM always boots via DFU restore, so only one identity is needed.
| Variant | Boot Chain | Ramdisk |
|---------|-----------|---------|
| Variant | Boot Chain | Ramdisk |
| -------------------------------------------- | -------------------------------------------------- | --------- |
| `Darwin Cloud Customer Erase Install (IPSW)` | PCC RELEASE (LLB/iBSS/iBEC) + RESEARCH (iBoot/TXM) | PCC erase |
idevicerestore selects this identity by partial-matching `Info.Variant` against
@@ -198,44 +199,45 @@ idevicerestore selects this identity by partial-matching `Info.Variant` against
**Boot chain patches** (`fw_patch.py`) — all 6 targets from **PCC**:
| Component | Patches | Technique |
|-----------|---------|-----------|
| AVPBooter | 1 | `mov x0, #0` (DGST bypass) |
| iBSS | 2 | Dynamic via `patchers/iboot.py` (string anchors, instruction patterns) |
| iBEC | 3 | Dynamic via `patchers/iboot.py` (string anchors, instruction patterns) |
| LLB | 6 | Dynamic via `patchers/iboot.py` (string anchors, instruction patterns) |
| TXM | 1 | Dynamic via `patchers/txm.py` (trustcache hash lookup bypass) |
| KernelCache | 25 | Dynamic via `patchers/kernel.py` (string anchors, ADRP+ADD xrefs, BL frequency) |
| Component | Patches | Technique |
| ----------- | ------- | ------------------------------------------------------------------------------- |
| AVPBooter | 1 | `mov x0, #0` (DGST bypass) |
| iBSS | 2 | Dynamic via `patchers/iboot.py` (string anchors, instruction patterns) |
| iBEC | 3 | Dynamic via `patchers/iboot.py` (string anchors, instruction patterns) |
| LLB | 6 | Dynamic via `patchers/iboot.py` (string anchors, instruction patterns) |
| TXM | 1 | Dynamic via `patchers/txm.py` (trustcache hash lookup bypass) |
| KernelCache | 25 | Dynamic via `patchers/kernel.py` (string anchors, ADRP+ADD xrefs, BL frequency) |
**JB extension patches** (`fw_patch_jb.py`) — runs base patches first, then adds:
| Component | JB Patches | Technique |
|-----------|-----------|-----------|
| iBSS | +1 | `patchers/iboot_jb.py` (skip nonce generation) |
| TXM | +13 | `patchers/txm_jb.py` (CS validation bypass, get-task-allow, debugger ent, dev mode) |
| KernelCache | +34 | `patchers/kernel_jb.py` (trustcache, execve, sandbox, task/VM, kcall10) |
| Component | JB Patches | Technique |
| ----------- | ---------- | ----------------------------------------------------------------------------------- |
| iBSS | +1 | `patchers/iboot_jb.py` (skip nonce generation) |
| TXM | +13 | `patchers/txm_jb.py` (CS validation bypass, get-task-allow, debugger ent, dev mode) |
| KernelCache | +34 | `patchers/kernel_jb.py` (trustcache, execve, sandbox, task/VM, kcall10) |
**CFW patches** (`patchers/cfw.py` / `cfw_install.sh`) — targets from **iPhone** Cryptex SystemOS:
| Binary | Technique | Purpose | Mode |
|--------|-----------|---------|------|
| seputil | String patch (`/%s.gl``/AA.gl`) | Gigalocker UUID fix | Base |
| launchd_cache_loader | NOP (disassembly-anchored) | Bypass cache validation | Base |
| mobileactivationd | Return true (disassembly-anchored) | Skip activation check | Base |
| launchd.plist | Plist injection | Add bash/dropbear/trollvnc daemons | Base |
| launchd | Branch (skip jetsam guard) + LC_LOAD_DYLIB injection | Prevent jetsam panic + load launchdhook.dylib | JB |
| Binary | Technique | Purpose | Mode |
| -------------------- | ---------------------------------------------------- | --------------------------------------------- | ---- |
| seputil | String patch (`/%s.gl``/AA.gl`) | Gigalocker UUID fix | Base |
| launchd_cache_loader | NOP (disassembly-anchored) | Bypass cache validation | Base |
| mobileactivationd | Return true (disassembly-anchored) | Skip activation check | Base |
| launchd.plist | Plist injection | Add bash/dropbear/trollvnc daemons | Base |
| launchd | Branch (skip jetsam guard) + LC_LOAD_DYLIB injection | Prevent jetsam panic + load launchdhook.dylib | JB |
**JB install phases** (`cfw_install_jb.sh``cfw_install.sh` with `CFW_JB_MODE=1`):
| Phase | Action |
|-------|--------|
| JB-1 | Patch `/mnt1/sbin/launchd`: inject `launchdhook.dylib` LC_LOAD_DYLIB + jetsam guard bypass |
| JB-2 | Install procursus bootstrap to `/mnt5/<hash>/jb-vphone/procursus` |
| JB-3 | Deploy BaseBin hooks (`systemhook.dylib`, `launchdhook.dylib`, `libellekit.dylib`) to `/mnt1/cores/` |
| Phase | Action |
| ----- | ---------------------------------------------------------------------------------------------------- |
| JB-1 | Patch `/mnt1/sbin/launchd`: inject `launchdhook.dylib` LC_LOAD_DYLIB + jetsam guard bypass |
| JB-2 | Install procursus bootstrap to `/mnt5/<hash>/jb-vphone/procursus` |
| JB-3 | Deploy BaseBin hooks (`systemhook.dylib`, `launchdhook.dylib`, `libellekit.dylib`) to `/mnt1/cores/` |
### Boot Flow
**Base** (`fw_patch` + `cfw_install`):
```
AVPBooter (ROM, PCC)
→ LLB (PCC, patched)
@@ -248,6 +250,7 @@ AVPBooter (ROM, PCC)
```
**Jailbreak** (`fw_patch_jb` + `cfw_install_jb`):
```
AVPBooter (ROM, PCC)
→ LLB (PCC, patched)
@@ -269,6 +272,7 @@ AVPBooter (ROM, PCC)
### CFW Installation (`cfw_install.sh`)
7 phases (+ 2 JB phases), safe to re-run (idempotent):
1. Decrypt/mount Cryptex SystemOS and AppOS DMGs (`ipsw` + `aea`)
2. Patch seputil (gigalocker UUID)
3. Install GPU driver (AppleParavirtGPUMetalIOGPUFamily)
@@ -278,6 +282,7 @@ AVPBooter (ROM, PCC)
7. Install LaunchDaemons (bash, dropbear SSH, trollvnc)
**JB-only phases** (enabled via `make cfw_install_jb` or `CFW_JB_MODE=1`):
- JB-1: Patch launchd jetsam guard (prevents jetsam panic on boot)
- JB-2: Install procursus bootstrap + optional Sileo to `/mnt5/<hash>/jb-vphone/`
@@ -325,6 +330,7 @@ Always use `make build` — never `swift build` alone, as the unsigned binary wi
## VM Creation (`make vm_new`)
Creates a VM directory with:
- Sparse disk image (default 64 GB)
- SEP storage (512 KB flat file)
- AVPBooter + AVPSEPBooter ROMs (copied from `/System/Library/Frameworks/Virtualization.framework/`)
@@ -393,40 +399,42 @@ Branch is 8 commits ahead of `main`. All changes are **additive** — non-JB cod
### Diff vs Main
| File | Change | Impact on non-JB |
|------|--------|-----------------|
| `kernel.py` | +1 line: `self.patches = []` reset in `find_all()` | None (harmless init) |
| `cfw.py` | +`patch-launchd-jetsam`, +`inject-dylib` commands | None (new commands only) |
| `kernel_jb.py` | **New file** — 2128 lines | N/A |
| `txm_jb.py` | **New file** — 335 lines | N/A |
| `iboot_jb.py` | **New file** — 105 lines | N/A |
| `fw_patch_jb.py` | **New file** — 115 lines (WIP) | N/A |
| `cfw_install_jb.sh` | **New file** — 214 lines | N/A |
| `cfw_jb_input.tar.zst` | **New file** — JB resources | N/A |
| `Makefile` | +JB targets (`fw_patch_jb`, `cfw_install_jb`) | None (additive) |
| `AGENTS.md` | Documentation updates | N/A |
| File | Change | Impact on non-JB |
| ---------------------- | -------------------------------------------------- | ------------------------ |
| `kernel.py` | +1 line: `self.patches = []` reset in `find_all()` | None (harmless init) |
| `cfw.py` | +`patch-launchd-jetsam`, +`inject-dylib` commands | None (new commands only) |
| `kernel_jb.py` | **New file** — 2128 lines | N/A |
| `txm_jb.py` | **New file** — 335 lines | N/A |
| `iboot_jb.py` | **New file** — 105 lines | N/A |
| `fw_patch_jb.py` | **New file** — 115 lines (WIP) | N/A |
| `cfw_install_jb.sh` | **New file** — 214 lines | N/A |
| `cfw_jb_input.tar.zst` | **New file** — JB resources | N/A |
| `Makefile` | +JB targets (`fw_patch_jb`, `cfw_install_jb`) | None (additive) |
| `AGENTS.md` | Documentation updates | N/A |
### Patch Counts
**Base patcher** (`kernel.py`): **25 patches** — verified identical to main.
**JB patcher** (`kernel_jb.py`): **160 patches** from 22 methods:
- **19 of 22 PASSING** — Groups A (sandbox hooks, AMFI, execve), B (string-anchored), C (shellcode)
- **3 FAILING** — see below
### 3 Remaining Failures
| Patch | Upstream Offset | Root Cause | Proposed Strategy |
|-------|----------------|------------|-------------------|
| `patch_nvram_verify_permission` | NOP BL at `0x1234034` | 332 identical IOKit methods match structural filter; "krn." string leads to wrong function | Find via "IONVRAMController" string → metaclass ctor → PAC disc `#0xcda1` → search `__DATA_CONST` vtable entries (first entry after 3 nulls) with matching PAC disc + BL to memmove |
| `patch_thid_should_crash` | Zero `0x67EB50` | String in `__PRELINK_INFO` plist (no code refs); value already `0x00000000` in PCC kernel | Safe to return True (no-op); or find via `sysctl_oid` struct search in `__DATA` |
| `patch_hook_cred_label_update_execve` | Shellcode at `0xAB17D8` + ops table at `0xA54518` | Needs `_vfs_context_current` (`0xCC5EAC`) and `_vnode_getattr` (`0xCC91C0`) — 0 symbols available | Find via sandbox ops table → original hook func → BL targets by caller count (vfs_context_current = highest, vnode_getattr = near `mov wN, #0x380`) |
| Patch | Upstream Offset | Root Cause | Proposed Strategy |
| ------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `patch_nvram_verify_permission` | NOP BL at `0x1234034` | 332 identical IOKit methods match structural filter; "krn." string leads to wrong function | Find via "IONVRAMController" string → metaclass ctor → PAC disc `#0xcda1` → search `__DATA_CONST` vtable entries (first entry after 3 nulls) with matching PAC disc + BL to memmove |
| `patch_thid_should_crash` | Zero `0x67EB50` | String in `__PRELINK_INFO` plist (no code refs); value already `0x00000000` in PCC kernel | Safe to return True (no-op); or find via `sysctl_oid` struct search in `__DATA` |
| `patch_hook_cred_label_update_execve` | Shellcode at `0xAB17D8` + ops table at `0xA54518` | Needs `_vfs_context_current` (`0xCC5EAC`) and `_vnode_getattr` (`0xCC91C0`) — 0 symbols available | Find via sandbox ops table → original hook func → BL targets by caller count (vfs_context_current = highest, vnode_getattr = near `mov wN, #0x380`) |
### Key Findings (from `researchs/kernel_jb_remaining_patches.md`)
**All offsets in `kernel.py` are file offsets**`bl_callers` dict, `_is_bl()`, `_disas_at()`, `find_string_refs()` all use file offsets, not VAs.
**IONVRAMController vtable discovery chain**:
```
"IONVRAMController" string @ 0xA2FEB
→ ADRP+ADD refs → metaclass ctor @ 0x125D2C0
@@ -438,6 +446,7 @@ Branch is 8 commits ahead of `main`. All changes are **additive** — non-JB cod
```
**vfs_context_current / vnode_getattr resolution**:
```
sandbox ops table → entry[16] = original hook @ 0x239A0B4
→ disassemble hook → find BL targets:
@@ -447,16 +456,16 @@ sandbox ops table → entry[16] = original hook @ 0x239A0B4
### Upstream Reference Offsets (iPhone17,3 26.1)
| Symbol | File Offset | Notes |
|--------|-------------|-------|
| kern_text | `0xA74000``0x24B0000` | |
| base_va | `0xFFFFFE0007004000` | |
| verifyPermission func | `0x1233E40` | vtable @ `0x7410B8` |
| verifyPermission patch | `0x1234034` | NOP BL to memmove |
| _thid_should_crash var | `0x67EB50` | already 0 |
| _vfs_context_current | `0xCC5EAC` | from BL encoding |
| _vnode_getattr | `0xCC91C0` | from BL encoding |
| hook_cred_label orig | `0x239A0B4` | from B encoding |
| sandbox ops entry | `0xA54518` | index 16 |
| OSMetaClass::OSMetaClass() | `0x10EA790` | 5236 callers |
| memmove | `0x12CB0D0` | 3114 callers |
| Symbol | File Offset | Notes |
| -------------------------- | ------------------------ | ------------------- |
| kern_text | `0xA74000``0x24B0000` | |
| base_va | `0xFFFFFE0007004000` | |
| verifyPermission func | `0x1233E40` | vtable @ `0x7410B8` |
| verifyPermission patch | `0x1234034` | NOP BL to memmove |
| \_thid_should_crash var | `0x67EB50` | already 0 |
| \_vfs_context_current | `0xCC5EAC` | from BL encoding |
| \_vnode_getattr | `0xCC91C0` | from BL encoding |
| hook_cred_label orig | `0x239A0B4` | from B encoding |
| sandbox ops entry | `0xA54518` | index 16 |
| OSMetaClass::OSMetaClass() | `0x10EA790` | 5236 callers |
| memmove | `0x12CB0D0` | 3114 callers |
+37 -49
View File
@@ -22,6 +22,7 @@ INFO_PLIST := sources/Info.plist
ENTITLEMENTS := sources/vphone.entitlements
VENV := .venv
LIMD_PREFIX := .limd
TOOLS_PREFIX := .tools
IRECOVERY := $(LIMD_PREFIX)/bin/irecovery
IDEVICERESTORE := $(LIMD_PREFIX)/bin/idevicerestore
PYTHON := $(CURDIR)/$(VENV)/bin/python3
@@ -29,26 +30,26 @@ PYTHON := $(CURDIR)/$(VENV)/bin/python3
SWIFT_SOURCES := $(shell find sources -name '*.swift')
# ─── Environment — prefer project-local binaries ────────────────
export PATH := $(CURDIR)/$(LIMD_PREFIX)/bin:$(CURDIR)/$(VENV)/bin:$(CURDIR)/.build/release:$(PATH)
export PATH := $(CURDIR)/$(TOOLS_PREFIX)/bin:$(CURDIR)/$(LIMD_PREFIX)/bin:$(CURDIR)/$(VENV)/bin:$(CURDIR)/.build/release:$(PATH)
# ─── Default ──────────────────────────────────────────────────────
.PHONY: help
help:
@echo "vphone-cli — Virtual iPhone boot tool"
@echo ""
@echo "LazyCat (AIO):"
@echo " make setup_machine Full setup through First Boot"
@echo " Options: JB=1 Jailbreak firmware/CFW path (WIP)"
@echo " SKIP_PROJECT_SETUP=1 Skip setup_tools/build"
@echo ""
@echo "Setup (one-time):"
@echo " make setup_machine Full setup through README First Boot"
@echo " Optional: JB=1 for jailbreak firmware/CFW path"
@echo " Optional: SKIP_PROJECT_SETUP=1 to skip setup_libimobiledevice/setup_venv/build"
@echo " make setup_venv Create Python .venv"
@echo " make setup_libimobiledevice Build libimobiledevice toolchain"
@echo " make setup_tools Install all tools (brew, trustcache, libimobiledevice, venv)"
@echo ""
@echo "Build:"
@echo " make build Build + sign vphone-cli"
@echo " make vphoned Cross-compile vphoned for iOS"
@echo " make vphoned_sign Sign vphoned (requires cfw_input)"
@echo " make install Build + copy to ./bin/"
@echo " make clean Remove .build/"
@echo " make vphoned Cross-compile + sign vphoned for iOS"
@echo " make clean Remove all build artifacts (keeps IPSWs)"
@echo ""
@echo "VM management:"
@echo " make vm_new Create VM directory"
@echo " make boot Boot VM (GUI)"
@@ -56,6 +57,8 @@ help:
@echo ""
@echo "Firmware pipeline:"
@echo " make fw_prepare Download IPSWs, extract, merge"
@echo " Options: IPHONE_SOURCE= URL or local path to iPhone IPSW"
@echo " CLOUDOS_SOURCE= URL or local path to cloudOS IPSW"
@echo " make fw_patch Patch boot chain (6 components)"
@echo " make fw_patch_jb Run fw_patch + JB extension patches (WIP)"
@echo ""
@@ -77,24 +80,30 @@ help:
# Setup
# ═══════════════════════════════════════════════════════════════════
.PHONY: setup_machine setup_venv setup_libimobiledevice
.PHONY: setup_machine setup_tools
setup_machine:
zsh $(SCRIPTS)/setup_machine.sh \
$(if $(filter 1 true yes YES TRUE,$(JB)),--jb,) \
$(if $(filter 1 true yes YES TRUE,$(SKIP_PROJECT_SETUP)),--skip-project-setup,)
setup_venv:
zsh $(SCRIPTS)/setup_venv.sh
setup_tools:
zsh $(SCRIPTS)/setup_tools.sh
setup_libimobiledevice:
bash $(SCRIPTS)/setup_libimobiledevice.sh
# ═══════════════════════════════════════════════════════════════════
# Clean — remove all untracked/ignored files (preserves IPSWs only)
# ═══════════════════════════════════════════════════════════════════
.PHONY: clean
clean:
@echo "=== Cleaning all untracked files (preserving IPSWs) ==="
git clean -fdx -e '*.ipsw' -e '*_Restore*'
# ═══════════════════════════════════════════════════════════════════
# Build
# ═══════════════════════════════════════════════════════════════════
.PHONY: build install clean bundle
.PHONY: build bundle
build: $(BINARY)
@@ -109,46 +118,25 @@ $(BINARY): $(SWIFT_SOURCES) Package.swift $(ENTITLEMENTS)
@echo " signed OK"
bundle: build $(INFO_PLIST)
@mkdir -p $(BUNDLE)/Contents/MacOS
@mkdir -p $(BUNDLE)/Contents/MacOS $(BUNDLE)/Contents/Resources
@cp -f $(BINARY) $(BUNDLE_BIN)
@cp -f $(INFO_PLIST) $(BUNDLE)/Contents/Info.plist
@cp -f $(SCRIPTS)/vphoned/signcert.p12 $(BUNDLE)/Contents/Resources/signcert.p12
@cp -f $$(command -v ldid) $(BUNDLE)/Contents/MacOS/ldid
@cp -f $$(command -v ideviceinstaller) $(BUNDLE)/Contents/MacOS/ideviceinstaller
@cp -f $$(command -v idevice_id) $(BUNDLE)/Contents/MacOS/idevice_id
@codesign --force --sign - --entitlements $(ENTITLEMENTS) $(BUNDLE_BIN)
@echo " bundled → $(BUNDLE)"
install: build
mkdir -p ./bin
cp -f $(BINARY) ./bin/vphone-cli
@echo "Installed to ./bin/vphone-cli"
clean:
swift package clean
rm -rf .build
rm -f $(SCRIPTS)/vphoned/vphoned
rm -f $(BUILD_INFO)
# Cross-compile vphoned daemon for iOS arm64 (installed into VM by cfw_install)
# Cross-compile + sign vphoned daemon for iOS arm64 (requires ldid)
.PHONY: vphoned
vphoned: $(SCRIPTS)/vphoned/vphoned
VPHONED_SRCS := $(addprefix $(SCRIPTS)/vphoned/, \
vphoned.m vphoned_protocol.m vphoned_hid.m \
vphoned_devmode.m vphoned_location.m vphoned_files.m)
$(SCRIPTS)/vphoned/vphoned: $(VPHONED_SRCS)
@echo "=== Building vphoned (arm64, iphoneos) ==="
xcrun -sdk iphoneos clang -arch arm64 -Os -fobjc-arc \
-I$(SCRIPTS)/vphoned \
-DVPHONED_BUILD_HASH='"$(GIT_HASH)"' \
-o $@ $(VPHONED_SRCS) -framework Foundation
@echo " built OK"
# Sign vphoned with entitlements using cfw_input tools (requires make cfw_install to have unpacked cfw_input)
.PHONY: vphoned_sign
vphoned_sign: $(SCRIPTS)/vphoned/vphoned
@test -f "$(VM_DIR)/$(CFW_INPUT)/tools/ldid_macosx_arm64" \
|| (echo "Error: ldid not found. Run 'make cfw_install' first to unpack cfw_input." && exit 1)
vphoned:
@command -v ldid >/dev/null 2>&1 \
|| (echo "Error: ldid not found. Run: brew install ldid-procursus" && exit 1)
$(MAKE) -C $(SCRIPTS)/vphoned GIT_HASH=$(GIT_HASH)
@echo "=== Signing vphoned ==="
cp $(SCRIPTS)/vphoned/vphoned $(VM_DIR)/.vphoned.signed
$(VM_DIR)/$(CFW_INPUT)/tools/ldid_macosx_arm64 \
ldid \
-S$(SCRIPTS)/vphoned/entitlements.plist \
-M "-K$(SCRIPTS)/vphoned/signcert.p12" \
$(VM_DIR)/.vphoned.signed
@@ -163,7 +151,7 @@ vphoned_sign: $(SCRIPTS)/vphoned/vphoned
vm_new:
zsh $(SCRIPTS)/vm_create.sh --dir $(VM_DIR) --disk-size $(DISK_SIZE)
boot: bundle vphoned_sign
boot: bundle vphoned
cd $(VM_DIR) && "$(CURDIR)/$(BUNDLE_BIN)" \
--rom ./AVPBooter.vresearch1.bin \
--disk ./Disk.img \
+1
View File
@@ -24,6 +24,7 @@ let package = Package(
.linkedFramework("AppKit"),
.linkedFramework("SwiftUI"),
.linkedFramework("CoreLocation"),
.linkedFramework("AVFoundation"),
]
),
]
+27 -20
View File
@@ -1,4 +1,4 @@
<div align="right"><strong><a href="./README_zh.md">🇨🇳中文</a></strong> | <strong>🇬🇧English</strong></div>
<div align="right"><strong><a href="./README_ja.md">🇯🇵日本語</a></strong> | <strong><a href="./README_zh.md">🇨🇳中文</a></strong> | <strong>🇬🇧English</strong></div>
# vphone-cli
@@ -8,10 +8,10 @@ Boot a virtual iPhone (iOS 26) via Apple's Virtualization.framework using PCC re
## Tested Environments
| Host | iPhone | CloudOS |
|------|--------|---------|
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Host | iPhone | CloudOS |
| ------------- | ------------------ | ------------- |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
## Prerequisites
@@ -36,7 +36,14 @@ Restart once more.
**Install dependencies:**
```bash
brew install gnu-tar sshpass keystone autoconf automake pkg-config libtool
brew install wget gnu-tar openssl@3 ldid-procursus sshpass keystone autoconf automake pkg-config libtool git-lfs
```
**Git LFS** — this repo uses Git LFS for large resource archives. Install and pull before building:
```bash
git lfs install
git lfs pull
```
## First setup
@@ -45,8 +52,7 @@ brew install gnu-tar sshpass keystone autoconf automake pkg-config libtool
make setup_machine # full automation through "First Boot" (includes restore/ramdisk/CFW)
# equivalent manual steps:
make setup_libimobiledevice # build libimobiledevice toolchain
make setup_venv # create Python venv
make setup_tools # install brew deps, build trustcache + libimobiledevice, create Python venv
source .venv/bin/activate
```
@@ -144,6 +150,7 @@ iproxy 5910 5910 # RPC
```
Connect via:
- **SSH:** `ssh -p 22222 [email protected]` (password: `alpine`)
- **VNC:** `vnc://127.0.0.1:5901`
- [**RPC:**](http://github.com/doronz88/rpc-project) `rpcclient -p 5910 127.0.0.1`
@@ -152,19 +159,19 @@ Connect via:
Run `make help` for the full list. Key targets:
| Target | Description |
|--------|-------------|
| `build` | Build + sign vphone-cli |
| `vm_new` | Create VM directory |
| `fw_prepare` | Download/merge IPSWs |
| `fw_patch` | Patch boot chain |
| Target | Description |
| ------------------- | ---------------------------- |
| `build` | Build + sign vphone-cli |
| `vm_new` | Create VM directory |
| `fw_prepare` | Download/merge IPSWs |
| `fw_patch` | Patch boot chain |
| `boot` / `boot_dfu` | Boot VM (GUI / DFU headless) |
| `restore_get_shsh` | Fetch SHSH blob |
| `restore` | Flash firmware |
| `ramdisk_build` | Build SSH ramdisk |
| `ramdisk_send` | Send ramdisk to device |
| `cfw_install` | Install CFW mods |
| `clean` | Remove build artifacts |
| `restore_get_shsh` | Fetch SHSH blob |
| `restore` | Flash firmware |
| `ramdisk_build` | Build SSH ramdisk |
| `ramdisk_send` | Send ramdisk to device |
| `cfw_install` | Install CFW mods |
| `clean` | Remove build artifacts |
## FAQ
+220
View File
@@ -0,0 +1,220 @@
<div align="right"><strong>🇯🇵日本語</strong> | <strong><a href="./README_zh.md">🇨🇳中文</a></strong> | <strong><a href="./README.md">🇬🇧English</a></strong></div>
# vphone-cli
Apple の Virtualization.framework と PCC の研究用 VM インフラを使用して、仮想 iPhone (iOS 26) を起動するためのツール
![poc](./demo.png)
## 検証済み環境
| ホスト | iPhone | CloudOS |
| ------------- | ------------------ | ------------- |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
## 前提条件
**SIPとAMFIを無効化** — プライベートな Virtualization.framework の entitlement を使うために必要です。
復旧モードで起動し(電源ボタンを長押し)、ターミナルを開いて以下を実行します:
```bash
csrutil disable
csrutil allow-research-guests enable
```
通常の macOS に再起動した後:
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v"
```
実行したらもう一度再起動します。
**依存関係のインストール:**
```bash
brew install gnu-tar openssl@3 ldid-procursus sshpass keystone autoconf automake pkg-config libtool git-lfs
```
**Git LFS** — このリポジトリは大きなリソースアーカイブに Git LFS を使用しています。ビルド前にインストールと pull を行ってください:
```bash
git lfs install
git lfs pull
```
## 初回セットアップ
```bash
make setup_machine # 初回起動までを完全自動化(復元/ラムディスク/CFWを含む)
# 手動で行う場合:
make setup_tools # brew の依存関係インストール、trustcache + libimobiledevice のビルド、Python venv の作成
source .venv/bin/activate
```
`make setup_machine` は、依然として手動での **リカバリーモードの SIP/research-guest 設定** と、出力される初回起動コマンドを実行するためのインタラクティブなVMコンソールが必要です。スクリプトはこれらのセキュリティ設定を検証しません。
## クイックスタート
```bash
make build # vphone-cliのビルド + 署名
make vm_new # vm/ ディレクトリの作成(ROM、ディスク、SEP ストレージ)
make fw_prepare # IPSW のダウンロード、抽出、マージ、マニフェスト生成
make fw_patch # ブートチェーンのパッチ当て(6コンポーネント、41箇所以上の変更)
```
## 復元
復元プロセスには **2つのターミナル** が必要です。ターミナル 2 を使用している間、ターミナル 1 を実行し続けてください。
```bash
# ターミナル 1
make boot_dfu # DFUモードでVMを起動(実行したままにする)
```
```bash
# ターミナル 2
make restore_get_shsh # SHSH blob の取得
make restore # idevicerestore 経由でファームウェアを焼き込み
```
## Ramdisk と CFW
ターミナル 1 の DFU 起動を停止し(Ctrl+C)、Ramdisk 用に再び DFU で起動します:
```bash
# ターミナル 1
make boot_dfu # 実行したままにする
```
```bash
# ターミナル 2
make ramdisk_build # 署名済みSSH Ramdisk のビルド
make ramdisk_send # デバイスへ送信
```
Ramdisk が起動したら(出力に `Running server` と表示されるはずです)、iproxy トンネル用 に **3つ目のターミナル** を開き、ターミナル 2 から CFW をインストールします:
```bash
# ターミナル 3 — 実行したままにする
iproxy 2222 22
```
```bash
# ターミナル 2
make cfw_install
```
## 初回起動
ターミナル 1 の DFU 起動を停止し(Ctrl+C)、以下を実行します:
```bash
make boot
```
これにより VM に **直接繋がるコンソール** が開きます。`bash-4.4#` と表示されたら、Enter を押し、シェル環境を初期化して SSH ホストキーを生成するために以下のコマンドを実行します:
```bash
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/bin/X11:/usr/games:/iosbinpack64/usr/local/sbin:/iosbinpack64/usr/local/bin:/iosbinpack64/usr/sbin:/iosbinpack64/usr/bin:/iosbinpack64/sbin:/iosbinpack64/bin'
mkdir -p /var/dropbear
cp /iosbinpack64/etc/profile /var/profile
cp /iosbinpack64/etc/motd /var/motd
# SSHホストキーの生成(SSHを機能させるために必要)
dropbearkey -t rsa -f /var/dropbear/dropbear_rsa_host_key
dropbearkey -t ecdsa -f /var/dropbear/dropbear_ecdsa_host_key
shutdown -h now
```
> **注意:** ホストキー生成手順を行わないと、dropbear(SSH サーバー)は接続を受け付けますが、SSH ハンドシェイクを実行するためのキーがないためすぐに切断されます。
## 2回目以降の起動
```bash
make boot
```
別のターミナルで iproxy トンネルを開始します:
```bash
iproxy 22222 22222 # SSH
iproxy 5901 5901 # VNC
iproxy 5910 5910 # RPC
```
以下で接続します:
- **SSH:** `ssh -p 22222 [email protected]` (パスワード: `alpine`)
- **VNC:** `vnc://127.0.0.1:5901`
- [**RPC:**](http://github.com/doronz88/rpc-project) `rpcclient -p 5910 127.0.0.1`
## Makefile の全ターゲット
完全なリストは `make help` を実行してください。主なターゲット:
| ターゲット | 説明 |
| ------------------- | ---------------------------- |
| `build` | vphone-cli のビルド + 署名 |
| `vm_new` | VM ディレクトリの作成 |
| `fw_prepare` | IPSW のダウンロード/マージ |
| `fw_patch` | ブートチェーンのパッチ当て |
| `boot` / `boot_dfu` | VMの起動 (GUI / DFU ヘッドレス) |
| `restore_get_shsh` | SHSH blobの取得 |
| `restore` | ファームウェアのフラッシュ |
| `ramdisk_build` | SSH Ramdisk のビルド |
| `ramdisk_send` | Ramdisk の送信 |
| `cfw_install` | CFW のインストール |
| `clean` | ビルドアーティファクトの削除 |
## よくある質問 (FAQ)
> **何よりもまず — `git pull` を実行して最新バージョンであることを確認してください**
**Q: 実行しようとすると `zsh: killed ./vphone-cli` と表示されます**
AMFIが無効化されていません。boot-arg を設定して再起動してください:
```bash
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v"
```
**Q: "Press home to continue" の画面から進めません**
VNC経由で接続し(`vnc://127.0.0.1:5901`)、画面の任意の場所を右クリック(Mac のトラックパッドでは 2 本指クリック)してください。これによりホームボタンの押下がシミュレートされます。
**Q: SSH を接続した後にすぐ切断されます(`Connection closed by 127.0.0.1`**
初回起動時にDropbearホストキーが生成されていません。VNC または `make boot` コンソール経由で接続し、以下を実行してください:
```bash
export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/bin/X11:/usr/games:/iosbinpack64/usr/local/sbin:/iosbinpack64/usr/local/bin:/iosbinpack64/usr/sbin:/iosbinpack64/usr/bin:/iosbinpack64/sbin:/iosbinpack64/bin'
mkdir -p /var/dropbear
dropbearkey -t rsa -f /var/dropbear/dropbear_rsa_host_key
dropbearkey -t ecdsa -f /var/dropbear/dropbear_ecdsa_host_key
killall dropbear
dropbear -R -p 22222
```
**Q: もっと新しいiOSバージョンにアップデートできますか?**
はい。`fw_prepare` に希望するバージョンの IPSW URL を指定することでできます:
```bash
export IPHONE_SOURCE=/path/to/some_os.ipsw
export CLOUDOS_SOURCE=/path/to/some_os.ipsw
make fw_prepare
make fw_patch
```
私たちのパッチは静的なオフセットではなくバイナリ解析によって適用されるため、新しいバージョンでも動作するはずです。何か壊れた場合は AI に聞いてください。
## 謝辞
- [wh1te4ever/super-tart-vphone-writeup](https://github.com/wh1te4ever/super-tart-vphone-writeup)
+26 -20
View File
@@ -1,4 +1,4 @@
<div align="right"><strong>🇨🇳中文</strong> | <strong><a href="./README.md">🇬🇧English</a></strong></div>
<div align="right"><strong><a href="./README_ja.md">🇯🇵日本語</a></strong> | <strong>🇨🇳中文</strong> | <strong><a href="./README.md">🇬🇧English</a></strong></div>
# vphone-cli
@@ -8,10 +8,10 @@
## 测试环境
| 主机 | iPhone 系统 | CloudOS |
|------|-------------|---------|
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| 主机 | iPhone 系统 | CloudOS |
| ------------- | ------------------ | ------------- |
| Mac16,12 26.3 | `17,3_26.1_23B85` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.1-23B85` |
| Mac16,12 26.3 | `17,3_26.3_23D127` | `26.3-23D128` |
## 先决条件
@@ -36,7 +36,14 @@ sudo nvram boot-args="amfi_get_out_of_my_way=1 -v"
**安装依赖:**
```bash
brew install gnu-tar sshpass keystone autoconf automake pkg-config libtool
brew install gnu-tar openssl@3 ldid-procursus sshpass keystone autoconf automake pkg-config libtool git-lfs
```
**Git LFS** —— 本仓库使用 Git LFS 存储大型资源文件。构建前请先安装并拉取:
```bash
git lfs install
git lfs pull
```
## 第一次设置
@@ -45,8 +52,7 @@ brew install gnu-tar sshpass keystone autoconf automake pkg-config libtool
make setup_machine # 完全自动化完成“首次启动”流程(包含 restore/ramdisk/CFW
# 等价的手动步骤:
make setup_libimobiledevice # 构建 libimobiledevice 工具链
make setup_venv # 创建 Python 虚拟环境
make setup_tools # 安装 brew 依赖、构建 trustcache + libimobiledevice、创建 Python 虚拟环境
source .venv/bin/activate
```
@@ -151,19 +157,19 @@ iproxy 5901 5901 # VNC
运行 `make help` 获取完整列表。关键目标:
| 目标 | 描述 |
|------|------|
| `build` | 构建并签名 vphone-cli |
| `vm_new` | 创建 VM 目录 |
| `fw_prepare` | 下载/合并 IPSWs |
| `fw_patch` | 修补启动链 |
| 目标 | 描述 |
| ------------------- | ------------------------- |
| `build` | 构建并签名 vphone-cli |
| `vm_new` | 创建 VM 目录 |
| `fw_prepare` | 下载/合并 IPSWs |
| `fw_patch` | 修补启动链 |
| `boot` / `boot_dfu` | 启动 VMGUI / 无头 DFU |
| `restore_get_shsh` | 获取 SHSH blob |
| `restore` | 刷写固件 |
| `ramdisk_build` | 构建 SSH ramdisk |
| `ramdisk_send` | 发送 ramdisk 到设备 |
| `cfw_install` | 安装 CFW 修改 |
| `clean` | 删除构建产物 |
| `restore_get_shsh` | 获取 SHSH blob |
| `restore` | 刷写固件 |
| `ramdisk_build` | 构建 SSH ramdisk |
| `ramdisk_send` | 发送 ramdisk 到设备 |
| `cfw_install` | 安装 CFW 修改 |
| `clean` | 删除构建产物 |
## 常见问题(FAQ
+544
View File
@@ -0,0 +1,544 @@
# iBoot Patch Analysis: iBSS / iBEC / LLB
Analysis of iBoot patches for vresearch101 from PCC-CloudOS 26.3 (23D128).
## Source Files
All six vresearch101 iBoot variants share just two unique **payload** binaries
(after IM4P decode/decompress):
| Variant | IM4P Size | Raw Size | Payload SHA256 (first 16) | Fourcc |
|---------|-----------|----------|---------------------------|--------|
| iBSS RELEASE | 303068 | 605312 | `4c9e7df663af76fa` | ibss |
| iBEC RELEASE | 303098 | 605312 | `4c9e7df663af76fa` | ibec |
| LLB RELEASE | 303068 | 605312 | `4c9e7df663af76fa` | illb |
| iBSS RESEARCH | 308188 | 622512 | `8c3cc980f25f9027` | ibss |
| iBEC RESEARCH | 308218 | 622512 | `8c3cc980f25f9027` | ibec |
| LLB RESEARCH | 308188 | 622512 | `8c3cc980f25f9027` | illb |
**Key finding:** This "identical" claim is strictly about the decoded payload bytes.
At the IM4P container level, iBSS/iBEC/LLB are still different files (different
fourcc and full-file hashes). Within each build variant (RELEASE or RESEARCH),
the decoded payload bytes are identical.
Mode/stage identity is therefore not encoded as different payload binaries in
these pristine IPSW extracts; it comes from how the boot chain loads and treats
each image.
`fw_patch.py` targets the RELEASE variants, matching the BuildManifest identity
(`PCC RELEASE` for LLB/iBSS/iBEC). The dynamic patcher works on both variants.
> Note: if you compare files under `vm/...` **after** running patch scripts,
> RELEASE payloads will no longer be identical (expected), because mode-specific
> patches are applied to iBEC/LLB.
## Binary Layout
Single flat ROM segment (no Mach-O, no sections):
| Property | RELEASE | RESEARCH |
|----------|---------|----------|
| Base VA | `0x7006C000` | `0x7006C000` |
| Size | 605312 (591.1 KB) | 622512 (607.9 KB) |
| Compression | BVX2 (LZFSE) | BVX2 (LZFSE) |
| Encrypted | No | No |
File offset = VA `0x7006C000`.
## Patch Summary
### Base Patches (`fw_patch.py` via `IBootPatcher`)
| # | Patch | iBSS | iBEC | LLB | Total |
|---|-------|:----:|:----:|:---:|:-----:|
| 1 | Serial labels (×2) | ✅ | ✅ | ✅ | 2 |
| 2 | image4 callback bypass | ✅ | ✅ | ✅ | 2 |
| 3 | Boot-args redirect | — | ✅ | ✅ | 3 |
| 4 | Rootfs bypass | — | — | ✅ | 5 |
| 5 | Panic bypass | — | — | ✅ | 1 |
| | **Subtotal** | **4** | **7** | **13** | |
### JB Extension Patch (implemented)
| # | Patch | Base | JB |
|---|-------|:----:|:--:|
| 6 | **Skip generate_nonce** (iBSS only) | — | ✅ |
Status: implemented in `IBootJBPatcher.patch_skip_generate_nonce()` and applied
by `fw_patch_jb.py` (JB flow). This follows the current pipeline split where
base boot patching stays minimal and nonce control is handled in JB/research flow.
## Patch Details (RELEASE variant, 26.3)
### Patch 1: Serial Labels
**Purpose:** Replace two `===...===` banner strings with descriptive labels for
serial log identification.
**Anchoring:** Find runs of ≥20 `=` characters in the binary. There are exactly
4 such runs, but only the first 2 are the banners (the other 2 are
`"Start of %s serial output"` / `"End of %s serial output"` format strings).
| Patch | File Offset | VA | Original | Patched |
|-------|-------------|-----|----------|---------|
| Label 1 | `0x084549` | `0x700F0549` | `=====...` | `Loaded iBSS` |
| Label 2 | `0x0845F4` | `0x700F05F4` | `=====...` | `Loaded iBSS` |
Label text changes per mode: `Loaded iBSS` / `Loaded iBEC` / `Loaded LLB`.
**Containing function:** `sub_7006F71C` (main boot function, ~0x9B4 bytes).
### Patch 2: image4_validate_property_callback
**Purpose:** Force the image4 property validation callback to always return 0
(success), bypassing signature/property verification for all image4 objects.
**Function:** `sub_70075350` (~0xA98 bytes) — the image4 property callback handler.
Dispatches on 4-char property tags (BORD, CHIP, CEPO, CSEC, DICE, BNCH, etc.)
and validates each against expected values.
**Anchoring pattern:**
1. `B.NE` followed immediately by `MOV X0, X22`
2. `CMP` within 8 instructions before the `B.NE`
3. `MOVN W22, #0` (setting error return = -1) within 64 instructions before
The `B.NE` is the stack canary check at the function epilogue. `X22` holds the
computed return value (0 = success, -1 = failure). The patch forces return 0
regardless of validation results.
| Patch | File Offset | VA | Original | Patched |
|-------|-------------|-----|----------|---------|
| NOP b.ne | `0x009D14` | `0x70075D14` | `B.NE 0x70075E50` | `NOP` |
| Force ret=0 | `0x009D18` | `0x70075D18` | `MOV X0, X22` | `MOV X0, #0` |
**Context (function epilogue):**
```
70075CFC MOV W22, #0xFFFFFFFF ; error return code
70075D00 LDUR X8, [X29, #var_60] ; load stack canary
70075D04 ADRL X9, "160D" ; expected canary
70075D0C LDR X9, [X9]
70075D10 CMP X9, X8 ; canary check
70075D14 B.NE loc_70075E50 ; → panic if mismatch ← NOP
70075D18 MOV X0, X22 ; return x22 ← MOV X0, #0
70075D1C LDP X29, X30, [SP, ...] ; epilogue
...
70075D38 RETAB
```
### Patch 3: Boot-args (iBEC / LLB only)
**Purpose:** Replace the default boot-args format string `"%s"` with
`"serial=3 -v debug=0x2014e %s"` to enable serial output, verbose boot,
and debug flags.
**Anchoring:**
1. Find `"rd=md0"` string → search nearby for standalone `"%s"` (NUL-terminated)
2. Find `ADRP+ADD X2` pair referencing that `"%s"` offset
3. Write new string to a NUL-padded area, redirect ADRP+ADD to it
| Patch | File Offset | VA | Description |
|-------|-------------|-----|-------------|
| String | `0x023F40` | `0x700D5F40` | New boot-args string |
| ADRP x2 | `0x0122E0` | `0x700DE2E0` | Redirect to new page |
| ADD x2 | `0x0122E4` | `0x700DE2E4` | Redirect to new offset |
### Patch 4: Rootfs Bypass (LLB only)
**Purpose:** 5 patches that bypass root filesystem signature verification,
allowing modified rootfs to boot.
| # | File Offset | VA | Original | Patched | Anchor |
|---|-------------|-----|----------|---------|--------|
| 4a | `0x02B068` | `0x700D7068` | `CBZ W0, ...` | `B ...` | error code `0x3B7` |
| 4b | `0x02AD20` | `0x700D6D20` | `B.HS ...` | `NOP` | `CMP X8, #0x400` |
| 4c | `0x02B0BC` | `0x700D70BC` | `CBZ W0, ...` | `B ...` | error code `0x3C2` |
| 4d | `0x02ED6C` | `0x700DAD6C` | `CBZ X8, ...` | `NOP` | `LDR X8, [xN, #0x78]` |
| 4e | `0x02EF68` | `0x700DAF68` | `CBZ W0, ...` | `B ...` | error code `0x110` |
**Anchoring techniques:**
- **4a, 4c, 4e:** Find unique `MOV W8, #<error>` instruction, the `CBZ` is 4 bytes
before. Convert conditional branch to unconditional `B` (same target).
- **4b:** Find unique `CMP X8, #0x400`, NOP the `B.HS` that follows.
- **4d:** Scan backwards from error `0x110` for `LDR X8, [xN, #0x78]` + `CBZ X8`,
NOP the `CBZ`.
### Patch 5: Panic Bypass (LLB only)
**Purpose:** Prevent panic when a specific boot check fails.
**Anchoring:** Find `MOV W8, #0x328` followed by `MOVK W8, #0x40, LSL #16`
(forming constant `0x400328`), walk forward to `BL; CBNZ W0`, NOP the `CBNZ`.
| Patch | File Offset | VA | Original | Patched |
|-------|-------------|-----|----------|---------|
| NOP cbnz | `0x01A038` | `0x70086038` | `CBNZ W0, ...` | `NOP` |
### Patch 6: Skip generate_nonce (iBSS only, JB flow)
**Purpose:** Skip nonce generation to preserve the existing AP nonce. Required for
deterministic DFU restore — without this, iBSS generates a random nonce on each
boot, which can interfere with the restore process.
**Function:** `sub_70077064` (~0x1C00 bytes) — iBSS platform initialization.
**Anchoring:** Find `"boot-nonce"` string reference via ADRP+ADD, then scan forward
for: `TBZ W0, #0` + `MOV W0, #0` + `BL` pattern. Convert `TBZ` to unconditional `B`.
| Patch | File Offset | VA | Original | Patched |
|-------|-------------|-----|----------|---------|
| Skip nonce | `0x00B7B8` | `0x700777B8` | `TBZ W0, #0, 0x700777F0` | `B 0x700777F0` |
**Disassembly context:**
```
70077750 ADD X8, X8, #("boot-nonce" - ...) ; 1st ref: read nonce env var
70077754 BL sub_70079590 ; env_get
...
7007778C ADRL X8, "boot-nonce" ; 2nd ref: nonce generation block
70077798 ADD X8, X8, #("dram-vendor" - ...)
7007779C BL sub_70079570 ; env_set
700777A0 BL sub_700797B4
...
700777B4 BL sub_7009F620 ; check if nonce needed
700777B8 TBZ W0, #0, loc_700777F0 ; skip if bit0=0 ← patch to B
700777BC MOV W0, #0
700777C0 BL sub_70087414 ; generate_nonce(0)
700777C4 STR X0, [SP, ...] ; store nonce
...
700777F0 ADRL X8, "dram-vendor" ; continue init
```
The `generate_nonce` function (`sub_70087414`) calls a random number generator
(`sub_70083FA4`) to create a new 64-bit nonce and stores it in the platform state.
The patch makes the `TBZ` unconditional so the nonce generation block is always
skipped, preserving whatever nonce was already set (or leaving it empty).
**Current placement (rewrite/JB path):**
This patch is intentionally kept in the JB extension path (`fw_patch_jb.py` +
`IBootJBPatcher`) so the base flow remains unchanged. Use JB flow when you need
deterministic nonce behavior for restore/research scenarios.
## RELEASE vs RESEARCH_RELEASE Variants
Both variants work with all dynamic patches. Offsets differ but the patcher
finds them by pattern matching:
| Patch | RELEASE offset | RESEARCH offset |
|-------|---------------|-----------------|
| Serial label 1 | `0x084549` | `0x0861C9` |
| Serial label 2 | `0x0845F4` | `0x086274` |
| image4 callback (nop) | `0x009D14` | `0x00A0DC` |
| image4 callback (mov) | `0x009D18` | `0x00A0E0` |
| Skip generate_nonce *(JB patch)* | `0x00B7B8` | `0x00BC08` |
`fw_patch.py` targets RELEASE, matching the BuildManifest identity
(PCC RELEASE for LLB/iBSS/iBEC). The reference script used RESEARCH_RELEASE.
Both work — the dynamic patcher is variant-agnostic.
## Cross-Version Comparison (26.1 → 26.3)
Reference hardcoded offsets (26.1 RESEARCH_RELEASE) vs dynamic patcher results
(26.3 RELEASE):
| Patch | 26.1 (hardcoded) | 26.3 RELEASE (dynamic) | 26.3 RESEARCH (dynamic) |
|-------|-----------------|----------------------|------------------------|
| Serial label 1 | `0x84349` | `0x84549` | `0x861C9` |
| Serial label 2 | `0x843F4` | `0x845F4` | `0x86274` |
| image4 nop | `0x09D10` | `0x09D14` | `0x0A0DC` |
| image4 mov | `0x09D14` | `0x09D18` | `0x0A0E0` |
| generate_nonce | `0x1B544` | `0x0B7B8` | `0x0BC08` |
Offsets shift significantly between versions and variants, confirming that
hardcoded offsets would break. The dynamic patcher handles all combinations.
## Appendix: IDA Pseudocode / Disassembly
### A. Serial Label Banners (`ibss_main` @ `0x7006F71C`)
```
ibss_main (ROM @ 0x7006fa98):
; --- banner 1 ---
7006faa8 ADRL X0, "\n\n=======================================\n" ; 0x700F0546
7006fab0 BL serial_printf
7006fab4 ADRL X20, "::\n"
7006fabc MOV X0, X20
7006fac0 BL serial_printf
...
; :: <build info lines> ::
...
7006fc30 BL serial_printf
7006fc34 MOV X0, X20
7006fc38 BL serial_printf
; --- banner 2 ---
7006fc3c ADRL X0, "=======================================\n\n" ; 0x700F05F3
7006fc44 BL serial_printf
7006fc48 BL sub_700C8674
```
Patcher writes `"Loaded iBSS"` at banner+1 (offset into the `===...===` run).
### B. image4_validate_property_callback (`0x70075350`)
**Pseudocode:**
```c
// image4_validate_property_callback — dispatches on image4 property tags.
// Returns 0 on success, -1 on failure.
// X22 accumulates the return code throughout the function.
//
// Property tags handled (FourCC → hex):
// BORD=0x424F5244 CHIP=0x43484950 CEPO=0x4345504F CSEC=0x43534543
// DICE=0x45434944 EPRO=0x4550524F ESEC=0x45534543 EKEY=0x454B4559
// DPRO=0x4450524F SDOM=0x53444F4D CPRO=0x4350524F BNCH=0x424E4348
// pndp=0x706E6470 osev=0x6F736576 nrde=0x6E726465 slvn=0x736C766E
// dpoc=0x64706F63 anrd=0x616E7264 exrm=0x6578726D hclo=0x68636C6F
// AMNM=0x414D4E4D
//
int64_t image4_validate_property_callback(tag, a2, capture_mode, a4, ...) {
if (MEMORY[0x701004D8] != 1)
goto dispatch;
// Handle ASN1 types 1, 2, 4 via registered callbacks
switch (*(_QWORD *)(a2 + 16)) {
case 1: if (callback_bool) callback_bool(tag, capture_mode == 1, value); break;
case 2: if (callback_int) callback_int(tag, capture_mode == 1, value); break;
case 4: if (callback_data) callback_data(tag, capture_mode == 1, ptr, ptr, end, ...); break;
default: log_printf(0, "Unknown ASN1 type %llu\n"); return -1;
}
dispatch:
// Main tag dispatch (capture_mode: 0=verify, 1=capture)
if (capture_mode == 1) {
switch (tag) {
case 'BORD': ... // board ID
case 'CHIP': ... // chip ID
...
}
} else if (capture_mode == 0) {
switch (tag) {
case 'BNCH': ... // boot nonce hash
case 'CEPO': ... // certificate epoch
...
}
}
// ... (21 property handlers)
return x22; // 0=success, -1=failure
}
```
**Epilogue disassembly (patch site):**
```
; At this point X22 = return value (0 or -1)
70075CFC MOV W22, #0xFFFFFFFF ; set error return = -1
70075D00 LDUR X8, [X29, #var_60] ; load saved stack cookie
70075D04 ADRL X9, "160D" ; expected cookie value
70075D0C LDR X9, [X9]
70075D10 CMP X9, X8 ; stack canary check
70075D14 B.NE loc_70075E50 ; → stack_chk_fail ◄── PATCH 2a: NOP
70075D18 MOV X0, X22 ; return x22 ◄── PATCH 2b: MOV X0, #0
70075D1C LDP X29, X30, [SP, ...] ; restore callee-saved
70075D20 LDP X20, X19, [SP, ...]
70075D24 LDP X22, X21, [SP, ...]
70075D28 LDP X24, X23, [SP, ...]
70075D2C LDP X26, X25, [SP, ...]
70075D30 LDP X28, X27, [SP, ...]
70075D34 ADD SP, SP, #0x110
70075D38 RETAB
```
Effect: function always returns 0 (success) regardless of property validation.
### C. generate_nonce (`0x70087414`)
**Pseudocode:**
```c
// generate_nonce — creates a random 64-bit AP nonce.
// Called from platform_init when boot-nonce environment needs a new nonce.
//
uint64_t generate_nonce() {
platform_state *ps = get_platform_state();
if (ps->flags & 2) // nonce already generated?
goto return_existing;
uint64_t nonce = random64(0); // generate random 64-bit value
ps->nonce = nonce; // store at offset +40
ps->flags |= 2; // mark nonce as valid
if (ps->nonce_lo == 0) { // sanity check
get_platform_state2();
log_assert(1630); // "nonce is zero" assertion
return_existing:
nonce = ps->nonce;
}
return nonce;
}
```
### D. Skip generate_nonce — `platform_init` (`0x70077064`)
**Disassembly (boot-nonce handling region):**
```
; --- Phase 1: read existing boot-nonce from env ---
70077744 ADRL X8, "effective-security-mode-ap"
7007774C STP X8, X8, [SP, #var_238]
70077750 ADD X8, X8, #("boot-nonce" - ...) ; 1st ref to "boot-nonce"
70077754 BL env_get ; read boot-nonce env var
70077758 STP X24, X24, [SP, #var_2F0]
7007775C ADRL X7, ...
70077764 BL sub_7007968C
70077768 ADD X6, X19, #0x20
7007776C BL env_check_property ; check if boot-nonce exists
70077770 TBZ W0, #0, loc_7007778C ; if no existing nonce, skip
70077774 BL sub_700BF1D8 ; get security mode
70077778 MOV X23, X0
7007777C BL sub_700795D8
70077780 CCMP X0, X2, #2, CS
70077784 B.CS loc_70078C44 ; error path
70077788 BL sub_700798D0
; --- Phase 2: generate new nonce (PATCHED OUT) ---
7007778C ADRL X8, "boot-nonce" ; 2nd ref to "boot-nonce"
70077794 STP X8, X8, [SP, #var_238]
70077798 ADD X8, X8, #("dram-vendor" - ...)
7007779C BL env_set ; set boot-nonce env key
700777A0 BL env_clear
700777A4 ADRL X7, ...
700777AC BL sub_7007968C
700777B0 ADD X6, X19, #0x20
700777B4 BL env_check_property ; check if nonce generation needed
700777B8 TBZ W0, #0, loc_700777F0 ; ◄── PATCH 6: change to B (always skip)
700777BC MOV W0, #0
700777C0 BL generate_nonce ; generate_nonce(0) — SKIPPED
700777C4 STR X0, [SP, #var_190] ; store nonce result
700777C8 BL sub_70079680
700777CC ADD X8, SP, #var_190
700777D0 LDR W9, [SP, #var_214]
700777D4 STR X9, [SP, #var_2F0]
700777D8 ADRL X7, ...
700777E0 ADD X4, SP, #var_190
700777E4 ADD X5, SP, #var_190
700777E8 ADD X6, X8, #8
700777EC BL sub_700A8F24 ; commit nonce to env
; --- Phase 3: continue with dram-vendor init ---
700777F0 ADRL X8, "dram-vendor" ; ◄── branch target (skip lands here)
700777F8 STP X8, X8, [SP, #var_238]
700777FC ADD X8, X8, #("dram-vendor-id" - ...)
70077800 BL env_get
```
**Patch effect:** `TBZ W0, #0, 0x700777F0``B 0x700777F0`
Unconditionally skips the `generate_nonce(0)` call and all nonce storage logic,
jumping directly to the "dram-vendor" init. Preserves any existing AP nonce from
a previous boot or NVRAM.
## Appendix E: Nonce Skip — IDA Pseudocode Before/After
### generate_nonce (`sub_70087414`)
```c
unsigned __int64 generate_nonce()
{
platform_state *ps = get_platform_state();
if ( (ps->flags & 2) != 0 ) // nonce already generated?
goto return_existing;
uint64_t nonce = random64(0); // generate random 64-bit value
*(uint64_t *)(ps + 40) = nonce; // store nonce
*(uint32_t *)ps |= 2u; // mark nonce as valid
if ( !*(uint32_t *)(ps + 40) ) // sanity: nonce_lo == 0?
{
v4 = get_platform_state2();
log_assert(v4, 1630); // "nonce is zero" assertion
return_existing:
nonce = *(uint64_t *)(ps + 40); // return existing nonce
}
return nonce;
}
```
### platform_init — boot-nonce region: BEFORE patch
```c
// --- Phase 1: read existing boot-nonce from env ---
env_get(..., /*0x70077754*/
"effective-security-mode-ap",
"effective-security-mode-ap", ...);
env_check_property(...); /*0x7007776c*/
if ( (v271 & 1) != 0 ) /*0x70077770*/
{
// existing nonce found — security mode check
v97 = get_security_mode(); /*0x70077778*/
v279 = validate_security(v97); /*0x7007777c*/
if ( !v42 || v279 >= v280 ) /*0x70077780*/
goto LABEL_311; // error path
}
// --- Phase 2: set boot-nonce env, check if generation needed ---
env_set(..., /*0x7007779c*/
"boot-nonce",
"boot-nonce", ...);
env_clear(); /*0x700777a0*/
v290 = env_check_property(...); /*0x700777b4*/
if ( (v290 & 1) != 0 ) /*0x700777b8 ← TBZ W0, #0*/
{
nonce = generate_nonce(); /*0x700777c4 ← BL generate_nonce*/
sub_70079680(nonce); /*0x700777c8*/
sub_700A8F24(...); /*0x700777ec — commit nonce to env*/
}
// --- Phase 3: continue with dram-vendor init ---
env_get(..., /*0x70077800*/
"dram-vendor",
"dram-vendor", ...);
```
### platform_init — boot-nonce region: AFTER patch
```c
// --- Phase 2: set boot-nonce env ---
env_set(..., /*0x7007779c*/
"boot-nonce",
"boot-nonce", ...);
env_clear(); /*0x700777a0*/
v290 = env_check_property(...); /*0x700777b4*/
// generate_nonce() block ELIMINATED by decompiler
// (unconditional B at 0x700777B8 makes it dead code)
// --- Phase 3: continue with dram-vendor init ---
v298 = env_get(..., /*0x70077800*/
"dram-vendor",
"dram-vendor", ...);
```
**Patch effect in decompiler:** The entire `if` block containing `generate_nonce()`
is removed. The decompiler recognizes the unconditional `B` creates dead code and
eliminates it entirely — execution flows straight from `env_check_property()` to
the `"dram-vendor"` env_get.
### Byte Comparison
Reference: `patch(0x1b544, 0x1400000e)` (26.1 RESEARCH, hardcoded)
| | Reference (26.1) | Dynamic (26.3 RELEASE) | Dynamic (26.3 RESEARCH) |
|---|---|---|---|
| **Offset** | `0x1B544` | `0x0B7B8` | `0x0BC08` |
| **Original** | `TBZ W0, #0, +0x38` | `TBZ W0, #0, +0x38` | `TBZ W0, #0, +0x38` |
| **Patched** | `B +0x38` | `B +0x38` | `B +0x38` |
| **Bytes** | `0E 00 00 14` | `0E 00 00 14` | `0E 00 00 14` |
All three produce byte-identical `0x1400000E` — same branch delta `+0x38` (14 words)
across all variants. Only the file offset differs between versions.
## Status
`patch_skip_generate_nonce()` is active in the rewrite/JB path via
`IBootJBPatcher` and `fw_patch_jb.py` (iBSS JB component enabled).
+480
View File
@@ -0,0 +1,480 @@
# TXM Jailbreak Patch Analysis
Analysis of 13 TXM jailbreak patches applied by `txm_jb.py` on the RESEARCH variant
of TXM from iPhone17,3 / PCC-CloudOS 26.x.
## Address Mapping
| Segment | VM Address | File Offset | Size |
|---------|------------|-------------|------|
| `__TEXT_EXEC` | `0xFFFFFFF017020000` | `0x1c000` | `0x44000` |
| `__TEXT_BOOT_EXEC` | `0xFFFFFFF017064000` | `0x60000` | `0xc000` |
Conversion: `VA = file_offset - 0x1c000 + 0xFFFFFFF017020000` (for `__TEXT_EXEC`)
---
## TXM Selector Dispatch
All TXM operations enter through a single dispatch function (`sub_FFFFFFF01702AE80`),
a large switch on the selector number (151). Each case validates arguments and calls
a dedicated handler. Relevant selectors:
| Selector | Handler | Purpose |
|----------|---------|---------|
| 24 | `sub_FFFFFFF017024834` → validation chain | CodeSignature validation |
| 41 | `sub_FFFFFFF017023558` | Process entitlement setup (get-task-allow) |
| 42 | `sub_FFFFFFF017023368` | Debug memory mapping |
| — | `sub_FFFFFFF017023A20` | Developer mode configuration (called during init) |
The dispatcher passes raw page pointers through `sub_FFFFFFF0170280A4` (a bounds
validator that returns the input pointer unchanged) before calling handlers.
---
## Patch 12: CodeSignature Hash Comparison Bypass (selector 24)
**Error**: `TXM [Error]: CodeSignature: selector: 24 | 0xA1 | 0x30 | 1`
### Addresses
| File Offset | VA | Original Instruction | Patch |
|---|---|---|---|
| `0x313ec` | `0xFFFFFFF0170353EC` | `LDR X1, [X20, #0x38]` | NOP |
| `0x313f4` | `0xFFFFFFF0170353F4` | `BL sub_FFFFFFF0170335F8` | NOP |
### Function: `sub_FFFFFFF0170353B8` — CS hash flags validator
**Call chain**: selector 24 → `sub_FFFFFFF017024834` (CS handler) →
`sub_FFFFFFF0170356F8` (CS validation pipeline) → `sub_FFFFFFF017035A00`
(multi-step validation, step 4 of 8) → `sub_FFFFFFF0170353B8`
### Decompiled (pre-patch)
```c
// sub_FFFFFFF0170353B8(manifest_ptr, version)
__int64 __fastcall sub_FFFFFFF0170353B8(__int64 **a1, unsigned int a2)
{
__int64 v4 = **a1;
__int64 v7 = 0; // hash data pointer
int v6 = 0; // hash flags
// Patch 1: NOP removes arg load (LDR X1, [X20, #0x38])
// Patch 2: NOP removes this call entirely:
sub_FFFFFFF0170335F8(a1[6], a1[7], &v6); // extract hash flags from CS blob
sub_FFFFFFF017033718(a1[6], a1[7], &v7); // extract hash data pointer
if ( a2 >= 6 && *(v4 + 8) )
return 0xA1; // 161
// Critical comparison: does hash presence match flags?
if ( (v7 != 0) == ((v6 & 2) >> 1) )
return 0x130A1; // 77985 — hash mismatch
// ... further version-dependent checks return 0xA1 or 0x22DA1
}
```
### What `sub_FFFFFFF0170335F8` does
Extracts hash flags from the CodeSignature blob header. Reads `bswap32(*(blob + 12))`
into the output parameter (the flags bitmask). Bit 1 of the flags indicates whether
a code hash is present.
### What `sub_FFFFFFF017033718` does
Locates the hash data within the CodeSignature blob. Validates blob header version
(`bswap32(*(blob+8)) >> 9 >= 0x101`), then follows a length-prefixed string pointer
at offset 48 to find the hash data. Returns the hash data pointer via output param.
### Effect of NOP
With `sub_FFFFFFF0170335F8` NOPed, `v6` stays at its initialized value of **0**.
This means `(v6 & 2) >> 1 = 0` (hash-present flag is cleared). As long as
`sub_FFFFFFF017033718` returns a non-null hash pointer (`v7 != 0`), the comparison
becomes `(1 == 0)`**false**, so the `0x130A1` error is skipped. The function
falls through to the version checks which return success for version ≤ 5.
This effectively bypasses CodeSignature hash validation — the hash data exists
in the blob but the hash-present flag is suppressed, so the consistency check passes.
### `txm_jb.py` dynamic finder: `patch_selector24_hashcmp_calls()`
Scans for the instruction pattern `mov w2, #0x14 / bl X / cbz w0, Y` to find
hashcmp BL callsites, then patches the BL to `mov x0, #0`. This also includes
`patch_selector24_a1_path()` which NOPs the `b.lo` and `cbz x9` guards around
the `mov w0, #0xa1` error path.
---
## Patch 3: get-task-allow Force True (selector 41)
**Error**: `TXM [Error]: selector: 41 | 29`
### Address
| File Offset | VA | Original Instruction | Patch |
|---|---|---|---|
| `0x1f5d4` | `0xFFFFFFF0170235D4` | `BL sub_FFFFFFF017022A30` | `MOV X0, #1` |
### Function: `sub_FFFFFFF017023558` — selector 41 handler
**Call chain**: selector 41 → `sub_FFFFFFF0170280A4` (ptr validation) →
`sub_FFFFFFF017023558`
### Decompiled (pre-patch)
```c
// sub_FFFFFFF017023558(manifest)
__int64 __fastcall sub_FFFFFFF017023558(__int64 a1)
{
// Check developer mode is enabled (byte_FFFFFFF017070F24)
if ( (byte_FFFFFFF017070F24 & 1) == 0 )
return 27; // developer mode not enabled
// Check license-to-operate entitlement (always first)
sub_FFFFFFF017022A30(0, "research.com.apple.license-to-operate", 0);
// Lock manifest
sub_FFFFFFF017027074(a1, 0, 0);
if ( *(a1 + 36) == 1 ) // special manifest type
goto error_path; // return via panic(0x81)
// === PATCHED INSTRUCTION ===
// Original: BL sub_FFFFFFF017022A30 — entitlement_lookup(manifest, "get-task-allow", 0)
// Patched: MOV X0, #1
if ( (sub_FFFFFFF017022A30(a1, "get-task-allow", 0) & 1) != 0 ) // TBNZ w0, #0
{
v3 = 0; // success
*(a1 + 0x30) = 1; // set get-task-allow flag on manifest
}
else
{
v3 = 29; // ERROR 29: no get-task-allow entitlement
}
sub_FFFFFFF01702717C(a1, 0); // unlock manifest
return v3;
}
```
### Assembly at patch site
```asm
FFFFFFF0170235C4 ADRL X1, "get-task-allow"
FFFFFFF0170235CC MOV X0, X19 ; manifest object
FFFFFFF0170235D0 MOV X2, #0
FFFFFFF0170235D4 BL sub_FFFFFFF017022A30 ; <-- PATCHED to MOV X0, #1
FFFFFFF0170235D8 TBNZ W0, #0, loc_... ; always taken when x0=1
```
### Effect
Replaces the entitlement lookup call with a constant `1`. The subsequent `TBNZ W0, #0`
always takes the branch to the success path, which sets `*(manifest + 0x30) = 1`
(the get-task-allow flag byte). Every process now has get-task-allow, enabling
debugging via `task_for_pid` and LLDB attach.
### What `sub_FFFFFFF017022A30` does
Universal entitlement lookup function. When `a1 != 0`, it resolves the manifest's
entitlement dictionary and searches for the named key via `sub_FFFFFFF017036294`.
Returns a composite status word where bit 0 indicates the entitlement was found.
### `txm_jb.py` dynamic finder: `patch_get_task_allow_force_true()`
Searches for string refs to `"get-task-allow"`, then scans forward for the pattern
`BL X / TBNZ w0, #0, Y`. Patches the BL to `MOV X0, #1`.
---
## Patch 4: selector 42|29 Shellcode (Debug Mapping Gate)
**Error**: `TXM [Error]: selector: 42 | 29`
### Addresses
| File Offset | VA | Patch |
|---|---|---|
| `0x2717c` | `0xFFFFFFF01702B17C` | `B #0x36238` (→ shellcode) |
| `0x5d3b4` | `0xFFFFFFF0170613B4` | `NOP` (pad) |
| `0x5d3b8` | `0xFFFFFFF0170613B8` | `MOV X0, #1` |
| `0x5d3bc` | `0xFFFFFFF0170613BC` | `STRB W0, [X20, #0x30]` |
| `0x5d3c0` | `0xFFFFFFF0170613C0` | `MOV X0, X20` |
| `0x5d3c4` | `0xFFFFFFF0170613C4` | `B #-0x36244` (→ 0xB180) |
### Context: Dispatcher case 42
```asm
; jumptable case 42 entry in sub_FFFFFFF01702AE80:
FFFFFFF01702B178 BTI j
FFFFFFF01702B17C MOV X0, X20 ; <-- PATCHED to B shellcode
FFFFFFF01702B180 BL sub_FFFFFFF0170280A4 ; validate pointer
FFFFFFF01702B184 MOV X1, X21
FFFFFFF01702B188 MOV X2, X22
FFFFFFF01702B18C BL sub_FFFFFFF017023368 ; selector 42 handler
FFFFFFF01702B190 B loc_FFFFFFF01702B344 ; return result
```
### Shellcode (at zero-filled code cave in `__TEXT_EXEC`)
```asm
; 0xFFFFFFF0170613B4 — cave was all zeros
NOP ; pad (original 0x00000000)
MOV X0, #1 ; value to store
STRB W0, [X20, #0x30] ; force manifest->get_task_allow = 1
MOV X0, X20 ; restore original instruction (was at 0xB17C)
B #-0x36244 ; jump back to 0xFFFFFFF01702B180 (BL validate)
```
### Why this is needed
Selector 42's handler `sub_FFFFFFF017023368` checks the get-task-allow byte early:
```c
// sub_FFFFFFF017023368(manifest, addr, size)
// ... after debugger entitlement check ...
v8 = atomic_load((unsigned __int8 *)(a1 + 48)); // offset 0x30
if ( (v8 & 1) == 0 )
{
v6 = 29; // ERROR 29: get-task-allow not set
goto unlock_and_return;
}
// ... proceed with debug memory mapping ...
```
Selector 41 (patch 3) sets this byte during entitlement validation, but
there are code paths where selector 42 can be called before selector 41 has run
for a given manifest. The shellcode ensures the flag is always set at the dispatch
level before the handler even sees it.
### `sub_FFFFFFF0170280A4` — pointer validator
```c
// Validates page alignment and bounds, returns input pointer unchanged
unsigned __int64 sub_FFFFFFF0170280A4(unsigned __int64 a1) {
if ( (a1 & ~0x3FFF) == 0 ) panic(64);
if ( a1 >= 0xFFFFFFFFFFFFC000 ) panic(66);
// ... bounds checks ...
return (a1 & ~0x3FFF) + (a1 & 0x3FFF); // == a1
}
```
Since the validator returns the pointer unchanged, `x20` (raw arg) and the validated
pointer both refer to the same object. The shellcode's `STRB W0, [X20, #0x30]`
writes to the correct location.
### `txm_jb.py` dynamic finder: `patch_selector42_29_shellcode()`
1. Finds the "debugger gate function" via string refs to `"com.apple.private.cs.debugger"`
2. Locates the dispatch stub by matching `BTI j / MOV X0, X20 / BL / MOV X1, X21 / MOV X2, X22 / BL debugger_gate / B`
3. Finds a zero-filled code cave via `_find_udf_cave()` near the stub
4. Emits the branch + shellcode + branch-back
---
## Patch 5: Debugger Entitlement Force True (selector 42)
**Error**: `TXM [Error]: selector: 42 | 37`
### Address
| File Offset | VA | Original Instruction | Patch |
|---|---|---|---|
| `0x1f3b8` | `0xFFFFFFF0170233B8` | `BL sub_FFFFFFF017022A30` | `MOV W0, #1` |
### Function: `sub_FFFFFFF017023368` — selector 42 handler (debug memory mapping)
### Assembly at patch site
```asm
; Check com.apple.private.cs.debugger entitlement
FFFFFFF0170233A8 ADRL X1, "com.apple.private.cs.debugger"
FFFFFFF0170233B0 MOV X0, #0 ; check global manifest (a1=0)
FFFFFFF0170233B4 MOV X2, #0
FFFFFFF0170233B8 BL sub_FFFFFFF017022A30 ; <-- PATCHED to MOV W0, #1
FFFFFFF0170233BC TBNZ W0, #0, loc_... ; always taken when w0=1
FFFFFFF0170233C0 ADRL X8, fallback_flag ; secondary check (also bypassed)
FFFFFFF0170233C8 LDRB W8, [X8, #offset]
FFFFFFF0170233CC TBNZ W8, #0, loc_... ; secondary bypass path
FFFFFFF0170233D0 ADRL X0, "disallowed non-debugger initiated debug mapping"
FFFFFFF0170233D8 BL sub_FFFFFFF017025B7C ; log error
FFFFFFF0170233DC MOV W20, #0x25 ; error 37
FFFFFFF0170233E0 B unlock_return
```
### Decompiled (pre-patch)
```c
// First check in sub_FFFFFFF017023368 after input validation:
if ( (sub_FFFFFFF017022A30(0, "com.apple.private.cs.debugger", 0) & 1) == 0 )
{
// Fallback: check a static byte flag
if ( (fallback_flag & 1) == 0 )
{
log("disallowed non-debugger initiated debug mapping");
return 37; // 0x25
}
}
// Continue with debug mapping...
```
### Effect
Replaces the entitlement lookup with `MOV W0, #1`. The `TBNZ W0, #0` always
branches to the success path, bypassing both the entitlement check and the
fallback flag check. This allows any process to create debug memory mappings
regardless of whether it has `com.apple.private.cs.debugger`.
### `txm_jb.py` dynamic finder: `patch_debugger_entitlement_force_true()`
Searches for string refs to `"com.apple.private.cs.debugger"`, then matches
the pattern: `mov x0, #0 / mov x2, #0 / bl X / tbnz w0, #0, Y`. Patches the BL
to `MOV W0, #1`.
---
## Patch 6: Developer Mode Bypass
### Address
| File Offset | VA | Original Instruction | Patch |
|---|---|---|---|
| `0x1FA58` | `0xFFFFFFF017023A58` | `TBNZ W9, #0, loc_FFFFFFF017023A6C` | NOP |
### Function: `sub_FFFFFFF017023A20` — developer mode configuration
Called during TXM initialization to determine and store the developer mode state.
The result is stored in `byte_FFFFFFF017070F24`, which is the gate flag checked by
selector 41 (`sub_FFFFFFF017023558`).
### Assembly at patch site
```asm
; Check system policy configuration
FFFFFFF017023A50 LDR X9, [X8, #off_FFFFFFF0170146C0]
FFFFFFF017023A54 LDRB W9, [X9, #0x4D] ; load system policy byte
FFFFFFF017023A58 TBNZ W9, #0, loc_FFFFFFF017023A6C ; <-- PATCHED to NOP
; Fall through to force-enable:
FFFFFFF017023A5C MOV W20, #1 ; developer_mode = ENABLED
FFFFFFF017023A60 ADRL X0, "developer mode enabled due to system policy configuration"
FFFFFFF017023A68 B log_and_store
```
### Decompiled (pre-patch)
```c
__int64 sub_FFFFFFF017023A20(__int64 manifest)
{
char devmode;
// Check 1: PCC research variant flag
if ( pcc_research_flag )
{
devmode = 1;
goto apply;
}
// Check 2: System policy (patched here)
byte policy = *(system_config_ptr + 0x4D);
if ( (policy & 1) != 0 ) // <-- TBNZ jumps past force-enable
goto normal_path; // to xART / user-config checks
// Force-enable path (reached by NOPing the TBNZ):
devmode = 1;
log("developer mode enabled due to system policy configuration");
goto apply;
normal_path:
// ... xART availability check ...
// ... user configuration check ...
// May set devmode = 0 (disabled) based on config
apply:
byte_FFFFFFF017070F24 = devmode; // global developer mode state
return result;
}
```
### Effect
NOPing the `TBNZ` makes execution always fall through to `MOV W20, #1`, forcing
developer mode enabled regardless of the system policy byte. Without this:
- The `TBNZ` would jump to `loc_FFFFFFF017023A6C` (the normal path)
- The normal path checks xART availability, device tree flags, and user configuration
- On PCC VMs, this can result in developer mode being **disabled**
Developer mode is a **prerequisite** for selectors 41 and 42 — the selector 41
handler returns error 27 immediately if `byte_FFFFFFF017070F24` is not set:
```c
// In sub_FFFFFFF017023558 (selector 41):
if ( (byte_FFFFFFF017070F24 & 1) == 0 )
return 27; // developer mode not enabled
```
### `txm_jb.py` dynamic finder: `patch_developer_mode_bypass()`
Searches for string refs to `"developer mode enabled due to system policy
configuration"`, then scans backwards for a `tbz/tbnz/cbz/cbnz` instruction
matching `w9, #0`. NOPs it.
---
## Patch Dependency Chain
The patches have a logical ordering — later patches depend on earlier ones:
```
Patch 6: Developer Mode Bypass
│ Forces byte_FFFFFFF017070F24 = 1
├──► Patch 3: get-task-allow Force True (selector 41)
│ Requires developer mode (checks byte_FFFFFFF017070F24)
│ Forces manifest[0x30] = 1
├──► Patch 4: selector 42|29 Shellcode
│ Forces manifest[0x30] = 1 at dispatch level
│ Safety net for Patch 3 (covers cases where sel 42 runs before sel 41)
├──► Patch 5: Debugger Entitlement Force True (selector 42)
│ Bypasses com.apple.private.cs.debugger check
│ Allows debug memory mapping for all processes
└──► Patches 12: CodeSignature Hash Bypass (selector 24)
Independent — bypasses CS hash validation in the signature chain
```
### Boot-time flow
1. TXM initializes → `sub_FFFFFFF017023A20` runs → **Patch 6** forces devmode ON
2. Process loads → selector 24 validates CodeSignature → **Patches 12** skip hash check
3. Process requests entitlements → selector 41 → **Patch 3** grants get-task-allow
4. Debugger attaches → selector 42 → **Patch 4** pre-sets flag + **Patch 5** grants debugger ent
5. Debug mapping succeeds → LLDB can attach to any process
---
## Summary Table
| # | File Offset | VA | Function | Patch | Purpose |
|---|---|---|---|---|---|
| 1 | `0x313ec` | `0xFFFFFFF0170353EC` | `sub_FFFFFFF0170353B8` (CS hash validator) | NOP | Remove hash flag load |
| 2 | `0x313f4` | `0xFFFFFFF0170353F4` | `sub_FFFFFFF0170353B8` (CS hash validator) | NOP | Skip hash flag extraction call |
| 3 | `0x1f5d4` | `0xFFFFFFF0170235D4` | `sub_FFFFFFF017023558` (selector 41) | `MOV X0, #1` | Force get-task-allow = true |
| 4 | `0x2717c` | `0xFFFFFFF01702B17C` | `sub_FFFFFFF01702AE80` (dispatcher, case 42) | `B shellcode` | Redirect to shellcode cave |
| 4a | `0x5d3b4` | `0xFFFFFFF0170613B4` | code cave (zeros) | `NOP` | Shellcode padding |
| 4b | `0x5d3b8` | `0xFFFFFFF0170613B8` | code cave | `MOV X0, #1` | Set value for flag |
| 4c | `0x5d3bc` | `0xFFFFFFF0170613BC` | code cave | `STRB W0, [X20,#0x30]` | Force get-task-allow flag |
| 4d | `0x5d3c0` | `0xFFFFFFF0170613C0` | code cave | `MOV X0, X20` | Restore original instruction |
| 4e | `0x5d3c4` | `0xFFFFFFF0170613C4` | code cave | `B back` | Return to dispatcher |
| 5 | `0x1f3b8` | `0xFFFFFFF0170233B8` | `sub_FFFFFFF017023368` (selector 42) | `MOV W0, #1` | Force debugger entitlement = true |
| 6 | `0x1FA58` | `0xFFFFFFF017023A58` | `sub_FFFFFFF017023A20` (devmode init) | NOP | Force developer mode ON |
**Total**: 6 logical patches, 10 instruction modifications (counting shellcode), enabling:
- CodeSignature bypass (patches 12)
- Universal get-task-allow (patches 34)
- Universal debugger entitlement (patch 5)
- Forced developer mode (patch 6)
+155
View File
@@ -0,0 +1,155 @@
# TXM Variant Analysis: release vs research
Analysis of TXM (Trusted Execution Monitor) variants from iPhone17,3 26.3 (23D127)
and PCC-CloudOS 26.3 (23D128) IPSWs.
## Source Files
| Source | Variant | IM4P Size | SHA256 |
|--------|---------|-----------|--------|
| cloudos | release | 161025 | `3453eb476cfb53d8...` |
| cloudos | research | 161028 | `93ad9e382d8c6353...` |
| iphone | release | 161025 | `3453eb476cfb53d8...` |
| iphone | research | 161028 | `93ad9e382d8c6353...` |
**Key finding:** Both IPSWs contain identical TXM files (same SHA256).
The TXM binary is shared across iPhone and cloudOS IPSWs.
## Decompressed Binary Overview
| Property | RELEASE | RESEARCH |
|----------|---------|----------|
| Compressed size | 160726 bytes | 160729 bytes |
| Decompressed size | 458784 bytes | 458784 bytes |
| Compression | BVX2 (LZFSE) | BVX2 (LZFSE) |
| Format | Mach-O 64-bit ARM64 | Mach-O 64-bit ARM64 |
| SHA256 | `bfc493e3c7b7dc00...` | `62f40b9cd32a2a03...` |
| File type | 2 (MH_EXECUTE) | 2 (MH_EXECUTE) |
| Load commands | 11 | 11 |
| Flags | `0x00200001` | `0x00200001` |
## Mach-O Segments
Both variants have identical segment layout:
| Segment | VM Address | VM Size | File Offset | File Size |
|---------|------------|---------|-------------|-----------|
| `__TEXT` | `0xfffffff017004000` | `0x10000` | `0x0` | `0x10000` |
| `__DATA_CONST` | `0xfffffff017014000` | `0xc000` | `0x10000` | `0xc000` |
| `__TEXT_EXEC` | `0xfffffff017020000` | `0x44000` | `0x1c000` | `0x44000` |
| `__TEXT_BOOT_EXEC` | `0xfffffff017064000` | `0xc000` | `0x60000` | `0xc000` |
| `__DATA` | `0xfffffff017070000` | `0x4000` | `0x6c000` | `0x4000` |
| `__LINKEDIT` | `0xfffffff017074000` | `0x4000` | `0x70000` | `0x20` |
Segment layout identical: **True**
## Diff Summary
- Total differing bytes: **3358** / 458784 (0.73%)
- Diff regions (16-byte merge gap): **87**
### Diffs by Segment
| Segment | Regions | Bytes Changed | % of Segment |
|---------|---------|---------------|--------------|
| `__TEXT` | 3 | 3304 | 5.04% |
| `__TEXT_EXEC` | 84 | 409 | 0.15% |
## Diff Classification
### 1. Build Identifier String (Primary Difference)
The largest diff region (`0x17c5` - `0x2496`, 3282 bytes) is in the `__TEXT` segment
string/const data area. The key difference is the build variant identifier:
| Offset | RELEASE | RESEARCH |
|--------|---------|----------|
| `0x17c5` | `lease.TrustedExecutionMonitor_Guarded-182.40.3` | `search.TrustedExecutionMonitor_Guarded-182.40.3` |
| `0xcb7f` | `lease` | `search` |
Full build string:
- **RELEASE:** `release.TrustedExecutionMonitor_Guarded-182.40.3`
- **RESEARCH:** `research.TrustedExecutionMonitor_Guarded-182.40.3`
Because `"research"` (8 chars) is 1 byte longer than `"release"` (7 chars),
all subsequent strings in `__TEXT` are shifted by +1 byte,
causing a cascade of instruction-level diffs in code that references these strings.
### 2. String Reference Adjustments (Code Diffs)
The remaining diffs are in `__TEXT_EXEC` — all `ADD` instruction immediate adjustments
compensating for the 1-byte string shift:
```
RELEASE: add x8, x8, #0x822 ; points to string at original offset
RESEARCH: add x8, x8, #0x823 ; points to same string, shifted +1
```
- ADD immediate adjustments: **84** regions (all in `__TEXT_EXEC`)
- Other code diffs: **0** regions
- String data regions: **3** regions in `__TEXT` (3304 bytes total)
Sample code diffs (first 10):
| Offset | RELEASE instruction | RESEARCH instruction |
|--------|---------------------|----------------------|
| `0x2572c` | `add x8, x8, #0x822` | `add x8, x8, #0x823` |
| `0x25794` | `add x8, x8, #0x861` | `add x8, x8, #0x862` |
| `0x257d8` | `add x0, x0, #0x877` | `add x0, x0, #0x878` |
| `0x25980` | `add x0, x0, #0x8d7` | `add x0, x0, #0x8d8` |
| `0x25ac8` | `add x0, x0, #0x8a1` | `add x0, x0, #0x8a2` |
| `0x25af0` | `add x4, x4, #0x8eb` | `add x4, x4, #0x8ec` |
| `0x25b78` | `add x0, x0, #0x8f9` | `add x0, x0, #0x8fa` |
| `0x25c34` | `add x2, x2, #0x911` | `add x2, x2, #0x912` |
| `0x25c58` | `add x2, x2, #0x919` | `add x2, x2, #0x91a` |
| `0x25c98` | `add x0, x0, #0x927` | `add x0, x0, #0x928` |
### 4. Functional Differences
**None.** All code diffs are string pointer adjustments caused by the 1-byte
shift from `"release"` to `"research"`. The two variants are **functionally
identical** — same logic, same security policies, same code paths.
## Security-Relevant Strings
Both variants contain identical security-relevant strings:
| Offset | String |
|--------|--------|
| `0xd31` | `restricted execution mode` |
| `0x1919` | `debug-enabled` |
| `0x1a4e` | `darwinos-security-environment` |
| `0x1ad0` | `security-mode-change-enable` |
| `0x1b4b` | `amfi-only-platform-code` |
| `0x1bd6` | `research-enabled` |
| `0x1c4c` | `sec-research-device-erm-enabled` |
| `0x1cca` | `vmm-present` |
| `0x1d33` | `sepfw-load-at-boot` |
| `0x1de8` | `sepfw-never-boot` |
| `0x1e85` | `osenvironment` |
| `0x1ec4` | `device-recovery` |
| `0x1f81` | `TrustCache` |
| `0x202a` | `iboot-build-variant` |
| `0x20a9` | `development` |
| `0x23da` | `image4 dispatch` |
## Implications for Patching
1. **Either variant works** — the code is functionally identical.
2. **`fw_patch.py` uses the research variant** (`txm.iphoneos.research.im4p`)
because the `iboot-build-variant` device tree property in PCC VMs is set to
`"research"`, and TXM validates this matches its own embedded variant string.
3. **String-based patch anchors** that reference the build variant string
(`"release"` / `"research"`) will match at different offsets — patchers should
use variant-agnostic anchors (e.g., `mov w19, #0x2446` as in `txm.py`).
4. **The 3-byte IM4P size difference** (161025 vs 161028 bytes) comes from the
extra byte in `"research"` plus LZFSE compression variance.
5. **Both IPSWs ship the same TXM** — no need to prefer one source over the other.
## Conclusion
The TXM `release` and `research` variants are **cosmetically different but
functionally identical**. The only real difference is the embedded build variant
string (`"release"` vs `"research"`), which causes a 1-byte cascade in string
offsets and corresponding `ADD` immediate adjustments in code.
Both IPSWs (iPhone and cloudOS) ship the same pair of TXM binaries.
+115 -150
View File
@@ -35,141 +35,106 @@ SSH_USER="root"
SSH_HOST="localhost"
SSHPASS_BIN=""
SSH_OPTS=(
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o PreferredAuthentications=password
-o ConnectTimeout=30
-q
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o PreferredAuthentications=password
-o ConnectTimeout=30
-q
)
# ── Helpers ─────────────────────────────────────────────────────
die() { echo "[-] $*" >&2; exit 1; }
is_exec_compatible() {
local bin="$1"
local host_arch file_out archs
[[ -x "$bin" ]] || return 1
host_arch="$(uname -m)"
file_out="$(file "$bin" 2>/dev/null || true)"
# Non Mach-O executables (scripts/wrappers) are accepted.
if [[ "$file_out" != *"Mach-O"* ]]; then
return 0
fi
if command -v lipo >/dev/null 2>&1; then
archs="$(lipo -archs "$bin" 2>/dev/null || true)"
[[ -n "$archs" && " $archs " == *" $host_arch "* ]] && return 0
return 1
fi
[[ "$file_out" == *"$host_arch"* || "$file_out" == *"universal"* ]]
die() {
echo "[-] $*" >&2
exit 1
}
resolve_sshpass() {
local bundled="$VM_DIR/$CFW_INPUT/tools/sshpass"
local host_sshpass
host_sshpass="$(command -v sshpass 2>/dev/null || true)"
if is_exec_compatible "$bundled"; then
SSHPASS_BIN="$bundled"
echo "[+] Using bundled sshpass: $SSHPASS_BIN"
return
fi
if [[ -x "$bundled" ]]; then
echo "[!] Bundled sshpass is not compatible with host arch ($(uname -m)): $bundled"
fi
if [[ -n "$host_sshpass" ]] && is_exec_compatible "$host_sshpass"; then
SSHPASS_BIN="$host_sshpass"
echo "[+] Using host sshpass: $SSHPASS_BIN"
return
fi
[[ -n "$host_sshpass" ]] && \
echo "[!] Host sshpass is present but incompatible with host arch: $host_sshpass"
die "No compatible sshpass found. Install one with: brew install hudochenkov/sshpass/sshpass"
check_prerequisites() {
local missing=()
command -v sshpass &>/dev/null || missing+=("sshpass")
command -v ldid &>/dev/null || missing+=("ldid (brew install ldid-procursus)")
if ((${#missing[@]} > 0)); then
die "Missing required tools: ${missing[*]}. Run: make setup_tools"
fi
SSHPASS_BIN="$(command -v sshpass)"
}
_sshpass() {
"$SSHPASS_BIN" -p "$SSH_PASS" "$@"
"$SSHPASS_BIN" -p "$SSH_PASS" "$@"
}
ssh_cmd() {
_sshpass ssh "${SSH_OPTS[@]}" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "$@"
_sshpass ssh "${SSH_OPTS[@]}" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "$@"
}
scp_to() {
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" -r "$1" "$SSH_USER@$SSH_HOST:$2"
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" -r "$1" "$SSH_USER@$SSH_HOST:$2"
}
scp_from() {
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" "$SSH_USER@$SSH_HOST:$1" "$2"
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" "$SSH_USER@$SSH_HOST:$1" "$2"
}
remote_file_exists() {
ssh_cmd "test -f '$1'" 2>/dev/null
ssh_cmd "test -f '$1'" 2>/dev/null
}
ldid_sign() {
local file="$1" bundle_id="${2:-}"
local args=(-S -M "-K$VM_DIR/$CFW_INPUT/signcert.p12")
[[ -n "$bundle_id" ]] && args+=("-I$bundle_id")
"$VM_DIR/$CFW_INPUT/tools/ldid_macosx_arm64" "${args[@]}" "$file"
local file="$1" bundle_id="${2:-}"
local args=(-S -M "-K$VM_DIR/$CFW_INPUT/signcert.p12")
[[ -n "$bundle_id" ]] && args+=("-I$bundle_id")
ldid "${args[@]}" "$file"
}
# Detach a DMG mountpoint if currently mounted, ignore errors
safe_detach() {
local mnt="$1"
if mount | grep -q "$mnt"; then
sudo hdiutil detach -force "$mnt" 2>/dev/null || true
fi
local mnt="$1"
if mount | grep -q "$mnt"; then
sudo hdiutil detach -force "$mnt" 2>/dev/null || true
fi
}
# Mount device filesystem, tolerate already-mounted
remote_mount() {
local dev="$1" mnt="$2" opts="${3:-rw}"
ssh_cmd "/sbin/mount_apfs -o $opts $dev $mnt 2>/dev/null || true"
local dev="$1" mnt="$2" opts="${3:-rw}"
ssh_cmd "/sbin/mount_apfs -o $opts $dev $mnt 2>/dev/null || true"
}
# ── Find restore directory ─────────────────────────────────────
find_restore_dir() {
for dir in "$VM_DIR"/iPhone*_Restore; do
[[ -f "$dir/BuildManifest.plist" ]] && echo "$dir" && return
done
die "No restore directory found in $VM_DIR"
for dir in "$VM_DIR"/iPhone*_Restore; do
[[ -f "$dir/BuildManifest.plist" ]] && echo "$dir" && return
done
die "No restore directory found in $VM_DIR"
}
# ── Setup input resources ──────────────────────────────────────
setup_cfw_input() {
[[ -d "$VM_DIR/$CFW_INPUT" ]] && return
local archive
for search_dir in "$SCRIPT_DIR/resources" "$SCRIPT_DIR" "$VM_DIR"; do
archive="$search_dir/$CFW_ARCHIVE"
if [[ -f "$archive" ]]; then
echo " Extracting $CFW_ARCHIVE..."
tar --zstd -xf "$archive" -C "$VM_DIR"
return
fi
done
die "Neither $CFW_INPUT/ nor $CFW_ARCHIVE found"
[[ -d "$VM_DIR/$CFW_INPUT" ]] && return
local archive
for search_dir in "$SCRIPT_DIR/resources" "$SCRIPT_DIR" "$VM_DIR"; do
archive="$search_dir/$CFW_ARCHIVE"
if [[ -f "$archive" ]]; then
echo " Extracting $CFW_ARCHIVE..."
tar --zstd -xf "$archive" -C "$VM_DIR"
return
fi
done
die "Neither $CFW_INPUT/ nor $CFW_ARCHIVE found"
}
# ── Check prerequisites ────────────────────────────────────────
check_prereqs() {
command -v ipsw >/dev/null 2>&1 || die "'ipsw' not found. Install: brew install blacktop/tap/ipsw"
command -v aea >/dev/null 2>&1 || die "'aea' not found (requires macOS 12+)"
command -v python3 >/dev/null 2>&1 || die "python3 not found"
python3 -c "import capstone, keystone" 2>/dev/null \
|| die "Missing Python deps. Install: pip install capstone keystone-engine"
command -v ipsw >/dev/null 2>&1 || die "'ipsw' not found. Install: brew install blacktop/tap/ipsw"
command -v aea >/dev/null 2>&1 || die "'aea' not found (requires macOS 12+)"
command -v python3 >/dev/null 2>&1 || die "python3 not found"
python3 -c "import capstone, keystone" 2>/dev/null ||
die "Missing Python deps. Install: pip install capstone keystone-engine"
}
# ── Cleanup trap (unmount DMGs on error) ───────────────────────
cleanup_on_exit() {
safe_detach "$TEMP_DIR/mnt_sysos"
safe_detach "$TEMP_DIR/mnt_appos"
safe_detach "$TEMP_DIR/mnt_sysos"
safe_detach "$TEMP_DIR/mnt_appos"
}
trap cleanup_on_exit EXIT
@@ -186,7 +151,7 @@ echo "[+] Restore directory: $RESTORE_DIR"
setup_cfw_input
INPUT_DIR="$VM_DIR/$CFW_INPUT"
echo "[+] Input resources: $INPUT_DIR"
resolve_sshpass
check_prerequisites
mkdir -p "$TEMP_DIR"
@@ -210,20 +175,20 @@ MNT_APPOS="$TEMP_DIR/mnt_appos"
# Decrypt SystemOS AEA (cached — skip if already decrypted)
if [[ ! -f "$SYSOS_DMG" ]]; then
echo " Extracting AEA key..."
AEA_KEY=$(ipsw fw aea --key "$RESTORE_DIR/$CRYPTEX_SYSOS")
echo " key: $AEA_KEY"
echo " Decrypting SystemOS..."
aea decrypt -i "$RESTORE_DIR/$CRYPTEX_SYSOS" -o "$SYSOS_DMG" -key-value "$AEA_KEY"
echo " Extracting AEA key..."
AEA_KEY=$(ipsw fw aea --key "$RESTORE_DIR/$CRYPTEX_SYSOS")
echo " key: $AEA_KEY"
echo " Decrypting SystemOS..."
aea decrypt -i "$RESTORE_DIR/$CRYPTEX_SYSOS" -o "$SYSOS_DMG" -key-value "$AEA_KEY"
else
echo " Using cached SystemOS DMG"
echo " Using cached SystemOS DMG"
fi
# Copy AppOS (unencrypted, cached)
if [[ ! -f "$APPOS_DMG" ]]; then
cp "$RESTORE_DIR/$CRYPTEX_APPOS" "$APPOS_DMG"
cp "$RESTORE_DIR/$CRYPTEX_APPOS" "$APPOS_DMG"
else
echo " Using cached AppOS DMG"
echo " Using cached AppOS DMG"
fi
# Detach any leftover mounts from previous runs
@@ -244,23 +209,23 @@ remote_mount /dev/disk1s1 /mnt1
echo " Checking APFS snapshots..."
SNAP_LIST=$(ssh_cmd "snaputil -l /mnt1 2>/dev/null" || true)
if echo "$SNAP_LIST" | grep -q "^orig-fs$"; then
echo " Snapshot 'orig-fs' already exists, skipping rename"
echo " Snapshot 'orig-fs' already exists, skipping rename"
else
UPDATE_SNAP=$(echo "$SNAP_LIST" | grep "^com\.apple\.os\.update-" | head -1)
if [[ -n "$UPDATE_SNAP" ]]; then
echo " Renaming snapshot: $UPDATE_SNAP -> orig-fs"
ssh_cmd "snaputil -n '$UPDATE_SNAP' orig-fs /mnt1"
# Verify rename succeeded
if ! ssh_cmd "snaputil -l /mnt1 2>/dev/null" | grep -q "^orig-fs$"; then
die "Failed to rename snapshot to orig-fs"
fi
echo " Snapshot renamed, remounting..."
ssh_cmd "/sbin/umount /mnt1"
remote_mount /dev/disk1s1 /mnt1
echo " [+] Snapshot renamed to orig-fs"
else
echo " No com.apple.os.update- snapshot found, skipping"
UPDATE_SNAP=$(echo "$SNAP_LIST" | grep "^com\.apple\.os\.update-" | head -1)
if [[ -n "$UPDATE_SNAP" ]]; then
echo " Renaming snapshot: $UPDATE_SNAP -> orig-fs"
ssh_cmd "snaputil -n '$UPDATE_SNAP' orig-fs /mnt1"
# Verify rename succeeded
if ! ssh_cmd "snaputil -l /mnt1 2>/dev/null" | grep -q "^orig-fs$"; then
die "Failed to rename snapshot to orig-fs"
fi
echo " Snapshot renamed, remounting..."
ssh_cmd "/sbin/umount /mnt1"
remote_mount /dev/disk1s1 /mnt1
echo " [+] Snapshot renamed to orig-fs"
else
echo " No com.apple.os.update- snapshot found, skipping"
fi
fi
ssh_cmd "/bin/rm -rf /mnt1/System/Cryptexes/App /mnt1/System/Cryptexes/OS"
@@ -292,8 +257,8 @@ echo "[2/7] Patching seputil..."
# Always patch from .bak (original unpatched binary)
if ! remote_file_exists "/mnt1/usr/libexec/seputil.bak"; then
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/usr/libexec/seputil /mnt1/usr/libexec/seputil.bak"
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/usr/libexec/seputil /mnt1/usr/libexec/seputil.bak"
fi
scp_from "/mnt1/usr/libexec/seputil.bak" "$TEMP_DIR/seputil"
@@ -348,8 +313,8 @@ echo "[5/7] Patching launchd_cache_loader..."
# Always patch from .bak (original unpatched binary)
if ! remote_file_exists "/mnt1/usr/libexec/launchd_cache_loader.bak"; then
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/usr/libexec/launchd_cache_loader /mnt1/usr/libexec/launchd_cache_loader.bak"
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/usr/libexec/launchd_cache_loader /mnt1/usr/libexec/launchd_cache_loader.bak"
fi
scp_from "/mnt1/usr/libexec/launchd_cache_loader.bak" "$TEMP_DIR/launchd_cache_loader"
@@ -366,8 +331,8 @@ echo "[6/7] Patching mobileactivationd..."
# Always patch from .bak (original unpatched binary)
if ! remote_file_exists "/mnt1/usr/libexec/mobileactivationd.bak"; then
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/usr/libexec/mobileactivationd /mnt1/usr/libexec/mobileactivationd.bak"
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/usr/libexec/mobileactivationd /mnt1/usr/libexec/mobileactivationd.bak"
fi
scp_from "/mnt1/usr/libexec/mobileactivationd.bak" "$TEMP_DIR/mobileactivationd"
@@ -386,36 +351,36 @@ echo "[7/7] Installing LaunchDaemons..."
VPHONED_SRC="$SCRIPT_DIR/vphoned"
VPHONED_BIN="$VPHONED_SRC/vphoned"
VPHONED_SRCS=(
"$VPHONED_SRC/vphoned.m"
"$VPHONED_SRC/vphoned_protocol.m"
"$VPHONED_SRC/vphoned_hid.m"
"$VPHONED_SRC/vphoned_devmode.m"
"$VPHONED_SRC/vphoned_location.m"
"$VPHONED_SRC/vphoned_files.m"
"$VPHONED_SRC/vphoned.m"
"$VPHONED_SRC/vphoned_protocol.m"
"$VPHONED_SRC/vphoned_hid.m"
"$VPHONED_SRC/vphoned_devmode.m"
"$VPHONED_SRC/vphoned_location.m"
"$VPHONED_SRC/vphoned_files.m"
)
needs_vphoned_build=0
if [[ ! -f "$VPHONED_BIN" ]]; then
needs_vphoned_build=1
needs_vphoned_build=1
else
for src in "${VPHONED_SRCS[@]}"; do
if [[ "$src" -nt "$VPHONED_BIN" ]]; then
needs_vphoned_build=1
break
fi
done
for src in "${VPHONED_SRCS[@]}"; do
if [[ "$src" -nt "$VPHONED_BIN" ]]; then
needs_vphoned_build=1
break
fi
done
fi
if [[ "$needs_vphoned_build" == "1" ]]; then
echo " Building vphoned for arm64..."
xcrun -sdk iphoneos clang -arch arm64 -Os -fobjc-arc \
-I"$VPHONED_SRC" \
-o "$VPHONED_BIN" "${VPHONED_SRCS[@]}" \
-framework Foundation
echo " Building vphoned for arm64..."
xcrun -sdk iphoneos clang -arch arm64 -Os -fobjc-arc \
-I"$VPHONED_SRC" \
-o "$VPHONED_BIN" "${VPHONED_SRCS[@]}" \
-framework Foundation
fi
cp "$VPHONED_BIN" "$TEMP_DIR/vphoned"
"$VM_DIR/$CFW_INPUT/tools/ldid_macosx_arm64" \
-S"$VPHONED_SRC/entitlements.plist" \
-M "-K$VM_DIR/$CFW_INPUT/signcert.p12" \
"$TEMP_DIR/vphoned"
ldid \
-S"$VPHONED_SRC/entitlements.plist" \
-M "-K$VM_DIR/$CFW_INPUT/signcert.p12" \
"$TEMP_DIR/vphoned"
scp_to "$TEMP_DIR/vphoned" "/mnt1/usr/bin/vphoned"
ssh_cmd "/bin/chmod 0755 /mnt1/usr/bin/vphoned"
# Keep a copy of the signed binary for host-side auto-update
@@ -424,8 +389,8 @@ echo " [+] vphoned installed (signed copy at .vphoned.signed)"
# Send daemon plists (overwrite on re-run)
for plist in bash.plist dropbear.plist trollvnc.plist rpcserver_ios.plist; do
scp_to "$INPUT_DIR/jb/LaunchDaemons/$plist" "/mnt1/System/Library/LaunchDaemons/"
ssh_cmd "/bin/chmod 0644 /mnt1/System/Library/LaunchDaemons/$plist"
scp_to "$INPUT_DIR/jb/LaunchDaemons/$plist" "/mnt1/System/Library/LaunchDaemons/"
ssh_cmd "/bin/chmod 0644 /mnt1/System/Library/LaunchDaemons/$plist"
done
scp_to "$VPHONED_SRC/vphoned.plist" "/mnt1/System/Library/LaunchDaemons/"
ssh_cmd "/bin/chmod 0644 /mnt1/System/Library/LaunchDaemons/vphoned.plist"
@@ -433,8 +398,8 @@ ssh_cmd "/bin/chmod 0644 /mnt1/System/Library/LaunchDaemons/vphoned.plist"
# Always patch launchd.plist from .bak (original)
echo " Patching launchd.plist..."
if ! remote_file_exists "/mnt1/System/Library/xpc/launchd.plist.bak"; then
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/System/Library/xpc/launchd.plist /mnt1/System/Library/xpc/launchd.plist.bak"
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/System/Library/xpc/launchd.plist /mnt1/System/Library/xpc/launchd.plist.bak"
fi
scp_from "/mnt1/System/Library/xpc/launchd.plist.bak" "$TEMP_DIR/launchd.plist"
@@ -455,10 +420,10 @@ ssh_cmd "/sbin/umount /mnt3 2>/dev/null || true"
# Only remove temp binaries
echo "[*] Cleaning up temp binaries..."
rm -f "$TEMP_DIR/seputil" \
"$TEMP_DIR/launchd_cache_loader" \
"$TEMP_DIR/mobileactivationd" \
"$TEMP_DIR/vphoned" \
"$TEMP_DIR/launchd.plist"
"$TEMP_DIR/launchd_cache_loader" \
"$TEMP_DIR/mobileactivationd" \
"$TEMP_DIR/vphoned" \
"$TEMP_DIR/launchd.plist"
echo ""
echo "[+] CFW installation complete!"
@@ -466,7 +431,7 @@ echo " Reboot the device for changes to take effect."
echo " After boot, SSH will be available on port 22222 (password: alpine)"
if [[ "$CFW_SKIP_HALT" == "1" ]]; then
echo "[*] CFW_SKIP_HALT=1, skipping halt."
echo "[*] CFW_SKIP_HALT=1, skipping halt."
else
ssh_cmd "/sbin/halt" || true
ssh_cmd "/sbin/halt" || true
fi
+60 -95
View File
@@ -41,113 +41,78 @@ SSH_USER="root"
SSH_HOST="localhost"
SSHPASS_BIN=""
SSH_OPTS=(
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o PreferredAuthentications=password
-o ConnectTimeout=30
-q
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o PreferredAuthentications=password
-o ConnectTimeout=30
-q
)
# ── Helpers ─────────────────────────────────────────────────────
die() { echo "[-] $*" >&2; exit 1; }
is_exec_compatible() {
local bin="$1"
local host_arch file_out archs
[[ -x "$bin" ]] || return 1
host_arch="$(uname -m)"
file_out="$(file "$bin" 2>/dev/null || true)"
# Non Mach-O executables (scripts/wrappers) are accepted.
if [[ "$file_out" != *"Mach-O"* ]]; then
return 0
fi
if command -v lipo >/dev/null 2>&1; then
archs="$(lipo -archs "$bin" 2>/dev/null || true)"
[[ -n "$archs" && " $archs " == *" $host_arch "* ]] && return 0
return 1
fi
[[ "$file_out" == *"$host_arch"* || "$file_out" == *"universal"* ]]
die() {
echo "[-] $*" >&2
exit 1
}
resolve_sshpass() {
local bundled="$VM_DIR/$CFW_INPUT/tools/sshpass"
local host_sshpass
host_sshpass="$(command -v sshpass 2>/dev/null || true)"
if is_exec_compatible "$bundled"; then
SSHPASS_BIN="$bundled"
echo "[+] Using bundled sshpass: $SSHPASS_BIN"
return
fi
if [[ -x "$bundled" ]]; then
echo "[!] Bundled sshpass is not compatible with host arch ($(uname -m)): $bundled"
fi
if [[ -n "$host_sshpass" ]] && is_exec_compatible "$host_sshpass"; then
SSHPASS_BIN="$host_sshpass"
echo "[+] Using host sshpass: $SSHPASS_BIN"
return
fi
[[ -n "$host_sshpass" ]] && \
echo "[!] Host sshpass is present but incompatible with host arch: $host_sshpass"
die "No compatible sshpass found. Install one with: brew install hudochenkov/sshpass/sshpass"
check_prerequisites() {
local missing=()
command -v sshpass &>/dev/null || missing+=("sshpass")
command -v ldid &>/dev/null || missing+=("ldid (brew install ldid-procursus)")
if ((${#missing[@]} > 0)); then
die "Missing required tools: ${missing[*]}. Run: make setup_tools"
fi
SSHPASS_BIN="$(command -v sshpass)"
}
_sshpass() {
"$SSHPASS_BIN" -p "$SSH_PASS" "$@"
"$SSHPASS_BIN" -p "$SSH_PASS" "$@"
}
ssh_cmd() {
_sshpass ssh "${SSH_OPTS[@]}" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "$@"
_sshpass ssh "${SSH_OPTS[@]}" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "$@"
}
scp_to() {
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" -r "$1" "$SSH_USER@$SSH_HOST:$2"
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" -r "$1" "$SSH_USER@$SSH_HOST:$2"
}
scp_from() {
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" "$SSH_USER@$SSH_HOST:$1" "$2"
_sshpass scp -q "${SSH_OPTS[@]}" -P "$SSH_PORT" "$SSH_USER@$SSH_HOST:$1" "$2"
}
remote_file_exists() {
ssh_cmd "test -f '$1'" 2>/dev/null
ssh_cmd "test -f '$1'" 2>/dev/null
}
ldid_sign() {
local file="$1" bundle_id="${2:-}"
local args=(-S -M "-K$VM_DIR/$CFW_INPUT/signcert.p12")
[[ -n "$bundle_id" ]] && args+=("-I$bundle_id")
"$VM_DIR/$CFW_INPUT/tools/ldid_macosx_arm64" "${args[@]}" "$file"
local file="$1" bundle_id="${2:-}"
local args=(-S -M "-K$VM_DIR/$CFW_INPUT/signcert.p12")
[[ -n "$bundle_id" ]] && args+=("-I$bundle_id")
ldid "${args[@]}" "$file"
}
remote_mount() {
local dev="$1" mnt="$2" opts="${3:-rw}"
ssh_cmd "/sbin/mount_apfs -o $opts $dev $mnt 2>/dev/null || true"
local dev="$1" mnt="$2" opts="${3:-rw}"
ssh_cmd "/sbin/mount_apfs -o $opts $dev $mnt 2>/dev/null || true"
}
get_boot_manifest_hash() {
ssh_cmd "/bin/ls /mnt5 2>/dev/null" | awk 'length($0)==96{print; exit}'
ssh_cmd "/bin/ls /mnt5 2>/dev/null" | awk 'length($0)==96{print; exit}'
}
# ── Setup JB input resources ──────────────────────────────────
setup_cfw_jb_input() {
[[ -d "$VM_DIR/$CFW_JB_INPUT" ]] && return
local archive
for search_dir in "$SCRIPT_DIR/resources" "$SCRIPT_DIR" "$VM_DIR"; do
archive="$search_dir/$CFW_JB_ARCHIVE"
if [[ -f "$archive" ]]; then
echo " Extracting $CFW_JB_ARCHIVE..."
tar --zstd -xf "$archive" -C "$VM_DIR"
return
fi
done
die "JB mode: neither $CFW_JB_INPUT/ nor $CFW_JB_ARCHIVE found"
[[ -d "$VM_DIR/$CFW_JB_INPUT" ]] && return
local archive
for search_dir in "$SCRIPT_DIR/resources" "$SCRIPT_DIR" "$VM_DIR"; do
archive="$search_dir/$CFW_JB_ARCHIVE"
if [[ -f "$archive" ]]; then
echo " Extracting $CFW_JB_ARCHIVE..."
tar --zstd -xf "$archive" -C "$VM_DIR"
return
fi
done
die "JB mode: neither $CFW_JB_INPUT/ nor $CFW_JB_ARCHIVE found"
}
# ── Check JB prerequisites ────────────────────────────────────
@@ -157,7 +122,7 @@ setup_cfw_jb_input
JB_INPUT_DIR="$VM_DIR/$CFW_JB_INPUT"
echo ""
echo "[+] JB input resources: $JB_INPUT_DIR"
resolve_sshpass
check_prerequisites
mkdir -p "$TEMP_DIR"
@@ -169,16 +134,16 @@ echo ""
echo "[JB-1] Patching launchd (jetsam guard + hook injection)..."
if ! remote_file_exists "/mnt1/sbin/launchd.bak"; then
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/sbin/launchd /mnt1/sbin/launchd.bak"
echo " Creating backup..."
ssh_cmd "/bin/cp /mnt1/sbin/launchd /mnt1/sbin/launchd.bak"
fi
scp_from "/mnt1/sbin/launchd.bak" "$TEMP_DIR/launchd"
# Inject launchdhook.dylib load command (idempotent — skips if already present)
if [[ -d "$JB_INPUT_DIR/basebin" ]]; then
echo " Injecting LC_LOAD_DYLIB for /cores/launchdhook.dylib..."
python3 "$SCRIPT_DIR/patchers/cfw.py" inject-dylib "$TEMP_DIR/launchd" "/cores/launchdhook.dylib"
echo " Injecting LC_LOAD_DYLIB for /cores/launchdhook.dylib..."
python3 "$SCRIPT_DIR/patchers/cfw.py" inject-dylib "$TEMP_DIR/launchd" "/cores/launchdhook.dylib"
fi
python3 "$SCRIPT_DIR/patchers/cfw.py" patch-launchd-jetsam "$TEMP_DIR/launchd"
@@ -206,7 +171,7 @@ zstd -d -f "$BOOTSTRAP_ZST" -o "$BOOTSTRAP_TAR"
scp_to "$BOOTSTRAP_TAR" "/mnt5/$BOOT_HASH/bootstrap-iphoneos-arm64.tar"
if [[ -f "$SILEO_DEB" ]]; then
scp_to "$SILEO_DEB" "/mnt5/$BOOT_HASH/org.coolstar.sileo_2.5.1_iphoneos-arm64.deb"
scp_to "$SILEO_DEB" "/mnt5/$BOOT_HASH/org.coolstar.sileo_2.5.1_iphoneos-arm64.deb"
fi
ssh_cmd "/bin/mkdir -p /mnt5/$BOOT_HASH/jb-vphone"
@@ -226,23 +191,23 @@ echo " [+] procursus bootstrap installed"
# ═══════════ JB-3 DEPLOY BASEBIN HOOKS ═════════════════════════
BASEBIN_DIR="$JB_INPUT_DIR/basebin"
if [[ -d "$BASEBIN_DIR" ]]; then
echo ""
echo "[JB-3] Deploying BaseBin hooks to /cores/..."
echo ""
echo "[JB-3] Deploying BaseBin hooks to /cores/..."
ssh_cmd "/bin/mkdir -p /mnt1/cores"
ssh_cmd "/bin/chmod 0755 /mnt1/cores"
ssh_cmd "/bin/mkdir -p /mnt1/cores"
ssh_cmd "/bin/chmod 0755 /mnt1/cores"
for dylib in "$BASEBIN_DIR"/*.dylib; do
[[ -f "$dylib" ]] || continue
dylib_name="$(basename "$dylib")"
echo " Installing $dylib_name..."
# Re-sign with our certificate before deploying
ldid_sign "$dylib"
scp_to "$dylib" "/mnt1/cores/$dylib_name"
ssh_cmd "/bin/chmod 0755 /mnt1/cores/$dylib_name"
done
for dylib in "$BASEBIN_DIR"/*.dylib; do
[[ -f "$dylib" ]] || continue
dylib_name="$(basename "$dylib")"
echo " Installing $dylib_name..."
# Re-sign with our certificate before deploying
ldid_sign "$dylib"
scp_to "$dylib" "/mnt1/cores/$dylib_name"
ssh_cmd "/bin/chmod 0755 /mnt1/cores/$dylib_name"
done
echo " [+] BaseBin hooks deployed"
echo " [+] BaseBin hooks deployed"
fi
# ═══════════ CLEANUP ═════════════════════════════════════════
@@ -254,7 +219,7 @@ ssh_cmd "/sbin/umount /mnt5 2>/dev/null || true"
echo "[*] Cleaning up temp binaries..."
rm -f "$TEMP_DIR/launchd" \
"$TEMP_DIR/bootstrap-iphoneos-arm64.tar"
"$TEMP_DIR/bootstrap-iphoneos-arm64.tar"
echo ""
echo "[+] CFW + JB installation complete!"
+52 -37
View File
@@ -20,6 +20,7 @@ import copy, os, plistlib, sys
# Helpers
# ---------------------------------------------------------------------------
def load(path):
with open(path, "rb") as f:
return plistlib.load(f)
@@ -34,6 +35,7 @@ def entry(identities, idx, key):
# Identity discovery
# ---------------------------------------------------------------------------
def _is_research(bi):
"""Determine whether a build identity is a research variant."""
for comp in ("LLB", "iBSS", "iBEC"):
@@ -80,18 +82,18 @@ def find_iphone_erase(identities):
# Main
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <iphone_dir> <cloudos_dir>",
file=sys.stderr)
print(f"Usage: {sys.argv[0]} <iphone_dir> <cloudos_dir>", file=sys.stderr)
sys.exit(1)
iphone_dir, cloudos_dir = sys.argv[1], sys.argv[2]
cloudos_bm = load(os.path.join(cloudos_dir, "BuildManifest.plist"))
iphone_bm = load(os.path.join(iphone_dir, "BuildManifest.plist"))
iphone_bm = load(os.path.join(iphone_dir, "BuildManifest.plist"))
cloudos_rp = load(os.path.join(cloudos_dir, "Restore.plist"))
iphone_rp = load(os.path.join(iphone_dir, "Restore.plist"))
iphone_rp = load(os.path.join(iphone_dir, "Restore.plist"))
C = cloudos_bm["BuildIdentities"]
I = iphone_bm["BuildIdentities"]
@@ -100,8 +102,8 @@ def main():
# PROD / RES = vresearch101ap release / research (boot chain)
# VP / VPR = vphone600ap release / research (runtime)
PROD, RES = find_cloudos(C, "vresearch101ap")
VP, VPR = find_cloudos(C, "vphone600ap")
I_ERASE = find_iphone_erase(I)
VP, VPR = find_cloudos(C, "vphone600ap")
I_ERASE = find_iphone_erase(I)
print(f" cloudOS vresearch101ap: release=#{PROD}, research=#{RES}")
print(f" cloudOS vphone600ap: release=#{VP}, research=#{VPR}")
@@ -112,11 +114,11 @@ def main():
# (BDID 0x90) for TSS/SHSH signing.
bi = copy.deepcopy(C[PROD])
bi["Manifest"] = {}
bi["Ap,ProductType"] = "ComputeModule14,2"
bi["Ap,Target"] = "VRESEARCH101AP"
bi["Ap,TargetType"] = "vresearch101"
bi["ApBoardID"] = "0x90"
bi["ApChipID"] = "0xFE01"
bi["Ap,ProductType"] = "ComputeModule14,2"
bi["Ap,Target"] = "VRESEARCH101AP"
bi["Ap,TargetType"] = "vresearch101"
bi["ApBoardID"] = "0x90"
bi["ApChipID"] = "0xFE01"
bi["ApSecurityDomain"] = "0x01"
for k in ("NeRDEpoch", "RestoreAttestationMode"):
bi.pop(k, None)
@@ -143,62 +145,72 @@ def main():
m = bi["Manifest"]
# ── Boot chain (vresearch101 — matches DFU hardware) ─────────────
m["LLB"] = entry(C, PROD, "LLB")
m["LLB"] = entry(C, PROD, "LLB")
m["iBSS"] = entry(C, PROD, "iBSS")
m["iBEC"] = entry(C, PROD, "iBEC")
m["iBoot"] = entry(C, RES, "iBoot") # research iBoot
m["iBoot"] = entry(C, RES, "iBoot") # research iBoot
# ── Security monitors (shared across board configs) ──────────────
m["Ap,RestoreSecurePageTableMonitor"] = entry(C, PROD, "Ap,RestoreSecurePageTableMonitor")
m["Ap,RestoreTrustedExecutionMonitor"] = entry(C, PROD, "Ap,RestoreTrustedExecutionMonitor")
m["Ap,SecurePageTableMonitor"] = entry(C, PROD, "Ap,SecurePageTableMonitor")
m["Ap,TrustedExecutionMonitor"] = entry(C, RES, "Ap,TrustedExecutionMonitor")
m["Ap,RestoreSecurePageTableMonitor"] = entry(
C, PROD, "Ap,RestoreSecurePageTableMonitor"
)
m["Ap,RestoreTrustedExecutionMonitor"] = entry(
C, PROD, "Ap,RestoreTrustedExecutionMonitor"
)
m["Ap,SecurePageTableMonitor"] = entry(C, PROD, "Ap,SecurePageTableMonitor")
m["Ap,TrustedExecutionMonitor"] = entry(C, RES, "Ap,TrustedExecutionMonitor")
# ── Device tree (vphone600ap — sets MKB dt=1 for keybag-less boot)
m["DeviceTree"] = entry(C, VP, "DeviceTree")
m["DeviceTree"] = entry(C, VP, "DeviceTree")
m["RestoreDeviceTree"] = entry(C, VP, "RestoreDeviceTree")
# ── SEP (vphone600 — matches device tree) ────────────────────────
m["SEP"] = entry(C, VP, "SEP")
m["SEP"] = entry(C, VP, "SEP")
m["RestoreSEP"] = entry(C, VP, "RestoreSEP")
# ── Kernel (vphone600, patched by fw_patch.py) ────────────────────
m["KernelCache"] = entry(C, VPR, "KernelCache") # research
m["RestoreKernelCache"] = entry(C, VP, "RestoreKernelCache") # release
m["KernelCache"] = entry(C, VPR, "KernelCache") # research
m["RestoreKernelCache"] = entry(C, VP, "RestoreKernelCache") # release
# ── Recovery mode (vphone600ap carries this entry) ────────────────
m["RecoveryMode"] = entry(C, VP, "RecoveryMode")
# ── CloudOS erase ramdisk ────────────────────────────────────────
m["RestoreRamDisk"] = entry(C, PROD, "RestoreRamDisk")
m["RestoreRamDisk"] = entry(C, PROD, "RestoreRamDisk")
m["RestoreTrustCache"] = entry(C, PROD, "RestoreTrustCache")
# ── iPhone OS image ──────────────────────────────────────────────
m["Ap,SystemVolumeCanonicalMetadata"] = entry(I, I_ERASE, "Ap,SystemVolumeCanonicalMetadata")
m["OS"] = entry(I, I_ERASE, "OS")
m["Ap,SystemVolumeCanonicalMetadata"] = entry(
I, I_ERASE, "Ap,SystemVolumeCanonicalMetadata"
)
m["OS"] = entry(I, I_ERASE, "OS")
m["StaticTrustCache"] = entry(I, I_ERASE, "StaticTrustCache")
m["SystemVolume"] = entry(I, I_ERASE, "SystemVolume")
m["SystemVolume"] = entry(I, I_ERASE, "SystemVolume")
# ── Assemble BuildManifest ───────────────────────────────────────
build_manifest = {
"BuildIdentities": [bi],
"ManifestVersion": cloudos_bm["ManifestVersion"],
"ProductBuildVersion": cloudos_bm["ProductBuildVersion"],
"ProductVersion": cloudos_bm["ProductVersion"],
"BuildIdentities": [bi],
"ManifestVersion": cloudos_bm["ManifestVersion"],
"ProductBuildVersion": cloudos_bm["ProductBuildVersion"],
"ProductVersion": cloudos_bm["ProductVersion"],
"SupportedProductTypes": ["iPhone99,11"],
}
# ── Assemble Restore.plist ───────────────────────────────────────
restore = {
"ProductBuildVersion": cloudos_rp["ProductBuildVersion"],
"ProductVersion": cloudos_rp["ProductVersion"],
"DeviceMap": [iphone_rp["DeviceMap"][0]] + [
d for d in cloudos_rp["DeviceMap"]
"ProductVersion": cloudos_rp["ProductVersion"],
"DeviceMap": [iphone_rp["DeviceMap"][0]]
+ [
d
for d in cloudos_rp["DeviceMap"]
if d["BoardConfig"] in ("vphone600ap", "vresearch101ap")
],
"SupportedProductTypeIDs": {
cat: (iphone_rp["SupportedProductTypeIDs"][cat]
+ cloudos_rp["SupportedProductTypeIDs"][cat])
cat: (
iphone_rp["SupportedProductTypeIDs"][cat]
+ cloudos_rp["SupportedProductTypeIDs"][cat]
)
for cat in ("DFU", "Recovery")
},
"SupportedProductTypes": (
@@ -206,12 +218,15 @@ def main():
+ cloudos_rp.get("SupportedProductTypes", [])
),
"SystemRestoreImageFileSystems": copy.deepcopy(
iphone_rp["SystemRestoreImageFileSystems"]),
iphone_rp["SystemRestoreImageFileSystems"]
),
}
# ── Write output ─────────────────────────────────────────────────
for name, data in [("BuildManifest.plist", build_manifest),
("Restore.plist", restore)]:
for name, data in [
("BuildManifest.plist", build_manifest),
("Restore.plist", restore),
]:
path = os.path.join(iphone_dir, name)
with open(path, "wb") as f:
plistlib.dump(data, f, sort_keys=True)
+36 -14
View File
@@ -56,6 +56,7 @@ RET_MNEMONICS = {"ret", "retaa", "retab"}
# IM4P / raw file helpers — auto-detect format
# ══════════════════════════════════════════════════════════════════
def load_firmware(path):
"""Load firmware file, auto-detecting IM4P vs raw.
@@ -93,18 +94,30 @@ def save_firmware(path, im4p_obj, patched_data, was_im4p, original_raw=None):
def _save_im4p_with_payp(path, fourcc, patched_data, original_raw):
"""Repackage as lzfse-compressed IM4P and append PAYP from original."""
with tempfile.NamedTemporaryFile(suffix=".raw", delete=False) as tmp_raw, \
tempfile.NamedTemporaryFile(suffix=".im4p", delete=False) as tmp_im4p:
with (
tempfile.NamedTemporaryFile(suffix=".raw", delete=False) as tmp_raw,
tempfile.NamedTemporaryFile(suffix=".im4p", delete=False) as tmp_im4p,
):
tmp_raw_path = tmp_raw.name
tmp_im4p_path = tmp_im4p.name
tmp_raw.write(bytes(patched_data))
try:
subprocess.run(
["pyimg4", "im4p", "create",
"-i", tmp_raw_path, "-o", tmp_im4p_path,
"-f", fourcc, "--lzfse"],
check=True, capture_output=True,
[
"pyimg4",
"im4p",
"create",
"-i",
tmp_raw_path,
"-o",
tmp_im4p_path,
"-f",
fourcc,
"--lzfse",
],
check=True,
capture_output=True,
)
output = bytearray(open(tmp_im4p_path, "rb").read())
finally:
@@ -113,7 +126,7 @@ def _save_im4p_with_payp(path, fourcc, patched_data, original_raw):
payp_offset = original_raw.rfind(b"PAYP")
if payp_offset >= 0:
payp_data = original_raw[payp_offset - 10:]
payp_data = original_raw[payp_offset - 10 :]
output.extend(payp_data)
old_len = int.from_bytes(output[2:5], "big")
output[2:5] = (old_len + len(payp_data)).to_bytes(3, "big")
@@ -174,7 +187,7 @@ def patch_avpbooter(data):
target = insns[x0_idx]
file_off = target.address
data[file_off:file_off + 4] = MOV_X0_0
data[file_off : file_off + 4] = MOV_X0_0
print(f" 0x{file_off:X}: {target.mnemonic} {target.op_str} -> mov x0, #0")
return True
@@ -182,22 +195,23 @@ def patch_avpbooter(data):
# ── 24. iBSS / iBEC / LLB ───────────────────────────────────────
# Fully dynamic via IBootPatcher — no hardcoded offsets.
def patch_ibss(data):
p = IBootPatcher(data, mode='ibss', label="Loaded iBSS")
p = IBootPatcher(data, mode="ibss", label="Loaded iBSS")
n = p.apply()
print(f" [+] {n} iBSS patches applied dynamically")
return n > 0
def patch_ibec(data):
p = IBootPatcher(data, mode='ibec', label="Loaded iBEC")
p = IBootPatcher(data, mode="ibec", label="Loaded iBEC")
n = p.apply()
print(f" [+] {n} iBEC patches applied dynamically")
return n > 0
def patch_llb(data):
p = IBootPatcher(data, mode='llb', label="Loaded LLB")
p = IBootPatcher(data, mode="llb", label="Loaded LLB")
n = p.apply()
print(f" [+] {n} LLB patches applied dynamically")
return n > 0
@@ -206,6 +220,7 @@ def patch_llb(data):
# ── 5. TXM ───────────────────────────────────────────────────────
# Fully dynamic via TXMPatcher — no hardcoded offsets.
def patch_txm(data):
p = TXMPatcher(data)
n = p.apply()
@@ -216,6 +231,7 @@ def patch_txm(data):
# ── 6. Kernelcache ───────────────────────────────────────────────
# Fully dynamic via KernelPatcher — no hardcoded offsets.
def patch_kernelcache(data):
kp = KernelPatcher(data)
n = kp.apply()
@@ -227,6 +243,7 @@ def patch_kernelcache(data):
# File discovery
# ══════════════════════════════════════════════════════════════════
def find_restore_dir(base_dir):
for entry in sorted(os.listdir(base_dir)):
full = os.path.join(base_dir, entry)
@@ -255,7 +272,13 @@ COMPONENTS = [
("AVPBooter", False, ["AVPBooter*.bin"], patch_avpbooter, False),
("iBSS", True, ["Firmware/dfu/iBSS.vresearch101.RELEASE.im4p"], patch_ibss, False),
("iBEC", True, ["Firmware/dfu/iBEC.vresearch101.RELEASE.im4p"], patch_ibec, False),
("LLB", True, ["Firmware/all_flash/LLB.vresearch101.RELEASE.im4p"], patch_llb, False),
(
"LLB",
True,
["Firmware/all_flash/LLB.vresearch101.RELEASE.im4p"],
patch_llb,
False,
),
("TXM", True, ["Firmware/txm.iphoneos.research.im4p"], patch_txm, True),
("kernelcache", True, ["kernelcache.research.vphone600"], patch_kernelcache, True),
]
@@ -277,8 +300,7 @@ def patch_component(path, patch_fn, name, preserve_payp):
print(f" [-] FAILED: {name}")
sys.exit(1)
save_firmware(path, im4p, data, was_im4p,
original_raw if preserve_payp else None)
save_firmware(path, im4p, data, was_im4p, original_raw if preserve_payp else None)
print(f" [+] saved ({fmt})")
+19 -10
View File
@@ -19,17 +19,25 @@ from fw_patch import (
load_firmware,
save_firmware,
)
from patchers.iboot_jb import IBootJBPatcher
from patchers.kernel_jb import KernelJBPatcher
from patchers.txm_jb import TXMJBPatcher
def patch_kernelcache_jb(data):
kp = KernelJBPatcher(data, verbose=True)
kp = KernelJBPatcher(data)
n = kp.apply()
print(f" [+] {n} kernel JB patches applied dynamically")
return n > 0
def patch_ibss_jb(data):
p = IBootJBPatcher(data, mode="ibss", label="Loaded iBSS")
n = p.apply()
print(f" [+] {n} iBSS JB patches applied dynamically")
return n > 0
def patch_txm_jb(data):
p = TXMJBPatcher(data, verbose=True)
n = p.apply()
@@ -39,13 +47,15 @@ def patch_txm_jb(data):
COMPONENTS = [
# (name, search_base_is_restore, search_patterns, patch_function, preserve_payp)
# NOTE: iBSS nonce skip removed — nonce is not required for boot.
("TXM (JB)", True,
["Firmware/txm.iphoneos.research.im4p"],
patch_txm_jb, True),
("kernelcache (JB)", True,
["kernelcache.research.vphone600"],
patch_kernelcache_jb, True),
("iBSS (JB)", True, ["Firmware/dfu/iBSS.vresearch101.RELEASE.im4p"], patch_ibss_jb, False),
("TXM (JB)", True, ["Firmware/txm.iphoneos.research.im4p"], patch_txm_jb, True),
(
"kernelcache (JB)",
True,
["kernelcache.research.vphone600"],
patch_kernelcache_jb,
True,
),
]
@@ -63,8 +73,7 @@ def patch_component(path, patch_fn, name, preserve_payp):
print(f" [-] FAILED: {name}")
sys.exit(1)
save_firmware(path, im4p, data, was_im4p,
original_raw if preserve_payp else None)
save_firmware(path, im4p, data, was_im4p, original_raw if preserve_payp else None)
print(f" [+] saved ({fmt})")
+22 -10
View File
@@ -60,21 +60,33 @@ fetch() {
fi
}
fetch "$IPHONE_SOURCE" "$IPHONE_IPSW_PATH"
fetch "$IPHONE_SOURCE" "$IPHONE_IPSW_PATH"
fetch "$CLOUDOS_SOURCE" "$CLOUDOS_IPSW_PATH"
# ── Extract ───────────────────────────────────────────────────────────
# ── Extract (cached in IPSW_DIR, cloned to workdir via APFS COW) ─────
IPHONE_CACHE="${IPSW_DIR}/${IPHONE_DIR}"
CLOUDOS_CACHE="${IPSW_DIR}/${CLOUDOS_DIR}"
extract() {
local zip="$1" dir="$2"
rm -rf "$dir"
echo "==> Extracting $zip ..."
mkdir -p "$dir"
unzip -oq "$zip" -d "$dir"
chmod -R u+w "$dir"
local zip="$1" cache="$2" out="$3"
# Extract to cache if not already done
if [[ -d "$cache" && -n "$(ls -A "$cache" 2>/dev/null)" ]]; then
echo "==> Cached: ${cache##*/}"
else
rm -rf "$cache"
echo "==> Extracting ${zip##*/} ..."
mkdir -p "$cache"
unzip -oq "$zip" -d "$cache"
chmod -R u+w "$cache"
fi
# Clone from cache to working dir (APFS instant copy-on-write)
rm -rf "$out"
echo "==> Cloning ${cache##*/}${out##*/} ..."
cp -cR "$cache" "$out"
}
extract "$IPHONE_IPSW_PATH" "$IPHONE_DIR"
extract "$CLOUDOS_IPSW_PATH" "$CLOUDOS_DIR"
extract "$IPHONE_IPSW_PATH" "$IPHONE_CACHE" "$IPHONE_DIR"
extract "$CLOUDOS_IPSW_PATH" "$CLOUDOS_CACHE" "$CLOUDOS_DIR"
# ── Merge cloudOS firmware into iPhone restore directory ──────────────
echo "==> Importing cloudOS firmware components ..."
+50 -21
View File
@@ -118,9 +118,7 @@ def parse_macho_sections(data):
nsects = struct.unpack_from("<I", data, offset + 64)[0]
sect_off = offset + 72
for _ in range(nsects):
sectname = (
data[sect_off : sect_off + 16].split(b"\x00")[0].decode()
)
sectname = data[sect_off : sect_off + 16].split(b"\x00")[0].decode()
addr = struct.unpack_from("<Q", data, sect_off + 32)[0]
size = struct.unpack_from("<Q", data, sect_off + 40)[0]
file_off = struct.unpack_from("<I", data, sect_off + 48)[0]
@@ -233,13 +231,13 @@ def patch_seputil(filepath):
original = bytes(data[offset : offset + len(anchor)])
print(f" Found format string at 0x{offset:X}: {original!r}")
print(f" Before: {bytes(data[offset:offset+7]).hex(' ')}")
print(f" Before: {bytes(data[offset : offset + 7]).hex(' ')}")
# Replace %s (2 bytes) with AA — turns "/%s.gl" into "/AA.gl"
data[pct_s_off] = ord("A")
data[pct_s_off + 1] = ord("A")
print(f" After: {bytes(data[offset:offset+7]).hex(' ')}")
print(f" After: {bytes(data[offset : offset + 7]).hex(' ')}")
open(filepath, "wb").write(data)
print(f" [+] Patched at 0x{pct_s_off:X}: %s -> AA")
@@ -311,7 +309,9 @@ def patch_launchd_cache_loader(filepath):
end = data.index(0, str_start_off)
full_str = data[str_start_off:end].decode("ascii", errors="replace")
print(f" Found anchor '{anchor_str.decode()}' inside \"{full_str}\"")
print(f" String start: va:0x{str_start_va:X} (match at va:0x{substr_va:X})")
print(
f" String start: va:0x{str_start_va:X} (match at va:0x{substr_va:X})"
)
else:
print(f" Found anchor '{anchor_str.decode()}' at va:0x{str_start_va:X}")
@@ -395,7 +395,11 @@ def _find_adrp_add_ref(code, base_va, target_va):
if src_reg in adrp_cache:
adrp_va, page, adrp_idx = adrp_cache[src_reg]
# Only match if ADRP was within 8 instructions
if page == target_page and imm == target_pageoff and idx - adrp_idx <= 8:
if (
page == target_page
and imm == target_pageoff
and idx - adrp_idx <= 8
):
return adrp_va
return -1
@@ -560,10 +564,26 @@ def patch_launchd_jetsam(filepath):
code = bytes(data[text_foff : text_foff + text_size])
cond_mnemonics = {
"b.eq", "b.ne", "b.cs", "b.hs", "b.cc", "b.lo",
"b.mi", "b.pl", "b.vs", "b.vc", "b.hi", "b.ls",
"b.ge", "b.lt", "b.gt", "b.le",
"cbz", "cbnz", "tbz", "tbnz",
"b.eq",
"b.ne",
"b.cs",
"b.hs",
"b.cc",
"b.lo",
"b.mi",
"b.pl",
"b.vs",
"b.vc",
"b.hi",
"b.ls",
"b.ge",
"b.lt",
"b.gt",
"b.le",
"cbz",
"cbnz",
"tbz",
"tbnz",
}
anchors = [
@@ -827,7 +847,9 @@ def _check_existing_dylib(data, base, dylib_path):
# LC_REEXPORT_DYLIB, LC_LOAD_UPWARD_DYLIB
name_offset = struct.unpack_from("<I", data, offset + 8)[0]
name_end = data.index(0, offset + name_offset)
name = data[offset + name_offset : name_end].decode("ascii", errors="replace")
name = data[offset + name_offset : name_end].decode(
"ascii", errors="replace"
)
if name == dylib_path:
return True
offset += cmdsize
@@ -914,8 +936,10 @@ def _inject_lc_load_dylib(data, base, dylib_path):
first_section_abs = base + first_section
available = first_section_abs - header_end
print(f" Header end: 0x{header_end:X}, first section: 0x{first_section_abs:X}, "
f"available: {available}, need: {cmd_size}")
print(
f" Header end: 0x{header_end:X}, first section: 0x{first_section_abs:X}, "
f"available: {available}, need: {cmd_size}"
)
if available < cmd_size:
# Strip LC_CODE_SIGNATURE to reclaim header space (re-signed by ldid)
@@ -933,8 +957,10 @@ def _inject_lc_load_dylib(data, base, dylib_path):
if overflow > 256:
print(f" [-] Would overflow {overflow} bytes into section data (too much)")
return False
print(f" [!] Header overflow: {overflow} bytes into section data "
f"(same as optool — binary will be re-signed)")
print(
f" [!] Header overflow: {overflow} bytes into section data "
f"(same as optool — binary will be re-signed)"
)
# Write the new load command at the end of existing commands
data[header_end : header_end + cmd_size] = lc_data
@@ -962,7 +988,9 @@ def inject_dylib(filepath, dylib_path):
continue
if _inject_lc_load_dylib(data, slice_off, dylib_path):
print(f" [+] Injected LC_LOAD_DYLIB '{dylib_path}' at slice 0x{slice_off:X}")
print(
f" [+] Injected LC_LOAD_DYLIB '{dylib_path}' at slice 0x{slice_off:X}"
)
injected += 1
if injected == len(slices):
@@ -999,8 +1027,10 @@ def parse_cryptex_paths(manifest_path):
if sysos and appos:
return sysos, appos
print("[-] Cryptex1,SystemOS/AppOS paths not found in any BuildIdentity",
file=sys.stderr)
print(
"[-] Cryptex1,SystemOS/AppOS paths not found in any BuildIdentity",
file=sys.stderr,
)
sys.exit(1)
@@ -1012,8 +1042,7 @@ def parse_cryptex_paths(manifest_path):
def inject_daemons(plist_path, daemon_dir):
"""Inject bash/dropbear/trollvnc entries into launchd.plist."""
# Convert to XML first (macOS binary plist -> XML)
subprocess.run(["plutil", "-convert", "xml1", plist_path],
capture_output=True)
subprocess.run(["plutil", "-convert", "xml1", plist_path], capture_output=True)
with open(plist_path, "rb") as f:
target = plistlib.load(f)
+86 -60
View File
@@ -33,9 +33,9 @@ def _asm(s):
return bytes(enc)
NOP = _asm("nop")
NOP = _asm("nop")
MOV_X0_0 = _asm("mov x0, #0")
PACIBSP = _asm("hint #27")
PACIBSP = _asm("hint #27")
def _rd32(buf, off):
@@ -47,12 +47,12 @@ def _wr32(buf, off, v):
def _disasm_one(data, off):
insns = list(_cs.disasm(data[off:off + 4], off))
insns = list(_cs.disasm(data[off : off + 4], off))
return insns[0] if insns else None
def _disasm_n(data, off, n):
return list(_cs.disasm(data[off:off + n * 4], off))
return list(_cs.disasm(data[off : off + n * 4], off))
def _find_asm_pattern(data, asm_str):
@@ -88,6 +88,7 @@ def _encode_add_imm12(rd, rn, imm12):
# ── IBootPatcher ───────────────────────────────────────────────
class IBootPatcher:
"""Dynamic patcher for iBoot binaries (iBSS / iBEC / LLB).
@@ -100,12 +101,12 @@ class IBootPatcher:
BOOT_ARGS = b"serial=3 -v debug=0x2014e %s"
CHUNK_SIZE, OVERLAP = 0x2000, 0x100
def __init__(self, data, mode='ibss', label=None, verbose=True):
self.data = data # bytearray (mutable)
self.raw = bytes(data) # immutable snapshot
self.size = len(data)
self.mode = mode
self.label = label or f"Loaded {mode.upper()}"
def __init__(self, data, mode="ibss", label=None, verbose=True):
self.data = data # bytearray (mutable)
self.raw = bytes(data) # immutable snapshot
self.size = len(data)
self.mode = mode
self.label = label or f"Loaded {mode.upper()}"
self.verbose = verbose
self.patches = []
@@ -117,7 +118,7 @@ class IBootPatcher:
def emit(self, off, patch_bytes, desc):
self.patches.append((off, patch_bytes, desc))
if self.verbose:
original = self.raw[off:off + len(patch_bytes)]
original = self.raw[off : off + len(patch_bytes)]
before_insns = _disasm_n(self.raw, off, len(patch_bytes) // 4)
after_insns = list(_cs.disasm(patch_bytes, off))
b_str = "; ".join(f"{i.mnemonic} {i.op_str}" for i in before_insns) or "???"
@@ -129,7 +130,7 @@ class IBootPatcher:
self.patches.append((off, data_bytes, desc))
if self.verbose:
try:
txt = data_bytes.decode('ascii')
txt = data_bytes.decode("ascii")
except Exception:
txt = data_bytes.hex()
print(f" 0x{off:06X}: → {repr(txt)} [{desc}]")
@@ -138,7 +139,7 @@ class IBootPatcher:
"""Find all patches, apply them, return count."""
self.find_all()
for off, pb, _ in self.patches:
self.data[off:off + len(pb)] = pb
self.data[off : off + len(pb)] = pb
if self.verbose and self.patches:
self._log(f"\n [{len(self.patches)} {self.mode.upper()} patches applied]")
@@ -151,10 +152,10 @@ class IBootPatcher:
self.patch_serial_labels()
self.patch_image4_callback()
if self.mode in ('ibec', 'llb'):
if self.mode in ("ibec", "llb"):
self.patch_boot_args()
if self.mode == 'llb':
if self.mode == "llb":
self.patch_rootfs_bypass()
self.patch_panic_bypass()
@@ -168,9 +169,9 @@ class IBootPatcher:
eq_runs = []
i = 0
while i < self.size:
if self.raw[i] == ord('='):
if self.raw[i] == ord("="):
start = i
while i < self.size and self.raw[i] == ord('='):
while i < self.size and self.raw[i] == ord("="):
i += 1
if i - start >= 20:
eq_runs.append(start)
@@ -196,21 +197,23 @@ class IBootPatcher:
for i in range(len(insns) - 1):
if insns[i].mnemonic != "b.ne":
continue
if not (insns[i + 1].mnemonic == "mov"
and insns[i + 1].op_str == "x0, x22"):
if not (
insns[i + 1].mnemonic == "mov" and insns[i + 1].op_str == "x0, x22"
):
continue
addr = insns[i].address
if not any(insns[j].mnemonic == "cmp"
for j in range(max(0, i - 8), i)):
if not any(insns[j].mnemonic == "cmp" for j in range(max(0, i - 8), i)):
continue
# Prefer candidate with movn w22 (sets -1) earlier
neg1 = any(
(insns[j].mnemonic == "movn"
and insns[j].op_str.startswith("w22,"))
or (insns[j].mnemonic == "mov"
(insns[j].mnemonic == "movn" and insns[j].op_str.startswith("w22,"))
or (
insns[j].mnemonic == "mov"
and "w22" in insns[j].op_str
and ("#-1" in insns[j].op_str
or "#0xffffffff" in insns[j].op_str))
and (
"#-1" in insns[j].op_str or "#0xffffffff" in insns[j].op_str
)
)
for j in range(max(0, i - 64), i)
)
candidates.append((addr, neg1))
@@ -333,16 +336,20 @@ class IBootPatcher:
Convert conditional branch to unconditional b to same target."""
locs = _find_asm_pattern(self.raw, f"mov w8, #{error_code}")
if len(locs) != 1:
self._log(f" [-] {desc}: expected 1 'mov w8, #{error_code:#x}', "
f"found {len(locs)}")
self._log(
f" [-] {desc}: expected 1 'mov w8, #{error_code:#x}', "
f"found {len(locs)}"
)
return
err_off = locs[0]
cbz_off = err_off - 4
insn = _disasm_one(self.raw, cbz_off)
if not insn or insn.mnemonic not in ('cbz', 'cbnz'):
self._log(f" [-] {desc}: expected cbz/cbnz at 0x{cbz_off:X}, "
f"got {insn.mnemonic if insn else '???'}")
if not insn or insn.mnemonic not in ("cbz", "cbnz"):
self._log(
f" [-] {desc}: expected cbz/cbnz at 0x{cbz_off:X}, "
f"got {insn.mnemonic if insn else '???'}"
)
return
# Extract the branch target from the conditional instruction
@@ -354,16 +361,19 @@ class IBootPatcher:
"""Find unique 'cmp x8, #0x400', NOP the b.hs that follows."""
locs = _find_asm_pattern(self.raw, "cmp x8, #0x400")
if len(locs) != 1:
self._log(f" [-] rootfs b.hs: expected 1 'cmp x8, #0x400', "
f"found {len(locs)}")
self._log(
f" [-] rootfs b.hs: expected 1 'cmp x8, #0x400', found {len(locs)}"
)
return
cmp_off = locs[0]
bhs_off = cmp_off + 4
insn = _disasm_one(self.raw, bhs_off)
if not insn or insn.mnemonic != 'b.hs':
self._log(f" [-] rootfs b.hs: expected b.hs at 0x{bhs_off:X}, "
f"got {insn.mnemonic if insn else '???'}")
if not insn or insn.mnemonic != "b.hs":
self._log(
f" [-] rootfs b.hs: expected b.hs at 0x{bhs_off:X}, "
f"got {insn.mnemonic if insn else '???'}"
)
return
self.emit(bhs_off, NOP, "rootfs: NOP b.hs size check (0x400)")
@@ -373,8 +383,10 @@ class IBootPatcher:
NOP the cbz."""
locs = _find_asm_pattern(self.raw, "mov w8, #0x110")
if len(locs) != 1:
self._log(f" [-] rootfs null check: expected 1 'mov w8, #0x110', "
f"found {len(locs)}")
self._log(
f" [-] rootfs null check: expected 1 'mov w8, #0x110', "
f"found {len(locs)}"
)
return
err_off = locs[0]
@@ -382,11 +394,15 @@ class IBootPatcher:
for scan in range(err_off - 4, max(err_off - 0x300, 0), -4):
i1 = _disasm_one(self.raw, scan)
i2 = _disasm_one(self.raw, scan + 4)
if (i1 and i2
and i1.mnemonic == 'ldr' and '#0x78' in i1.op_str
and i2.mnemonic == 'cbz' and i2.op_str.startswith('x')):
self.emit(scan + 4, NOP,
"rootfs: NOP cbz x8 null check (#0x78)")
if (
i1
and i2
and i1.mnemonic == "ldr"
and "#0x78" in i1.op_str
and i2.mnemonic == "cbz"
and i2.op_str.startswith("x")
):
self.emit(scan + 4, NOP, "rootfs: NOP cbz x8 null check (#0x78)")
return
self._log(" [-] rootfs null check: ldr+cbz #0x78 pattern not found")
@@ -402,20 +418,22 @@ class IBootPatcher:
for loc in mov328_locs:
# Verify movk w8, #0x40, lsl #16 follows
next_insn = _disasm_one(self.raw, loc + 4)
if not (next_insn and next_insn.mnemonic == 'movk'
and 'w8' in next_insn.op_str
and '#0x40' in next_insn.op_str
and 'lsl #16' in next_insn.op_str):
if not (
next_insn
and next_insn.mnemonic == "movk"
and "w8" in next_insn.op_str
and "#0x40" in next_insn.op_str
and "lsl #16" in next_insn.op_str
):
continue
# Walk forward to find bl; cbnz w0
for step in range(loc + 8, loc + 32, 4):
i = _disasm_one(self.raw, step)
if i and i.mnemonic == 'bl':
if i and i.mnemonic == "bl":
ni = _disasm_one(self.raw, step + 4)
if ni and ni.mnemonic == 'cbnz':
self.emit(step + 4, NOP,
"panic bypass: NOP cbnz w0")
if ni and ni.mnemonic == "cbnz":
self.emit(step + 4, NOP, "panic bypass: NOP cbnz w0")
return
break
@@ -436,13 +454,19 @@ if __name__ == "__main__":
import sys, argparse
parser = argparse.ArgumentParser(
description="Dynamic iBoot patcher (iBSS / iBEC / LLB)")
description="Dynamic iBoot patcher (iBSS / iBEC / LLB)"
)
parser.add_argument("firmware", help="Path to raw or IM4P iBoot image")
parser.add_argument("-m", "--mode", choices=["ibss", "ibec", "llb"],
default="llb",
help="Patch mode (default: llb = all patches)")
parser.add_argument("-l", "--label", default=None,
help="Serial label text (default: 'Loaded MODE')")
parser.add_argument(
"-m",
"--mode",
choices=["ibss", "ibec", "llb"],
default="llb",
help="Patch mode (default: llb = all patches)",
)
parser.add_argument(
"-l", "--label", default=None, help="Serial label text (default: 'Loaded MODE')"
)
parser.add_argument("-q", "--quiet", action="store_true")
args = parser.parse_args()
@@ -452,6 +476,7 @@ if __name__ == "__main__":
# Auto-detect IM4P
try:
from pyimg4 import IM4P
im4p = IM4P(file_raw)
if im4p.payload.compression:
im4p.payload.decompress()
@@ -462,9 +487,10 @@ if __name__ == "__main__":
print(f" format: raw")
data = bytearray(payload)
print(f" size: {len(data)} bytes ({len(data)/1024:.1f} KB)\n")
print(f" size: {len(data)} bytes ({len(data) / 1024:.1f} KB)\n")
patcher = IBootPatcher(data, mode=args.mode, label=args.label,
verbose=not args.quiet)
patcher = IBootPatcher(
data, mode=args.mode, label=args.label, verbose=not args.quiet
)
n = patcher.apply()
print(f"\n {n} patches applied.")
+16 -8
View File
@@ -30,10 +30,12 @@ class IBootJBPatcher(IBootPatcher):
self.patch_skip_generate_nonce()
for off, pb, _ in self.patches:
self.data[off:off + len(pb)] = pb
self.data[off : off + len(pb)] = pb
if self.verbose and self.patches:
self._log(f"\n [{len(self.patches)} {self.mode.upper()} JB patches applied]")
self._log(
f"\n [{len(self.patches)} {self.mode.upper()} JB patches applied]"
)
return len(self.patches)
def _find_refs_to_offset(self, target_off):
@@ -85,11 +87,14 @@ class IBootJBPatcher(IBootPatcher):
continue
if len(i0.operands) < 3:
continue
if not (i0.operands[0].type == ARM64_OP_REG
and i0.operands[0].reg == ARM64_REG_W0):
if not (
i0.operands[0].type == ARM64_OP_REG
and i0.operands[0].reg == ARM64_REG_W0
):
continue
if not (i0.operands[1].type == ARM64_OP_IMM
and i0.operands[1].imm == 0):
if not (
i0.operands[1].type == ARM64_OP_IMM and i0.operands[1].imm == 0
):
continue
if i1.mnemonic != "mov" or i1.op_str != "w0, #0":
continue
@@ -97,8 +102,11 @@ class IBootJBPatcher(IBootPatcher):
continue
target = i0.operands[2].imm
self.emit(scan, self._asm_at(f"b #0x{target:X}", scan),
"JB: skip generate_nonce")
self.emit(
scan,
self._asm_at(f"b #0x{target:X}", scan),
"JB: skip generate_nonce",
)
return True
self._log(" [-] iBSS JB: generate_nonce branch pattern not found")
+301 -200
View File
@@ -13,8 +13,13 @@ import struct, plistlib
from collections import defaultdict
from keystone import Ks, KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN as KS_MODE_LE
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN
from capstone.arm64_const import (ARM64_OP_REG, ARM64_OP_IMM,
ARM64_REG_W0, ARM64_REG_X0, ARM64_REG_X8)
from capstone.arm64_const import (
ARM64_OP_REG,
ARM64_OP_IMM,
ARM64_REG_W0,
ARM64_REG_X0,
ARM64_REG_X8,
)
# ── Assembly / disassembly helpers ───────────────────────────────
_ks = Ks(KS_ARCH_ARM64, KS_MODE_LE)
@@ -29,12 +34,12 @@ def asm(s):
return bytes(enc)
NOP = asm("nop")
MOV_X0_0 = asm("mov x0, #0")
MOV_X0_1 = asm("mov x0, #1")
MOV_W0_0 = asm("mov w0, #0")
MOV_W0_1 = asm("mov w0, #1")
RET = asm("ret")
NOP = asm("nop")
MOV_X0_0 = asm("mov x0, #0")
MOV_X0_1 = asm("mov x0, #1")
MOV_W0_0 = asm("mov w0, #0")
MOV_W0_1 = asm("mov w0, #1")
RET = asm("ret")
CMP_W0_W0 = asm("cmp w0, w0")
CMP_X0_X0 = asm("cmp x0, x0")
@@ -48,16 +53,17 @@ def _verify_disas(u32_val, expected_mnemonic):
"""Verify a uint32 encoding disassembles to expected mnemonic via capstone."""
code = struct.pack("<I", u32_val)
insns = list(_cs.disasm(code, 0, 1))
assert insns and insns[0].mnemonic == expected_mnemonic, \
assert insns and insns[0].mnemonic == expected_mnemonic, (
f"0x{u32_val:08X} disassembles to {insns[0].mnemonic if insns else '???'}, expected {expected_mnemonic}"
)
return u32_val
# Named instruction constants (via keystone where possible, capstone-verified otherwise)
_PACIBSP_U32 = _asm_u32("hint #27") # keystone doesn't know 'pacibsp'
_RET_U32 = _asm_u32("ret")
_RETAA_U32 = _verify_disas(0xD65F0BFF, "retaa") # keystone can't assemble PAC returns
_RETAB_U32 = _verify_disas(0xD65F0FFF, "retab") # verified via capstone disassembly
_PACIBSP_U32 = _asm_u32("hint #27") # keystone doesn't know 'pacibsp'
_RET_U32 = _asm_u32("ret")
_RETAA_U32 = _verify_disas(0xD65F0BFF, "retaa") # keystone can't assemble PAC returns
_RETAB_U32 = _verify_disas(0xD65F0FFF, "retab") # verified via capstone disassembly
_FUNC_BOUNDARY_U32S = frozenset((_RET_U32, _RETAA_U32, _RETAB_U32, _PACIBSP_U32))
@@ -71,15 +77,17 @@ def _rd64(buf, off):
# ── KernelPatcher ────────────────────────────────────────────────
class KernelPatcher:
"""Dynamic kernel patcher — all offsets found at runtime."""
def __init__(self, data, verbose=True):
self.data = data # bytearray (mutable)
self.raw = bytes(data) # immutable snapshot for searching
self.size = len(data)
self.patches = [] # collected (offset, bytes, description)
def __init__(self, data, verbose=False):
self.data = data # bytearray (mutable)
self.raw = bytes(data) # immutable snapshot for searching
self.size = len(data)
self.patches = [] # collected (offset, bytes, description)
self.verbose = verbose
self._patch_num = 0 # running counter for clean one-liners
self._log("[*] Parsing Mach-O segments …")
self._parse_macho()
@@ -94,8 +102,10 @@ class KernelPatcher:
self._build_bl_index()
self._find_panic()
self._log(f"[*] _panic at foff 0x{self.panic_off:X} "
f"({len(self.bl_callers[self.panic_off])} callers)")
self._log(
f"[*] _panic at foff 0x{self.panic_off:X} "
f"({len(self.bl_callers[self.panic_off])} callers)"
)
# ── Logging ──────────────────────────────────────────────────
def _log(self, msg):
@@ -109,21 +119,21 @@ class KernelPatcher:
if magic != 0xFEEDFACF:
raise ValueError(f"Not a 64-bit Mach-O (magic 0x{magic:08X})")
self.code_ranges = [] # [(start_foff, end_foff), ...]
self.all_segments = [] # [(name, vmaddr, fileoff, filesize, initprot)]
self.base_va = None
self.code_ranges = [] # [(start_foff, end_foff), ...]
self.all_segments = [] # [(name, vmaddr, fileoff, filesize, initprot)]
self.base_va = None
ncmds = struct.unpack_from("<I", self.raw, 16)[0]
off = 32 # past mach_header_64
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from("<II", self.raw, off)
if cmd == 0x19: # LC_SEGMENT_64
segname = self.raw[off+8:off+24].split(b'\x00')[0].decode()
segname = self.raw[off + 8 : off + 24].split(b"\x00")[0].decode()
vmaddr, vmsize, fileoff, filesize = struct.unpack_from(
"<QQQQ", self.raw, off + 24)
"<QQQQ", self.raw, off + 24
)
initprot = struct.unpack_from("<I", self.raw, off + 60)[0]
self.all_segments.append(
(segname, vmaddr, fileoff, filesize, initprot))
self.all_segments.append((segname, vmaddr, fileoff, filesize, initprot))
if segname == "__TEXT":
self.base_va = vmaddr
CODE_SEGS = ("__PRELINK_TEXT", "__TEXT_EXEC", "__TEXT_BOOT_EXEC")
@@ -137,7 +147,9 @@ class KernelPatcher:
self.code_ranges.sort()
total_mb = sum(e - s for s, e in self.code_ranges) / (1024 * 1024)
self._log(f" BASE_VA = 0x{self.base_va:016X}")
self._log(f" {len(self.code_ranges)} executable ranges, total {total_mb:.1f} MB")
self._log(
f" {len(self.code_ranges)} executable ranges, total {total_mb:.1f} MB"
)
def _va(self, foff):
return self.base_va + foff
@@ -148,7 +160,7 @@ class KernelPatcher:
# ── Kext range discovery ─────────────────────────────────────
def _discover_kext_ranges(self):
"""Parse __PRELINK_INFO + embedded kext Mach-Os to find code section ranges."""
self.kext_ranges = {} # bundle_id -> (text_start, text_end)
self.kext_ranges = {} # bundle_id -> (text_start, text_end)
# Find __PRELINK_INFO segment
prelink_info = None
@@ -163,7 +175,7 @@ class KernelPatcher:
return
foff, fsize = prelink_info
pdata = self.raw[foff:foff + fsize]
pdata = self.raw[foff : foff + fsize]
# Parse the XML plist
xml_start = pdata.find(b"<?xml")
@@ -173,7 +185,7 @@ class KernelPatcher:
self._set_fallback_ranges()
return
xml = pdata[xml_start:xml_end + len(b"</plist>")]
xml = pdata[xml_start : xml_end + len(b"</plist>")]
pl = plistlib.loads(xml)
items = pl.get("_PrelinkInfoDictionary", [])
@@ -199,8 +211,10 @@ class KernelPatcher:
text_range = self._parse_kext_text_exec(kext_foff)
if text_range:
self.kext_ranges[tag] = text_range
self._log(f" {tag:10s} __text: 0x{text_range[0]:08X} - 0x{text_range[1]:08X} "
f"({(text_range[1]-text_range[0])//1024} KB)")
self._log(
f" {tag:10s} __text: 0x{text_range[0]:08X} - 0x{text_range[1]:08X} "
f"({(text_range[1] - text_range[0]) // 1024} KB)"
)
# Derive the ranges used by patch methods
self._set_ranges_from_kexts()
@@ -220,7 +234,7 @@ class KernelPatcher:
break
cmd, cmdsize = struct.unpack_from("<II", self.raw, off)
if cmd == 0x19: # LC_SEGMENT_64
segname = self.raw[off+8:off+24].split(b'\x00')[0].decode()
segname = self.raw[off + 8 : off + 24].split(b"\x00")[0].decode()
if segname == "__TEXT_EXEC":
vmaddr = struct.unpack_from("<Q", self.raw, off + 24)[0]
filesize = struct.unpack_from("<Q", self.raw, off + 48)[0]
@@ -230,10 +244,18 @@ class KernelPatcher:
for _ in range(nsects):
if sect_off + 80 > self.size:
break
sectname = self.raw[sect_off:sect_off+16].split(b'\x00')[0].decode()
sectname = (
self.raw[sect_off : sect_off + 16]
.split(b"\x00")[0]
.decode()
)
if sectname == "__text":
sect_addr = struct.unpack_from("<Q", self.raw, sect_off + 32)[0]
sect_size = struct.unpack_from("<Q", self.raw, sect_off + 40)[0]
sect_addr = struct.unpack_from(
"<Q", self.raw, sect_off + 32
)[0]
sect_size = struct.unpack_from(
"<Q", self.raw, sect_off + 40
)[0]
sect_foff = sect_addr - self.base_va
return (sect_foff, sect_foff + sect_size)
sect_off += 80
@@ -256,11 +278,11 @@ class KernelPatcher:
text_exec = (0, self.size)
self.text_exec_range = text_exec
self.apfs_text = self.kext_ranges.get("apfs", text_exec)
self.amfi_text = self.kext_ranges.get("amfi", text_exec)
self.apfs_text = self.kext_ranges.get("apfs", text_exec)
self.amfi_text = self.kext_ranges.get("amfi", text_exec)
self.sandbox_text = self.kext_ranges.get("sandbox", text_exec)
# Kernel code = full __TEXT_EXEC (includes all kexts, but that's OK)
self.kern_text = text_exec
self.kern_text = text_exec
def _set_fallback_ranges(self):
"""Use __TEXT_EXEC for everything when __PRELINK_INFO is unavailable."""
@@ -273,10 +295,10 @@ class KernelPatcher:
text_exec = (0, self.size)
self.text_exec_range = text_exec
self.apfs_text = text_exec
self.amfi_text = text_exec
self.apfs_text = text_exec
self.amfi_text = text_exec
self.sandbox_text = text_exec
self.kern_text = text_exec
self.kern_text = text_exec
# ── Index builders ───────────────────────────────────────────
def _build_adrp_index(self):
@@ -287,13 +309,13 @@ class KernelPatcher:
insn = _rd32(self.raw, off)
if (insn & 0x9F000000) != 0x90000000:
continue
rd = insn & 0x1F
immhi = (insn >> 5) & 0x7FFFF
rd = insn & 0x1F
immhi = (insn >> 5) & 0x7FFFF
immlo = (insn >> 29) & 0x3
imm = (immhi << 2) | immlo
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= (1 << 21)
pc = self._va(off)
imm -= 1 << 21
pc = self._va(off)
page = (pc & ~0xFFF) + (imm << 12)
self.adrp_by_page[page].append((off, rd))
@@ -310,7 +332,7 @@ class KernelPatcher:
continue
imm26 = insn & 0x3FFFFFF
if imm26 & (1 << 25):
imm26 -= (1 << 26)
imm26 -= 1 << 26
target = off + imm26 * 4
self.bl_callers[target].append(off)
@@ -334,12 +356,12 @@ class KernelPatcher:
immlo = (prev >> 29) & 0x3
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= (1 << 21)
pc = self._va(back - 4)
imm -= 1 << 21
pc = self._va(back - 4)
page = (pc & ~0xFFF) + (imm << 12)
str_foff = self._foff(page + add_imm)
if 0 <= str_foff < self.size - 10:
snippet = self.raw[str_foff:str_foff + 60]
snippet = self.raw[str_foff : str_foff + 60]
if b"@%s:%d" in snippet or b"%s:%d" in snippet:
confirmed += 1
break
@@ -372,7 +394,9 @@ class KernelPatcher:
return False
i = insns[0]
if i.mnemonic in ("cbz", "cbnz", "tbz", "tbnz"):
return i.operands[0].type == ARM64_OP_REG and i.operands[0].reg == ARM64_REG_W0
return (
i.operands[0].type == ARM64_OP_REG and i.operands[0].reg == ARM64_REG_W0
)
return False
def find_string(self, s, start=0):
@@ -390,9 +414,9 @@ class KernelPatcher:
def find_string_refs(self, str_off, code_start=None, code_end=None):
"""Find all (adrp_off, add_off, dest_reg) referencing str_off via ADRP+ADD."""
target_va = self._va(str_off)
target_va = self._va(str_off)
target_page = target_va & ~0xFFF
page_off = target_va & 0xFFF
page_off = target_va & 0xFFF
refs = []
for adrp_off, rd in self.adrp_by_page.get(target_page, []):
@@ -406,7 +430,7 @@ class KernelPatcher:
# ADD (imm) 64-bit: 1001_0001_00_imm12_Rn_Rd
if (nxt & 0xFFC00000) != 0x91000000:
continue
add_rn = (nxt >> 5) & 0x1F
add_rn = (nxt >> 5) & 0x1F
add_imm = (nxt >> 10) & 0xFFF
if add_rn == rd and add_imm == page_off:
add_rd = nxt & 0x1F
@@ -491,7 +515,9 @@ class KernelPatcher:
previously allocated shellcode and won't reuse the same cave.
"""
self.patches.append((off, patch_bytes, desc))
self.data[off:off + len(patch_bytes)] = patch_bytes
self.data[off : off + len(patch_bytes)] = patch_bytes
self._patch_num += 1
print(f" [{self._patch_num:2d}] 0x{off:08X} {desc}")
if self.verbose:
self._print_patch_context(off, patch_bytes, desc)
@@ -519,7 +545,7 @@ class KernelPatcher:
return val & 0xFFFFFFFF
else: # non-auth rebase
target = val & 0x7FFFFFFFFFF # bits[42:0]
high8 = (val >> 43) & 0xFF
high8 = (val >> 43) & 0xFF
full_va = (high8 << 56) | target
if full_va > self.base_va:
return full_va - self.base_va
@@ -534,12 +560,14 @@ class KernelPatcher:
self._log("\n[1] _apfs_vfsop_mount: root snapshot sealed volume check")
refs = self._find_by_string_in_range(
b"Rooting from snapshot with xid",
self.apfs_text, "apfs_vfsop_mount log")
b"Rooting from snapshot with xid", self.apfs_text, "apfs_vfsop_mount log"
)
if not refs:
refs = self._find_by_string_in_range(
b"Failed to find the root snapshot",
self.apfs_text, "root snapshot panic")
self.apfs_text,
"root snapshot panic",
)
if not refs:
return False
@@ -553,13 +581,18 @@ class KernelPatcher:
continue
# Check: tbz/tbnz w8, #5, ...
ops = i.operands
if (len(ops) >= 2
and ops[0].type == ARM64_OP_REG
and ops[1].type == ARM64_OP_IMM
and ops[1].imm == 5):
self.emit(scan, NOP,
f"NOP {i.mnemonic} {i.op_str} "
"(sealed vol check) [_apfs_vfsop_mount]")
if (
len(ops) >= 2
and ops[0].type == ARM64_OP_REG
and ops[1].type == ARM64_OP_IMM
and ops[1].imm == 5
):
self.emit(
scan,
NOP,
f"NOP {i.mnemonic} {i.op_str} "
"(sealed vol check) [_apfs_vfsop_mount]",
)
return True
self._log(" [-] tbz/tbnz w8,#5 not found near xref")
@@ -598,20 +631,41 @@ class KernelPatcher:
for back in range(adrp_off - 4, max(adrp_off - 0x200, 0), -4):
target, kind = self._decode_branch_target(back)
if target is not None and err_lo <= target <= bl_off + 4:
self.emit(back, NOP,
f"NOP {kind} (seal broken) "
"[_authapfs_seal_is_broken]")
self.emit(
back,
NOP,
f"NOP {kind} (seal broken) [_authapfs_seal_is_broken]",
)
return True
self._log(" [-] could not find conditional branch to NOP")
return False
_COND_BRANCH_MNEMONICS = frozenset((
"b.eq", "b.ne", "b.cs", "b.hs", "b.cc", "b.lo",
"b.mi", "b.pl", "b.vs", "b.vc", "b.hi", "b.ls",
"b.ge", "b.lt", "b.gt", "b.le", "b.al",
"cbz", "cbnz", "tbz", "tbnz",
))
_COND_BRANCH_MNEMONICS = frozenset(
(
"b.eq",
"b.ne",
"b.cs",
"b.hs",
"b.cc",
"b.lo",
"b.mi",
"b.pl",
"b.vs",
"b.vc",
"b.hi",
"b.ls",
"b.ge",
"b.lt",
"b.gt",
"b.le",
"b.al",
"cbz",
"cbnz",
"tbz",
"tbnz",
)
)
def _decode_branch_target(self, off):
"""Decode conditional branch at off via capstone. Returns (target, mnemonic) or (None, None)."""
@@ -655,14 +709,13 @@ class KernelPatcher:
# Search backwards for a conditional branch whose target is in
# the error path (the block ending with BL _panic).
# The error path is typically a few instructions before BL _panic.
err_lo = bl_panic_off - 0x40 # error block start (generous)
err_hi = bl_panic_off + 4 # error block end
err_lo = bl_panic_off - 0x40 # error block start (generous)
err_hi = bl_panic_off + 4 # error block end
for back in range(adrp_off - 4, max(adrp_off - 0x400, 0), -4):
target, kind = self._decode_branch_target(back)
if target is not None and err_lo <= target <= err_hi:
self.emit(back, NOP,
f"NOP {kind} (rootvp auth) [_bsd_init]")
self.emit(back, NOP, f"NOP {kind} (rootvp auth) [_bsd_init]")
return True
self._log(" [-] conditional branch into panic path not found")
@@ -692,10 +745,10 @@ class KernelPatcher:
func_start = self.find_function_start(adrp_off)
if func_start < 0:
continue
self.emit(func_start, MOV_W0_0,
"mov w0,#0 [_proc_check_launch_constraints]")
self.emit(func_start + 4, RET,
"ret [_proc_check_launch_constraints]")
self.emit(
func_start, MOV_W0_0, "mov w0,#0 [_proc_check_launch_constraints]"
)
self.emit(func_start + 4, RET, "ret [_proc_check_launch_constraints]")
return True
self._log(" [-] function start not found")
@@ -716,7 +769,7 @@ class KernelPatcher:
if cmd == 0x80000035: # LC_FILESET_ENTRY
vmaddr = struct.unpack_from("<Q", self.raw, off + 8)[0]
str_off_in_cmd = struct.unpack_from("<I", self.raw, off + 24)[0]
entry_id = self.raw[off + str_off_in_cmd:].split(b'\x00')[0].decode()
entry_id = self.raw[off + str_off_in_cmd :].split(b"\x00")[0].decode()
if entry_id == "com.apple.kernel":
kext_foff = vmaddr - self.base_va
text_range = self._parse_kext_text_exec(kext_foff)
@@ -744,7 +797,7 @@ class KernelPatcher:
if name == "__LINKEDIT":
linkedit = (fileoff, fileoff + filesize)
if linkedit and linkedit[0] <= str_off < linkedit[1]:
name_end = self.raw.find(b'\x00', str_off + 1)
name_end = self.raw.find(b"\x00", str_off + 1)
if name_end > 0:
for probe in range(name_end + 1, min(name_end + 32, self.size - 7)):
val = _rd64(self.raw, probe)
@@ -752,10 +805,14 @@ class KernelPatcher:
if self.kern_text[0] <= func_foff < self.kern_text[1]:
first_insn = _rd32(self.raw, func_foff)
if first_insn != 0 and first_insn != 0xD503201F:
self.emit(func_foff, MOV_X0_1,
"mov x0,#1 [_PE_i_can_has_debugger]")
self.emit(func_foff + 4, RET,
"ret [_PE_i_can_has_debugger]")
self.emit(
func_foff,
MOV_X0_1,
"mov x0,#1 [_PE_i_can_has_debugger]",
)
self.emit(
func_foff + 4, RET, "ret [_PE_i_can_has_debugger]"
)
return True
# Strategy 2: code pattern — function starts with ADRP x8,
@@ -786,7 +843,12 @@ class KernelPatcher:
if off + k * 4 >= self.size:
break
dk = self._disas_at(off + k * 4)
if dk and dk[0].mnemonic == "ldr" and dk[0].op_str.startswith("w") and "x8" in dk[0].op_str:
if (
dk
and dk[0].mnemonic == "ldr"
and dk[0].op_str.startswith("w")
and "x8" in dk[0].op_str
):
has_w_load = True
break
if not has_w_load:
@@ -799,7 +861,9 @@ class KernelPatcher:
best_off = off
if best_off >= 0:
self._log(f" [+] code pattern match at 0x{best_off:X} ({best_callers} callers)")
self._log(
f" [+] code pattern match at 0x{best_off:X} ({best_callers} callers)"
)
self.emit(best_off, MOV_X0_1, "mov x0,#1 [_PE_i_can_has_debugger]")
self.emit(best_off + 4, RET, "ret [_PE_i_can_has_debugger]")
return True
@@ -835,9 +899,12 @@ class KernelPatcher:
if not insns:
continue
if insns[0].mnemonic == "tbnz":
self.emit(scan, NOP,
f"NOP {insns[0].mnemonic} {insns[0].op_str} "
"[txm post-validation]")
self.emit(
scan,
NOP,
f"NOP {insns[0].mnemonic} {insns[0].op_str} "
"[txm post-validation]",
)
return True
self._log(" [-] TBNZ not found after TXM error string ref")
@@ -909,9 +976,11 @@ class KernelPatcher:
break
if not has_bl:
continue
self.emit(off, CMP_W0_W0,
f"cmp w0,w0 (was {i0.mnemonic} {i0.op_str}) "
"[postValidation]")
self.emit(
off,
CMP_W0_W0,
f"cmp w0,w0 (was {i0.mnemonic} {i0.op_str}) [postValidation]",
)
return True
self._log(" [-] CMP+B.NE pattern not found in caller's BL targets")
@@ -928,7 +997,8 @@ class KernelPatcher:
# Anchor: entitlement string referenced from within the function
str_off = self.find_string(
b"com.apple.developer.swift-playgrounds-app.development-build")
b"com.apple.developer.swift-playgrounds-app.development-build"
)
if str_off < 0:
self._log(" [-] swift-playgrounds entitlement string not found")
return False
@@ -942,7 +1012,7 @@ class KernelPatcher:
for adrp_off, add_off, _ in refs:
# Walk backward from the ADRP, looking for BL + conditional-on-w0 pairs
bls_with_cond = [] # [(bl_off, bl_target), ...]
bls_with_cond = [] # [(bl_off, bl_target), ...]
for back in range(adrp_off - 4, max(adrp_off - 80, 0), -4):
bl_target = self._is_bl(back)
if bl_target < 0:
@@ -951,17 +1021,23 @@ class KernelPatcher:
bls_with_cond.append((back, bl_target))
if len(bls_with_cond) >= 2:
bl2_off, bl2_tgt = bls_with_cond[0] # closer to ADRP
bl1_off, bl1_tgt = bls_with_cond[1] # farther from ADRP
bl2_off, bl2_tgt = bls_with_cond[0] # closer to ADRP
bl1_off, bl1_tgt = bls_with_cond[1] # farther from ADRP
# The two BLs must call DIFFERENT functions — this
# distinguishes _check_dyld_policy_internal from other
# functions that repeat calls to the same helper.
if bl1_tgt == bl2_tgt:
continue
self.emit(bl1_off, MOV_W0_1,
"mov w0,#1 (was BL) [_check_dyld_policy_internal @1]")
self.emit(bl2_off, MOV_W0_1,
"mov w0,#1 (was BL) [_check_dyld_policy_internal @2]")
self.emit(
bl1_off,
MOV_W0_1,
"mov w0,#1 (was BL) [_check_dyld_policy_internal @1]",
)
self.emit(
bl2_off,
MOV_W0_1,
"mov w0,#1 (was BL) [_check_dyld_policy_internal @2]",
)
return True
self._log(" [-] _check_dyld_policy_internal BL pair not found")
@@ -1033,7 +1109,9 @@ class KernelPatcher:
refs_upgrade = self._find_by_string_in_range(
b"apfs_mount_upgrade_checks\x00",
self.apfs_text, "apfs_mount_upgrade_checks")
self.apfs_text,
"apfs_mount_upgrade_checks",
)
if not refs_upgrade:
return False
@@ -1061,7 +1139,11 @@ class KernelPatcher:
continue
# Scan a wider range — the CMP can be 0x800+ bytes before the BL
caller_func = self.find_function_start(caller_off)
scan_start = caller_func if caller_func >= 0 else max(caller_off - 0x800, self.apfs_text[0])
scan_start = (
caller_func
if caller_func >= 0
else max(caller_off - 0x800, self.apfs_text[0])
)
scan_end = min(caller_off + 0x100, self.apfs_text[1])
for scan in range(scan_start, scan_end, 4):
@@ -1080,9 +1162,12 @@ class KernelPatcher:
# Skip CMP x0, x0 (already patched or trivial)
if ops[0].reg == ops[1].reg:
continue
self.emit(scan, CMP_X0_X0,
f"cmp x0,x0 (was {dis[0].mnemonic} {dis[0].op_str}) "
"[_apfs_vfsop_mount]")
self.emit(
scan,
CMP_X0_X0,
f"cmp x0,x0 (was {dis[0].mnemonic} {dis[0].op_str}) "
"[_apfs_vfsop_mount]",
)
return True
self._log(" [-] CMP x0,Xm not found near mount_upgrade_checks caller")
@@ -1099,7 +1184,9 @@ class KernelPatcher:
refs = self._find_by_string_in_range(
b"apfs_mount_upgrade_checks\x00",
self.apfs_text, "apfs_mount_upgrade_checks")
self.apfs_text,
"apfs_mount_upgrade_checks",
)
if not refs:
return False
@@ -1134,10 +1221,13 @@ class KernelPatcher:
continue
i = insns[0]
if i.mnemonic == "tbnz" and len(i.operands) >= 1:
if (i.operands[0].type == ARM64_OP_REG and
i.operands[0].reg == ARM64_REG_W0):
self.emit(next_off, MOV_W0_0,
"mov w0,#0 [_apfs_mount_upgrade_checks]")
if (
i.operands[0].type == ARM64_OP_REG
and i.operands[0].reg == ARM64_REG_W0
):
self.emit(
next_off, MOV_W0_0, "mov w0,#0 [_apfs_mount_upgrade_checks]"
)
return True
self._log(" [-] BL + TBNZ w0 pattern not found")
@@ -1210,8 +1300,10 @@ class KernelPatcher:
if seatbelt_off < 0 or sandbox_off < 0:
self._log(" [-] Sandbox/Seatbelt strings not found")
return None
self._log(f" [*] Sandbox string at foff 0x{sandbox_off:X}, "
f"Seatbelt at 0x{seatbelt_off:X}")
self._log(
f" [*] Sandbox string at foff 0x{sandbox_off:X}, "
f"Seatbelt at 0x{seatbelt_off:X}"
)
data_ranges = []
for name, vmaddr, fileoff, filesize, prot in self.all_segments:
@@ -1231,8 +1323,10 @@ class KernelPatcher:
val_ops = _rd64(self.raw, i + 32)
if not (val_ops & (1 << 63)):
ops_off = val_ops & 0x7FFFFFFFFFF
self._log(f" [+] mac_policy_conf at foff 0x{i:X}, "
f"mpc_ops -> 0x{ops_off:X}")
self._log(
f" [+] mac_policy_conf at foff 0x{i:X}, "
f"mpc_ops -> 0x{ops_off:X}"
)
return ops_off
self._log(" [-] mac_policy_conf not found")
@@ -1260,11 +1354,11 @@ class KernelPatcher:
return False
HOOK_INDICES = {
"file_check_mmap": 36,
"mount_check_mount": 87,
"file_check_mmap": 36,
"mount_check_mount": 87,
"mount_check_remount": 88,
"mount_check_umount": 91,
"vnode_check_rename": 120,
"mount_check_umount": 91,
"vnode_check_rename": 120,
}
sb_start, sb_end = self.sandbox_text
@@ -1276,8 +1370,10 @@ class KernelPatcher:
self._log(f" [-] ops[{idx}] {hook_name}: NULL or invalid")
continue
if not (sb_start <= func_off < sb_end):
self._log(f" [-] ops[{idx}] {hook_name}: foff 0x{func_off:X} "
f"outside Sandbox (0x{sb_start:X}-0x{sb_end:X})")
self._log(
f" [-] ops[{idx}] {hook_name}: foff 0x{func_off:X} "
f"outside Sandbox (0x{sb_start:X}-0x{sb_end:X})"
)
continue
self.emit(func_off, MOV_X0_0, f"mov x0,#0 [_hook_{hook_name}]")
@@ -1294,39 +1390,30 @@ class KernelPatcher:
def find_all(self):
"""Find and record all kernel patches. Returns list of (offset, bytes, desc)."""
self.patches = []
self.patch_apfs_root_snapshot() # 1
self.patch_apfs_seal_broken() # 2
self.patch_bsd_init_rootvp() # 3
self.patch_proc_check_launch_constraints() # 4-5
self.patch_PE_i_can_has_debugger() # 6-7
self.patch_post_validation_nop() # 8
self.patch_post_validation_cmp() # 9
self.patch_check_dyld_policy() # 10-11
self.patch_apfs_graft() # 12
self.patch_apfs_vfsop_mount_cmp() # 13
self.patch_apfs_mount_upgrade_checks() # 14
self.patch_handle_fsioc_graft() # 15
self.patch_sandbox_hooks() # 16-25
self._patch_num = 0
self.patch_apfs_root_snapshot() # 1
self.patch_apfs_seal_broken() # 2
self.patch_bsd_init_rootvp() # 3
self.patch_proc_check_launch_constraints() # 4-5
self.patch_PE_i_can_has_debugger() # 6-7
self.patch_post_validation_nop() # 8
self.patch_post_validation_cmp() # 9
self.patch_check_dyld_policy() # 10-11
self.patch_apfs_graft() # 12
self.patch_apfs_vfsop_mount_cmp() # 13
self.patch_apfs_mount_upgrade_checks() # 14
self.patch_handle_fsioc_graft() # 15
self.patch_sandbox_hooks() # 16-25
return self.patches
def apply(self):
"""Find all patches and apply them to self.data. Returns patch count."""
self._patch_num = 0
patches = self.find_all()
# emit() already writes patches through to self.data,
# but re-apply in case subclasses override find_all().
for off, patch_bytes, desc in patches:
self.data[off:off + len(patch_bytes)] = patch_bytes
if self.verbose and patches:
self._log(f"\n{''*60}")
self._log(f"VERIFICATION: {len(patches)} patches applied")
self._log(f"{''*60}")
for off, patch_bytes, desc in sorted(patches):
insns = self._disas_n(self.data, off, len(patch_bytes) // 4)
if insns:
dis_str = "; ".join(f"{i.mnemonic} {i.op_str}" for i in insns)
else:
dis_str = "???"
self._log(f" 0x{off:08X}: {dis_str:40s}{desc}")
self.data[off : off + len(patch_bytes)] = patch_bytes
return len(patches)
@@ -1335,12 +1422,22 @@ if __name__ == "__main__":
import sys, argparse
parser = argparse.ArgumentParser(
description="Dynamic kernel patcher — find & apply patches on iOS kernelcaches")
description="Dynamic kernel patcher — find & apply patches on iOS kernelcaches"
)
parser.add_argument("kernelcache", help="Path to raw or IM4P kernelcache")
parser.add_argument("-c", "--context", type=int, default=5,
help="Instructions of context before/after each patch (default: 5)")
parser.add_argument("-q", "--quiet", action="store_true",
help="Suppress index-building progress (only show patches)")
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show detailed before/after disassembly for each patch",
)
parser.add_argument(
"-c",
"--context",
type=int,
default=5,
help="Instructions of context before/after each patch (default: 5, requires -v)",
)
args = parser.parse_args()
path = args.kernelcache
@@ -1354,6 +1451,7 @@ if __name__ == "__main__":
else:
try:
from pyimg4 import IM4P
im4p = IM4P(file_raw)
if im4p.payload.compression:
im4p.payload.decompress()
@@ -1364,58 +1462,61 @@ if __name__ == "__main__":
print(f" format: unknown (treating as raw)")
data = bytearray(payload)
print(f" size: {len(data)} bytes ({len(data)/1024/1024:.1f} MB)\n")
print(f" size: {len(data)} bytes ({len(data) / 1024 / 1024:.1f} MB)\n")
kp = KernelPatcher(data, verbose=not args.quiet)
kp = KernelPatcher(data, verbose=args.verbose)
patches = kp.find_all()
print(f"\n {len(patches)} patches found")
# ── Print ranged before / after disassembly for every patch ──
ctx = args.context
if args.verbose:
# ── Print ranged before / after disassembly for every patch ──
ctx = args.context
print(f"\n{''*72}")
print(f" {len(patches)} PATCHES — before / after disassembly (context={ctx})")
print(f"{''*72}")
print(f"\n{'' * 72}")
print(f" {len(patches)} PATCHES — before / after disassembly (context={ctx})")
print(f"{'' * 72}")
# Apply patches to get the "after" image
after = bytearray(kp.raw) # start from original
for off, pb, _ in patches:
after[off:off + len(pb)] = pb
# Apply patches to get the "after" image
after = bytearray(kp.raw) # start from original
for off, pb, _ in patches:
after[off : off + len(pb)] = pb
for i, (off, patch_bytes, desc) in enumerate(sorted(patches), 1):
n_insns = len(patch_bytes) // 4
start = max(off - ctx * 4, 0)
end = off + n_insns * 4 + ctx * 4
total = (end - start) // 4
for i, (off, patch_bytes, desc) in enumerate(sorted(patches), 1):
n_insns = len(patch_bytes) // 4
start = max(off - ctx * 4, 0)
end = off + n_insns * 4 + ctx * 4
total = (end - start) // 4
before_insns = kp._disas_n(kp.raw, start, total)
after_insns = kp._disas_n(after, start, total)
before_insns = kp._disas_n(kp.raw, start, total)
after_insns = kp._disas_n(after, start, total)
print(f"\n{''*70}")
print(f" │ [{i:2d}] 0x{off:08X}: {desc}")
print(f"{''*34}{''*35}")
print(f"{'BEFORE':^33}{'AFTER':^34}")
print(f"{''*34}{''*35}")
print(f"\n{'' * 70}")
print(f" │ [{i:2d}] 0x{off:08X}: {desc}")
print(f"{'' * 34}{'' * 35}")
print(f"{'BEFORE':^33}{'AFTER':^34}")
print(f"{'' * 34}{'' * 35}")
# Build line pairs
max_lines = max(len(before_insns), len(after_insns))
for j in range(max_lines):
def fmt(insn):
if insn is None:
return " " * 33
h = insn.bytes.hex()
return f"0x{insn.address:07X} {h:8s} {insn.mnemonic:6s} {insn.op_str}"
# Build line pairs
max_lines = max(len(before_insns), len(after_insns))
for j in range(max_lines):
bi = before_insns[j] if j < len(before_insns) else None
ai = after_insns[j] if j < len(after_insns) else None
def fmt(insn):
if insn is None:
return " " * 33
h = insn.bytes.hex()
return f"0x{insn.address:07X} {h:8s} {insn.mnemonic:6s} {insn.op_str}"
bl = fmt(bi)
al = fmt(ai)
bi = before_insns[j] if j < len(before_insns) else None
ai = after_insns[j] if j < len(after_insns) else None
# Mark if this address is inside the patched range
addr = (bi.address if bi else ai.address) if (bi or ai) else 0
in_patch = off <= addr < off + len(patch_bytes)
marker = "" if in_patch else " "
bl = fmt(bi)
al = fmt(ai)
print(f"{bl:33s}{al:33s}{marker}")
# Mark if this address is inside the patched range
addr = (bi.address if bi else ai.address) if (bi or ai) else 0
in_patch = off <= addr < off + len(patch_bytes)
marker = "" if in_patch else " "
print(f" {''*34}{''*35}")
print(f" {bl:33s}{al:33s}{marker}")
print(f"{'' * 34}{'' * 35}")
File diff suppressed because it is too large Load Diff
+33 -23
View File
@@ -30,7 +30,7 @@ MOV_X0_0 = _asm("mov x0, #0")
def _disasm_one(data, off):
insns = list(_cs.disasm(data[off:off + 4], off))
insns = list(_cs.disasm(data[off : off + 4], off))
return insns[0] if insns else None
@@ -50,6 +50,7 @@ def _find_asm_pattern(data, asm_str):
# ── TXMPatcher ─────────────────────────────────────────────────
class TXMPatcher:
"""Dynamic patcher for TXM images.
@@ -60,9 +61,9 @@ class TXMPatcher:
"""
def __init__(self, data, verbose=True):
self.data = data
self.raw = bytes(data)
self.size = len(data)
self.data = data
self.raw = bytes(data)
self.size = len(data)
self.verbose = verbose
self.patches = []
@@ -73,18 +74,24 @@ class TXMPatcher:
def emit(self, off, patch_bytes, desc):
self.patches.append((off, patch_bytes, desc))
if self.verbose:
before_insns = list(_cs.disasm(self.raw[off:off + 4], off))
before_insns = list(_cs.disasm(self.raw[off : off + 4], off))
after_insns = list(_cs.disasm(patch_bytes, off))
b_str = (f"{before_insns[0].mnemonic} {before_insns[0].op_str}"
if before_insns else "???")
a_str = (f"{after_insns[0].mnemonic} {after_insns[0].op_str}"
if after_insns else "???")
b_str = (
f"{before_insns[0].mnemonic} {before_insns[0].op_str}"
if before_insns
else "???"
)
a_str = (
f"{after_insns[0].mnemonic} {after_insns[0].op_str}"
if after_insns
else "???"
)
print(f" 0x{off:06X}: {b_str}{a_str} [{desc}]")
def apply(self):
self.find_all()
for off, pb, _ in self.patches:
self.data[off:off + len(pb)] = pb
self.data[off : off + len(pb)] = pb
if self.verbose and self.patches:
self._log(f"\n [{len(self.patches)} TXM patches applied]")
return len(self.patches)
@@ -114,8 +121,7 @@ class TXMPatcher:
# Step 1: Find the unique function marker (mov w19, #0x2446)
locs = _find_asm_pattern(self.raw, "mov w19, #0x2446")
if len(locs) != 1:
self._log(f" [-] TXM: expected 1 'mov w19, #0x2446', "
f"found {len(locs)}")
self._log(f" [-] TXM: expected 1 'mov w19, #0x2446', found {len(locs)}")
return
marker_off = locs[0]
@@ -123,7 +129,7 @@ class TXMPatcher:
pacibsp = _asm("hint #27")
func_start = None
for scan in range(marker_off & ~3, max(0, marker_off - 0x200), -4):
if self.raw[scan:scan + 4] == pacibsp:
if self.raw[scan : scan + 4] == pacibsp:
func_start = scan
break
if func_start is None:
@@ -135,19 +141,23 @@ class TXMPatcher:
insns = list(_cs.disasm(self.raw[func_start:func_end], func_start))
for i, ins in enumerate(insns):
if not (ins.mnemonic == 'mov' and ins.op_str == 'w2, #0x14'):
if not (ins.mnemonic == "mov" and ins.op_str == "w2, #0x14"):
continue
if i + 3 >= len(insns):
continue
bl_ins = insns[i + 1]
cbz_ins = insns[i + 2]
tbnz_ins = insns[i + 3]
if (bl_ins.mnemonic == 'bl'
and cbz_ins.mnemonic == 'cbz' and 'w0' in cbz_ins.op_str
and tbnz_ins.mnemonic in ('tbnz', 'tbz')
and '#0x1f' in tbnz_ins.op_str):
self.emit(bl_ins.address, MOV_X0_0,
"trustcache bypass: bl → mov x0, #0")
if (
bl_ins.mnemonic == "bl"
and cbz_ins.mnemonic == "cbz"
and "w0" in cbz_ins.op_str
and tbnz_ins.mnemonic in ("tbnz", "tbz")
and "#0x1f" in tbnz_ins.op_str
):
self.emit(
bl_ins.address, MOV_X0_0, "trustcache bypass: bl → mov x0, #0"
)
return
self._log(" [-] TXM: binary search pattern not found in function")
@@ -157,8 +167,7 @@ class TXMPatcher:
if __name__ == "__main__":
import sys, argparse
parser = argparse.ArgumentParser(
description="Dynamic TXM patcher")
parser = argparse.ArgumentParser(description="Dynamic TXM patcher")
parser.add_argument("txm", help="Path to raw or IM4P TXM image")
parser.add_argument("-q", "--quiet", action="store_true")
args = parser.parse_args()
@@ -168,6 +177,7 @@ if __name__ == "__main__":
try:
from pyimg4 import IM4P
im4p = IM4P(file_raw)
if im4p.payload.compression:
im4p.payload.decompress()
@@ -178,7 +188,7 @@ if __name__ == "__main__":
print(f" format: raw")
data = bytearray(payload)
print(f" size: {len(data)} bytes ({len(data)/1024:.1f} KB)\n")
print(f" size: {len(data)} bytes ({len(data) / 1024:.1f} KB)\n")
patcher = TXMPatcher(data, verbose=not args.quiet)
n = patcher.apply()
+119 -73
View File
@@ -26,15 +26,14 @@ class TXMJBPatcher(TXMPatcher):
def apply(self):
self.find_all()
for off, pb, _ in self.patches:
self.data[off:off + len(pb)] = pb
self.data[off : off + len(pb)] = pb
if self.verbose and self.patches:
self._log(f"\n [{len(self.patches)} TXM JB patches applied]")
return len(self.patches)
def find_all(self):
self.patches = []
self.patch_selector24_hashcmp_calls()
self.patch_selector24_a1_path()
self.patch_selector24_hash_extraction_nop()
self.patch_get_task_allow_force_true()
self.patch_selector42_29_shellcode()
self.patch_debugger_entitlement_force_true()
@@ -51,14 +50,14 @@ class TXMJBPatcher(TXMPatcher):
def _find_func_start(self, off, back=0x1000):
start = max(0, off - back)
for scan in range(off & ~3, start - 1, -4):
if self.raw[scan:scan + 4] == PACIBSP:
if self.raw[scan : scan + 4] == PACIBSP:
return scan
return None
def _find_func_end(self, func_start, forward=0x1200):
end = min(self.size, func_start + forward)
for scan in range(func_start + 4, end, 4):
if self.raw[scan:scan + 4] == PACIBSP:
if self.raw[scan : scan + 4] == PACIBSP:
return scan
return end
@@ -115,10 +114,15 @@ class TXMJBPatcher(TXMPatcher):
p2 = _disasm_one(self.raw, scan - 8) if scan >= 8 else None
if not all((i, n, p1, p2)):
continue
if not (i.mnemonic == "bl"
and n.mnemonic == "tbnz" and n.op_str.startswith("w0, #0,")
and p1.mnemonic == "mov" and p1.op_str == "x2, #0"
and p2.mnemonic == "mov" and p2.op_str == "x0, #0"):
if not (
i.mnemonic == "bl"
and n.mnemonic == "tbnz"
and n.op_str.startswith("w0, #0,")
and p1.mnemonic == "mov"
and p1.op_str == "x2, #0"
and p2.mnemonic == "mov"
and p2.op_str == "x0, #0"
):
continue
fs = self._find_func_start(scan)
if fs is not None:
@@ -136,13 +140,25 @@ class TXMJBPatcher(TXMPatcher):
off = start
while off < end:
run = off
while run < end and self.raw[run:run + 4] == b"\x00\x00\x00\x00":
while run < end and self.raw[run : run + 4] == b"\x00\x00\x00\x00":
run += 4
if run - off >= need:
prev = _disasm_one(self.raw, off - 4) if off >= 4 else None
if prev and prev.mnemonic in (
"b", "b.eq", "b.ne", "b.lo", "b.hs", "cbz", "cbnz", "tbz", "tbnz"
"b",
"b.eq",
"b.ne",
"b.lo",
"b.hs",
"cbz",
"cbnz",
"tbz",
"tbnz",
):
# Leave 2-word safety gap after the preceding branch
padded = off + 8
if padded + need <= run:
return padded
return off
if near_off is not None and _disasm_one(self.raw, off):
dist = abs(off - near_off)
@@ -153,55 +169,56 @@ class TXMJBPatcher(TXMPatcher):
return best
# ── JB patches ───────────────────────────────────────────────
def patch_selector24_hashcmp_calls(self):
"""Patch remaining selector-24 hashcmp BL callsites: bl -> mov x0,#0."""
patched = 0
for off in range(0, self.size - 8, 4):
i0 = _disasm_one(self.raw, off)
i1 = _disasm_one(self.raw, off + 4)
i2 = _disasm_one(self.raw, off + 8)
if not i0 or not i1 or not i2:
continue
if not (i0.mnemonic == "mov" and i0.op_str == "w2, #0x14"):
continue
if not (i1.mnemonic == "bl" and i2.mnemonic == "cbz"
and i2.op_str.startswith("w0,")):
continue
self.emit(off + 4, MOV_X0_0,
f"selector24 hashcmp bypass #{patched + 1}: bl -> mov x0,#0")
patched += 1
def patch_selector24_hash_extraction_nop(self):
"""NOP the hash flags extraction BL and its LDR X1 arg setup.
if patched > 3:
self._log(f" [-] TXM JB: selector24 hashcmp sites too many ({patched})")
return False
if patched == 0:
self._log(" [-] TXM JB: no selector24 hashcmp BL sites to patch")
return False
return True
The CS hash validator function has a distinctive dual-BL pattern:
LDR X0, [Xn, #0x30] ; blob data
LDR X1, [Xn, #0x38] ; blob size <-- NOP
ADD X2, SP, #... ; output ptr
BL hash_flags_extract ; <-- NOP
LDP X0, X1, [Xn, #0x30] ; reload for 2nd call
ADD X2, SP, #...
BL hash_data_lookup ; (keep)
def patch_selector24_a1_path(self):
"""Selector-24 A1 path bypass: NOP b.lo + cbz around mov w0,#0xa1."""
locs = []
for scan in range(0, self.size - 4, 4):
ins = _disasm_one(self.raw, scan)
if ins and ins.mnemonic == "mov" and ins.op_str == "w0, #0xa1":
i_blo = _disasm_one(self.raw, scan - 0xC)
i_cbz = _disasm_one(self.raw, scan - 0x4)
if not i_blo or not i_cbz:
Found via 'mov w0, #0xa1' anchor unique to this function.
"""
for off in range(0, self.size - 4, 4):
ins = _disasm_one(self.raw, off)
if not (ins and ins.mnemonic == "mov" and ins.op_str == "w0, #0xa1"):
continue
func_start = self._find_func_start(off)
if func_start is None:
continue
# Scan function for: LDR X1,[Xn,#0x38] / ADD X2,... / BL / LDP
for scan in range(func_start, off, 4):
i0 = _disasm_one(self.raw, scan)
i1 = _disasm_one(self.raw, scan + 4)
i2 = _disasm_one(self.raw, scan + 8)
i3 = _disasm_one(self.raw, scan + 12)
if not all((i0, i1, i2, i3)):
continue
if not (
i0.mnemonic == "ldr"
and "x1," in i0.op_str
and "#0x38]" in i0.op_str
):
continue
if not (i1.mnemonic == "add" and i1.op_str.startswith("x2,")):
continue
if i2.mnemonic != "bl":
continue
if i3.mnemonic != "ldp":
continue
if (i_blo.mnemonic == "b.lo"
and i_cbz.mnemonic == "cbz"
and i_cbz.op_str.startswith("x9,")):
locs.append(scan)
if len(locs) != 1:
self._log(f" [-] TXM JB: expected 1 selector24 A1 site, found {len(locs)}")
return False
off = locs[0]
self.emit(scan, NOP, "selector24 CS: nop ldr x1,[xN,#0x38]")
self.emit(scan + 8, NOP, "selector24 CS: nop bl hash_flags_extract")
return True
self.emit(off - 0xC, NOP, "selector24 A1: b.lo -> nop")
self.emit(off - 0x4, NOP, "selector24 A1: cbz x9 -> nop")
return True
self._log(" [-] TXM JB: selector24 hash extraction site not found")
return False
def patch_get_task_allow_force_true(self):
"""Force get-task-allow entitlement call to return true."""
@@ -217,11 +234,17 @@ class TXMJBPatcher(TXMPatcher):
n = _disasm_one(self.raw, scan + 4)
if not i or not n:
continue
if i.mnemonic == "bl" and n.mnemonic == "tbnz" and n.op_str.startswith("w0, #0,"):
if (
i.mnemonic == "bl"
and n.mnemonic == "tbnz"
and n.op_str.startswith("w0, #0,")
):
cands.append(scan)
if len(cands) != 1:
self._log(f" [-] TXM JB: expected 1 get-task-allow BL site, found {len(cands)}")
self._log(
f" [-] TXM JB: expected 1 get-task-allow BL site, found {len(cands)}"
)
return False
self.emit(cands[0], MOV_X0_1, "get-task-allow: bl -> mov x0,#1")
@@ -249,17 +272,24 @@ class TXMJBPatcher(TXMPatcher):
continue
if not (i0.mnemonic == "mov" and i0.op_str == "x0, x20"):
continue
if not (i1.mnemonic == "bl" and i2.mnemonic == "mov"
and i2.op_str == "x1, x21"):
if not (
i1.mnemonic == "bl" and i2.mnemonic == "mov" and i2.op_str == "x1, x21"
):
continue
if not (i3.mnemonic == "mov" and i3.op_str == "x2, x22"
and i4.mnemonic == "bl" and i5.mnemonic == "b"):
if not (
i3.mnemonic == "mov"
and i3.op_str == "x2, x22"
and i4.mnemonic == "bl"
and i5.mnemonic == "b"
):
continue
if i4.operands and i4.operands[0].imm == fn:
stubs.append(off)
if len(stubs) != 1:
self._log(f" [-] TXM JB: selector42|29 stub expected 1, found {len(stubs)}")
self._log(
f" [-] TXM JB: selector42|29 stub expected 1, found {len(stubs)}"
)
return False
stub_off = stubs[0]
@@ -268,14 +298,22 @@ class TXMJBPatcher(TXMPatcher):
self._log(" [-] TXM JB: no UDF cave found for selector42|29 shellcode")
return False
self.emit(stub_off, self._asm_at(f"b #0x{cave:X}", stub_off),
"selector42|29: branch to shellcode")
self.emit(
stub_off,
self._asm_at(f"b #0x{cave:X}", stub_off),
"selector42|29: branch to shellcode",
)
self.emit(cave, NOP, "selector42|29 shellcode pad: udf -> nop")
self.emit(cave + 4, MOV_X0_1, "selector42|29 shellcode: mov x0,#1")
self.emit(cave + 8, STRB_W0_X20_30, "selector42|29 shellcode: strb w0,[x20,#0x30]")
self.emit(
cave + 8, STRB_W0_X20_30, "selector42|29 shellcode: strb w0,[x20,#0x30]"
)
self.emit(cave + 12, MOV_X0_X20, "selector42|29 shellcode: mov x0,x20")
self.emit(cave + 16, self._asm_at(f"b #0x{stub_off + 4:X}", cave + 16),
"selector42|29 shellcode: branch back")
self.emit(
cave + 16,
self._asm_at(f"b #0x{stub_off + 4:X}", cave + 16),
"selector42|29 shellcode: branch back",
)
return True
def patch_debugger_entitlement_force_true(self):
@@ -294,10 +332,15 @@ class TXMJBPatcher(TXMPatcher):
p2 = _disasm_one(self.raw, scan - 8) if scan >= 8 else None
if not all((i, n, p1, p2)):
continue
if (i.mnemonic == "bl"
and n.mnemonic == "tbnz" and n.op_str.startswith("w0, #0,")
and p1.mnemonic == "mov" and p1.op_str == "x2, #0"
and p2.mnemonic == "mov" and p2.op_str == "x0, #0"):
if (
i.mnemonic == "bl"
and n.mnemonic == "tbnz"
and n.op_str.startswith("w0, #0,")
and p1.mnemonic == "mov"
and p1.op_str == "x2, #0"
and p2.mnemonic == "mov"
and p2.op_str == "x0, #0"
):
cands.append(scan)
if len(cands) != 1:
@@ -310,7 +353,8 @@ class TXMJBPatcher(TXMPatcher):
def patch_developer_mode_bypass(self):
"""Developer-mode bypass: NOP conditional guard before deny log path."""
refs = self._find_string_refs(
b"developer mode enabled due to system policy configuration")
b"developer mode enabled due to system policy configuration"
)
if not refs:
self._log(" [-] TXM JB: developer-mode string ref not found")
return False
@@ -328,7 +372,9 @@ class TXMJBPatcher(TXMPatcher):
cands.append(back)
if len(cands) != 1:
self._log(f" [-] TXM JB: expected 1 developer mode guard, found {len(cands)}")
self._log(
f" [-] TXM JB: expected 1 developer mode guard, found {len(cands)}"
)
return False
self.emit(cands[0], NOP, "developer mode bypass")
+215 -150
View File
@@ -22,7 +22,6 @@ Prerequisites:
import gzip
import glob
import os
import platform
import plistlib
import shutil
import subprocess
@@ -33,7 +32,7 @@ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
sys.path.insert(0, _SCRIPT_DIR)
from pyimg4 import IM4P
from pyimg4 import IM4M, IM4P, IMG4
from fw_patch import (
load_firmware,
@@ -61,16 +60,24 @@ KERNEL_FOURCC = "rkrn"
# Files to remove from ramdisk to save space
RAMDISK_REMOVE = [
"usr/bin/img4tool", "usr/bin/img4",
"usr/sbin/dietappleh13camerad", "usr/sbin/dietappleh16camerad",
"usr/local/bin/wget", "usr/local/bin/procexp",
"usr/bin/img4tool",
"usr/bin/img4",
"usr/sbin/dietappleh13camerad",
"usr/sbin/dietappleh16camerad",
"usr/local/bin/wget",
"usr/local/bin/procexp",
]
# Directories to re-sign in ramdisk
SIGN_DIRS = [
"usr/local/bin/*", "usr/local/lib/*",
"usr/bin/*", "bin/*",
"usr/lib/*", "sbin/*", "usr/sbin/*", "usr/libexec/*",
"usr/local/bin/*",
"usr/local/lib/*",
"usr/bin/*",
"bin/*",
"usr/lib/*",
"sbin/*",
"usr/sbin/*",
"usr/libexec/*",
]
# Compressed archive of ramdisk_input/ (located next to this script)
@@ -81,6 +88,7 @@ INPUT_ARCHIVE = "ramdisk_input.tar.zst"
# Setup — extract ramdisk_input/ from zstd archive if needed
# ══════════════════════════════════════════════════════════════════
def setup_input(vm_dir):
"""Ensure ramdisk_input/ exists, extracting from .tar.zst if needed."""
input_dir = os.path.join(vm_dir, INPUT_DIR)
@@ -108,6 +116,7 @@ def setup_input(vm_dir):
# SHSH / signing helpers
# ══════════════════════════════════════════════════════════════════
def find_shsh(shsh_dir):
"""Find first SHSH blob in directory."""
for ext in ("*.shsh", "*.shsh2"):
@@ -127,28 +136,23 @@ def extract_im4m(shsh_path, im4m_path):
open(tmp, "wb").write(raw)
subprocess.run(
["pyimg4", "im4m", "extract", "-i", tmp, "-o", im4m_path],
check=True, capture_output=True,
check=True,
capture_output=True,
)
finally:
if os.path.exists(tmp):
os.remove(tmp)
def sign_img4(im4p_path, img4_path, im4m_path, tag=None, input_dir="."):
"""Create IMG4 from IM4P + IM4M. Uses tools/img4 for tag override."""
def sign_img4(im4p_path, img4_path, im4m_path, tag=None):
"""Create IMG4 from IM4P + IM4M using pyimg4 Python API."""
im4p = IM4P(open(im4p_path, "rb").read())
if tag:
img4_tool = os.path.join(input_dir, "tools/img4")
subprocess.run(
[img4_tool, "-i", im4p_path, "-o", img4_path,
"-M", im4m_path, "-T", tag],
check=True, capture_output=True,
)
else:
subprocess.run(
["pyimg4", "img4", "create",
"-p", im4p_path, "-o", img4_path, "-m", im4m_path],
check=True, capture_output=True,
)
im4p.fourcc = tag
im4m = IM4M(open(im4m_path, "rb").read())
img4 = IMG4(im4p=im4p, im4m=im4m)
with open(img4_path, "wb") as f:
f.write(img4.output())
def run(cmd, **kwargs):
@@ -156,60 +160,25 @@ def run(cmd, **kwargs):
return subprocess.run(cmd, check=True, **kwargs)
def is_exec_compatible(path):
"""Return True if an executable is usable on this host arch."""
if not path or not os.path.isfile(path) or not os.access(path, os.X_OK):
return False
file_out = subprocess.run(
["file", path], capture_output=True, text=True
).stdout
# Non-Mach-O executables (scripts/wrappers) are accepted.
if "Mach-O" not in file_out:
return True
host_arch = platform.machine()
try:
archs = subprocess.run(
["lipo", "-archs", path], capture_output=True, text=True, check=True
).stdout.split()
return host_arch in archs
except Exception:
return host_arch in file_out or "universal" in file_out
def resolve_tar_extractor(input_dir):
"""Select tar tool with compatibility fallback and clear diagnostics."""
bundled_gtar = os.path.join(input_dir, "tools/gtar")
host_gtar = shutil.which("gtar")
host_tar = shutil.which("tar")
host_arch = platform.machine()
if is_exec_compatible(bundled_gtar):
return bundled_gtar, True, "bundled gtar"
if os.path.exists(bundled_gtar):
print(f" [!] Bundled gtar is not compatible with host arch ({host_arch}): {bundled_gtar}")
if host_gtar and is_exec_compatible(host_gtar):
return host_gtar, True, "host gtar"
if host_gtar:
print(f" [!] Host gtar is present but incompatible with host arch ({host_arch}): {host_gtar}")
if host_tar and is_exec_compatible(host_tar):
return host_tar, False, "host tar"
if host_tar:
print(f" [!] Host tar is present but incompatible with host arch ({host_arch}): {host_tar}")
print("[-] No compatible tar extractor found.")
print(" Install GNU tar with: brew install gnu-tar")
sys.exit(1)
def check_prerequisites():
"""Verify required host tools are available."""
missing = []
for tool, pkg in [("gtar", "gnu-tar"), ("ldid", "ldid-procursus"), ("trustcache", "trustcache (make setup_tools)")]:
if not shutil.which(tool):
missing.append(f" {tool:12s}{pkg}")
if missing:
print("[-] Missing required tools:")
for m in missing:
print(m)
print("\n Run: make setup_tools")
sys.exit(1)
# ══════════════════════════════════════════════════════════════════
# Firmware extraction and IM4P creation
# ══════════════════════════════════════════════════════════════════
def extract_to_raw(src_path, raw_path):
"""Extract IM4P payload to .raw file. Returns (im4p_obj, data, original_raw)."""
im4p, data, was_im4p, original_raw = load_firmware(src_path)
@@ -233,6 +202,7 @@ def create_im4p_uncompressed(raw_data, fourcc, description, output_path):
# iBEC boot-args patching
# ══════════════════════════════════════════════════════════════════
def patch_ibec_bootargs(data):
"""Replace normal boot-args with ramdisk boot-args in already-patched iBEC.
@@ -247,7 +217,7 @@ def patch_ibec_bootargs(data):
return False
args = RAMDISK_BOOT_ARGS + b"\x00"
data[off:off + len(args)] = args
data[off : off + len(args)] = args
# Zero out any leftover from the previous string
end = off + len(args)
@@ -263,6 +233,7 @@ def patch_ibec_bootargs(data):
# Ramdisk DMG building
# ══════════════════════════════════════════════════════════════════
def build_ramdisk(restore_dir, im4m_path, vm_dir, input_dir, output_dir, temp_dir):
"""Build custom SSH ramdisk from restore DMG."""
# Read RestoreRamDisk path dynamically from BuildManifest.plist
@@ -274,43 +245,78 @@ def build_ramdisk(restore_dir, im4m_path, vm_dir, input_dir, output_dir, temp_di
mountpoint = os.path.join(vm_dir, "SSHRD")
ramdisk_raw = os.path.join(temp_dir, "ramdisk.raw.dmg")
ramdisk_custom = os.path.join(temp_dir, "ramdisk1.dmg")
tar_bin, tar_is_gnu, tar_label = resolve_tar_extractor(input_dir)
print(f" Using archive extractor: {tar_label} ({tar_bin})")
gtar_bin = shutil.which("gtar")
ldid_bin = shutil.which("ldid")
tc_bin = shutil.which("trustcache")
# Extract base ramdisk
print(" Extracting base ramdisk...")
run(["pyimg4", "im4p", "extract", "-i", ramdisk_src, "-o", ramdisk_raw],
capture_output=True)
run(
["pyimg4", "im4p", "extract", "-i", ramdisk_src, "-o", ramdisk_raw],
capture_output=True,
)
os.makedirs(mountpoint, exist_ok=True)
try:
# Mount, create expanded copy
print(" Mounting base ramdisk...")
run(["sudo", "hdiutil", "attach", "-mountpoint", mountpoint,
ramdisk_raw, "-owners", "off"])
run(
[
"sudo",
"hdiutil",
"attach",
"-mountpoint",
mountpoint,
ramdisk_raw,
"-owners",
"off",
]
)
print(" Creating expanded ramdisk (254 MB)...")
run(["sudo", "hdiutil", "create", "-size", "254m",
"-imagekey", "diskimage-class=CRawDiskImage",
"-format", "UDZO", "-fs", "APFS", "-layout", "NONE",
"-srcfolder", mountpoint, "-copyuid", "root",
ramdisk_custom])
run(
[
"sudo",
"hdiutil",
"create",
"-size",
"254m",
"-imagekey",
"diskimage-class=CRawDiskImage",
"-format",
"UDZO",
"-fs",
"APFS",
"-layout",
"NONE",
"-srcfolder",
mountpoint,
"-copyuid",
"root",
ramdisk_custom,
]
)
run(["sudo", "hdiutil", "detach", "-force", mountpoint])
# Mount expanded, inject SSH
print(" Mounting expanded ramdisk...")
run(["sudo", "hdiutil", "attach", "-mountpoint", mountpoint,
ramdisk_custom, "-owners", "off"])
run(
[
"sudo",
"hdiutil",
"attach",
"-mountpoint",
mountpoint,
ramdisk_custom,
"-owners",
"off",
]
)
print(" Injecting SSH tools...")
ssh_tar = os.path.join(input_dir, "ssh.tar.gz")
extract_cmd = ["sudo", tar_bin, "-x", "-f", ssh_tar, "-C", mountpoint]
if tar_is_gnu:
extract_cmd = ["sudo", tar_bin, "-x", "--no-overwrite-dir",
"-f", ssh_tar, "-C", mountpoint]
run(extract_cmd)
run(["sudo", gtar_bin, "-x", "--no-overwrite-dir", "-f", ssh_tar, "-C", mountpoint])
# Remove unnecessary files
for rel_path in RAMDISK_REMOVE:
@@ -320,17 +326,21 @@ def build_ramdisk(restore_dir, im4m_path, vm_dir, input_dir, output_dir, temp_di
# Re-sign Mach-O binaries
print(" Re-signing Mach-O binaries...")
ldid = os.path.join(input_dir, "tools/ldid_macosx_arm64")
signcert = os.path.join(input_dir, "signcert.p12")
for pattern in SIGN_DIRS:
for path in glob.glob(os.path.join(mountpoint, pattern)):
if os.path.isfile(path) and not os.path.islink(path):
if "Mach-O" in subprocess.run(
["file", path], capture_output=True, text=True,
).stdout:
if (
"Mach-O"
in subprocess.run(
["file", path],
capture_output=True,
text=True,
).stdout
):
subprocess.run(
[ldid, "-S", "-M", f"-K{signcert}", path],
[ldid_bin, "-S", "-M", f"-K{signcert}", path],
capture_output=True,
)
@@ -338,34 +348,44 @@ def build_ramdisk(restore_dir, im4m_path, vm_dir, input_dir, output_dir, temp_di
sftp_ents = os.path.join(input_dir, "sftp_server_ents.plist")
sftp_server = os.path.join(mountpoint, "usr/libexec/sftp-server")
if os.path.exists(sftp_server):
run([ldid, f"-S{sftp_ents}", "-M", f"-K{signcert}", sftp_server])
run([ldid_bin, f"-S{sftp_ents}", "-M", f"-K{signcert}", sftp_server])
# Build trustcache
print(" Building trustcache...")
tc_tool = os.path.join(input_dir, "tools/trustcache_macos_arm64")
tc_raw = os.path.join(temp_dir, "sshrd.raw.tc")
tc_im4p = os.path.join(temp_dir, "trustcache.im4p")
run([tc_tool, "create", tc_raw, mountpoint])
run(["pyimg4", "im4p", "create", "-i", tc_raw, "-o", tc_im4p,
"-f", "rtsc"], capture_output=True)
sign_img4(tc_im4p, os.path.join(output_dir, "trustcache.img4"),
im4m_path, input_dir=input_dir)
run([tc_bin, "create", tc_raw, mountpoint])
run(
["pyimg4", "im4p", "create", "-i", tc_raw, "-o", tc_im4p, "-f", "rtsc"],
capture_output=True,
)
sign_img4(
tc_im4p,
os.path.join(output_dir, "trustcache.img4"),
im4m_path,
)
print(f" [+] trustcache.img4")
finally:
subprocess.run(["sudo", "hdiutil", "detach", "-force", mountpoint],
capture_output=True)
subprocess.run(
["sudo", "hdiutil", "detach", "-force", mountpoint], capture_output=True
)
# Shrink and sign ramdisk
run(["sudo", "hdiutil", "resize", "-sectors", "min", ramdisk_custom])
print(" Signing ramdisk...")
rd_im4p = os.path.join(temp_dir, "ramdisk.im4p")
run(["pyimg4", "im4p", "create", "-i", ramdisk_custom, "-o", rd_im4p,
"-f", "rdsk"], capture_output=True)
sign_img4(rd_im4p, os.path.join(output_dir, "ramdisk.img4"),
im4m_path, input_dir=input_dir)
run(
["pyimg4", "im4p", "create", "-i", ramdisk_custom, "-o", rd_im4p, "-f", "rdsk"],
capture_output=True,
)
sign_img4(
rd_im4p,
os.path.join(output_dir, "ramdisk.img4"),
im4m_path,
)
print(f" [+] ramdisk.img4")
@@ -373,6 +393,7 @@ def build_ramdisk(restore_dir, im4m_path, vm_dir, input_dir, output_dir, temp_di
# Main
# ══════════════════════════════════════════════════════════════════
def main():
vm_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else os.getcwd())
@@ -394,12 +415,8 @@ def main():
print(f"[-] No *Restore* directory found in {vm_dir}")
sys.exit(1)
# Check pyimg4 CLI
try:
subprocess.run(["pyimg4", "--help"], capture_output=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
print("[-] pyimg4 CLI not found. Install with: pip install pyimg4")
sys.exit(1)
# Check host tools
check_prerequisites()
# Setup input resources (copy from CFW if needed)
print(f"[*] Setting up {INPUT_DIR}/...")
@@ -426,96 +443,144 @@ def main():
print(f"\n{'=' * 60}")
print(f" 1. iBSS (already patched — extract & sign)")
print(f"{'=' * 60}")
ibss_src = find_file(restore_dir, [
"Firmware/dfu/iBSS.vresearch101.RELEASE.im4p",
], "iBSS")
ibss_src = find_file(
restore_dir,
[
"Firmware/dfu/iBSS.vresearch101.RELEASE.im4p",
],
"iBSS",
)
ibss_raw = os.path.join(temp_dir, "iBSS.raw")
ibss_im4p = os.path.join(temp_dir, "iBSS.im4p")
im4p_obj, data, _ = extract_to_raw(ibss_src, ibss_raw)
create_im4p_uncompressed(data, im4p_obj.fourcc, im4p_obj.description, ibss_im4p)
sign_img4(ibss_im4p, os.path.join(output_dir, "iBSS.vresearch101.RELEASE.img4"),
im4m_path, input_dir=input_dir)
sign_img4(
ibss_im4p,
os.path.join(output_dir, "iBSS.vresearch101.RELEASE.img4"),
im4m_path,
)
print(f" [+] iBSS.vresearch101.RELEASE.img4")
# ── 2. iBEC (already patched — just fix boot-args for ramdisk)
print(f"\n{'=' * 60}")
print(f" 2. iBEC (patch boot-args for ramdisk)")
print(f"{'=' * 60}")
ibec_src = find_file(restore_dir, [
"Firmware/dfu/iBEC.vresearch101.RELEASE.im4p",
], "iBEC")
ibec_src = find_file(
restore_dir,
[
"Firmware/dfu/iBEC.vresearch101.RELEASE.im4p",
],
"iBEC",
)
ibec_raw = os.path.join(temp_dir, "iBEC.raw")
ibec_im4p = os.path.join(temp_dir, "iBEC.im4p")
im4p_obj, data, _ = extract_to_raw(ibec_src, ibec_raw)
patch_ibec_bootargs(data)
create_im4p_uncompressed(data, im4p_obj.fourcc, im4p_obj.description, ibec_im4p)
sign_img4(ibec_im4p, os.path.join(output_dir, "iBEC.vresearch101.RELEASE.img4"),
im4m_path, input_dir=input_dir)
sign_img4(
ibec_im4p,
os.path.join(output_dir, "iBEC.vresearch101.RELEASE.img4"),
im4m_path,
)
print(f" [+] iBEC.vresearch101.RELEASE.img4")
# ── 3. SPTM (sign only) ─────────────────────────────────────
print(f"\n{'=' * 60}")
print(f" 3. SPTM (sign only)")
print(f"{'=' * 60}")
sptm_src = find_file(restore_dir, [
"Firmware/sptm.vresearch1.release.im4p",
], "SPTM")
sign_img4(sptm_src, os.path.join(output_dir, "sptm.vresearch1.release.img4"),
im4m_path, tag="sptm", input_dir=input_dir)
sptm_src = find_file(
restore_dir,
[
"Firmware/sptm.vresearch1.release.im4p",
],
"SPTM",
)
sign_img4(
sptm_src,
os.path.join(output_dir, "sptm.vresearch1.release.img4"),
im4m_path,
tag="sptm",
)
print(f" [+] sptm.vresearch1.release.img4")
# ── 4. DeviceTree (sign only) ────────────────────────────────
print(f"\n{'=' * 60}")
print(f" 4. DeviceTree (sign only)")
print(f"{'=' * 60}")
dt_src = find_file(restore_dir, [
"Firmware/all_flash/DeviceTree.vphone600ap.im4p",
], "DeviceTree")
sign_img4(dt_src, os.path.join(output_dir, "DeviceTree.vphone600ap.img4"),
im4m_path, tag="rdtr", input_dir=input_dir)
dt_src = find_file(
restore_dir,
[
"Firmware/all_flash/DeviceTree.vphone600ap.im4p",
],
"DeviceTree",
)
sign_img4(
dt_src,
os.path.join(output_dir, "DeviceTree.vphone600ap.img4"),
im4m_path,
tag="rdtr",
)
print(f" [+] DeviceTree.vphone600ap.img4")
# ── 5. SEP (sign only) ───────────────────────────────────────
print(f"\n{'=' * 60}")
print(f" 5. SEP (sign only)")
print(f"{'=' * 60}")
sep_src = find_file(restore_dir, [
"Firmware/all_flash/sep-firmware.vresearch101.RELEASE.im4p",
], "SEP")
sign_img4(sep_src, os.path.join(output_dir, "sep-firmware.vresearch101.RELEASE.img4"),
im4m_path, tag="rsep", input_dir=input_dir)
sep_src = find_file(
restore_dir,
[
"Firmware/all_flash/sep-firmware.vresearch101.RELEASE.im4p",
],
"SEP",
)
sign_img4(
sep_src,
os.path.join(output_dir, "sep-firmware.vresearch101.RELEASE.img4"),
im4m_path,
tag="rsep",
)
print(f" [+] sep-firmware.vresearch101.RELEASE.img4")
# ── 6. TXM (release variant — needs patching) ────────────────
print(f"\n{'=' * 60}")
print(f" 6. TXM (patch release variant)")
print(f"{'=' * 60}")
txm_src = find_file(restore_dir, [
"Firmware/txm.iphoneos.release.im4p",
], "TXM")
txm_src = find_file(
restore_dir,
[
"Firmware/txm.iphoneos.release.im4p",
],
"TXM",
)
txm_raw = os.path.join(temp_dir, "txm.raw")
im4p_obj, data, original_raw = extract_to_raw(txm_src, txm_raw)
patch_txm(data)
txm_im4p = os.path.join(temp_dir, "txm.im4p")
_save_im4p_with_payp(txm_im4p, TXM_FOURCC, data, original_raw)
sign_img4(txm_im4p, os.path.join(output_dir, "txm.img4"),
im4m_path, input_dir=input_dir)
sign_img4(
txm_im4p, os.path.join(output_dir, "txm.img4"), im4m_path
)
print(f" [+] txm.img4")
# ── 7. Kernelcache (already patched — repack with rkrn) ──────
print(f"\n{'=' * 60}")
print(f" 7. Kernelcache (already patched — repack as rkrn)")
print(f"{'=' * 60}")
kc_src = find_file(restore_dir, [
"kernelcache.research.vphone600",
], "kernelcache")
kc_src = find_file(
restore_dir,
[
"kernelcache.research.vphone600",
],
"kernelcache",
)
kc_raw = os.path.join(temp_dir, "kcache.raw")
im4p_obj, data, original_raw = extract_to_raw(kc_src, kc_raw)
print(f" format: IM4P, {len(data)} bytes")
kc_im4p = os.path.join(temp_dir, "krnl.im4p")
_save_im4p_with_payp(kc_im4p, KERNEL_FOURCC, data, original_raw)
sign_img4(kc_im4p, os.path.join(output_dir, "krnl.img4"),
im4m_path, input_dir=input_dir)
sign_img4(
kc_im4p, os.path.join(output_dir, "krnl.img4"), im4m_path
)
print(f" [+] krnl.img4")
# ── 8. Ramdisk + Trustcache ──────────────────────────────────
+72 -85
View File
@@ -17,7 +17,13 @@ LOG="$PREFIX/log"
NPROC="$(sysctl -n hw.logicalcpu)"
SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig"
OPENSSL_PREFIX="$(brew --prefix openssl@3 2>/dev/null || true)"
[[ -d "$OPENSSL_PREFIX" ]] || {
echo "[-] openssl@3 not found. Run: brew install openssl@3" >&2
exit 1
}
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:$OPENSSL_PREFIX/lib/pkgconfig"
export CFLAGS="-mmacosx-version-min=14.0 -isysroot $SDKROOT"
export CPPFLAGS="$CFLAGS"
export LDFLAGS="-mmacosx-version-min=14.0"
@@ -26,39 +32,43 @@ mkdir -p "$SRC" "$LOG"
# ── Helpers ──────────────────────────────────────────────────────
die() { echo "[-] $*" >&2; exit 1; }
die() {
echo "[-] $*" >&2
exit 1
}
check_tools() {
local missing=()
for cmd in autoconf automake pkg-config cmake git; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
command -v glibtoolize &>/dev/null || command -v libtoolize &>/dev/null \
|| missing+=("libtool(ize)")
(( ${#missing[@]} == 0 )) || die "Missing: ${missing[*]} — brew install ${missing[*]}"
local missing=()
for cmd in autoconf automake pkg-config cmake git; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
command -v glibtoolize &>/dev/null || command -v libtoolize &>/dev/null ||
missing+=("libtool(ize)")
((${#missing[@]} == 0)) || die "Missing: ${missing[*]} — brew install ${missing[*]}"
}
clone() {
local url=$1 dir=$2
if [[ -d "$dir/.git" ]]; then
git -C "$dir" fetch --depth 1 origin --quiet
git -C "$dir" reset --hard FETCH_HEAD --quiet
git -C "$dir" clean -fdx --quiet
else
git clone --depth 1 "$url" "$dir" --quiet
fi
local url=$1 dir=$2
if [[ -d "$dir/.git" ]]; then
git -C "$dir" fetch --depth 1 origin --quiet
git -C "$dir" reset --hard FETCH_HEAD --quiet
git -C "$dir" clean -fdx --quiet
else
git clone --depth 1 "$url" "$dir" --quiet
fi
}
build_lib() {
local name=$1; shift
echo " $name"
cd "$SRC/$name"
./autogen.sh --prefix="$PREFIX" \
--enable-shared=no --enable-static=yes \
"$@" > "$LOG/$name-configure.log" 2>&1
make -j"$NPROC" > "$LOG/$name-build.log" 2>&1
make install > "$LOG/$name-install.log" 2>&1
cd "$SRC"
local name=$1
shift
echo " $name"
cd "$SRC/$name"
./autogen.sh --prefix="$PREFIX" \
--enable-shared=no --enable-static=yes \
"$@" >"$LOG/$name-configure.log" 2>&1
make -j"$NPROC" >"$LOG/$name-build.log" 2>&1
make install >"$LOG/$name-install.log" 2>&1
cd "$SRC"
}
# ── Preflight ────────────────────────────────────────────────────
@@ -67,51 +77,28 @@ check_tools
echo "Building libimobiledevice toolchain → $PREFIX"
echo ""
# ── 1. OpenSSL (static) ─────────────────────────────────────────
# ── 1. Core libraries ───────────────────────────────────────────
echo "[1/4] OpenSSL"
OPENSSL_TAG=$(curl -sS "https://api.github.com/repos/openssl/openssl/releases/latest" \
| grep '"tag_name"' | cut -d'"' -f4)
if [[ ! -d "$SRC/openssl/.git" ]]; then
git clone --depth 1 --branch "$OPENSSL_TAG" \
"https://github.com/openssl/openssl" "$SRC/openssl" --quiet
else
cd "$SRC/openssl"
git fetch --depth 1 origin tag "$OPENSSL_TAG" --quiet 2>/dev/null || true
git checkout "$OPENSSL_TAG" --quiet 2>/dev/null || true
git clean -fdx --quiet
cd "$SRC"
fi
echo " openssl ($OPENSSL_TAG)"
cd "$SRC/openssl"
./config --prefix="$PREFIX" no-shared no-tests \
> "$LOG/openssl-configure.log" 2>&1
make -j"$NPROC" > "$LOG/openssl-build.log" 2>&1
make install_sw > "$LOG/openssl-install.log" 2>&1
cd "$SRC"
# ── 2. Core libraries ───────────────────────────────────────────
echo "[2/4] Core libraries"
echo "[1/3] Core libraries (using homebrew openssl@3)"
for lib in libplist libimobiledevice-glue libusbmuxd libtatsu libimobiledevice; do
clone "https://github.com/libimobiledevice/$lib" "$SRC/$lib"
case "$lib" in
libplist|libimobiledevice) build_lib "$lib" --without-cython ;;
*) build_lib "$lib" ;;
esac
clone "https://github.com/libimobiledevice/$lib" "$SRC/$lib"
case "$lib" in
libplist | libimobiledevice) build_lib "$lib" --without-cython ;;
*) build_lib "$lib" ;;
esac
done
# ── 3. libirecovery (+ PCC research VM patch) ───────────────────
# ── 2. libirecovery (+ PCC research VM patch) ───────────────────
echo "[3/4] libirecovery + libzip"
echo "[2/3] libirecovery + libzip"
clone "https://github.com/libimobiledevice/libirecovery" "$SRC/libirecovery"
# PR #150: register iPhone99,11 / vresearch101ap for PCC research VMs
if ! grep -q 'vresearch101ap' "$SRC/libirecovery/src/libirecovery.c"; then
cd "$SRC/libirecovery"
git apply "$SCRIPT_DIR/patches/libirecovery-pcc-vm.patch" \
|| die "Failed to apply libirecovery PCC patch — check context"
cd "$SRC"
cd "$SRC/libirecovery"
git apply "$SCRIPT_DIR/patches/libirecovery-pcc-vm.patch" ||
die "Failed to apply libirecovery PCC patch — check context"
cd "$SRC"
fi
build_lib libirecovery
@@ -119,34 +106,34 @@ build_lib libirecovery
LIBZIP_VER="1.11.4"
if [[ ! -f "$PREFIX/lib/pkgconfig/libzip.pc" ]]; then
echo " libzip"
[[ -d "$SRC/libzip-$LIBZIP_VER" ]] || \
curl -LfsS "https://github.com/nih-at/libzip/releases/download/v$LIBZIP_VER/libzip-$LIBZIP_VER.tar.gz" \
| tar xz -C "$SRC"
cmake -S "$SRC/libzip-$LIBZIP_VER" -B "$SRC/libzip-$LIBZIP_VER/build" \
-DCMAKE_INSTALL_PREFIX="$PREFIX" -DCMAKE_OSX_SYSROOT="$SDKROOT" \
-DBUILD_SHARED_LIBS=OFF -DBUILD_DOC=OFF -DBUILD_EXAMPLES=OFF \
-DBUILD_REGRESS=OFF -DBUILD_TOOLS=OFF \
-DENABLE_BZIP2=OFF -DENABLE_LZMA=OFF -DENABLE_ZSTD=OFF \
-DENABLE_GNUTLS=OFF -DENABLE_MBEDTLS=OFF -DENABLE_OPENSSL=OFF \
> "$LOG/libzip-cmake.log" 2>&1
cmake --build "$SRC/libzip-$LIBZIP_VER/build" -j"$NPROC" \
> "$LOG/libzip-build.log" 2>&1
cmake --install "$SRC/libzip-$LIBZIP_VER/build" \
> "$LOG/libzip-install.log" 2>&1
echo " libzip"
[[ -d "$SRC/libzip-$LIBZIP_VER" ]] ||
curl -LfsS "https://github.com/nih-at/libzip/releases/download/v$LIBZIP_VER/libzip-$LIBZIP_VER.tar.gz" |
tar xz -C "$SRC"
cmake -S "$SRC/libzip-$LIBZIP_VER" -B "$SRC/libzip-$LIBZIP_VER/build" \
-DCMAKE_INSTALL_PREFIX="$PREFIX" -DCMAKE_OSX_SYSROOT="$SDKROOT" \
-DBUILD_SHARED_LIBS=OFF -DBUILD_DOC=OFF -DBUILD_EXAMPLES=OFF \
-DBUILD_REGRESS=OFF -DBUILD_TOOLS=OFF \
-DENABLE_BZIP2=OFF -DENABLE_LZMA=OFF -DENABLE_ZSTD=OFF \
-DENABLE_GNUTLS=OFF -DENABLE_MBEDTLS=OFF -DENABLE_OPENSSL=OFF \
>"$LOG/libzip-cmake.log" 2>&1
cmake --build "$SRC/libzip-$LIBZIP_VER/build" -j"$NPROC" \
>"$LOG/libzip-build.log" 2>&1
cmake --install "$SRC/libzip-$LIBZIP_VER/build" \
>"$LOG/libzip-install.log" 2>&1
fi
# ── 4. idevicerestore ───────────────────────────────────────────
# ── 3. idevicerestore ───────────────────────────────────────────
echo "[4/4] idevicerestore"
echo "[3/3] idevicerestore"
clone "https://github.com/libimobiledevice/idevicerestore" "$SRC/idevicerestore"
build_lib idevicerestore \
libcurl_CFLAGS="-I$SDKROOT/usr/include" \
libcurl_LIBS="-lcurl" \
libcurl_VERSION="$(/usr/bin/curl-config --version | cut -d' ' -f2)" \
zlib_CFLAGS="-I$SDKROOT/usr/include" \
zlib_LIBS="-lz" \
zlib_VERSION="1.2"
libcurl_CFLAGS="-I$SDKROOT/usr/include" \
libcurl_LIBS="-lcurl" \
libcurl_VERSION="$(/usr/bin/curl-config --version | cut -d' ' -f2)" \
zlib_CFLAGS="-I$SDKROOT/usr/include" \
zlib_LIBS="-lz" \
zlib_VERSION="1.2"
# ── Done ─────────────────────────────────────────────────────────
+126 -16
View File
@@ -27,7 +27,14 @@ BOOT_FIFO_FD=""
VM_DIR="${VM_DIR:-vm}"
VM_DIR_ABS="${VM_DIR:A}"
AUTO_KILL_VM_LOCKS="${AUTO_KILL_VM_LOCKS:-0}"
AUTO_KILL_VM_LOCKS="${AUTO_KILL_VM_LOCKS:-1}"
POST_RESTORE_KILL_DELAY="${POST_RESTORE_KILL_DELAY:-30}"
POST_KILL_SETTLE_DELAY="${POST_KILL_SETTLE_DELAY:-5}"
RAMDISK_SSH_TIMEOUT="${RAMDISK_SSH_TIMEOUT:-60}"
RAMDISK_SSH_INTERVAL="${RAMDISK_SSH_INTERVAL:-2}"
RAMDISK_SSH_PORT="${RAMDISK_SSH_PORT:-2222}"
RAMDISK_SSH_USER="${RAMDISK_SSH_USER:-root}"
RAMDISK_SSH_PASS="${RAMDISK_SSH_PASS:-alpine}"
JB_MODE=0
SKIP_PROJECT_SETUP=0
@@ -42,7 +49,9 @@ require_cmd() {
}
collect_vm_lock_pids() {
local -a paths pids file_pids
local -a paths pids
local path pid
typeset -U pids
paths=(
"${VM_DIR_ABS}/nvram.bin"
@@ -53,14 +62,14 @@ collect_vm_lock_pids() {
for path in "${paths[@]}"; do
[[ -e "$path" ]] || continue
file_pids=("${(@f)$(lsof -t -- "$path" 2>/dev/null || true)}")
for pid in "${file_pids[@]}"; do
[[ -n "$pid" ]] && pids+=("$pid")
done
while IFS= read -r pid; do
[[ "$pid" == <-> ]] || continue
[[ "$pid" == "$$" ]] && continue
pids+=("$pid")
done < <(lsof -t -- "$path" 2>/dev/null || true)
done
pids=("${(@u)pids}")
(( ${#pids[@]} > 0 )) && print -l -- "${pids[@]}"
(( ${#pids[@]} > 0 )) && print -l -- "${pids[@]}" || true
}
check_vm_storage_locks() {
@@ -70,7 +79,7 @@ check_vm_storage_locks() {
fi
local -a lock_pids
lock_pids=("${(@f)$(collect_vm_lock_pids)}")
lock_pids=(${(@f)$(collect_vm_lock_pids)})
(( ${#lock_pids[@]} == 0 )) && return
echo "[-] VM storage files are currently in use: ${VM_DIR_ABS}"
@@ -92,7 +101,7 @@ check_vm_storage_locks() {
done
sleep 1
lock_pids=("${(@f)$(collect_vm_lock_pids)}")
lock_pids=(${(@f)$(collect_vm_lock_pids)})
(( ${#lock_pids[@]} == 0 )) && { echo "[+] Cleared VM storage locks"; return; }
echo "[-] VM storage locks still present after AUTO_KILL_VM_LOCKS attempt."
fi
@@ -118,6 +127,23 @@ kill_descendants() {
[[ ${#descendants[@]} -gt 0 ]] && kill -9 "${descendants[@]}" >/dev/null 2>&1 || true
}
force_release_vm_locks() {
local -a lock_pids
local pid
lock_pids=(${(@f)$(collect_vm_lock_pids)})
(( ${#lock_pids[@]} == 0 )) && return
echo "[*] Releasing lingering VM lock holders..."
for pid in "${lock_pids[@]}"; do
[[ -z "$pid" || "$pid" == "$$" ]] && continue
kill_descendants "$pid"
kill -9 "$pid" >/dev/null 2>&1 || true
done
sleep 1
}
cleanup() {
if [[ -n "$BOOT_FIFO_FD" ]]; then
exec {BOOT_FIFO_FD}>&- || true
@@ -275,6 +301,32 @@ stop_boot_dfu() {
wait "$DFU_PID" 2>/dev/null || true
fi
DFU_PID=""
force_release_vm_locks
}
wait_for_post_restore_reboot() {
local remaining="${POST_RESTORE_KILL_DELAY}"
local panic_seen=0
echo "[*] Restore complete; waiting up to ${POST_RESTORE_KILL_DELAY}s for reboot/panic before stopping DFU..."
while (( remaining > 0 )); do
if [[ -f "$DFU_LOG" ]] && grep -Eiq 'panic|kernel panic' "$DFU_LOG"; then
panic_seen=1
break
fi
if [[ -n "$DFU_PID" ]] && ! kill -0 "$DFU_PID" 2>/dev/null; then
echo "[*] DFU process exited during post-restore reboot window."
return
fi
sleep 1
(( remaining-- ))
done
if (( panic_seen == 1 )); then
echo "[+] Panic marker observed; stopping DFU now."
else
echo "[*] No panic marker observed in ${POST_RESTORE_KILL_DELAY}s; stopping DFU anyway."
fi
}
wait_for_recovery() {
@@ -298,9 +350,22 @@ wait_for_recovery() {
start_iproxy_2222() {
local iproxy_bin
local -a stale_pids
local pid
iproxy_bin="${PROJECT_ROOT}/.limd/bin/iproxy"
[[ -x "$iproxy_bin" ]] || die "iproxy not found at $iproxy_bin (run: make setup_libimobiledevice)"
stale_pids=(${(@f)$(lsof -n -t -iTCP:2222 -sTCP:LISTEN 2>/dev/null || true)})
if (( ${#stale_pids[@]} > 0 )); then
echo "[*] Found stale listener(s) on tcp/2222, terminating..."
for pid in "${stale_pids[@]}"; do
[[ -z "$pid" || "$pid" == "$$" ]] && continue
kill_descendants "$pid"
kill -9 "$pid" >/dev/null 2>&1 || true
done
sleep 1
fi
mkdir -p "$LOG_DIR"
: > "$IPROXY_LOG"
@@ -318,6 +383,49 @@ start_iproxy_2222() {
echo "[+] iproxy running (pid=$IPROXY_PID, log=$IPROXY_LOG)"
}
wait_for_ramdisk_ssh() {
local sshpass_bin
local waited=0
[[ "$RAMDISK_SSH_TIMEOUT" == <-> ]] || die "RAMDISK_SSH_TIMEOUT must be an integer (seconds)"
[[ "$RAMDISK_SSH_INTERVAL" == <-> ]] || die "RAMDISK_SSH_INTERVAL must be an integer (seconds)"
(( RAMDISK_SSH_TIMEOUT > 0 )) || die "RAMDISK_SSH_TIMEOUT must be > 0"
(( RAMDISK_SSH_INTERVAL > 0 )) || die "RAMDISK_SSH_INTERVAL must be > 0"
sshpass_bin="$(command -v sshpass || true)"
[[ -x "$sshpass_bin" ]] || die "sshpass not found (run: make setup_tools)"
echo "[*] Waiting for ramdisk SSH on ${RAMDISK_SSH_USER}@127.0.0.1:${RAMDISK_SSH_PORT} (timeout=${RAMDISK_SSH_TIMEOUT}s)..."
while (( waited < RAMDISK_SSH_TIMEOUT )); do
if "$sshpass_bin" -p "$RAMDISK_SSH_PASS" ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o PreferredAuthentications=password \
-o ConnectTimeout=5 \
-q \
-p "$RAMDISK_SSH_PORT" \
"${RAMDISK_SSH_USER}@127.0.0.1" "echo ready" >/dev/null 2>&1
then
echo "[+] Ramdisk SSH is ready"
return
fi
if (( waited == 0 || waited % 10 == 0 )); then
echo " waiting... ${waited}s elapsed"
fi
sleep "$RAMDISK_SSH_INTERVAL"
(( waited += RAMDISK_SSH_INTERVAL ))
done
echo "[-] Timed out waiting for ramdisk SSH readiness."
echo "[-] iproxy log tail:"
tail -n 40 "$IPROXY_LOG" 2>/dev/null || true
echo "[-] boot_dfu log tail:"
tail -n 60 "$DFU_LOG" 2>/dev/null || true
die "Ramdisk SSH did not become ready in ${RAMDISK_SSH_TIMEOUT}s."
}
stop_iproxy_2222() {
if [[ -n "$IPROXY_PID" ]] && kill -0 "$IPROXY_PID" 2>/dev/null; then
echo "[*] Stopping iproxy (pid=$IPROXY_PID)..."
@@ -344,7 +452,7 @@ Usage: setup_machine.sh [--jb] [--skip-project-setup]
Options:
--jb Use jailbreak firmware patching + jailbreak CFW install.
--skip-project-setup Skip setup_libimobiledevice/setup_venv/build stage.
--skip-project-setup Skip setup_tools/build stage.
EOF
exit 0
;;
@@ -371,14 +479,13 @@ main() {
if [[ "$SKIP_PROJECT_SETUP" -eq 1 ]]; then
echo ""
echo "=== Project setup ==="
echo "[*] Skipping setup_libimobiledevice/setup_venv/build"
echo "[*] Skipping setup_tools/build"
else
check_platform
install_brew_deps
ensure_python_linked
run_make "Project setup" setup_libimobiledevice
run_make "Project setup" setup_venv
run_make "Project setup" setup_tools
run_make "Project setup" build
fi
@@ -392,7 +499,10 @@ main() {
wait_for_recovery
run_make "Restore" restore_get_shsh
run_make "Restore" restore
wait_for_post_restore_reboot
stop_boot_dfu
echo "[*] Waiting ${POST_KILL_SETTLE_DELAY}s for cleanup before ramdisk stage..."
sleep "$POST_KILL_SETTLE_DELAY"
echo ""
echo "=== Ramdisk + CFW phase ==="
@@ -402,11 +512,11 @@ main() {
run_make "Ramdisk" ramdisk_send
start_iproxy_2222
sleep 10 # for some reason there is a statistical faiure here if not enough time is given to initialization
wait_for_ramdisk_ssh
run_make "CFW install" "$cfw_install_target"
stop_iproxy_2222
stop_boot_dfu
stop_iproxy_2222
echo ""
echo "=== First boot ==="
+72
View File
@@ -0,0 +1,72 @@
#!/bin/zsh
# setup_tools.sh — Install all required host tools for vphone-cli
#
# Installs brew packages, builds trustcache from source,
# builds libimobiledevice toolchain, and creates Python venv.
#
# Run: make setup_tools
set -euo pipefail
SCRIPT_DIR="${0:a:h}"
PROJECT_DIR="${SCRIPT_DIR:h}"
TOOLS_PREFIX="${TOOLS_PREFIX:-$PROJECT_DIR/.tools}"
# ── Brew packages ──────────────────────────────────────────────
echo "[1/4] Checking brew packages..."
BREW_PACKAGES=(gnu-tar openssl@3 ldid-procursus sshpass)
BREW_MISSING=()
for pkg in "${BREW_PACKAGES[@]}"; do
if ! brew list "$pkg" &>/dev/null; then
BREW_MISSING+=("$pkg")
fi
done
if ((${#BREW_MISSING[@]} > 0)); then
echo " Installing: ${BREW_MISSING[*]}"
brew install "${BREW_MISSING[@]}"
else
echo " All brew packages installed"
fi
# ── Trustcache ─────────────────────────────────────────────────
echo "[2/4] trustcache"
TRUSTCACHE_BIN="$TOOLS_PREFIX/bin/trustcache"
if [[ -x "$TRUSTCACHE_BIN" ]]; then
echo " Already built: $TRUSTCACHE_BIN"
else
echo " Building from source (CRKatri/trustcache)..."
BUILD_DIR=$(mktemp -d)
trap "rm -rf '$BUILD_DIR'" EXIT
git clone --depth 1 https://github.com/CRKatri/trustcache.git "$BUILD_DIR/trustcache" --quiet
OPENSSL_PREFIX="$(brew --prefix openssl@3)"
make -C "$BUILD_DIR/trustcache" \
OPENSSL=1 \
CFLAGS="-I$OPENSSL_PREFIX/include -DOPENSSL -w" \
LDFLAGS="-L$OPENSSL_PREFIX/lib" \
-j"$(sysctl -n hw.logicalcpu)" >/dev/null 2>&1
mkdir -p "$TOOLS_PREFIX/bin"
cp "$BUILD_DIR/trustcache/trustcache" "$TRUSTCACHE_BIN"
echo " Installed: $TRUSTCACHE_BIN"
fi
# ── Libimobiledevice ──────────────────────────────────────────
echo "[3/4] libimobiledevice"
bash "$SCRIPT_DIR/setup_libimobiledevice.sh"
# ── Python venv ────────────────────────────────────────────────
echo "[4/4] Python venv"
zsh "$SCRIPT_DIR/setup_venv.sh"
echo ""
echo "All tools installed."
+10 -10
View File
@@ -17,8 +17,8 @@ REQUIREMENTS="${PROJECT_ROOT}/requirements.txt"
# Use system Python3
PYTHON="$(command -v python3)"
if [[ -z "${PYTHON}" ]]; then
echo "Error: python3 not found in PATH"
exit 1
echo "Error: python3 not found in PATH"
exit 1
fi
echo "=== Creating venv ==="
@@ -32,7 +32,7 @@ echo ""
# Activate and install pip packages
source "${VENV_DIR}/bin/activate"
pip install --upgrade pip > /dev/null
pip install --upgrade pip >/dev/null
pip install -r "${REQUIREMENTS}"
# --- Build keystone native library ---
@@ -43,13 +43,13 @@ echo ""
echo "=== Building keystone dylib ==="
KEYSTONE_DIR="/opt/homebrew/Cellar/keystone"
if [ ! -d "${KEYSTONE_DIR}" ]; then
echo "Error: keystone not found. Install with: brew install keystone"
exit 1
echo "Error: keystone not found. Install with: brew install keystone"
exit 1
fi
KEYSTONE_STATIC="$(find "${KEYSTONE_DIR}" -name 'libkeystone.a' -type f 2>/dev/null | head -1)"
if [[ -z "${KEYSTONE_STATIC}" ]]; then
echo "Error: libkeystone.a not found. Install with: brew install keystone"
exit 1
echo "Error: libkeystone.a not found. Install with: brew install keystone"
exit 1
fi
PYVER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
@@ -60,9 +60,9 @@ echo " static lib: ${KEYSTONE_STATIC}"
echo " dylib dest: ${KS_DYLIB}"
clang -shared -o "${KS_DYLIB}" \
-Wl,-all_load "${KEYSTONE_STATIC}" \
-lc++ \
-install_name @rpath/libkeystone.dylib
-Wl,-all_load "${KEYSTONE_STATIC}" \
-lc++ \
-install_name @rpath/libkeystone.dylib
echo " dylib built OK"
+6 -6
View File
@@ -15,16 +15,16 @@ REQUIREMENTS="${PROJECT_ROOT}/requirements.txt"
echo "=== Installing system deps ==="
if command -v apt-get &>/dev/null; then
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip cmake gcc g++ pkg-config 2>/dev/null
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip cmake gcc g++ pkg-config 2>/dev/null
elif command -v dnf &>/dev/null; then
dnf install -y python3 python3-pip cmake gcc gcc-c++ 2>/dev/null
dnf install -y python3 python3-pip cmake gcc gcc-c++ 2>/dev/null
fi
PYTHON="$(command -v python3)"
if [[ -z "${PYTHON}" ]]; then
echo "Error: python3 not found in PATH"
exit 1
echo "Error: python3 not found in PATH"
exit 1
fi
echo ""
@@ -37,7 +37,7 @@ echo ""
"${PYTHON}" -m venv "${VENV_DIR}"
source "${VENV_DIR}/bin/activate"
pip install --upgrade pip > /dev/null
pip install --upgrade pip >/dev/null
pip install -r "${REQUIREMENTS}"
# --- Verify ---
+24 -9
View File
@@ -18,7 +18,7 @@ set -euo pipefail
# --- Defaults ---
VM_DIR="vm"
DISK_SIZE_GB=64
SEP_STORAGE_SIZE=$((512 * 1024)) # 512 KB (same as vrevm)
SEP_STORAGE_SIZE=$((512 * 1024)) # 512 KB (same as vrevm)
# Framework-bundled ROMs (vresearch1 / research1 chip)
FW_ROM_DIR="/System/Library/Frameworks/Virtualization.framework/Versions/A/Resources"
@@ -28,11 +28,23 @@ SEPROM_SRC="${FW_ROM_DIR}/AVPSEPBooter.vresearch1.bin"
# --- Parse args ---
while [[ $# -gt 0 ]]; do
case "$1" in
--dir) VM_DIR="$2"; shift 2 ;;
--disk-size) DISK_SIZE_GB="$2"; shift 2 ;;
--rom) ROM_SRC="$2"; shift 2 ;;
--seprom) SEPROM_SRC="$2"; shift 2 ;;
-h|--help)
--dir)
VM_DIR="$2"
shift 2
;;
--disk-size)
DISK_SIZE_GB="$2"
shift 2
;;
--rom)
ROM_SRC="$2"
shift 2
;;
--seprom)
SEPROM_SRC="$2"
shift 2
;;
-h | --help)
echo "Usage: $0 [--dir VM] [--disk-size 64] [--rom path] [--seprom path]"
echo ""
echo "Options:"
@@ -42,7 +54,10 @@ while [[ $# -gt 0 ]]; do
echo " --seprom Path to AVPSEPBooter ROM (default: framework built-in)"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
@@ -111,14 +126,14 @@ if [[ -f "${ROM_DST}" ]] && cmp -s "${ROM_SRC}" "${ROM_DST}"; then
echo " AVPBooter.vresearch1.bin — up to date"
else
cp "${ROM_SRC}" "${ROM_DST}"
echo " AVPBooter.vresearch1.bin — copied ($(wc -c < "${ROM_DST}" | tr -d ' ') bytes)"
echo " AVPBooter.vresearch1.bin — copied ($(wc -c <"${ROM_DST}" | tr -d ' ') bytes)"
fi
if [[ -f "${SEPROM_DST}" ]] && cmp -s "${SEPROM_SRC}" "${SEPROM_DST}"; then
echo " AVPSEPBooter.vresearch1.bin — up to date"
else
cp "${SEPROM_SRC}" "${SEPROM_DST}"
echo " AVPSEPBooter.vresearch1.bin — copied ($(wc -c < "${SEPROM_DST}" | tr -d ' ') bytes)"
echo " AVPSEPBooter.vresearch1.bin — copied ($(wc -c <"${SEPROM_DST}" | tr -d ' ') bytes)"
fi
# --- Create .gitkeep ---
+22
View File
@@ -0,0 +1,22 @@
# vphoned — iOS guest daemon (cross-compiled for arm64 iphoneos)
SRCS := $(wildcard *.m)
OUT := vphoned
# Passed from parent Makefile
GIT_HASH ?= unknown
.PHONY: all clean
all: $(OUT)
$(OUT): $(SRCS) $(wildcard *.h)
@echo "=== Building vphoned (arm64, iphoneos) ==="
xcrun -sdk iphoneos clang -arch arm64 -Os -fobjc-arc \
-I. \
-DVPHONED_BUILD_HASH='"$(GIT_HASH)"' \
-o $@ $(SRCS) -framework Foundation
@echo " built OK"
clean:
rm -f $(OUT)
@@ -127,6 +127,11 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
if let provider = locationProvider {
mc.locationProvider = provider
}
mc.screenRecorder = VPhoneScreenRecorder()
if let signer = VPhoneSigner() {
mc.signer = signer
mc.ipaInstaller = VPhoneIPAInstaller(signer: signer)
}
menuController = mc
// Wire location toggle through onConnect/onDisconnect
+164
View File
@@ -0,0 +1,164 @@
import Foundation
// MARK: - IPA Installer
/// Host-side IPA installer. Uses VPhoneSigner for re-signing,
/// ideviceinstaller for USB installation via usbmuxd.
@MainActor
class VPhoneIPAInstaller {
let signer: VPhoneSigner
private let ideviceInstallerURL: URL
private let ideviceIdURL: URL
init?(signer: VPhoneSigner, bundle: Bundle = .main) {
guard let execURL = bundle.executableURL else { return nil }
let macosDir = execURL.deletingLastPathComponent()
ideviceInstallerURL = macosDir.appendingPathComponent("ideviceinstaller")
ideviceIdURL = macosDir.appendingPathComponent("idevice_id")
let fm = FileManager.default
guard fm.fileExists(atPath: ideviceInstallerURL.path),
fm.fileExists(atPath: ideviceIdURL.path)
else { return nil }
self.signer = signer
}
// MARK: - Install
/// Install an IPA. If `resign` is true, re-sign all Mach-O binaries
/// preserving their original entitlements before installing.
func install(ipaURL: URL, resign: Bool) async throws {
let udid = try await getUDID()
print("[ipa] device UDID: \(udid)")
var installURL = ipaURL
var tempDir: URL?
if resign {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("vphone-cli-resign-\(UUID().uuidString)")
tempDir = dir
installURL = try await resignIPA(ipaURL: ipaURL, tempDir: dir)
}
defer {
if let tempDir {
try? FileManager.default.removeItem(at: tempDir)
}
}
print("[ipa] installing \(installURL.lastPathComponent) to \(udid)...")
let result = try await signer.run(
ideviceInstallerURL,
arguments: ["-u", udid, "install", installURL.path]
)
guard result.status == 0 else {
let msg = result.stderr.isEmpty ? result.stdout : result.stderr
throw IPAError.installFailed(msg.trimmingCharacters(in: .whitespacesAndNewlines))
}
print("[ipa] installed successfully")
}
// MARK: - UDID Discovery
private func getUDID() async throws -> String {
let result = try await signer.run(ideviceIdURL, arguments: ["-l"])
guard result.status == 0 else {
throw IPAError.noDevice
}
let udids = result.stdout
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard let first = udids.first else {
throw IPAError.noDevice
}
return first
}
// MARK: - Re-sign IPA
private func resignIPA(ipaURL: URL, tempDir: URL) async throws -> URL {
let fm = FileManager.default
try fm.createDirectory(at: tempDir, withIntermediateDirectories: true)
// Unzip
print("[ipa] extracting \(ipaURL.lastPathComponent)...")
let unzip = try await signer.run(
URL(fileURLWithPath: "/usr/bin/unzip"),
arguments: ["-o", ipaURL.path, "-d", tempDir.path]
)
guard unzip.status == 0 else {
throw IPAError.extractFailed(unzip.stderr)
}
// Remove macOS resource fork files that break iOS installd
_ = try? await signer.run(
URL(fileURLWithPath: "/usr/bin/find"),
arguments: [tempDir.path, "-name", "._*", "-delete"]
)
_ = try? await signer.run(
URL(fileURLWithPath: "/usr/bin/find"),
arguments: [tempDir.path, "-name", ".DS_Store", "-delete"]
)
// Find Payload/*.app
let payloadDir = tempDir.appendingPathComponent("Payload")
guard fm.fileExists(atPath: payloadDir.path) else {
throw IPAError.invalidIPA("no Payload directory")
}
let contents = try fm.contentsOfDirectory(atPath: payloadDir.path)
guard let appName = contents.first(where: { $0.hasSuffix(".app") }) else {
throw IPAError.invalidIPA("no .app bundle in Payload")
}
let appDir = payloadDir.appendingPathComponent(appName)
// Walk and re-sign all Mach-O files
let machoFiles = signer.findMachOFiles(in: appDir)
print("[ipa] re-signing \(machoFiles.count) Mach-O binaries...")
for file in machoFiles {
do {
try await signer.signFile(at: file, tempDir: tempDir)
} catch {
print("[ipa] warning: \(error)")
}
}
// Re-zip (use zip from the temp dir so Payload/ is at the root)
let outputIPA = tempDir.appendingPathComponent("resigned.ipa")
print("[ipa] re-packaging...")
let zip = try await signer.run(
URL(fileURLWithPath: "/usr/bin/zip"),
arguments: ["-r", "-y", outputIPA.path, "Payload"],
currentDirectory: tempDir
)
guard zip.status == 0 else {
throw IPAError.repackFailed(zip.stderr)
}
return outputIPA
}
// MARK: - Errors
enum IPAError: Error, CustomStringConvertible {
case noDevice
case extractFailed(String)
case invalidIPA(String)
case repackFailed(String)
case installFailed(String)
var description: String {
switch self {
case .noDevice: "no device found (is the VM running?)"
case let .extractFailed(msg): "failed to extract IPA: \(msg)"
case let .invalidIPA(msg): "invalid IPA: \(msg)"
case let .repackFailed(msg): "failed to repackage IPA: \(msg)"
case let .installFailed(msg): "install failed: \(msg)"
}
}
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ extension VPhoneMenuController {
menu.addItem(makeItem("File Browser", action: #selector(openFiles)))
menu.addItem(NSMenuItem.separator())
menu.addItem(makeItem("Developer Mode Status", action: #selector(devModeStatus)))
menu.addItem(makeItem("Enable Developer Mode", action: #selector(devModeEnable)))
menu.addItem(makeItem("Enable Developer Mode [WIP]", action: #selector(devModeEnable)))
menu.addItem(NSMenuItem.separator())
menu.addItem(makeItem("Ping", action: #selector(sendPing)))
menu.addItem(makeItem("Guest Version", action: #selector(queryGuestVersion)))
@@ -77,7 +77,7 @@ extension VPhoneMenuController {
// MARK: - Alert
private func showAlert(title: String, message: String, style: NSAlert.Style) {
func showAlert(title: String, message: String, style: NSAlert.Style) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
@@ -11,6 +11,10 @@ class VPhoneMenuController {
var onFilesPressed: (() -> Void)?
var locationProvider: VPhoneLocationProvider?
var locationMenuItem: NSMenuItem?
var screenRecorder: VPhoneScreenRecorder?
var recordingItem: NSMenuItem?
var signer: VPhoneSigner?
var ipaInstaller: VPhoneIPAInstaller?
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
self.keyHelper = keyHelper
@@ -39,7 +43,9 @@ class VPhoneMenuController {
mainMenu.addItem(buildKeysMenu())
mainMenu.addItem(buildTypeMenu())
mainMenu.addItem(buildConnectMenu())
mainMenu.addItem(buildInstallMenu())
mainMenu.addItem(buildLocationMenu())
mainMenu.addItem(buildRecordMenu())
NSApp.mainMenu = mainMenu
}
+116
View File
@@ -0,0 +1,116 @@
import AppKit
import UniformTypeIdentifiers
// MARK: - Install Menu
extension VPhoneMenuController {
func buildInstallMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Install")
menu.addItem(makeItem("Install Package (.ipa) [WIP]", action: #selector(installPackage)))
menu.addItem(makeItem("Install Package with Resign (.ipa) [WIP]", action: #selector(installPackageResign)))
menu.addItem(NSMenuItem.separator())
menu.addItem(makeItem("Upload Binary to Guest", action: #selector(uploadBinary)))
menu.addItem(makeItem("Upload Binary with Resign to Guest", action: #selector(uploadBinaryResign)))
item.submenu = menu
return item
}
// MARK: - IPA Install
@objc func installPackage() {
pickAndInstall(resign: false)
}
@objc func installPackageResign() {
pickAndInstall(resign: true)
}
private func pickAndInstall(resign: Bool) {
let panel = NSOpenPanel()
panel.title = "Select IPA"
panel.allowedContentTypes = [.init(filenameExtension: "ipa")!]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url else { return }
guard let installer = ipaInstaller else {
showAlert(
title: "Install Package",
message: "IPA installer not available (bundled tools missing).",
style: .warning
)
return
}
Task {
do {
try await installer.install(ipaURL: url, resign: resign)
showAlert(
title: "Install Package",
message: "Successfully installed \(url.lastPathComponent).",
style: .informational
)
} catch {
showAlert(
title: "Install Package",
message: "\(error)",
style: .warning
)
}
}
}
// MARK: - Upload Binary
@objc func uploadBinary() {
pickAndUploadBinary(resign: false)
}
@objc func uploadBinaryResign() {
pickAndUploadBinary(resign: true)
}
private func pickAndUploadBinary(resign: Bool) {
let panel = NSOpenPanel()
panel.title = "Select Binary to Upload"
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url else { return }
Task {
do {
var data = try Data(contentsOf: url)
if resign {
guard let signer else {
showAlert(
title: "Upload Binary",
message: "Signing tools not available (bundled tools missing).",
style: .warning
)
return
}
data = try await signer.resign(data: data, filename: url.lastPathComponent)
}
let filename = url.lastPathComponent
let remotePath = "/var/root/Library/Caches/\(filename)"
try await control.uploadFile(path: remotePath, data: data, permissions: "755")
showAlert(
title: "Upload Binary",
message: "Uploaded \(filename) to \(remotePath) (\(data.count) bytes)\(resign ? " [resigned]" : "").",
style: .informational
)
} catch {
showAlert(
title: "Upload Binary",
message: "\(error)",
style: .warning
)
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import AppKit
// MARK: - Record Menu
extension VPhoneMenuController {
func buildRecordMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Record")
let toggle = makeItem("Start Recording", action: #selector(toggleRecording))
recordingItem = toggle
menu.addItem(toggle)
item.submenu = menu
return item
}
@objc func toggleRecording() {
if screenRecorder?.isRecording == true {
Task { @MainActor in
_ = await screenRecorder?.stopRecording()
recordingItem?.title = "Start Recording"
}
} else {
guard let window = NSApp.keyWindow,
let view = window.contentView
else {
print("[record] no active window")
return
}
do {
try screenRecorder?.startRecording(view: view)
recordingItem?.title = "Stop Recording"
} catch {
print("[record] failed to start: \(error)")
}
}
}
}
@@ -0,0 +1,138 @@
import AppKit
import AVFoundation
import CoreVideo
// MARK: - Screen Recorder
@MainActor
class VPhoneScreenRecorder {
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var timer: Timer?
private var frameCount: Int64 = 0
private var outputURL: URL?
private weak var view: NSView?
var isRecording: Bool {
writer?.status == .writing
}
func startRecording(view: NSView) throws {
guard !isRecording else { return }
let backingSize = view.convertToBacking(view.bounds.size)
let width = Int(backingSize.width)
let height = Int(backingSize.height)
let timestamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
let desktop = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Desktop")
let url = desktop.appendingPathComponent("vphone-recording-\(timestamp).mov")
outputURL = url
let writer = try AVAssetWriter(outputURL: url, fileType: .mov)
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
]
let input = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
input.expectsMediaDataInRealTime = true
let bufferAttrs: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
]
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: bufferAttrs
)
writer.add(input)
writer.startWriting()
writer.startSession(atSourceTime: .zero)
self.writer = writer
videoInput = input
self.adaptor = adaptor
self.view = view
frameCount = 0
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) {
[weak self] _ in
Task { @MainActor in
self?.captureFrame()
}
}
print("[record] started — \(url.lastPathComponent) (\(width)x\(height))")
}
func stopRecording() async -> URL? {
guard let writer, writer.status == .writing else { return nil }
timer?.invalidate()
timer = nil
videoInput?.markAsFinished()
await writer.finishWriting()
let url = outputURL
self.writer = nil
videoInput = nil
adaptor = nil
outputURL = nil
view = nil
if let url {
print("[record] saved — \(url.path)")
}
return url
}
// MARK: - Frame Capture
private func captureFrame() {
guard let view, let adaptor, let input = videoInput,
input.isReadyForMoreMediaData
else { return }
// Render view into bitmap at backing (retina) resolution
let bounds = view.bounds
guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { return }
view.cacheDisplay(in: bounds, to: rep)
guard let cgImage = rep.cgImage else { return }
// Get pixel buffer from pool
guard let pool = adaptor.pixelBufferPool else { return }
var pixelBuffer: CVPixelBuffer?
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer)
guard let pb = pixelBuffer else { return }
// Draw CGImage into pixel buffer
CVPixelBufferLockBaseAddress(pb, [])
let pbWidth = CVPixelBufferGetWidth(pb)
let pbHeight = CVPixelBufferGetHeight(pb)
if let ctx = CGContext(
data: CVPixelBufferGetBaseAddress(pb),
width: pbWidth,
height: pbHeight,
bitsPerComponent: 8,
bytesPerRow: CVPixelBufferGetBytesPerRow(pb),
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
| CGBitmapInfo.byteOrder32Little.rawValue
) {
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: pbWidth, height: pbHeight))
}
CVPixelBufferUnlockBaseAddress(pb, [])
let time = CMTime(value: frameCount, timescale: 30)
adaptor.append(pb, withPresentationTime: time)
frameCount += 1
}
}
+157
View File
@@ -0,0 +1,157 @@
import Foundation
// MARK: - Code Signer
/// Host-side code signing using bundled ldid + signcert.p12.
/// Preserves existing entitlements when re-signing.
@MainActor
class VPhoneSigner {
private let ldidURL: URL
private let signcertURL: URL
init?(bundle: Bundle = .main) {
guard let execURL = bundle.executableURL else { return nil }
let macosDir = execURL.deletingLastPathComponent()
let resourcesDir = macosDir
.deletingLastPathComponent()
.appendingPathComponent("Resources")
ldidURL = macosDir.appendingPathComponent("ldid")
signcertURL = resourcesDir.appendingPathComponent("signcert.p12")
let fm = FileManager.default
guard fm.fileExists(atPath: ldidURL.path),
fm.fileExists(atPath: signcertURL.path)
else { return nil }
}
// MARK: - Sign Binary
/// Re-sign a single Mach-O binary in-memory. Preserves existing entitlements.
func resign(data: Data, filename: String) async throws -> Data {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("vphone-cli-sign-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let binaryURL = tempDir.appendingPathComponent(filename)
try data.write(to: binaryURL)
try await signFile(at: binaryURL, tempDir: tempDir)
return try Data(contentsOf: binaryURL)
}
/// Re-sign a Mach-O binary on disk in-place. Preserves existing entitlements.
func signFile(at url: URL, tempDir: URL) async throws {
let entsResult = try await run(ldidURL, arguments: ["-e", url.path])
let entsXML = entsResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
let cert = signcertURL.path
var args: [String]
if !entsXML.isEmpty, entsXML.hasPrefix("<?xml") || entsXML.hasPrefix("<!DOCTYPE") {
let entsFile = tempDir.appendingPathComponent("ents-\(UUID().uuidString).plist")
try entsXML.write(to: entsFile, atomically: true, encoding: .utf8)
args = ["-S\(entsFile.path)", "-M", "-K\(cert)", url.path]
} else {
args = ["-S", "-M", "-K\(cert)", url.path]
}
let result = try await run(ldidURL, arguments: args)
guard result.status == 0 else {
throw SignError.ldidFailed(url.lastPathComponent, result.stderr)
}
print("[sign] signed \(url.lastPathComponent)")
}
// MARK: - Mach-O Detection
/// Recursively find all Mach-O files in a directory.
func findMachOFiles(in directory: URL) -> [URL] {
let fm = FileManager.default
guard let enumerator = fm.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else { return [] }
var results: [URL] = []
for case let url as URL in enumerator {
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey]),
values.isRegularFile == true,
Self.isMachO(at: url)
else { continue }
results.append(url)
}
return results
}
static func isMachO(at url: URL) -> Bool {
guard let fh = try? FileHandle(forReadingFrom: url) else { return false }
defer { try? fh.close() }
guard let data = try? fh.read(upToCount: 4), data.count == 4 else { return false }
let magic = data.withUnsafeBytes { $0.load(as: UInt32.self) }
return magic == 0xFEED_FACF // MH_MAGIC_64
|| magic == 0xCFFA_EDFE // MH_CIGAM_64
|| magic == 0xFEED_FACE // MH_MAGIC
|| magic == 0xCEFA_EDFE // MH_CIGAM
|| magic == 0xCAFE_BABE // FAT_MAGIC
|| magic == 0xBEBA_FECA // FAT_CIGAM
}
// MARK: - Process Runner
struct ProcessResult: Sendable {
let stdout: String
let stderr: String
let status: Int32
}
func run(
_ executable: URL,
arguments: [String],
currentDirectory: URL? = nil
) async throws -> ProcessResult {
let execPath = executable.path
let args = arguments
let dirPath = currentDirectory?.path
return try await Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: execPath)
process.arguments = args
if let dirPath {
process.currentDirectoryURL = URL(fileURLWithPath: dirPath)
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
process.waitUntilExit()
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
return ProcessResult(
stdout: String(data: outData, encoding: .utf8) ?? "",
stderr: String(data: errData, encoding: .utf8) ?? "",
status: process.terminationStatus
)
}.value
}
// MARK: - Errors
enum SignError: Error, CustomStringConvertible {
case ldidFailed(String, String)
var description: String {
switch self {
case let .ldidFailed(file, msg): "failed to sign \(file): \(msg)"
}
}
}
}