diff --git a/.gitignore b/.gitignore index 7b403e9..598aec3 100644 --- a/.gitignore +++ b/.gitignore @@ -311,6 +311,7 @@ __marimo__/ /VM /ipsws/ .limd/ +.tools/ /.swiftpm *.ipsw /updates-cdn diff --git a/AGENTS.md b/AGENTS.md index 033d4cc..56bd5c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//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//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//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 | diff --git a/Makefile b/Makefile index 660518a..261a492 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/Package.swift b/Package.swift index 74f0243..6f42dec 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,7 @@ let package = Package( .linkedFramework("AppKit"), .linkedFramework("SwiftUI"), .linkedFramework("CoreLocation"), + .linkedFramework("AVFoundation"), ] ), ] diff --git a/README.md b/README.md index 40441df..f17c032 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
🇨🇳中文 | 🇬🇧English
+
🇯🇵日本語 | 🇨🇳中文 | 🇬🇧English
# 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 root@127.0.0.1` (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 diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 0000000..9a0e2b0 --- /dev/null +++ b/README_ja.md @@ -0,0 +1,220 @@ +
🇯🇵日本語 | 🇨🇳中文 | 🇬🇧English
+ +# 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 root@127.0.0.1` (パスワード: `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) diff --git a/README_zh.md b/README_zh.md index fdd630f..7908dd0 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,4 +1,4 @@ -
🇨🇳中文 | 🇬🇧English
+
🇯🇵日本語 | 🇨🇳中文 | 🇬🇧English
# 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` | 启动 VM(GUI / 无头 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) diff --git a/researchs/iboot_patch_analysis.md b/researchs/iboot_patch_analysis.md new file mode 100644 index 0000000..f4e20de --- /dev/null +++ b/researchs/iboot_patch_analysis.md @@ -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, #` 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 +... +; :: :: +... +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). diff --git a/researchs/txm_jb_patches.md b/researchs/txm_jb_patches.md new file mode 100644 index 0000000..ec9b060 --- /dev/null +++ b/researchs/txm_jb_patches.md @@ -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 (1–51). 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 1–2: 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 1–2: 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 1–2** 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 1–2) +- Universal get-task-allow (patches 3–4) +- Universal debugger entitlement (patch 5) +- Forced developer mode (patch 6) diff --git a/researchs/txm_variant_analysis.md b/researchs/txm_variant_analysis.md new file mode 100644 index 0000000..0401252 --- /dev/null +++ b/researchs/txm_variant_analysis.md @@ -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. \ No newline at end of file diff --git a/scripts/cfw_install.sh b/scripts/cfw_install.sh index e062803..0b212c6 100755 --- a/scripts/cfw_install.sh +++ b/scripts/cfw_install.sh @@ -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 diff --git a/scripts/cfw_install_jb.sh b/scripts/cfw_install_jb.sh index fde05c8..912c268 100755 --- a/scripts/cfw_install_jb.sh +++ b/scripts/cfw_install_jb.sh @@ -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!" diff --git a/scripts/fw_manifest.py b/scripts/fw_manifest.py index 1ab22bb..283b172 100755 --- a/scripts/fw_manifest.py +++ b/scripts/fw_manifest.py @@ -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]} ", - file=sys.stderr) + print(f"Usage: {sys.argv[0]} ", 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) diff --git a/scripts/fw_patch.py b/scripts/fw_patch.py index 0b71330..dfd6d49 100755 --- a/scripts/fw_patch.py +++ b/scripts/fw_patch.py @@ -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): # ── 2–4. 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})") diff --git a/scripts/fw_patch_jb.py b/scripts/fw_patch_jb.py index 680e20b..78cfbb6 100644 --- a/scripts/fw_patch_jb.py +++ b/scripts/fw_patch_jb.py @@ -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})") diff --git a/scripts/fw_prepare.sh b/scripts/fw_prepare.sh index 0634b7b..cd4feac 100755 --- a/scripts/fw_prepare.sh +++ b/scripts/fw_prepare.sh @@ -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 ..." diff --git a/scripts/patchers/cfw.py b/scripts/patchers/cfw.py index 61b8b19..d838115 100755 --- a/scripts/patchers/cfw.py +++ b/scripts/patchers/cfw.py @@ -118,9 +118,7 @@ def parse_macho_sections(data): nsects = struct.unpack_from(" 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(" 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) diff --git a/scripts/patchers/iboot.py b/scripts/patchers/iboot.py index 1c64aa4..4cd34f8 100755 --- a/scripts/patchers/iboot.py +++ b/scripts/patchers/iboot.py @@ -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.") diff --git a/scripts/patchers/iboot_jb.py b/scripts/patchers/iboot_jb.py index e1e3a6d..c39db94 100644 --- a/scripts/patchers/iboot_jb.py +++ b/scripts/patchers/iboot_jb.py @@ -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") diff --git a/scripts/patchers/kernel.py b/scripts/patchers/kernel.py index fdf09af..dc80be9 100755 --- a/scripts/patchers/kernel.py +++ b/scripts/patchers/kernel.py @@ -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(" (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 = pdata[xml_start : xml_end + len(b"")] 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(" 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("> 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(" 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}") diff --git a/scripts/patchers/kernel_jb.py b/scripts/patchers/kernel_jb.py index 4fdeb19..becc100 100644 --- a/scripts/patchers/kernel_jb.py +++ b/scripts/patchers/kernel_jb.py @@ -21,8 +21,13 @@ import struct from collections import Counter from capstone.arm64_const import ( - ARM64_OP_REG, ARM64_OP_IMM, ARM64_OP_MEM, - ARM64_REG_X0, ARM64_REG_X1, ARM64_REG_W0, ARM64_REG_X8, + ARM64_OP_REG, + ARM64_OP_IMM, + ARM64_OP_MEM, + ARM64_REG_X0, + ARM64_REG_X1, + ARM64_REG_W0, + ARM64_REG_X8, ) from .kernel import ( @@ -50,7 +55,7 @@ MOV_X8_XZR = asm("mov x8, xzr") class KernelJBPatcher(KernelPatcher): """JB-only kernel patcher.""" - def __init__(self, data, verbose=True): + def __init__(self, data, verbose=False): super().__init__(data, verbose) self._build_symbol_table() @@ -115,7 +120,8 @@ class KernelJBPatcher(KernelPatcher): if entry_off + 16 > self.size: break n_strx, n_type, n_sect, n_desc, n_value = struct.unpack_from( - "= self.size: continue - name_end = self.raw.find(b'\x00', name_off) + name_end = self.raw.find(b"\x00", name_off) if name_end < 0 or name_end - name_off > 512: continue - name = self.raw[name_off:name_end].decode('ascii', errors='replace') + name = self.raw[name_off:name_end].decode("ascii", errors="replace") foff = n_value - self.base_va if 0 <= foff < self.size: self.symbols[name] = foff @@ -293,7 +299,7 @@ class KernelJBPatcher(KernelPatcher): def apply(self): patches = self.find_all() for off, patch_bytes, _ in patches: - self.data[off:off + len(patch_bytes)] = patch_bytes + self.data[off : off + len(patch_bytes)] = patch_bytes return len(patches) # ══════════════════════════════════════════════════════════════ @@ -331,32 +337,43 @@ class KernelJBPatcher(KernelPatcher): break insns.append(d[0]) - i1 = _find_after(insns, 0, - lambda x: x.mnemonic == "mov" and x.op_str == "x19, x2") + i1 = _find_after( + insns, 0, lambda x: x.mnemonic == "mov" and x.op_str == "x19, x2" + ) if i1 < 0: continue - i2 = _find_after(insns, i1 + 1, - lambda x: x.mnemonic == "stp" - and x.op_str.startswith("xzr, xzr, [sp")) + i2 = _find_after( + insns, + i1 + 1, + lambda x: x.mnemonic == "stp" and x.op_str.startswith("xzr, xzr, [sp"), + ) if i2 < 0: continue - i3 = _find_after(insns, i2 + 1, - lambda x: x.mnemonic == "mov" and x.op_str == "x2, sp") + i3 = _find_after( + insns, i2 + 1, lambda x: x.mnemonic == "mov" and x.op_str == "x2, sp" + ) if i3 < 0: continue i4 = _find_after(insns, i3 + 1, lambda x: x.mnemonic == "bl") if i4 < 0: continue - i5 = _find_after(insns, i4 + 1, - lambda x: x.mnemonic == "mov" and x.op_str == "x20, x0") + i5 = _find_after( + insns, i4 + 1, lambda x: x.mnemonic == "mov" and x.op_str == "x20, x0" + ) if i5 < 0: continue - i6 = _find_after(insns, i5 + 1, - lambda x: x.mnemonic == "cbnz" and x.op_str.startswith("w0,")) + i6 = _find_after( + insns, + i5 + 1, + lambda x: x.mnemonic == "cbnz" and x.op_str.startswith("w0,"), + ) if i6 < 0: continue - i7 = _find_after(insns, i6 + 1, - lambda x: x.mnemonic == "cbz" and x.op_str.startswith("x19,")) + i7 = _find_after( + insns, + i6 + 1, + lambda x: x.mnemonic == "cbz" and x.op_str.startswith("x19,"), + ) if i7 < 0: continue @@ -367,14 +384,10 @@ class KernelJBPatcher(KernelPatcher): return False func_start = hits[0] - self.emit(func_start, MOV_X0_1, - "mov x0,#1 [AMFIIsCDHashInTrustCache]") - self.emit(func_start + 4, CBZ_X2_8, - "cbz x2,+8 [AMFIIsCDHashInTrustCache]") - self.emit(func_start + 8, STR_X0_X2, - "str x0,[x2] [AMFIIsCDHashInTrustCache]") - self.emit(func_start + 12, RET, - "ret [AMFIIsCDHashInTrustCache]") + self.emit(func_start, MOV_X0_1, "mov x0,#1 [AMFIIsCDHashInTrustCache]") + self.emit(func_start + 4, CBZ_X2_8, "cbz x2,+8 [AMFIIsCDHashInTrustCache]") + self.emit(func_start + 8, STR_X0_X2, "str x0,[x2] [AMFIIsCDHashInTrustCache]") + self.emit(func_start + 12, RET, "ret [AMFIIsCDHashInTrustCache]") return True def patch_amfi_execve_kill_path(self): @@ -424,8 +437,10 @@ class KernelJBPatcher(KernelPatcher): hits.append(off) if len(hits) != 2: - self._log(f" [-] execve helper at 0x{func_start:X}: " - f"expected 2 early BL+W0-branch sites, found {len(hits)}") + self._log( + f" [-] execve helper at 0x{func_start:X}: " + f"expected 2 early BL+W0-branch sites, found {len(hits)}" + ) continue self.emit(hits[0], MOV_X0_0, "mov x0,#0 [AMFI execve helper A]") @@ -491,11 +506,14 @@ class KernelJBPatcher(KernelPatcher): candidates.append(off) if len(candidates) != 1: - self._log(f" [-] expected 1 task-conversion guard site, found {len(candidates)}") + self._log( + f" [-] expected 1 task-conversion guard site, found {len(candidates)}" + ) return False - self.emit(candidates[0], CMP_XZR_XZR, - "cmp xzr,xzr [_task_conversion_eval_internal]") + self.emit( + candidates[0], CMP_XZR_XZR, "cmp xzr,xzr [_task_conversion_eval_internal]" + ) return True def patch_sandbox_hooks_extended(self): @@ -615,8 +633,7 @@ class KernelJBPatcher(KernelPatcher): has_bl = True break if has_bl: - self.emit(off, CMP_W0_W0, - f"cmp w0,w0 [postValidation additional]") + self.emit(off, CMP_W0_W0, f"cmp w0,w0 [postValidation additional]") patched += 1 if patched == 0: @@ -673,7 +690,9 @@ class KernelJBPatcher(KernelPatcher): return False proc_info_end = self._find_func_end(proc_info_func, 0x4000) - self._log(f" [+] _proc_info at 0x{proc_info_func:X} (size 0x{proc_info_end - proc_info_func:X})") + self._log( + f" [+] _proc_info at 0x{proc_info_func:X} (size 0x{proc_info_end - proc_info_func:X})" + ) # Count BL targets within _proc_info — the most frequent one # is _proc_security_policy (called once per switch case) @@ -716,7 +735,11 @@ class KernelJBPatcher(KernelPatcher): hits = [] for off in range(foff, func_end, 4): d = self._disas_at(off) - if d and d[0].mnemonic in ("cbz", "cbnz") and d[0].op_str.startswith("w"): + if ( + d + and d[0].mnemonic in ("cbz", "cbnz") + and d[0].op_str.startswith("w") + ): hits.append(off) if len(hits) >= 2: self.emit(hits[0], NOP, "NOP [_proc_pidinfo pid-0 guard A]") @@ -788,7 +811,9 @@ class KernelJBPatcher(KernelPatcher): return False for adrp_off, add_off, _ in refs: - bl_panic = self._find_bl_to_panic_in_range(add_off, min(add_off + 0x40, self.size)) + bl_panic = self._find_bl_to_panic_in_range( + add_off, min(add_off + 0x40, self.size) + ) if bl_panic < 0: continue resume_off = bl_panic + 4 @@ -798,9 +823,12 @@ class KernelJBPatcher(KernelPatcher): if target is not None and err_lo <= target <= bl_panic + 4: b_bytes = self._encode_b(back, resume_off) if b_bytes: - self.emit(back, b_bytes, - f"b #0x{resume_off - back:X} " - f"[_convert_port_to_map skip panic]") + self.emit( + back, + b_bytes, + f"b #0x{resume_off - back:X} " + f"[_convert_port_to_map skip panic]", + ) return True self._log(" [-] branch site not found") @@ -871,8 +899,10 @@ class KernelJBPatcher(KernelPatcher): break i1 = d1[0] if i1.mnemonic in ("tbnz", "tbz") and len(i1.operands) >= 2: - if i1.operands[0].type == ARM64_OP_REG and \ - i1.operands[0].reg == ARM64_REG_W0: + if ( + i1.operands[0].type == ARM64_OP_REG + and i1.operands[0].reg == ARM64_REG_W0 + ): return off return None @@ -911,8 +941,9 @@ class KernelJBPatcher(KernelPatcher): if target > off: b_bytes = self._encode_b(off, target) if b_bytes: - self.emit(off, b_bytes, - f"b #0x{target - off:X} [_vm_map_protect]") + self.emit( + off, b_bytes, f"b #0x{target - off:X} [_vm_map_protect]" + ) return True self._log(" [-] patch site not found") @@ -944,7 +975,10 @@ class KernelJBPatcher(KernelPatcher): mc_end = self._find_func_end(mount_common_func, 0x2000) for off in range(mount_common_func, mc_end, 4): target = self._is_bl(off) - if target >= 0 and self.kern_text[0] <= target < self.kern_text[1]: + if ( + target >= 0 + and self.kern_text[0] <= target < self.kern_text[1] + ): # Check if this target contains BL+CBNZ w0 pattern # (mac check) followed by a mov to x8 te = self._find_func_end(target, 0x1000) @@ -953,7 +987,11 @@ class KernelJBPatcher(KernelPatcher): if not d0 or d0[0].mnemonic != "bl": continue d1 = self._disas_at(off2 + 4) - if d1 and d1[0].mnemonic == "cbnz" and d1[0].op_str.startswith("w0,"): + if ( + d1 + and d1[0].mnemonic == "cbnz" + and d1[0].op_str.startswith("w0,") + ): foff = target break if foff >= 0: @@ -982,8 +1020,7 @@ class KernelJBPatcher(KernelPatcher): continue if d2[0].mnemonic == "mov" and "x8" in d2[0].op_str: if d2[0].op_str != "x8, xzr": - self.emit(off2, MOV_X8_XZR, - "mov x8,xzr [___mac_mount]") + self.emit(off2, MOV_X8_XZR, "mov x8,xzr [___mac_mount]") patched += 1 break break @@ -1021,7 +1058,9 @@ class KernelJBPatcher(KernelPatcher): # Check BL targets from this function for off in range(caller, caller_end, 4): target = self._is_bl(off) - if target < 0 or not (self.kern_text[0] <= target < self.kern_text[1]): + if target < 0 or not ( + self.kern_text[0] <= target < self.kern_text[1] + ): continue te = self._find_func_end(target, 0x400) result = self._find_mac_check_bl(target, te) @@ -1106,15 +1145,15 @@ class KernelJBPatcher(KernelPatcher): return False # Filter to kern_text range (exclude kexts) - kern_candidates = [c for c in candidates - if ks <= c < ke] + kern_candidates = [c for c in candidates if ks <= c < ke] if not kern_candidates: kern_candidates = candidates # Pick the last one in the kernel (bsd_init is typically late in boot) bl_off = kern_candidates[-1] - self._log(f" [+] auth BL at 0x{bl_off:X} " - f"({len(kern_candidates)} kern candidates)") + self._log( + f" [+] auth BL at 0x{bl_off:X} ({len(kern_candidates)} kern candidates)" + ) self.emit(bl_off, MOV_X0_0, "mov x0,#0 [_bsd_init auth]") return True @@ -1167,12 +1206,11 @@ class KernelJBPatcher(KernelPatcher): if d2[0].mnemonic == "tbnz" and "#1" in d2[0].op_str: # Verify it's a w-register if d2[0].op_str.startswith("w"): - self._log(f" [+] LDR at 0x{off:X}, " - f"TBNZ at 0x{off + delta:X}") - self.emit(off, NOP, - "NOP [_spawn_validate_persona LDR]") - self.emit(off + delta, NOP, - "NOP [_spawn_validate_persona TBNZ]") + self._log(f" [+] LDR at 0x{off:X}, TBNZ at 0x{off + delta:X}") + self.emit(off, NOP, "NOP [_spawn_validate_persona LDR]") + self.emit( + off + delta, NOP, "NOP [_spawn_validate_persona TBNZ]" + ) return True self._log(" [-] pattern not found") @@ -1211,8 +1249,7 @@ class KernelJBPatcher(KernelPatcher): func_end = self._find_func_end(foff, 0x800) patch_off = self._find_second_ldr490(foff, func_end) if patch_off: - self.emit(patch_off, NOP, - "NOP [_task_for_pid proc_ro copy]") + self.emit(patch_off, NOP, "NOP [_task_for_pid proc_ro copy]") return True # Pattern search: scan kern_text for functions matching the profile @@ -1244,12 +1281,18 @@ class KernelJBPatcher(KernelPatcher): i = d[0] if i.mnemonic == "ldadda": ldadda_count += 1 - elif i.mnemonic == "ldr" and "#0x490" in i.op_str \ - and i.op_str.startswith("w"): + elif ( + i.mnemonic == "ldr" + and "#0x490" in i.op_str + and i.op_str.startswith("w") + ): d2 = self._disas_at(o + 4) - if d2 and d2[0].mnemonic == "str" \ - and "#0xc" in d2[0].op_str \ - and d2[0].op_str.startswith("w"): + if ( + d2 + and d2[0].mnemonic == "str" + and "#0xc" in d2[0].op_str + and d2[0].op_str.startswith("w") + ): ldr490_count += 1 ldr490_offs.append(o) elif i.mnemonic == "movk" and "#0xc8a2" in i.op_str: @@ -1261,13 +1304,17 @@ class KernelJBPatcher(KernelPatcher): if 500 < n_callers < 8000: has_high_caller_bl = True - if ldadda_count >= 2 and ldr490_count >= 2 \ - and has_movk_c8a2 and has_high_caller_bl: + if ( + ldadda_count >= 2 + and ldr490_count >= 2 + and has_movk_c8a2 + and has_high_caller_bl + ): patch_off = ldr490_offs[1] # NOP the second occurrence - self._log(f" [+] _task_for_pid at 0x{func_start:X}, " - f"patch at 0x{patch_off:X}") - self.emit(patch_off, NOP, - "NOP [_task_for_pid proc_ro copy]") + self._log( + f" [+] _task_for_pid at 0x{func_start:X}, patch at 0x{patch_off:X}" + ) + self.emit(patch_off, NOP, "NOP [_task_for_pid proc_ro copy]") return True off = func_end @@ -1285,9 +1332,12 @@ class KernelJBPatcher(KernelPatcher): if "#0x490" not in d[0].op_str or not d[0].op_str.startswith("w"): continue d2 = self._disas_at(off + 4) - if d2 and d2[0].mnemonic == "str" \ - and "#0xc" in d2[0].op_str \ - and d2[0].op_str.startswith("w"): + if ( + d2 + and d2[0].mnemonic == "str" + and "#0xc" in d2[0].op_str + and d2[0].op_str.startswith("w") + ): count += 1 if count == 2: return off @@ -1314,8 +1364,11 @@ class KernelJBPatcher(KernelPatcher): tst_off, beq_target = result b_bytes = self._encode_b(tst_off, beq_target) if b_bytes: - self.emit(tst_off, b_bytes, - f"b #0x{beq_target - tst_off:X} [_load_dylinker]") + self.emit( + tst_off, + b_bytes, + f"b #0x{beq_target - tst_off:X} [_load_dylinker]", + ) return True # Pattern search: find functions with 3+ TST+B.EQ+MOVK(#0xc8a2) @@ -1342,11 +1395,13 @@ class KernelJBPatcher(KernelPatcher): if len(d3) < 3: continue i0, i1, i2 = d3[0], d3[1], d3[2] - if i0.mnemonic == "tst" \ - and "40000000000000" in i0.op_str \ - and i1.mnemonic == "b.eq" \ - and i2.mnemonic == "movk" \ - and "#0xc8a2" in i2.op_str: + if ( + i0.mnemonic == "tst" + and "40000000000000" in i0.op_str + and i1.mnemonic == "b.eq" + and i2.mnemonic == "movk" + and "#0xc8a2" in i2.op_str + ): beq_target = i1.operands[-1].imm triplets.append((o, beq_target)) @@ -1355,11 +1410,15 @@ class KernelJBPatcher(KernelPatcher): tst_off, beq_target = triplets[-1] b_bytes = self._encode_b(tst_off, beq_target) if b_bytes: - self._log(f" [+] rebase func at 0x{func_start:X}, " - f"patch TST at 0x{tst_off:X}") - self.emit(tst_off, b_bytes, - f"b #0x{beq_target - tst_off:X} " - f"[_load_dylinker PAC bypass]") + self._log( + f" [+] rebase func at 0x{func_start:X}, " + f"patch TST at 0x{tst_off:X}" + ) + self.emit( + tst_off, + b_bytes, + f"b #0x{beq_target - tst_off:X} [_load_dylinker PAC bypass]", + ) return True off = func_end @@ -1375,11 +1434,13 @@ class KernelJBPatcher(KernelPatcher): if len(d) < 3: continue i0, i1, i2 = d[0], d[1], d[2] - if i0.mnemonic == "tst" \ - and "40000000000000" in i0.op_str \ - and i1.mnemonic == "b.eq" \ - and i2.mnemonic == "movk" \ - and "#0xc8a2" in i2.op_str: + if ( + i0.mnemonic == "tst" + and "40000000000000" in i0.op_str + and i1.mnemonic == "b.eq" + and i2.mnemonic == "movk" + and "#0xc8a2" in i2.op_str + ): last = (off, i1.operands[-1].imm) return last @@ -1393,7 +1454,8 @@ class KernelJBPatcher(KernelPatcher): foff = self._resolve_symbol("_shared_region_map_and_slide_setup") if foff < 0: foff = self._find_func_by_string( - b"/private/preboot/Cryptexes", self.kern_text) + b"/private/preboot/Cryptexes", self.kern_text + ) if foff < 0: foff = self._find_func_by_string(b"/private/preboot/Cryptexes") if foff < 0: @@ -1413,8 +1475,9 @@ class KernelJBPatcher(KernelPatcher): if len(ops) < 2: continue if ops[0].type == ARM64_OP_REG and ops[1].type == ARM64_OP_REG: - self.emit(off, CMP_X0_X0, - "cmp x0,x0 [_shared_region_map_and_slide_setup]") + self.emit( + off, CMP_X0_X0, "cmp x0,x0 [_shared_region_map_and_slide_setup]" + ) return True self._log(" [-] CMP+B.NE pattern not found") @@ -1428,7 +1491,8 @@ class KernelJBPatcher(KernelPatcher): # Try symbol first sym_off = self._resolve_symbol( - "__ZL16verifyPermission16IONVRAMOperationPKhPKcb") + "__ZL16verifyPermission16IONVRAMOperationPKhPKcb" + ) if sym_off < 0: for sym, off in self.symbols.items(): if "verifyPermission" in sym and "NVRAM" in sym: @@ -1445,13 +1509,15 @@ class KernelJBPatcher(KernelPatcher): if refs: ref_off = refs[0][0] # ADRP instruction offset - foff = sym_off if sym_off >= 0 else ( - self.find_function_start(ref_off) if ref_off >= 0 else -1) + foff = ( + sym_off + if sym_off >= 0 + else (self.find_function_start(ref_off) if ref_off >= 0 else -1) + ) if foff < 0: # Fallback: try NVRAM entitlement string - ent_off = self.find_string( - b"com.apple.private.iokit.nvram-write-access") + ent_off = self.find_string(b"com.apple.private.iokit.nvram-write-access") if ent_off >= 0: ent_refs = self.find_string_refs(ent_off) if ent_refs: @@ -1515,8 +1581,9 @@ class KernelJBPatcher(KernelPatcher): if target and target > off: b_bytes = self._encode_b(off, target) if b_bytes: - self.emit(off, b_bytes, - f"b #0x{target - off:X} [_IOSecureBSDRoot]") + self.emit( + off, b_bytes, f"b #0x{target - off:X} [_IOSecureBSDRoot]" + ) return True self._log(" [-] conditional branch not found") @@ -1532,8 +1599,7 @@ class KernelJBPatcher(KernelPatcher): # Try symbol first foff = self._resolve_symbol("_thid_should_crash") if foff >= 0: - self.emit(foff, b'\x00\x00\x00\x00', - "zero [_thid_should_crash]") + self.emit(foff, b"\x00\x00\x00\x00", "zero [_thid_should_crash]") return True # Find the string in __DATA (sysctl name string) @@ -1551,9 +1617,11 @@ class KernelJBPatcher(KernelPatcher): # # Search forward from the string for 8-byte values whose low32 # points to a valid location holding a small non-zero value. - data_const_ranges = [(fo, fo + fs) for name, _, fo, fs, _ - in self.all_segments - if name in ("__DATA_CONST",) and fs > 0] + data_const_ranges = [ + (fo, fo + fs) + for name, _, fo, fs, _ in self.all_segments + if name in ("__DATA_CONST",) and fs > 0 + ] for delta in range(0, 128, 8): check = str_off + delta @@ -1576,13 +1644,15 @@ class KernelJBPatcher(KernelPatcher): in_data = any( fo <= low32 < fo + fs for name, _, fo, fs, _ in self.all_segments - if "DATA" in name and fs > 0) + if "DATA" in name and fs > 0 + ) if in_data: - self._log(f" [+] variable at foff 0x{low32:X} " - f"(value={target_val}, found via sysctl_oid " - f"at str+0x{delta:X})") - self.emit(low32, b'\x00\x00\x00\x00', - "zero [_thid_should_crash]") + self._log( + f" [+] variable at foff 0x{low32:X} " + f"(value={target_val}, found via sysctl_oid " + f"at str+0x{delta:X})" + ) + self.emit(low32, b"\x00\x00\x00\x00", "zero [_thid_should_crash]") return True # Fallback: if string has code refs, search via ADRP+ADD @@ -1598,14 +1668,16 @@ class KernelJBPatcher(KernelPatcher): i0, i1 = d[0], d[1] if i0.mnemonic == "adrp" and i1.mnemonic == "add": page = (i0.operands[1].imm - self.base_va) & ~0xFFF - imm12 = (i1.operands[2].imm if len(i1.operands) > 2 - else 0) + imm12 = i1.operands[2].imm if len(i1.operands) > 2 else 0 target = page + imm12 if 0 < target < self.size: tv = _rd32(self.raw, target) if 1 <= tv <= 255: - self.emit(target, b'\x00\x00\x00\x00', - "zero [_thid_should_crash]") + self.emit( + target, + b"\x00\x00\x00\x00", + "zero [_thid_should_crash]", + ) return True self._log(" [-] variable not found") @@ -1697,14 +1769,18 @@ class KernelJBPatcher(KernelPatcher): # Assemble shellcode shellcode = ( - asm("ldr x0, [sp, #8]") + # load cred pointer - asm("ldr w1, [x0]") + # load cs_flags - asm("orr w1, w1, #0x4000000") + # set CS_PLATFORM_BINARY - asm("orr w1, w1, #0xF") + # set CS_VALID|CS_ADHOC|CS_GET_TASK_ALLOW|CS_INSTALLER - bytes([0x21, 0x64, 0x12, 0x12]) + # AND w1, w1, #0xFFFFC0FF (clear CS_HARD|CS_KILL etc) - asm("str w1, [x0]") + # store back - asm("mov x0, xzr") + # return 0 - bytes([0xFF, 0x0F, 0x5F, 0xD6]) # RETAB + asm("ldr x0, [sp, #8]") # load cred pointer + + asm("ldr w1, [x0]") # load cs_flags + + asm("orr w1, w1, #0x4000000") # set CS_PLATFORM_BINARY + + asm( + "orr w1, w1, #0xF" + ) # set CS_VALID|CS_ADHOC|CS_GET_TASK_ALLOW|CS_INSTALLER + + bytes( + [0x21, 0x64, 0x12, 0x12] + ) # AND w1, w1, #0xFFFFC0FF (clear CS_HARD|CS_KILL etc) + + asm("str w1, [x0]") # store back + + asm("mov x0, xzr") # return 0 + + bytes([0xFF, 0x0F, 0x5F, 0xD6]) # RETAB ) # Find the return site in the function (last RETAB) @@ -1721,14 +1797,18 @@ class KernelJBPatcher(KernelPatcher): # Write shellcode to cave for i in range(0, len(shellcode), 4): - self.emit(cave + i, shellcode[i:i+4], - f"shellcode+{i} [_cred_label_update_execve]") + self.emit( + cave + i, + shellcode[i : i + 4], + f"shellcode+{i} [_cred_label_update_execve]", + ) # Branch from function return to cave b_bytes = self._encode_b(ret_off, cave) if b_bytes: - self.emit(ret_off, b_bytes, - f"b cave [_cred_label_update_execve -> 0x{cave:X}]") + self.emit( + ret_off, b_bytes, f"b cave [_cred_label_update_execve -> 0x{cave:X}]" + ) else: self._log(" [-] branch to cave out of range") return False @@ -1798,16 +1878,18 @@ class KernelJBPatcher(KernelPatcher): if (val & 0xFC000000) == 0x14000000: imm26 = val & 0x3FFFFFF if imm26 & (1 << 25): - imm26 -= (1 << 26) + imm26 -= 1 << 26 target = off + imm26 * 4 if self.kern_text[0] <= target < self.kern_text[1]: filter_off = target break if zalloc_off < 0 or filter_off < 0: - self._log(f" [-] required functions not found " - f"(zalloc={'found' if zalloc_off >= 0 else 'missing'}, " - f"filter={'found' if filter_off >= 0 else 'missing'})") + self._log( + f" [-] required functions not found " + f"(zalloc={'found' if zalloc_off >= 0 else 'missing'}, " + f"filter={'found' if filter_off >= 0 else 'missing'})" + ) return False # Find code cave (need ~160 bytes) @@ -1835,46 +1917,49 @@ class KernelJBPatcher(KernelPatcher): # Build shellcode shellcode_parts = [] for _ in range(10): - shellcode_parts.append(b'\xff\xff\xff\xff') + shellcode_parts.append(b"\xff\xff\xff\xff") - shellcode_parts.append(asm("cbz x2, #0x6c")) # idx 10 - shellcode_parts.append(asm("sub sp, sp, #0x40")) # idx 11 + shellcode_parts.append(asm("cbz x2, #0x6c")) # idx 10 + shellcode_parts.append(asm("sub sp, sp, #0x40")) # idx 11 shellcode_parts.append(asm("stp x19, x20, [sp, #0x10]")) # idx 12 shellcode_parts.append(asm("stp x21, x22, [sp, #0x20]")) # idx 13 shellcode_parts.append(asm("stp x29, x30, [sp, #0x30]")) # idx 14 - shellcode_parts.append(asm("mov x19, x0")) # idx 15 - shellcode_parts.append(asm("mov x20, x1")) # idx 16 - shellcode_parts.append(asm("mov x21, x2")) # idx 17 - shellcode_parts.append(asm("mov x22, x3")) # idx 18 - shellcode_parts.append(asm("mov x8, #8")) # idx 19 - shellcode_parts.append(asm("mov x0, x17")) # idx 20 - shellcode_parts.append(asm("mov x1, x21")) # idx 21 - shellcode_parts.append(asm("mov x2, #0")) # idx 22 + shellcode_parts.append(asm("mov x19, x0")) # idx 15 + shellcode_parts.append(asm("mov x20, x1")) # idx 16 + shellcode_parts.append(asm("mov x21, x2")) # idx 17 + shellcode_parts.append(asm("mov x22, x3")) # idx 18 + shellcode_parts.append(asm("mov x8, #8")) # idx 19 + shellcode_parts.append(asm("mov x0, x17")) # idx 20 + shellcode_parts.append(asm("mov x1, x21")) # idx 21 + shellcode_parts.append(asm("mov x2, #0")) # idx 22 # adr x3, #-0x5C — encode manually adr_delta = -(23 * 4) immhi = (adr_delta >> 2) & 0x7FFFF immlo = adr_delta & 0x3 adr_insn = 0x10000003 | (immlo << 29) | (immhi << 5) shellcode_parts.append(struct.pack(" 0x{cave_base + 40:X}]") + self.emit( + off, + b_to_cave, + f"b cave [_syscallmask_apply_to_proc -> 0x{cave_base + 40:X}]", + ) return True self._log(" [-] injection point not found") @@ -1915,8 +2006,10 @@ class KernelJBPatcher(KernelPatcher): if refs: vnode_getattr_off = self.find_function_start(refs[0][0]) if vnode_getattr_off >= 0: - self._log(f" [+] vnode_getattr at 0x" - f"{vnode_getattr_off:X} (via string)") + self._log( + f" [+] vnode_getattr at 0x" + f"{vnode_getattr_off:X} (via string)" + ) if vnode_getattr_off < 0: self._log(" [-] vnode_getattr not found") @@ -1948,12 +2041,15 @@ class KernelJBPatcher(KernelPatcher): orig_hook = entry if hook_index < 0 or best_size < 1000: - self._log(" [-] hook entry not found in ops table " - f"(best: idx={hook_index}, size={best_size})") + self._log( + " [-] hook entry not found in ops table " + f"(best: idx={hook_index}, size={best_size})" + ) return False - self._log(f" [+] hook at ops[{hook_index}] = 0x{orig_hook:X} " - f"({best_size} bytes)") + self._log( + f" [+] hook at ops[{hook_index}] = 0x{orig_hook:X} ({best_size} bytes)" + ) # ── 4. Find code cave ──────────────────────────────────── cave = self._find_code_cave(180) @@ -1986,58 +2082,61 @@ class KernelJBPatcher(KernelPatcher): # struct vfs_context { thread_t vc_thread; kauth_cred_t vc_ucred; } # We place it at [sp, #0x70] (between saved regs and vattr buffer). parts = [] - parts.append(NOP) # 0 - parts.append(asm("cbz x3, #0xa8")) # 1 - parts.append(asm("sub sp, sp, #0x400")) # 2 - parts.append(asm("stp x29, x30, [sp]")) # 3 - parts.append(asm("stp x0, x1, [sp, #16]")) # 4 - parts.append(asm("stp x2, x3, [sp, #32]")) # 5 - parts.append(asm("stp x4, x5, [sp, #48]")) # 6 - parts.append(asm("stp x6, x7, [sp, #64]")) # 7 + parts.append(NOP) # 0 + parts.append(asm("cbz x3, #0xa8")) # 1 + parts.append(asm("sub sp, sp, #0x400")) # 2 + parts.append(asm("stp x29, x30, [sp]")) # 3 + parts.append(asm("stp x0, x1, [sp, #16]")) # 4 + parts.append(asm("stp x2, x3, [sp, #32]")) # 5 + parts.append(asm("stp x4, x5, [sp, #48]")) # 6 + parts.append(asm("stp x6, x7, [sp, #64]")) # 7 # Construct vfs_context inline (replaces BL vfs_context_current) - parts.append(asm("mrs x8, tpidr_el1")) # 8: current_thread - parts.append(asm("stp x8, x0, [sp, #0x70]")) # 9: {thread, cred} - parts.append(asm("add x2, sp, #0x70")) # 10: ctx = &vfs_ctx + parts.append(asm("mrs x8, tpidr_el1")) # 8: current_thread + parts.append(asm("stp x8, x0, [sp, #0x70]")) # 9: {thread, cred} + parts.append(asm("add x2, sp, #0x70")) # 10: ctx = &vfs_ctx # Setup vnode_getattr(vp, &vattr, ctx) - parts.append(asm("ldr x0, [sp, #0x28]")) # 11: x0 = vp - parts.append(asm("add x1, sp, #0x80")) # 12: x1 = &vattr - parts.append(asm("mov w8, #0x380")) # 13: vattr size - parts.append(asm("stp xzr, x8, [x1]")) # 14: init vattr - parts.append(asm("stp xzr, xzr, [x1, #0x10]")) # 15: init vattr - parts.append(NOP) # 16 - parts.append(vnode_bl) # 17: BL vnode_getattr + parts.append(asm("ldr x0, [sp, #0x28]")) # 11: x0 = vp + parts.append(asm("add x1, sp, #0x80")) # 12: x1 = &vattr + parts.append(asm("mov w8, #0x380")) # 13: vattr size + parts.append(asm("stp xzr, x8, [x1]")) # 14: init vattr + parts.append(asm("stp xzr, xzr, [x1, #0x10]")) # 15: init vattr + parts.append(NOP) # 16 + parts.append(vnode_bl) # 17: BL vnode_getattr # Check result + propagate ownership - parts.append(asm("cbnz x0, #0x50")) # 18: error → skip - parts.append(asm("mov w2, #0")) # 19: changed = 0 - parts.append(asm("ldr w8, [sp, #0xCC]")) # 20: va_mode - parts.append(bytes([0xa8, 0x00, 0x58, 0x36])) # 21: tbz w8,#11 - parts.append(asm("ldr w8, [sp, #0xC4]")) # 22: va_uid - parts.append(asm("ldr x0, [sp, #0x18]")) # 23: new_cred - parts.append(asm("str w8, [x0, #0x18]")) # 24: cred->uid - parts.append(asm("mov w2, #1")) # 25: changed = 1 - parts.append(asm("ldr w8, [sp, #0xCC]")) # 26: va_mode - parts.append(bytes([0xa8, 0x00, 0x50, 0x36])) # 27: tbz w8,#10 - parts.append(asm("mov w2, #1")) # 28: changed = 1 - parts.append(asm("ldr w8, [sp, #0xC8]")) # 29: va_gid - parts.append(asm("ldr x0, [sp, #0x18]")) # 30: new_cred - parts.append(asm("str w8, [x0, #0x28]")) # 31: cred->gid - parts.append(asm("cbz w2, #0x1c")) # 32: if !changed - parts.append(asm("ldr x0, [sp, #0x20]")) # 33: proc - parts.append(asm("ldr w8, [x0, #0x454]")) # 34: p_csflags - parts.append(asm("orr w8, w8, #0x100")) # 35: CS_VALID - parts.append(asm("str w8, [x0, #0x454]")) # 36: store - parts.append(asm("ldp x0, x1, [sp, #16]")) # 37: restore - parts.append(asm("ldp x2, x3, [sp, #32]")) # 38 - parts.append(asm("ldp x4, x5, [sp, #48]")) # 39 - parts.append(asm("ldp x6, x7, [sp, #64]")) # 40 - parts.append(asm("ldp x29, x30, [sp]")) # 41 - parts.append(asm("add sp, sp, #0x400")) # 42 - parts.append(NOP) # 43 - parts.append(b_back) # 44: B orig_hook + parts.append(asm("cbnz x0, #0x50")) # 18: error → skip + parts.append(asm("mov w2, #0")) # 19: changed = 0 + parts.append(asm("ldr w8, [sp, #0xCC]")) # 20: va_mode + parts.append(bytes([0xA8, 0x00, 0x58, 0x36])) # 21: tbz w8,#11 + parts.append(asm("ldr w8, [sp, #0xC4]")) # 22: va_uid + parts.append(asm("ldr x0, [sp, #0x18]")) # 23: new_cred + parts.append(asm("str w8, [x0, #0x18]")) # 24: cred->uid + parts.append(asm("mov w2, #1")) # 25: changed = 1 + parts.append(asm("ldr w8, [sp, #0xCC]")) # 26: va_mode + parts.append(bytes([0xA8, 0x00, 0x50, 0x36])) # 27: tbz w8,#10 + parts.append(asm("mov w2, #1")) # 28: changed = 1 + parts.append(asm("ldr w8, [sp, #0xC8]")) # 29: va_gid + parts.append(asm("ldr x0, [sp, #0x18]")) # 30: new_cred + parts.append(asm("str w8, [x0, #0x28]")) # 31: cred->gid + parts.append(asm("cbz w2, #0x1c")) # 32: if !changed + parts.append(asm("ldr x0, [sp, #0x20]")) # 33: proc + parts.append(asm("ldr w8, [x0, #0x454]")) # 34: p_csflags + parts.append(asm("orr w8, w8, #0x100")) # 35: CS_VALID + parts.append(asm("str w8, [x0, #0x454]")) # 36: store + parts.append(asm("ldp x0, x1, [sp, #16]")) # 37: restore + parts.append(asm("ldp x2, x3, [sp, #32]")) # 38 + parts.append(asm("ldp x4, x5, [sp, #48]")) # 39 + parts.append(asm("ldp x6, x7, [sp, #64]")) # 40 + parts.append(asm("ldp x29, x30, [sp]")) # 41 + parts.append(asm("add sp, sp, #0x400")) # 42 + parts.append(NOP) # 43 + parts.append(b_back) # 44: B orig_hook for i, part in enumerate(parts): - self.emit(cave + i * 4, part, - f"shellcode+{i*4} [_hook_cred_label_update_execve]") + self.emit( + cave + i * 4, + part, + f"shellcode+{i * 4} [_hook_cred_label_update_execve]", + ) # ── 8. Rewrite ops table entry ─────────────────────────── # Preserve auth rebase upper 32 bits (PAC key, diversity, @@ -2045,9 +2144,12 @@ class KernelJBPatcher(KernelPatcher): entry_off = ops_table + hook_index * 8 orig_raw = _rd64(self.raw, entry_off) new_raw = (orig_raw & 0xFFFFFFFF00000000) | (cave & 0xFFFFFFFF) - self.emit(entry_off, struct.pack(" 0 and any( - s <= decoded2 < e for s, e in self.code_ranges): + s <= decoded2 < e for s, e in self.code_ranges + ): sysent_off = off break if sysent_off >= 0: @@ -2113,60 +2216,71 @@ class KernelJBPatcher(KernelPatcher): # Build kcall10 shellcode parts = [ - asm("ldr x10, [sp, #0x40]"), # 0 - asm("ldp x0, x1, [x10, #0]"), # 1 - asm("ldp x2, x3, [x10, #0x10]"), # 2 - asm("ldp x4, x5, [x10, #0x20]"), # 3 - asm("ldp x6, x7, [x10, #0x30]"), # 4 - asm("ldp x8, x9, [x10, #0x40]"), # 5 - asm("ldr x10, [x10, #0x50]"), # 6 - asm("mov x16, x0"), # 7 - asm("mov x0, x1"), # 8 - asm("mov x1, x2"), # 9 - asm("mov x2, x3"), # 10 - asm("mov x3, x4"), # 11 - asm("mov x4, x5"), # 12 - asm("mov x5, x6"), # 13 - asm("mov x6, x7"), # 14 - asm("mov x7, x8"), # 15 - asm("mov x8, x9"), # 16 - asm("mov x9, x10"), # 17 + asm("ldr x10, [sp, #0x40]"), # 0 + asm("ldp x0, x1, [x10, #0]"), # 1 + asm("ldp x2, x3, [x10, #0x10]"), # 2 + asm("ldp x4, x5, [x10, #0x20]"), # 3 + asm("ldp x6, x7, [x10, #0x30]"), # 4 + asm("ldp x8, x9, [x10, #0x40]"), # 5 + asm("ldr x10, [x10, #0x50]"), # 6 + asm("mov x16, x0"), # 7 + asm("mov x0, x1"), # 8 + asm("mov x1, x2"), # 9 + asm("mov x2, x3"), # 10 + asm("mov x3, x4"), # 11 + asm("mov x4, x5"), # 12 + asm("mov x5, x6"), # 13 + asm("mov x6, x7"), # 14 + asm("mov x7, x8"), # 15 + asm("mov x8, x9"), # 16 + asm("mov x9, x10"), # 17 asm("stp x29, x30, [sp, #-0x10]!"), # 18 bytes([0x00, 0x02, 0x3F, 0xD6]), # 19: BLR x16 - asm("ldp x29, x30, [sp], #0x10"), # 20 - asm("ldr x11, [sp, #0x40]"), # 21 - NOP, # 22 - asm("stp x0, x1, [x11, #0]"), # 23 + asm("ldp x29, x30, [sp], #0x10"), # 20 + asm("ldr x11, [sp, #0x40]"), # 21 + NOP, # 22 + asm("stp x0, x1, [x11, #0]"), # 23 asm("stp x2, x3, [x11, #0x10]"), # 24 asm("stp x4, x5, [x11, #0x20]"), # 25 asm("stp x6, x7, [x11, #0x30]"), # 26 asm("stp x8, x9, [x11, #0x40]"), # 27 - asm("str x10, [x11, #0x50]"), # 28 - asm("mov x0, #0"), # 29 - asm("ret"), # 30 - NOP, # 31 + asm("str x10, [x11, #0x50]"), # 28 + asm("mov x0, #0"), # 29 + asm("ret"), # 30 + NOP, # 31 ] for i, part in enumerate(parts): - self.emit(cave + i * 4, part, - f"shellcode+{i*4} [kcall10]") + self.emit(cave + i * 4, part, f"shellcode+{i * 4} [kcall10]") # Patch sysent[439] cave_va = self.base_va + cave - self.emit(entry_439, struct.pack("= 0: munge_va = self.base_va + munge_off - self.emit(entry_439 + 8, struct.pack("= 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() diff --git a/scripts/patchers/txm_jb.py b/scripts/patchers/txm_jb.py index 8cffc1f..36bb4db 100644 --- a/scripts/patchers/txm_jb.py +++ b/scripts/patchers/txm_jb.py @@ -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") diff --git a/scripts/ramdisk_build.py b/scripts/ramdisk_build.py index 544811a..e1b51f0 100755 --- a/scripts/ramdisk_build.py +++ b/scripts/ramdisk_build.py @@ -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 ────────────────────────────────── diff --git a/scripts/setup_libimobiledevice.sh b/scripts/setup_libimobiledevice.sh index d789fe8..7510784 100755 --- a/scripts/setup_libimobiledevice.sh +++ b/scripts/setup_libimobiledevice.sh @@ -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 ───────────────────────────────────────────────────────── diff --git a/scripts/setup_machine.sh b/scripts/setup_machine.sh index b3d6ee0..aec9508 100755 --- a/scripts/setup_machine.sh +++ b/scripts/setup_machine.sh @@ -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 ===" diff --git a/scripts/setup_tools.sh b/scripts/setup_tools.sh new file mode 100644 index 0000000..4344497 --- /dev/null +++ b/scripts/setup_tools.sh @@ -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." diff --git a/scripts/setup_venv.sh b/scripts/setup_venv.sh index 50f3543..3c38998 100755 --- a/scripts/setup_venv.sh +++ b/scripts/setup_venv.sh @@ -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" diff --git a/scripts/setup_venv_linux.sh b/scripts/setup_venv_linux.sh index 09fd12c..7129dd5 100644 --- a/scripts/setup_venv_linux.sh +++ b/scripts/setup_venv_linux.sh @@ -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 --- diff --git a/scripts/vm_create.sh b/scripts/vm_create.sh index 1d59e17..a3ce91a 100755 --- a/scripts/vm_create.sh +++ b/scripts/vm_create.sh @@ -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 --- diff --git a/scripts/vphoned/Makefile b/scripts/vphoned/Makefile new file mode 100644 index 0000000..6664ebf --- /dev/null +++ b/scripts/vphoned/Makefile @@ -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) diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index 93f96aa..d771ffd 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -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 diff --git a/sources/vphone-cli/VPhoneIPAInstaller.swift b/sources/vphone-cli/VPhoneIPAInstaller.swift new file mode 100644 index 0000000..0a08736 --- /dev/null +++ b/sources/vphone-cli/VPhoneIPAInstaller.swift @@ -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)" + } + } + } +} diff --git a/sources/vphone-cli/VPhoneMenuConnect.swift b/sources/vphone-cli/VPhoneMenuConnect.swift index 9cfa4ea..3312d05 100644 --- a/sources/vphone-cli/VPhoneMenuConnect.swift +++ b/sources/vphone-cli/VPhoneMenuConnect.swift @@ -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 diff --git a/sources/vphone-cli/VPhoneMenuController.swift b/sources/vphone-cli/VPhoneMenuController.swift index 09f14c2..8a05bb2 100644 --- a/sources/vphone-cli/VPhoneMenuController.swift +++ b/sources/vphone-cli/VPhoneMenuController.swift @@ -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 } diff --git a/sources/vphone-cli/VPhoneMenuInstall.swift b/sources/vphone-cli/VPhoneMenuInstall.swift new file mode 100644 index 0000000..443cf94 --- /dev/null +++ b/sources/vphone-cli/VPhoneMenuInstall.swift @@ -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 + ) + } + } + } +} diff --git a/sources/vphone-cli/VPhoneMenuRecord.swift b/sources/vphone-cli/VPhoneMenuRecord.swift new file mode 100644 index 0000000..2b7ab6d --- /dev/null +++ b/sources/vphone-cli/VPhoneMenuRecord.swift @@ -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)") + } + } + } +} diff --git a/sources/vphone-cli/VPhoneScreenRecorder.swift b/sources/vphone-cli/VPhoneScreenRecorder.swift new file mode 100644 index 0000000..db5c3ee --- /dev/null +++ b/sources/vphone-cli/VPhoneScreenRecorder.swift @@ -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 + } +} diff --git a/sources/vphone-cli/VPhoneSigner.swift b/sources/vphone-cli/VPhoneSigner.swift new file mode 100644 index 0000000..e4ca68c --- /dev/null +++ b/sources/vphone-cli/VPhoneSigner.swift @@ -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(" [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)" + } + } + } +}