diff --git a/Makefile b/Makefile index 439ee2a..cec6b44 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ CPU ?= 8 MEMORY ?= 8192 DISK_SIZE ?= 64 CFW_INPUT ?= cfw_input +BASE_PATCH ?= # ─── Build info ────────────────────────────────────────────────── GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") @@ -71,13 +72,18 @@ help: @echo "Ramdisk:" @echo " make ramdisk_build Build signed SSH ramdisk" @echo " make ramdisk_send Send ramdisk to device" + @echo " make testing_ramdisk_build Build testing boot chain (no SSH, no CFW)" + @echo " make testing_ramdisk_send Send testing boot chain to device" + @echo " make testing_checkpoint_save Save kernel checkpoint for patch testing" + @echo " Options: BASE_PATCH=normal|dev|jb" + @echo " make testing_exec Quick test flow (prepare -> patch_jb -> build/send -> boot_dfu)" @echo "" @echo "CFW:" @echo " make cfw_install Install CFW mods via SSH" @echo " make cfw_install_dev Install CFW mods via SSH (dev mode)" @echo " make cfw_install_jb Install CFW + JB extensions (jetsam/procursus/basebin)" @echo "" - @echo "Variables: VM_DIR=$(VM_DIR) CPU=$(CPU) MEMORY=$(MEMORY) DISK_SIZE=$(DISK_SIZE)" + @echo "Variables: VM_DIR=$(VM_DIR) CPU=$(CPU) MEMORY=$(MEMORY) DISK_SIZE=$(DISK_SIZE) BASE_PATCH=$(if $(BASE_PATCH),$(BASE_PATCH),jb)" # ═══════════════════════════════════════════════════════════════════ # Setup @@ -217,7 +223,7 @@ restore: # Ramdisk # ═══════════════════════════════════════════════════════════════════ -.PHONY: ramdisk_build ramdisk_send +.PHONY: ramdisk_build ramdisk_send testing_ramdisk_build testing_ramdisk_send testing_checkpoint_save testing_exec testing_kernel_patch testing_c23_bisect ramdisk_build: cd $(VM_DIR) && $(PYTHON) "$(CURDIR)/$(SCRIPTS)/ramdisk_build.py" . @@ -225,6 +231,30 @@ ramdisk_build: ramdisk_send: cd $(VM_DIR) && IRECOVERY="$(CURDIR)/$(IRECOVERY)" zsh "$(CURDIR)/$(SCRIPTS)/ramdisk_send.sh" +testing_ramdisk_build: + cd $(VM_DIR) && $(PYTHON) "$(CURDIR)/$(SCRIPTS)/testing_ramdisk_build.py" . + +testing_ramdisk_send: + cd $(VM_DIR) && IRECOVERY="$(CURDIR)/$(IRECOVERY)" zsh "$(CURDIR)/$(SCRIPTS)/testing_ramdisk_send.sh" + +testing_checkpoint_save: + VM_DIR="$(VM_DIR)" BASE_PATCH="$(if $(BASE_PATCH),$(BASE_PATCH),jb)" zsh "$(CURDIR)/$(SCRIPTS)/testing_checkpoint_save.sh" + +testing_exec: + VM_DIR="$(VM_DIR)" zsh "$(CURDIR)/$(SCRIPTS)/testing_exec.sh" + +testing_kernel_patch: + @if [ -z "$(strip $(or $(PATCHES),$(PATCH)))" ]; then \ + echo "Error: PATCH or PATCHES is required"; \ + echo " Example: make testing_kernel_patch PATCH=patch_kcall10"; \ + echo " Example: make testing_kernel_patch PATCHES='patch_a patch_b'"; \ + exit 1; \ + fi + cd $(VM_DIR) && BASE_PATCH="$(if $(BASE_PATCH),$(BASE_PATCH),jb)" $(PYTHON) "$(CURDIR)/$(SCRIPTS)/testing_kernel_patch.py" . --base-patch "$(if $(BASE_PATCH),$(BASE_PATCH),jb)" $(or $(PATCHES),$(PATCH)) + +testing_c23_bisect: + cd $(VM_DIR) && $(PYTHON) "$(CURDIR)/$(SCRIPTS)/testing_c23_bisect.py" . $(VARIANT) + # ═══════════════════════════════════════════════════════════════════ # CFW # ═══════════════════════════════════════════════════════════════════ diff --git a/research/jb_mount_failure_investigation_2026-03-04.md b/research/jb_mount_failure_investigation_2026-03-04.md index ffc748e..a40d096 100644 --- a/research/jb_mount_failure_investigation_2026-03-04.md +++ b/research/jb_mount_failure_investigation_2026-03-04.md @@ -17,7 +17,31 @@ From `make boot` serial log: - Ignition/boot path shows entitlement-like failure: - `handle_get_dev_by_role:13101: disk1s1 This operation needs entitlement` -This is consistent with missing mount-policy bypasses in the running kernel. +This indicates failure in APFS role-based device lookup during early boot mount tasks. + +## Runtime Evidence (DEV Control Run, 2026-03-04) + +From a separate `fw_patch_dev + cfw_install_dev` boot log (not JB): + +- `mount-phase-1` succeeded for xART: + - `disk1s3 mount-complete volume xART` + - `/dev/disk1s3 on /private/xarts ...` +- launch progressed to: + - `data-protection` + - `finish-obliteration` + - `detect-installed-roots` + - `mount-phase-2` + +Interpretation: APFS boot-mount path can work on this build/kernel family after recent APFS gate changes. +This does **not** prove JB flow is fixed; it is a control signal showing the kernel-side path is not universally broken. + +## Flow Separation (Critical) + +- The successful `xART mount-complete` / `mount-phase-2` log is from DEV pipeline: + - `fw_patch_dev` + `cfw_install_dev` +- JB pipeline remains: + - `fw_patch_jb` + `cfw_install_jb` +- `cfw_install_jb` does **not** call `cfw_install_dev`; it runs base `cfw_install.sh` first, then JB-only phases. ## Kernel Artifact Checks @@ -51,34 +75,129 @@ Interpretation: kernel is base-patched, but critical JB mount/syscall extensions So restore kernel is modified vs source, but not fully JB-complete. -## Root Cause (Current Working Hypothesis) +## IDA Deep-Dive (APFS mount-phase-1 path) -- The kernel used for install/boot is not fully JB-patched. -- Missing JB mount-related patches (`___mac_mount`, `_dounmount`) explain: - - remount failure in ramdisk CFW stage - - mount-phase-1 failures and panic during normal boot. +### 1) Failing function identified + +- APFS function: `sub_FFFFFE000948EB10` (log name: `handle_get_dev_by_role`) +- Trigger string in function: + - `"%s:%d: %s This operation needs entitlement\\n"` (line 13101) +- Caller xref: + - `sub_FFFFFE000947CFE4` dispatches to `sub_FFFFFE000948EB10` + +### 2) Gate logic at failure site + +The deny path is reached if either check fails: + +- Context gate: + - `BL sub_FFFFFE0007CCB994` + - `CBZ X0, deny` +- "Entitlement" gate (APFS role lookup privilege gate): + - `ADRL X1, "com.apple.apfs.get-dev-by-role"` + - `BL sub_FFFFFE000940CFC8` + - `CBZ W0, deny` +- Secondary role-path gate (role == 2 volume-group path): + - `BL sub_FFFFFE000817C240` + - `CBZ W0, deny` (to line 13115 block) + +The deny block logs line `13101` and returns failure. + +### 3) Patch sites (current vphone600 kernelcache) + +- File offsets: + - `0x0248AB50` — context gate branch (`CBZ X0, deny`) + - `0x0248AB64` — role-lookup privilege gate (`CBZ W0, deny`) + - `0x0248AC24` — secondary role==2 deny branch (`CBZ W0, deny`) +- All three patched to `NOP` in the additive APFS patch. + +### 4) Additional APFS EPERM(1) return paths in `apfs_vfsop_mount` + +Function: + +- `sub_FFFFFE0009478848` (`apfs_vfsop_mount`) + +Observed EPERM-relevant deny blocks: + +- Root-mount privilege deny: + - log string: `"%s:%d: not allowed to mount as root\n"` + - xref site: `0xFFFFFE000947905C` + - error return: sets `W25 = 1` +- Verification-mount privilege deny: + - log string: `"%s:%d: not allowed to do a verification mount of %s (is_suser %s ; uid %d)\n"` + - xref site: `0xFFFFFE0009479CA0` + - error return: sets `W25 = 1` + +Important relation to existing Patch 13: + +- At `0xFFFFFE0009479044` (same function), current code is `CMP X0, X0` (patched form), + which forces the following `B.EQ` path and should bypass one root privilege check in this region. +- Therefore, if JB still reports `mount_apfs ... Operation not permitted`, remaining EPERM candidates + include other deny branches (including the verification-mount gate path above), not only `handle_get_dev_by_role`. + +## Root Cause (Updated, Two-Stage) + +Stage 1 (confirmed and mitigated): + +- APFS `handle_get_dev_by_role` entitlement/role deny gates were a concrete mount-phase-1 blocker. +- Additive patch now NOPs all three relevant deny branches. + +Stage 2 (still under investigation, JB-only): + +- DEV control run can pass `mount-phase-1`/`mount-phase-2`. +- JB failures must be analyzed with JB-only artifacts/logs and likely involve JB-only deltas + (launchd dylib injection, BaseBin hooks, or JB preboot/bootstrap interaction), in addition to any remaining kernel checks. ## Mitigation Implemented -To reduce install fragility while preserving a JB target kernel: +### A) Ramdisk kernel split (updated implementation) - `scripts/fw_patch_jb.py` - - saves a pre-JB base/dev snapshot: - - `kernelcache.research.vphone600.ramdisk` + - no longer creates a ramdisk snapshot file - `scripts/ramdisk_build.py` + - derives ramdisk kernel source internally: + - uses legacy `kernelcache.research.vphone600.ramdisk` if present + - otherwise derives from pristine CloudOS `kernelcache.research.vphone600` + under `ipsws/*CloudOS*/` using base `KernelPatcher` - builds: - - `Ramdisk/krnl.ramdisk.img4` from the snapshot - - `Ramdisk/krnl.img4` from post-JB kernel + - `Ramdisk/krnl.ramdisk.img4` from derived/base source + - `Ramdisk/krnl.img4` from post-JB restore kernel - `scripts/ramdisk_send.sh` - prefers `krnl.ramdisk.img4` when present. +### B) Additive APFS boot-mount gate bypass (new) + +- Added new base kernel patch method: + - `KernelPatchApfsMountMixin.patch_apfs_get_dev_by_role_entitlement()` +- Added to base kernel patch sequence in `scripts/patchers/kernel.py`. +- Behavior: + - NOPs three deny branches in `handle_get_dev_by_role` + - does not modify existing filesystem patches (APFS snapshot/seal/graft/mount/sandbox hooks remain unchanged). + +### C) JB-only differential identified (for next isolation) + +Compared with DEV flow, JB adds unique early-boot risk factors: + +- launchd binary gets `LC_LOAD_DYLIB` injection for `/cores/launchdhook.dylib` +- `launchdhook.dylib`/BaseBin environment strings include: + - `JB_ROOT_PATH` + - `JB_TWEAKLOADER_PATH` + - explicit launchdhook startup logs (`hello` / `bye`) +- procursus/bootstrap content is written under preboot hash path (`/mnt5//jb-vphone`) + +These do not prove causality yet, but they are the primary JB-only candidates after Stage-1 APFS gate mitigation. + ## Next Validation -1. Re-run firmware patch and ramdisk build on the current tree: +1. Kernel/JB isolation run (requested): - `make fw_patch_jb` - `make ramdisk_build` - `make ramdisk_send` - - `make cfw_install_jb` -2. Verify remount succeeds in JB stage: - - `/dev/disk1s1 -> /mnt1` -3. Re-test normal boot and confirm no `mount-phase-1 exit(77)` panic. + - run `cfw_install_dev` (not JB) on this JB-patched firmware baseline +2. Compare normal boot result: + - If `mount-phase-1/2` succeeds: strong evidence issue is in JB-only userspace phases. + - If it still fails with `EPERM`: continue kernel/APFS deny-path tracing. +3. If step 2 succeeds, add back JB phases incrementally: + - first JB-1 (launchd inject + jetsam patch) + - then JB-2 (preboot bootstrap) + - then JB-3 (BaseBin hooks) + and capture first regression point. diff --git a/research/patch_comparison_all_variants.md b/research/patch_comparison_all_variants.md index 6c17499..4202a25 100644 --- a/research/patch_comparison_all_variants.md +++ b/research/patch_comparison_all_variants.md @@ -43,6 +43,7 @@ Three firmware variants are available, each building on the previous: ### TXM TXM patch composition by variant: + - Regular: `txm.py` (1 patch). - Dev: `txm.py` (1 patch) + `txm_dev.py` (11 patches) = 12 total. - JB: same as Dev (selector24 bypass now in `txm_dev.py`, no separate JB patcher). @@ -64,25 +65,26 @@ TXM patch composition by variant: ### Kernelcache -Regular and Dev share the same 25 base kernel patches. JB adds 34 additional patches. +Regular and Dev share the same 28 base kernel patches. JB adds 34 additional patches. #### Base patches (all variants) -| # | Patch | Function | Purpose | Regular | Dev | JB | -| ----- | -------------------------- | -------------------------------- | ---------------------------------------- | :-----: | :-: | :-: | -| 1 | NOP `tbnz w8,#5` | `_apfs_vfsop_mount` | Skip "root snapshot" sealed volume check | Y | Y | Y | -| 2 | NOP conditional | `_authapfs_seal_is_broken` | Skip "root volume seal" panic | Y | Y | Y | -| 3 | NOP conditional | `_bsd_init` | Skip "rootvp not authenticated" panic | Y | Y | Y | -| 4–5 | `mov w0,#0; ret` | `_proc_check_launch_constraints` | Bypass launch constraints | Y | Y | Y | -| 6–7 | `mov x0,#1` (2x) | `PE_i_can_has_debugger` | Enable kernel debugger | Y | Y | Y | -| 8 | NOP | `_postValidation` | Skip AMFI post-validation | Y | Y | Y | -| 9 | `cmp w0,w0` | `_postValidation` | Force comparison true | Y | Y | Y | -| 10–11 | `mov w0,#1` (2x) | `_check_dyld_policy_internal` | Allow dyld loading | Y | Y | Y | -| 12 | `mov w0,#0` | `_apfs_graft` | Allow APFS graft | Y | Y | Y | -| 13 | `cmp x0,x0` | `_apfs_vfsop_mount` | Skip mount check | Y | Y | Y | -| 14 | `mov w0,#0` | `_apfs_mount_upgrade_checks` | Allow mount upgrade | Y | Y | Y | -| 15 | `mov w0,#0` | `_handle_fsioc_graft` | Allow fsioc graft | Y | Y | Y | -| 16–25 | `mov x0,#0; ret` (5 hooks) | Sandbox MACF ops table | Stub 5 sandbox hooks | Y | Y | Y | +| # | Patch | Function | Purpose | Regular | Dev | JB | +| ----- | -------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------- | :-----: | :-: | :-: | +| 1 | NOP `tbnz w8,#5` | `_apfs_vfsop_mount` | Skip "root snapshot" sealed volume check | Y | Y | Y | +| 2 | NOP conditional | `_authapfs_seal_is_broken` | Skip "root volume seal" panic | Y | Y | Y | +| 3 | NOP conditional | `_bsd_init` | Skip "rootvp not authenticated" panic | Y | Y | Y | +| 4–5 | `mov w0,#0; ret` | `_proc_check_launch_constraints` | Bypass launch constraints | Y | Y | Y | +| 6–7 | `mov x0,#1` (2x) | `PE_i_can_has_debugger` | Enable kernel debugger | Y | Y | Y | +| 8 | NOP | `_postValidation` | Skip AMFI post-validation | Y | Y | Y | +| 9 | `cmp w0,w0` | `_postValidation` | Force comparison true | Y | Y | Y | +| 10–11 | `mov w0,#1` (2x) | `_check_dyld_policy_internal` | Allow dyld loading | Y | Y | Y | +| 12 | `mov w0,#0` | `_apfs_graft` | Allow APFS graft | Y | Y | Y | +| 13 | `cmp x0,x0` | `_apfs_vfsop_mount` | Skip mount check | Y | Y | Y | +| 14 | `mov w0,#0` | `_apfs_mount_upgrade_checks` | Allow mount upgrade | Y | Y | Y | +| 15 | `mov w0,#0` | `_handle_fsioc_graft` | Allow fsioc graft | Y | Y | Y | +| 16 | `NOP` (3x) | `handle_get_dev_by_role` | Bypass APFS role-lookup deny gates for boot mounts (context + entitlement + role==2 path) | Y | Y | Y | +| 17–26 | `mov x0,#0; ret` (5 hooks) | Sandbox MACF ops table | Stub 5 sandbox hooks | Y | Y | Y | #### JB-only kernel patches @@ -138,23 +140,39 @@ Regular and Dev share the same 25 base kernel patches. JB adds 34 additional pat | 7 | Procursus bootstrap | Bootstrap filesystem + optional Sileo deb | — | — | Y | | 8 | BaseBin hooks | systemhook.dylib, launchdhook.dylib, libellekit.dylib → `/cores/` | — | — | Y | +### CFW Installer Flow Matrix (Script-Level) + +| Flow item | Regular (`cfw_install.sh`) | Dev (`cfw_install_dev.sh`) | JB (`cfw_install_jb.sh`) | +| --- | --- | --- | --- | +| Base CFW phases (1/7 → 7/7) | Runs directly | Runs directly | Runs via `CFW_SKIP_HALT=1 zsh cfw_install.sh` | +| Dev overlay (`rpcserver_ios` replacement in `iosbinpack64.tar`) | — | Y (`apply_dev_overlay`) | — | +| SSH readiness wait before install | Y (`wait_for_device_ssh_ready`) | — | Y (inherited from base run) | +| `remote_mount` behavior | Ensures mountpoint and verifies mount success (hard fail) | Best-effort mount only (`mount_apfs ... || true`) | Ensures mountpoint and verifies mount success (hard fail) | +| launchd jetsam patch (`patch-launchd-jetsam`) | — | Y (base-flow injection) | Y (JB-1) | +| launchd dylib injection (`inject-dylib /cores/launchdhook.dylib`) | — | — | Y (JB-1) | +| Procursus bootstrap deployment (`/mnt5//jb-vphone/procursus`) | — | — | Y (JB-2) | +| BaseBin hook deployment (`*.dylib` → `/mnt1/cores`) | — | — | Y (JB-3) | +| Additional input resources | `cfw_input` | `cfw_input` + `resources/cfw_dev/rpcserver_ios` | `cfw_input` + `cfw_jb_input` | +| Extra tool requirement beyond base | — | — | `zstd` | +| Halt behavior | Halts unless `CFW_SKIP_HALT=1` | Halts unless `CFW_SKIP_HALT=1` | Always halts after JB phases | + ## Summary -| Component | Regular | Dev | JB | -| ------------------------ | :-----: | :----: | :----: | -| AVPBooter | 1 | 1 | 1 | -| iBSS | 2 | 2 | 3 | -| iBEC | 3 | 3 | 3 | -| LLB | 6 | 6 | 6 | -| TXM | 1 | 12 | 12 | -| Kernel | 25 | 25 | 59 | -| **Boot chain total** | **38** | **49** | **84** | -| | | | | -| CFW binary patches | 4 | 5 | 6 | -| CFW installed components | 6 | 7 | 8 | -| **CFW total** | **10** | **12** | **14** | -| | | | | -| **Grand total** | **48** | **61** | **98** | +| Component | Regular | Dev | JB | +| ------------------------ | :-----: | :----: | :-----: | +| AVPBooter | 1 | 1 | 1 | +| iBSS | 2 | 2 | 3 | +| iBEC | 3 | 3 | 3 | +| LLB | 6 | 6 | 6 | +| TXM | 1 | 12 | 12 | +| Kernel | 28 | 28 | 62 | +| **Boot chain total** | **41** | **52** | **87** | +| | | | | +| CFW binary patches | 4 | 5 | 6 | +| CFW installed components | 6 | 7 | 8 | +| **CFW total** | **10** | **12** | **14** | +| | | | | +| **Grand total** | **51** | **64** | **101** | ### What each variant adds @@ -184,18 +202,25 @@ Regular and Dev share the same 25 base kernel patches. JB adds 34 additional pat - `jb/org.coolstar.sileo_2.5.1_iphoneos-arm64.deb` - `basebin/*.dylib` (BaseBin hooks for JB-3) -## Ramdisk Kernel Split (JB mode) +## Ramdisk Variant Matrix (`make ramdisk_build`) -- `scripts/fw_patch_jb.py` now snapshots the base/dev-patched kernel before JB kernel extensions: - - `iPhone*_Restore/kernelcache.research.vphone600.ramdisk` -- `scripts/ramdisk_build.py` uses that snapshot to build: - - `Ramdisk/krnl.ramdisk.img4` (base/dev kernel for SSH ramdisk boot + CFW install) - - `Ramdisk/krnl.img4` (post-JB kernel, unchanged restore target) -- `scripts/ramdisk_send.sh` now prefers `krnl.ramdisk.img4` when present, otherwise falls back to `krnl.img4`. -- Intent: keep restore kernel fully JB-patched while booting the installer ramdisk with a - more conservative kernel variant to improve `/dev/disk1s1` remount reliability. -- Investigation details and runtime evidence: - - `research/jb_mount_failure_investigation_2026-03-04.md` +Why `ramdisk_build` still prints patch logs: + +- Step 6 patches `Firmware/txm.iphoneos.release.im4p` via `patch_txm()` (1 trustcache-bypass patch), then signs `Ramdisk/txm.img4`. +- Step 7 may derive `kernelcache.research.vphone600.ramdisk` from pristine CloudOS and apply base `KernelPatcher` (28 patches), then signs `Ramdisk/krnl.ramdisk.img4`. +- Step 7 also always signs restore kernel as `Ramdisk/krnl.img4`. + +| Variant | Pre-step before `make ramdisk_build` | `Ramdisk/txm.img4` | `Ramdisk/krnl.ramdisk.img4` | `Ramdisk/krnl.img4` | Effective kernel used by `ramdisk_send.sh` | +| ------------- | ------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------- | +| `RAMDISK` | `make fw_patch` | release TXM + base TXM patch (1) | base kernel (28): use legacy `*.ramdisk` if present, else derive from pristine CloudOS | restore kernel from `fw_patch` (28) | `krnl.ramdisk.img4` (preferred), fallback `krnl.img4` | +| `DEV+RAMDISK` | `make fw_patch_dev` | release TXM + base TXM patch (1) | base kernel (28): same derivation rule as above | restore kernel from `fw_patch_dev` (28) | `krnl.ramdisk.img4` (preferred), fallback `krnl.img4` | +| `JB+RAMDISK` | `make fw_patch_jb` | release TXM + base TXM patch (1) | base kernel (28): same derivation rule as above | restore kernel from `fw_patch_jb` (62 = 28 base + 34 JB) | `krnl.ramdisk.img4` (preferred), fallback `krnl.img4` | + +Notes: + +- `scripts/fw_patch_jb.py` no longer creates a ramdisk snapshot file directly. +- Intent: keep ramdisk boot on a conservative base kernel while preserving full patched restore kernel for later JB flow. +- Investigation details and runtime evidence: `research/jb_mount_failure_investigation_2026-03-04.md` ## Dynamic Implementation Log (JB Patchers) @@ -270,6 +295,12 @@ with capstone semantic matching and keystone-generated patch bytes only: 5. `_postValidation` additional CMP bypass 6. `_proc_security_policy` stub (mov x0,#0; ret) — FIXED: was patching copyio instead + - Runtime optimization (2026-03-05): locator switched from capstone full-text scan to + raw instruction-mask matching (`sub wN,wM,#1 ; cmp wN,#0x21`, strict W-form) + raw + BL decode in `_proc_info` body; shared `_proc_info` anchor scan cache reused by + `_proc_pidinfo`. + - JB timing logger readability tweak (2026-03-05): per-method `[T]` and timing summary + now only print slow methods (runtime `>=10s`), patch output/selection unchanged. 7. `_proc_pidinfo` pid-0 guard NOP (2 sites) 8. `_convert_port_to_map_with_flavor` panic skip — FIXED: was patching PAC check instead 9. `_vm_fault_enter_prepare` PMAP check NOP @@ -290,14 +321,14 @@ with capstone semantic matching and keystone-generated patch bytes only: 21. `_cred_label_update_execve` cs_flags shellcode 22. `_syscallmask_apply_to_proc` filter mask shellcode 23. `_hook_cred_label_update_execve` inline trampoline + vnode_getattr shellcode - - Code cave restricted to __TEXT_EXEC only (__PRELINK_TEXT excluded due to KTRR) - - Inline trampoline (B cave at function entry) replaces ops table pointer rewrite - - Ops table pointer modification breaks chained fixup integrity → PAC failures + - Code cave restricted to **TEXT_EXEC only (**PRELINK_TEXT excluded due to KTRR) + - Inline trampoline (B cave at function entry) replaces ops table pointer rewrite + - Ops table pointer modification breaks chained fixup integrity → PAC failures 24. `kcall10` syscall 439 replacement shellcode - - Sysent table base found via backward scan from first `_nosys` match (entry 0 is indirect syscall, not `_nosys`) - - `sy_call` encoded as auth rebase chained fixup pointer (diversity=0xBCAD, key=IA, addrDiv=0) - - Matches dispatch's `BLRAA X8, X17` with `X17=0xBCAD` PAC authentication - - Chain `next` field preserved from original entry to maintain fixup chain integrity + - Sysent table base found via backward scan from first `_nosys` match (entry 0 is indirect syscall, not `_nosys`) + - `sy_call` encoded as auth rebase chained fixup pointer (diversity=0xBCAD, key=IA, addrDiv=0) + - Matches dispatch's `BLRAA X8, X17` with `X17=0xBCAD` PAC authentication + - Chain `next` field preserved from original entry to maintain fixup chain integrity ## Cross-Version Dynamic Snapshot diff --git a/scripts/fw_patch_jb.py b/scripts/fw_patch_jb.py index d66dda8..74dc696 100644 --- a/scripts/fw_patch_jb.py +++ b/scripts/fw_patch_jb.py @@ -9,7 +9,6 @@ This script extends fw_patch_dev with additional JB-oriented patches. """ import os -import shutil import sys from fw_patch import ( @@ -26,9 +25,6 @@ from fw_patch_dev import patch_txm_dev from patchers.iboot_jb import IBootJBPatcher from patchers.kernel_jb import KernelJBPatcher -RAMDISK_KERNEL_SUFFIX = ".ramdisk" -KERNEL_SEARCH_PATTERNS = ["kernelcache.research.vphone600"] - def patch_ibss_jb(data): p = IBootJBPatcher(data, mode="ibss", label="Loaded iBSS") @@ -73,17 +69,6 @@ JB_COMPONENTS = [ True, ), ] - - -def snapshot_base_kernel_for_ramdisk(restore_dir): - """Save base/dev-patched kernel before JB extensions for ramdisk boot.""" - kernel_path = find_file(restore_dir, KERNEL_SEARCH_PATTERNS, "kernelcache") - ramdisk_kernel_path = f"{kernel_path}{RAMDISK_KERNEL_SUFFIX}" - shutil.copy2(kernel_path, ramdisk_kernel_path) - print(f"[*] Saved ramdisk kernel snapshot: {ramdisk_kernel_path}") - return ramdisk_kernel_path - - def main(): vm_dir = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() vm_dir = os.path.abspath(vm_dir) @@ -106,8 +91,6 @@ def main(): path = find_file(search_base, patterns, name) patch_component(path, patch_fn, name, preserve_payp) - snapshot_base_kernel_for_ramdisk(restore_dir) - if JB_COMPONENTS: print(f"\n[*] Applying {len(JB_COMPONENTS)} JB extension patches ...") for name, in_restore, patterns, patch_fn, preserve_payp in JB_COMPONENTS: diff --git a/scripts/patchers/kernel.py b/scripts/patchers/kernel.py index 9985d36..94668b8 100755 --- a/scripts/patchers/kernel.py +++ b/scripts/patchers/kernel.py @@ -55,8 +55,7 @@ class KernelPatcher( def find_all(self): """Find and record all kernel patches. Returns list of (offset, bytes, desc).""" - self.patches = [] - self._patch_num = 0 + self._reset_patch_state() self.patch_apfs_root_snapshot() # 1 self.patch_apfs_seal_broken() # 2 self.patch_bsd_init_rootvp() # 3 @@ -69,7 +68,8 @@ class KernelPatcher( 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_apfs_get_dev_by_role_entitlement() # 16 + self.patch_sandbox_hooks() # 17-26 return self.patches def apply(self): diff --git a/scripts/patchers/kernel_base.py b/scripts/patchers/kernel_base.py index d6eebaf..b4170c8 100644 --- a/scripts/patchers/kernel_base.py +++ b/scripts/patchers/kernel_base.py @@ -1,6 +1,6 @@ """Base class with all infrastructure for kernel patchers.""" -import struct, plistlib +import struct, plistlib, threading from collections import defaultdict from capstone.arm64_const import ( @@ -26,8 +26,16 @@ class KernelPatcherBase: self.raw = bytes(data) # immutable snapshot for searching self.size = len(data) self.patches = [] # collected (offset, bytes, description) + self._patch_by_off = {} # offset -> (patch_bytes, desc) self.verbose = verbose self._patch_num = 0 # running counter for clean one-liners + self._emit_lock = threading.Lock() + + # Hot-path caches (search/disassembly is repeated heavily in JB mode). + self._disas_cache = {} + self._disas_cache_limit = 200_000 + self._string_refs_cache = {} + self._func_start_cache = {} self._log("[*] Parsing Mach-O segments …") self._parse_macho() @@ -52,6 +60,12 @@ class KernelPatcherBase: if self.verbose: print(msg) + def _reset_patch_state(self): + """Reset patch bookkeeping before a fresh find/apply pass.""" + self.patches = [] + self._patch_by_off = {} + self._patch_num = 0 + # ── Mach-O / segment parsing ───────────────────────────────── def _parse_macho(self): """Parse top-level Mach-O: discover BASE_VA, segments, code ranges.""" @@ -314,11 +328,26 @@ class KernelPatcherBase: # ── Helpers ────────────────────────────────────────────────── def _disas_at(self, off, count=1): """Disassemble *count* instructions at file offset. Returns a list.""" - end = min(off + count * 4, self.size) if off < 0 or off >= self.size: return [] + + key = None + if count <= 4: + key = (off, count) + cached = self._disas_cache.get(key) + if cached is not None: + return cached + + end = min(off + count * 4, self.size) code = bytes(self.raw[off:end]) - return list(_cs.disasm(code, off, count)) + insns = list(_cs.disasm(code, off, count)) + + if key is not None: + if len(self._disas_cache) >= self._disas_cache_limit: + self._disas_cache.clear() + self._disas_cache[key] = insns + + return insns def _is_bl(self, off): """Return BL target file offset, or -1 if not a BL.""" @@ -354,6 +383,11 @@ class KernelPatcherBase: 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.""" + key = (str_off, code_start, code_end) + cached = self._string_refs_cache.get(key) + if cached is not None: + return cached + target_va = self._va(str_off) target_page = target_va & ~0xFFF page_off = target_va & 0xFFF @@ -375,6 +409,7 @@ class KernelPatcherBase: if add_rn == rd and add_imm == page_off: add_rd = nxt & 0x1F refs.append((adrp_off, adrp_off + 4, add_rd)) + self._string_refs_cache[key] = refs return refs def find_function_start(self, off, max_back=0x4000): @@ -384,19 +419,33 @@ class KernelPatcherBase: bytes to look for PACIBSP (ARM64e functions may have several STP instructions in the prologue before STP x29,x30). """ + use_cache = max_back == 0x4000 + if use_cache: + cached = self._func_start_cache.get(off) + if cached is not None: + return cached + + result = -1 for o in range(off - 4, max(off - max_back, 0), -4): insn = _rd32(self.raw, o) if insn == _PACIBSP_U32: - return o + result = o + break dis = self._disas_at(o) if dis and dis[0].mnemonic == "stp" and "x29, x30, [sp" in dis[0].op_str: # Check further back for PACIBSP (prologue may have # multiple STP instructions before x29,x30) for k in range(o - 4, max(o - 0x24, 0), -4): if _rd32(self.raw, k) == _PACIBSP_U32: - return k - return o - return -1 + result = k + break + if result < 0: + result = o + break + + if use_cache: + self._func_start_cache[off] = result + return result def _disas_n(self, buf, off, count): """Disassemble *count* instructions from *buf* at file offset *off*.""" @@ -454,10 +503,24 @@ class KernelPatcherBase: Writing through to self.data ensures _find_code_cave() sees 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._patch_num += 1 - print(f" [{self._patch_num:2d}] 0x{off:08X} {desc}") + patch_bytes = bytes(patch_bytes) + with self._emit_lock: + existing = self._patch_by_off.get(off) + if existing is not None: + existing_bytes, existing_desc = existing + if existing_bytes != patch_bytes: + raise RuntimeError( + f"Conflicting patch at 0x{off:08X}: " + f"{existing_desc!r} vs {desc!r}" + ) + return + + self._patch_by_off[off] = (patch_bytes, desc) + self.patches.append((off, patch_bytes, desc)) + self.data[off : off + len(patch_bytes)] = patch_bytes + self._patch_num += 1 + patch_num = self._patch_num + print(f" [{patch_num:2d}] 0x{off:08X} {desc}") if self.verbose: self._print_patch_context(off, patch_bytes, desc) @@ -614,4 +677,3 @@ class KernelPatcherBase: if val == 0: return 0 return self._decode_chained_ptr(val) - diff --git a/scripts/patchers/kernel_jb.py b/scripts/patchers/kernel_jb.py index e9c9b22..6d1acba 100644 --- a/scripts/patchers/kernel_jb.py +++ b/scripts/patchers/kernel_jb.py @@ -1,5 +1,7 @@ """kernel_jb.py — Jailbreak extension patcher for iOS kernelcache.""" +import time + from .kernel_jb_base import KernelJBPatcherBase from .kernel_jb_patch_amfi_trustcache import KernelJBPatchAmfiTrustcacheMixin from .kernel_jb_patch_amfi_execve import KernelJBPatchAmfiExecveMixin @@ -54,38 +56,80 @@ class KernelJBPatcher( KernelJBPatchAmfiTrustcacheMixin, KernelJBPatcherBase, ): + _TIMING_LOG_MIN_SECONDS = 10.0 + + _GROUP_AB_METHODS = ( + "patch_amfi_cdhash_in_trustcache", # A1 + "patch_amfi_execve_kill_path", # A2 + "patch_task_conversion_eval_internal", # A3 + "patch_sandbox_hooks_extended", # A4 + "patch_post_validation_additional", # B5 + "patch_proc_security_policy", # B6 + "patch_proc_pidinfo", # B7 + "patch_convert_port_to_map", # B8 + "patch_vm_fault_enter_prepare", # B9 + "patch_vm_map_protect", # B10 + "patch_mac_mount", # B11 + "patch_dounmount", # B12 + "patch_bsd_init_auth", # B13 + "patch_spawn_validate_persona", # B14 + "patch_task_for_pid", # B15 + "patch_load_dylinker", # B16 + "patch_shared_region_map", # B17 + "patch_nvram_verify_permission", # B18 + "patch_io_secure_bsd_root", # B19 + "patch_thid_should_crash", # B20 + ) + _GROUP_C_METHODS = ( + "patch_cred_label_update_execve", # C21 + "patch_syscallmask_apply_to_proc", # C22 + "patch_hook_cred_label_update_execve", # C23 + "patch_kcall10", # C24 + ) + + def __init__(self, data, verbose=False): + super().__init__(data, verbose) + self.patch_timings = [] + + def _run_patch_method_timed(self, method_name): + before = len(self.patches) + t0 = time.perf_counter() + getattr(self, method_name)() + dt = time.perf_counter() - t0 + added = len(self.patches) - before + self.patch_timings.append((method_name, dt, added)) + if dt >= self._TIMING_LOG_MIN_SECONDS: + print(f" [T] {method_name:36s} {dt:7.3f}s (+{added})") + + def _run_methods(self, methods): + for method_name in methods: + self._run_patch_method_timed(method_name) + + def _print_timing_summary(self): + if not self.patch_timings: + return + slow_items = [ + item + for item in sorted(self.patch_timings, key=lambda item: item[1], reverse=True) + if item[1] >= self._TIMING_LOG_MIN_SECONDS + ] + if not slow_items: + return + + print( + "\n [Timing Summary] JB patch method cost (desc, >= " + f"{self._TIMING_LOG_MIN_SECONDS:.0f}s):" + ) + for method_name, dt, added in slow_items: + print(f" {dt:7.3f}s (+{added:3d}) {method_name}") + def find_all(self): - self.patches = [] + self._reset_patch_state() + self.patch_timings = [] - # Group A: Existing patches - self.patch_amfi_cdhash_in_trustcache() # A1 - self.patch_amfi_execve_kill_path() # A2 - self.patch_task_conversion_eval_internal() # A3 - self.patch_sandbox_hooks_extended() # A4 - - # Group B: Simple patches (string-anchored / pattern-matched) - self.patch_post_validation_additional() # B5 - self.patch_proc_security_policy() # B6 (fixed: was patching copyio) - self.patch_proc_pidinfo() # B7 - self.patch_convert_port_to_map() # B8 (fixed: was patching PAC check) - self.patch_vm_fault_enter_prepare() # B9 - self.patch_vm_map_protect() # B10 - self.patch_mac_mount() # B11 - self.patch_dounmount() # B12 - self.patch_bsd_init_auth() # B13 - self.patch_spawn_validate_persona() # B14 - self.patch_task_for_pid() # B15 - self.patch_load_dylinker() # B16 - self.patch_shared_region_map() # B17 - self.patch_nvram_verify_permission() # B18 - self.patch_io_secure_bsd_root() # B19 - self.patch_thid_should_crash() # B20 - - # Group C: Complex shellcode patches - self.patch_cred_label_update_execve() # C21 - self.patch_syscallmask_apply_to_proc() # C22 - self.patch_hook_cred_label_update_execve() # C23 - self.patch_kcall10() # C24 + self._run_methods(self._GROUP_AB_METHODS) + self._run_methods(self._GROUP_C_METHODS) + self._print_timing_summary() return self.patches diff --git a/scripts/patchers/kernel_jb_base.py b/scripts/patchers/kernel_jb_base.py index bb17665..3191c6b 100644 --- a/scripts/patchers/kernel_jb_base.py +++ b/scripts/patchers/kernel_jb_base.py @@ -3,6 +3,7 @@ import struct from collections import Counter +from .kernel_asm import _PACIBSP_U32 from capstone.arm64_const import ( ARM64_OP_REG, ARM64_OP_IMM, @@ -38,6 +39,9 @@ MOV_X8_XZR = asm("mov x8, xzr") class KernelJBPatcherBase(KernelPatcher): def __init__(self, data, verbose=False): super().__init__(data, verbose) + self._jb_scan_cache = {} + self._proc_info_anchor_scanned = False + self._proc_info_anchor = (-1, -1) self._build_symbol_table() # ── Symbol table (best-effort, may find 0 on stripped kernels) ── @@ -122,6 +126,79 @@ class KernelJBPatcherBase(KernelPatcher): """Look up a function symbol, return file offset or -1.""" return self.symbols.get(name, -1) + # ── Shared kernel anchor finders ────────────────────────────── + + def _find_proc_info_anchor(self): + """Find `_proc_info` switch anchor as (func_start, switch_off). + + Shared by B6/B7 patches. Cached because searching this anchor in + `kern_text` is expensive on stripped kernels. + """ + if self._proc_info_anchor_scanned: + return self._proc_info_anchor + + def _scan_range(start, end): + """Fast raw matcher for: + sub wN, wM, #1 + cmp wN, #0x21 + """ + key = ("proc_info_switch", start, end) + cached = self._jb_scan_cache.get(key) + if cached is not None: + return cached + + scan_start = max(start, 0) + limit = min(end - 8, self.size - 8) + for off in range(scan_start, limit, 4): + i0 = _rd32(self.raw, off) + # SUB (immediate), 32-bit + if (i0 & 0xFF000000) != 0x51000000: + continue + if ((i0 >> 22) & 1) != 0: # sh must be 0 + continue + if ((i0 >> 10) & 0xFFF) != 1: + continue + sub_rd = i0 & 0x1F + + i1 = _rd32(self.raw, off + 4) + # CMP wN,#imm == SUBS wzr,wN,#imm alias (rd must be wzr) + if (i1 & 0xFF00001F) != 0x7100001F: + continue + if ((i1 >> 22) & 1) != 0: # sh must be 0 + continue + if ((i1 >> 10) & 0xFFF) != 0x21: + continue + cmp_rn = (i1 >> 5) & 0x1F + if sub_rd != cmp_rn: + continue + + self._jb_scan_cache[key] = off + return off + self._jb_scan_cache[key] = -1 + return -1 + + # Prefer direct symbol when present. + proc_info_func = self._resolve_symbol("_proc_info") + if proc_info_func >= 0: + search_end = min(proc_info_func + 0x800, self.size) + switch_off = _scan_range(proc_info_func, search_end) + if switch_off < 0: + switch_off = proc_info_func + self._proc_info_anchor = (proc_info_func, switch_off) + self._proc_info_anchor_scanned = True + return self._proc_info_anchor + + ks, ke = self.kern_text + switch_off = _scan_range(ks, ke) + if switch_off >= 0: + proc_info_func = self.find_function_start(switch_off) + self._proc_info_anchor = (proc_info_func, switch_off) + else: + self._proc_info_anchor = (-1, -1) + + self._proc_info_anchor_scanned = True + return self._proc_info_anchor + # ── Code cave finder ────────────────────────────────────────── def _find_code_cave(self, size, align=4): @@ -182,8 +259,7 @@ class KernelJBPatcherBase(KernelPatcher): """Find the end of a function (next PACIBSP or limit).""" limit = min(func_start + max_size, self.size) for off in range(func_start + 4, limit, 4): - d = self._disas_at(off) - if d and d[0].mnemonic == "pacibsp": + if _rd32(self.raw, off) == _PACIBSP_U32: return off return limit diff --git a/scripts/patchers/kernel_jb_patch_bsd_init_auth.py b/scripts/patchers/kernel_jb_patch_bsd_init_auth.py index 3d22f78..4b8510e 100644 --- a/scripts/patchers/kernel_jb_patch_bsd_init_auth.py +++ b/scripts/patchers/kernel_jb_patch_bsd_init_auth.py @@ -1,9 +1,16 @@ """Mixin: KernelJBPatchBsdInitAuthMixin.""" -from .kernel_jb_base import MOV_X0_0 +from .kernel_jb_base import MOV_X0_0, _rd32 class KernelJBPatchBsdInitAuthMixin: + # ldr x0, [xN, #0x2b8] (ignore xN/Rn) + _LDR_X0_2B8_MASK = 0xFFFFFC1F + _LDR_X0_2B8_VAL = 0xF9415C00 + # cbz {w0|x0},