From 9becab4031b4e77c4137babfd31b01aa79aa4b05 Mon Sep 17 00:00:00 2001 From: zqxwce Date: Sun, 19 Jul 2026 19:24:33 +0300 Subject: [PATCH] cfw: Fix iOS 27 DDI (/System/Developer) auto-mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Productionize the 3-part fix that makes `pymobiledevice3 mounter auto-mount` land the personalized DDI at /System/Developer on the iOS-27-userland / 26.4-vphone600-kernel (c0ecdb4b) JB hybrid. - KernelJBPatchDiskImages2.swift (new; wired into KernelJBPatcher.findAll): DiskImages2 ABI acceptance. GATE1/GATE2b NOP the CreateDevice/Connect cmp#9/b.ne ABI-version rejects (kernel driver v9 vs iOS-27 controller/daemon v11). GATE2 widens the RegisterNotificationPort backing array + both bound-check field loads (all-or-nothing) to clear the `type < getMaxPorts` off-by-one that otherwise fails the attach ("Can't register notification port"). - KernelJBPatchSandboxExtended: retarget mac_policy_ops[124] (mpo_proc_check_syscall_unix) to the allow stub so MobileStorageMounter's mount_apfs can make the mount(2) syscall (unix 167) — else the kernel Sandbox denies it ("Protobox: mount_apfs deny(1) syscall-unix 167"). - cfw_patch_diskimagesiod.py + cfw.py + cfw_install.sh (gated to 27.*): force -[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:] -> YES so MobileStorageMounter's waitForDAMount returns and it performs the real nobrowse mount. GATE2a anchor fix (the critical one): Capstone on this toolchain decodes the AllocPortsArray size-shift lsl-immediate (a UBFM alias) as 2 operands, not the xd,xn,#imm 3-operand shape. The original 3-operand + imm==3 match found 0, so the all-or-nothing silently skipped GATE2 on every build (only manual dd pokes ever worked). Now matched on mnemonic + destination x1 — the unique size-writing lsl in AllocPortsArray; the replacement is a fixed mov x1,#0x4000 so the shift amount is irrelevant. Verified from a clean build on c0ecdb4b via `patch-component --component kernel-jb --records-out`: all five di2 records emit (createdevice, connect, allocports_size, notif_boundcheck_d8/e8), and `pmd3 mounter auto-mount` -> rc=0 with the DDI mounted, no poke. Documented in research/0_binary_patch_comparison.md (JB-09, JB-28, CFW binary-patch #13). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013pk5tsoBeuu3jhkmnRFtic --- research/0_binary_patch_comparison.md | 14 +- scripts/cfw_install.sh | 35 +++ scripts/patchers/cfw.py | 18 +- scripts/patchers/cfw_patch_diskimagesiod.py | 155 +++++++++++ .../JBPatches/KernelJBPatchDiskImages2.swift | 240 ++++++++++++++++++ .../KernelJBPatchSandboxExtended.swift | 17 +- .../Kernel/KernelJBPatcher.swift | 22 +- 7 files changed, 486 insertions(+), 15 deletions(-) create mode 100644 scripts/patchers/cfw_patch_diskimagesiod.py create mode 100644 sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchDiskImages2.swift diff --git a/research/0_binary_patch_comparison.md b/research/0_binary_patch_comparison.md index 4354e44..b68fe7d 100644 --- a/research/0_binary_patch_comparison.md +++ b/research/0_binary_patch_comparison.md @@ -122,6 +122,8 @@ ### JB-Only Kernel Methods (Reference List) +> **⚠ iOS-27 hard-gate (2026-07-20).** Every patch below whose Purpose cites "iOS 27" / "27.0 on the 26.4 kernel" is **hard-gated to a 27.x base** via `KernelJBPatcher.applyIOS27` — set by `FirmwarePipeline` from the iPhone base `ProductVersion` (mirroring the `applyExcGuard`/iOS-18 mechanism at patch 27 above; standalone `patch-component` defaults it true, override with `--target-os`). Gated set: **JB-02b, JB-02d, JB-09 (the `ops[124]` entry only — the 201..316 hooks stay), JB-10b, JB-26, JB-27, JB-28**. On an 18.x/26.x base **none** of them run. This supersedes the per-row "No-op-in-effect for version-matched userlands" notes: those describe the effect *if applied*, but **JB-27 is NOT a no-op on 26.x** — its `cmp 0x588→0x6e0` retarget makes the 26.x userclient reject its own native 0x588 SwapEnd → dead display (the 26.5 regression that motivated the hard-gate). **Verified 2026-07-20:** a 26.5 base produces a **byte-identical** kernelcache to pre-branch `main` (`patch-component --component kernel-jb --target-os 26.5` vs main's output — `cmp` clean, 83 records each), and a 27.0 base additionally emits exactly the 11 gated records. + | # | Group | Method | Function | Purpose | JB Enabled | | ----- | ----- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------: | | JB-01 | A | `patch_amfi_cdhash_in_trustcache` | `AMFIIsCDHashInTrustCache` | Always return true + store hash | Y | @@ -134,7 +136,7 @@ | JB-06 | B | `patch_post_validation_additional` | `_postValidation` (additional) | Disable SHA256-only hash-type reject | Y | | JB-07 | C | `patch_syscallmask_apply_to_proc` | syscallmask apply wrapper (`_proc_apply_syscall_masks` path) | Faithful upstream C22: mutate installed Unix/Mach/KOBJ masks to all-ones via structural cave, then continue into setter; distinct from `NULL`-mask alternative | Y | | JB-08 | A | `patch_task_conversion_eval_internal` | `_task_conversion_eval_internal` | Allow task conversion | Y | -| JB-09 | A | `patch_sandbox_hooks_extended` | Sandbox MACF ops (extended) | Stub remaining 30+ sandbox hooks (incl. IOKit 201..210)| Y | +| JB-09 | A | `patch_sandbox_hooks_extended` | Sandbox MACF ops (extended) | Stub remaining 30+ sandbox hooks (incl. IOKit 201..210) + `mpo_proc_check_syscall_unix` (`ops[124]`) → allow. **The ops[124] hook is the iOS-27 DDI-mount syscall fix:** MobileStorageMounter's `mount_apfs` needs the `mount(2)` syscall (unix 167) to mount the personalized DDI at `/System/Developer`; on the 26.4-kernel / 27-userland hybrid the kernel Sandbox otherwise denies it (`Protobox: mount_apfs deny(1) syscall-unix 167`). Index calibrated against the reference-XNU `mac_policy_ops` struct order (co-verified: mpo_vnode_check_open=267, mpo_vnode_check_fsgetpath=316). Validated live on `c0ecdb4b` 26.4 (DDI auto-mounts). | Y | | JB-10 | A | `patch_iouc_failed_macf` | IOUC MACF shared gate | A5-v2: patch only the post-`mac_iokit_check_open` deny gate (`CBZ W0, allow` -> `B allow`) and keep the rest of the IOUserClient open path intact | Y | | JB-10b| A | `patch_iouc_failed_sandbox` | IOUC *sandbox* shared gate (string `"IOUC %s failed sandbox in process %s"`) | **THE iOS-27 display fix (CONFIRMED 2026-07-15).** Sibling to JB-10: the IOUserClient open path has a SEPARATE Sandbox gate beyond the MACF one. On 27 userland / 26.4 kernel it spuriously DENIES the render server (backboardd) its IOMobileFramebuffer/IOSurface/HID userclient opens (27-specific: ABSENT on native 26.4; backboardd absent from every `IOUserClientCreator`) → no present (no Apple logo) + `mainDisplay=nil` → SpringBoard FBSDisplayMonitor crash-loop. Gate shape (same fn as JB-10): `blraa` sandbox check (PAC-indirect) → `cmp w0,#0xe00002c7` (kIOReturnNotPermitted) `b.eq `; `ldr w8,[sp,#x]; cbnz w8,` (other error → deny block w/ fail-log ADRP → returns error); w0==0 path → `b `. Patch rewrites ``'s first insn → `b ` (deny → allow-proceed), leaving the w0==0 path intact. Anchor is structural (fail-log string xref → the CBNZ whose target encloses it → the preceding `b.eq` allow target). Verified: backboardd then holds an IOMFB userclient, `[CADisplay mainDisplay]` resolves to `LCD/primary`, SpringBoard runs with 0 crashes. | Y | | JB-11 | B | `patch_proc_security_policy` | `_proc_security_policy` | Bypass security policy | Y | @@ -154,6 +156,7 @@ | JB-25 | B | `patch_vm_map_protect` | `_vm_map_protect` | Skip upstream write-downgrade gate. Shape A (26.1–26.4) active; **Shape B (26.5) disabled 2026-07-05** — widened the `~VM_PROT_WRITE` COW strip (vm_map.c:6202) instead of the RWX gate (vm_map.c:5997), breaking COW and crashing the debugger (SPTM `VIOLATION_ILLEGAL_MAP`). Retired on 26.5+: SPTM code-mod (debugger + Substrate tweaks) uses write-then-flip via `vm_protect(VM_PROT_COPY)` → `XNU_USER_DEBUG`, so no RWX patch is needed. | Y/N | | JB-26 | B | `patch_iomfb_swapend_variable_size` | IOMFB userclient method-5 (SwapEnd) `__DATA_CONST` dispatch entry (`checkStructureInputSize`) | **iOS-27 VZ-view fix, kernel half — paired with DSC force-kern (DSC-patch item 11).** The 26.4 userclient's method-5 dispatch entry hard-checks `checkStructureInputSize == 0x588`; forced-kern iOS 27 sends its native `0x6e0`. Rewrite the size field to `kIOUCVariableStructureSize (0xFFFFFFFF)` so `IOUserClient::externalMethod` accepts 27's struct and reaches the handler. Anchor (structural): the sole `__DATA_CONST` entry `{ptr(ptrauth, top-byte≥0x80), scalarIn=0, structIn=0x588, scalarOut=0, structOut=0}` (verified unique; decompressed file-off 0x9c7228). No-op-in-effect for version-matched 26.x (sends 0x588). Re-enabled 2026-07-15 (was disabled when 27 present-path was still unknown). | Y | | JB-27 | B | `patch_iomfb_swapend_handler_size` | method-5 handler internal size gate (`cmp w2,#0x588 ; b.ne `) | Companion to JB-26: beyond the dispatch-table check the handler re-checks the struct size (`cmp w2,#0x588 ; b.ne `; verified unique at decompressed file-off 0x16ae22c; success path forwards the raw struct ptr to a `vtable+0x590` paravirt swap method). Retarget the `cmp` immediate to `0x6e0` so forced-kern iOS 27's native SwapEnd reaches real swap processing (27's IOMFBSwapRec prefix matches 26.x → handler reads valid fields). Semantic anchor (`cmp w2,#imm` word + following `b.ne` decode). Enabled together with JB-26 + DSC force-kern for iOS 27. | Y | +| JB-28 | A | `patch_disk_images2_client_abi` | `com.apple.driver.AppleDiskImages2` kext: `DIDeviceCreatorUserClient::CreateDevice` + `DIDeviceIOUserClient::Connect` ABI-version gates, and the `AllocPortsArray`/`RegisterNotificationPort` notif-port sizing | **iOS-27 DDI (`/System/Developer`) auto-mount — attach layer.** `pymobiledevice3 mounter auto-mount` on the 26.4-kernel / 27-userland hybrid fails at attach: the kernel DiskImages2 driver is ABI v9, the 27 userland's DiskImages2 controller/daemon is ABI v11 (`DIDeviceCreatorUserClient::CreateDevice: Incompatible client: expected ABI version 9 actual 11`). Three layers: **GATE1** NOPs the CreateDevice controller-ABI `cmp #9 ; b.ne` reject; **GATE2b** NOPs the Connect daemon-ABI `cmp #9 ; b.ne` reject (both anchored on the C++ signature cstring → unique `cmp #9`/`b.ne`; version-robust; no-op-in-effect on version-matched userlands where ABI 9==9). **GATE2** (array + 2 bound checks) fixes a RegisterNotificationPort off-by-one (userland registers at index==maxPorts, one past the array) by widening the AllocPortsArray allocation (`lsl x1,xN,#3` → `mov x1,#0x4000`) AND both bound-check field loads (`ldrh [.,#0xd8]`/`ldr [.,#0xe8]` → `mov wD,#0x800`); applied **all-or-nothing** (widening the bound checks without the backing array would let RegisterNotificationPort write past it → corruption) and skipped/logged on builds whose notif-port codegen differs (the off-by-one is 26.4-hybrid-specific). Pairs with the sandbox ops[124] allow (JB-09) and the diskimagesiod userland patch (CFW binary-patch #13). Anchors structural (Capstone decode of the pinned function's instructions); replacement bytes from the Keystone-backed `ARM64Encoder`. **GATE2a anchor note:** the AllocPortsArray size-shift is matched on `lsl` mnemonic + destination `x1` (the unique size-writing lsl in the function) — NOT a 3-operand `lsl xd,xn,#imm` shape, because Capstone on this toolchain decodes the lsl-immediate (a UBFM alias) as **2 operands**; requiring 3 operands makes GATE2 silently skip (the all-or-nothing returns a harmless no-op). Verified on the `c0ecdb4b` 26.4 deployment kernel via `patch-component --component kernel-jb --records-out`: all five di2 records emit (`di2_createdevice_abi`, `di2_connect_abi`, `di2_allocports_size`, `di2_notif_boundcheck_d8`, `di2_notif_boundcheck_e8`) — always verify the di2 records EMIT, not merely that the JB suite reports "no failures". No-op-in-effect for version-matched userlands. | Y | ### EXP-Only Kernel Methods (Reference List) @@ -180,9 +183,10 @@ do NOT execute these). | 7 | cstring byte 5 mangle `'h' → 'X'` (`"kern.hv_vmm_present"` → `"kern.Xv_vmm_present"`) + per-page slot-hash re-attestation, BLACKLIST semantics — **EXP only** | DSC dylibs | Companion to EXP kernel rename (`KernelEXPPatcher.patchHvVmmRename`). The mangle is applied to every DSC dylib EXCEPT those in `DONT_PATCH_INSTALL_NAMES` (sign-in / device-likeness consumers, ~15 entries). Patched dylibs query `kern.Xv_vmm_present` and get the truthful 1 (graphics / accel passthrough). Blacklisted dylibs keep the original cstring, hit ENOENT on the renamed kernel, cache 0, lie about VM presence. On `codeSigningMonitor == 2` hardware the byte-mangle alone causes `CODESIGNING/Invalid Page` SIGKILL because TXM enforces per-page hashes; the re-attestation pass recomputes the SHA-256 slot in the chunk's `CS_CodeDirectory` for every modified 16 KiB page. See `scripts/patchers/cfw_dsc_codesign.py` and `cfw_patch_hv_vmm_dsc.py`. | - | - | - | | 8 | (removed — was: standalone-binary mangle in 6 rootfs Mach-Os via SSH) | n/a | Removed in the blacklist-flip redesign. With the EXP kernel rename in place, the 6 rootfs binaries (MobileActivationMigrator, CheckerBoard, StoreKitUISceneService, storekitd, appstored, CorePrescriptionService) get the desired "cache 0 / not in a VM" behavior for free: they keep their original cstring, hit ENOENT on the renamed kernel sysctl, defensive `cbnz w0, skip` leaves the cached byte at BSS-zero. No SSH-time standalone patch needed. | - | - | - | | 9 | `mov w3,#` -> `mov w3,#` in `_kern_SwapEnd` — **26.0/26.0.1 and 18.x** | DSC `IOMobileFramebuffer` | Fixes host VZ GUI black-screen with the available PCC vphone600 userclient: the userclient does an exact `checkStructureInputSize` check on external-method-5 (SwapEnd) input, so a userland whose `_kern_SwapEnd` sends a different-sized state gets `kIOReturnBadArgument` and the host display stays black (guest still renders — the Apple logo is visible over VNC, just not in the vphone-cli view). **The accepted size is a property of the base kernel, not the userland**: 26.1 base -> **0x560**, 26.4 base (xnu-12377) -> **0x588**. The 0x588 value is confirmed two ways: the sole dispatch-shaped entry in `kernelcache.*.vphone600` with `checkStructureInputSize==0x588` (scalarIn=0, scalarOut=0, structOut=0, preceded by a ptrauth code ptr, at decompressed file offset 0x9c7228), and empirically — native 26.5 userland sends 0x588 and displays correctly on this stack. Source (userland-sent) sizes observed: 18.6.2 = 0x514, 26.0/26.0.1 = 0x548, 27.0 (24A5380h) = 0x6e0. The patcher is semantic (anchors on `mov w1,#5` -> `mov w3,#imm` -> `mov x4,#0`/`mov x5,#0` -> `bl` inside `_kern_SwapEnd`) and idempotent — rewrites the size to `--target-size` regardless of source and re-attests the modified DSC page. Install gate: `26.0*` / `18.*` -> 0x560 (26.1 base). Validated after host install on `17,3_26.0_23A341`, `17,3_26.0.1_23A355`, and `17,3_18.6.2_22G100` (Apple logo renders) against the 26.1 base. **CORRECTION (2026-07-15): iOS 27.0 is NO LONGER handled here.** 27 presents the paravirt display via IOMFB's `_virt_*` callback path — external method 5 is NEVER called — so no SwapEnd *size* change can help 27 (confirmed by kernel trace + live AppleParavirtGPU idle scheduler). iOS 27 now uses **force-kern (item 11)** to route present back onto method 5; this row applies to 26.0/26.0.1/18.x only. | Y | Y | Y | -| 10 | Zero `maxSlide` in `dyld_cache_header` (`@0xF0`) — **iOS 27.0 / any userland whose cache overflows the 6 GiB region** | DSC `dyld_shared_cache_arm64e` header | Fixes pid-1 `launchd` panic at boot on the vphone600 26.x kernel. The kernel reserves `SHARED_REGION_SIZE_ARM64 = 0x180000000` (6 GiB) and, at map time, needs room for the cache's mapped span **plus** the header `maxSlide` (ASLR range). iOS 27.0's cache (span `0x17c830000` ≈ 5.95 GiB) + `maxSlide 0x20000000` = `0x19c830000` > 6 GiB, so `_shared_region_map_and_slide` returns `ENOMEM`, dyld cannot map `libSystem.B.dylib`, and `launchd` panics (`initproc failed to start`). Zeroing `maxSlide` (LE u64) in the main chunk maps the cache at slide 0 (fits with ~58 MiB spare). Self-gating (`patch-dsc-maxslide`): no-op unless span + maxSlide > `0x180000000`, so 26.x / 18.x are untouched. **No** page re-attestation (header metadata, not a `cs_validate`'d code page — confirmed empirically). Validated on `17,3_27.0_24A5380h` + cloudOS 26.4 (`c0ecdb4b…`): `dyld cache mapped system-wide`, launchd reaches first unlock, vphoned connects as iOS 27.0.0, 0 panics. See `scripts/patchers/cfw_patch_dsc_maxslide.py`. | Y | Y | Y | +| 10 | Zero `maxSlide` in `dyld_cache_header` (`@0xF0`) — **iOS 27.0 / any userland whose cache overflows the 6 GiB region** | DSC `dyld_shared_cache_arm64e` header | Fixes pid-1 `launchd` panic at boot on the vphone600 26.x kernel. The kernel reserves `SHARED_REGION_SIZE_ARM64 = 0x180000000` (6 GiB) and, at map time, needs room for the cache's mapped span **plus** the header `maxSlide` (ASLR range). iOS 27.0's cache (span `0x17c830000` ≈ 5.95 GiB) + `maxSlide 0x20000000` = `0x19c830000` > 6 GiB, so `_shared_region_map_and_slide` returns `ENOMEM`, dyld cannot map `libSystem.B.dylib`, and `launchd` panics (`initproc failed to start`). Zeroing `maxSlide` (LE u64) in the main chunk maps the cache at slide 0 (fits with ~58 MiB spare). Install gate: **`27.*`** (hard-gated in `cfw_install.sh` as of 2026-07-20 — an 18.x/26.x base skips it entirely). The patcher additionally self-gates (`patch-dsc-maxslide`): no-op unless span + maxSlide > `0x180000000`, kept as defense-in-depth so 26.x / 18.x are untouched even if the install gate were removed. **No** page re-attestation (header metadata, not a `cs_validate`'d code page — confirmed empirically). Validated on `17,3_27.0_24A5380h` + cloudOS 26.4 (`c0ecdb4b…`): `dyld cache mapped system-wide`, launchd reaches first unlock, vphoned connects as iOS 27.0.0, 0 panics. See `scripts/patchers/cfw_patch_dsc_maxslide.py`. | Y | Y | Y | | 11 | Retarget public `_IOMobileFramebufferSwap*` trampolines -> `b _kern_Swap*` (force-kern) — **iOS 27.0** | DSC `IOMobileFramebuffer` | **iOS-27 VZ-view (host paravirt-GPU scanout) fix, userland half.** The host `VZVirtualMachineView` is fed by the guest `AppleParavirtGPU` scanout, which the 26.4 kernel drives ONLY from the IOMFB userclient SwapEnd (external method 5) — the `_kern_Swap*` path. iOS 27 defaults the paravirt display's present to IOMFB's parallel `_virt_Swap*` path (`_virt_SwapEnd` does no userclient call — it invokes an in-process callback `blraaz [conn+0xe68]` and hands the IOSurface to a virtual-display consumer), so the paravirt GPU never scans out → host VZ window black (guest still composites; GUI visible over in-guest TrollVNC; AppleParavirtGPU `SchedulerState` idle). The public `_IOMobileFramebufferSwap*` entrypoints are thin trampolines (`cbz x0; ldr xN,[x0,#slot]; cbz xN; braaz xN`) that tail-call the per-connection swap fp (kern or virt impl). This patch rewrites each trampoline's first insn to `b _kern_Swap`, forcing present onto method 5 regardless of how 27 classified the display (tail-call, args intact → behaviourally identical to selecting the kern fp). Fully dynamic: public + `_kern_` addrs resolved by name via `ipsw dyld symaddr`, trampoline shape verified by Capstone, branch bytes from Keystone `asm_at()`, modified DSC code pages re-attested. Requires ≥{SwapBegin,SwapEnd,SwapSetLayer} or raises (dry-run retargets 31 entrypoints on 24A5380h, skips 4 non-trampolines). **Pairs with the JB kernel patches (`patchIomfbSwapEndVariableSize` + `patchIomfbSwapEndHandlerSize`)** which relax the 26.4 userclient's two exact `0x588` size gates to accept 27's native `0x6e0` IOMFBSwapRec (prefix matches 26.x, so the paravirt swap handler reads valid fields). Install gate: `27.*`. See `scripts/patchers/cfw_patch_iomfb_force_kern.py`. **VALIDATED on-device (2026-07-15, `17,3_27.0_24A5380h` + cloudOS 26.4 `c0ecdb4b…`, JB): iOS 27 userland renders AND is interactive in the native VZ view (not just TrollVNC); clean boot — no `kIOReturnBadArgument`/SwapEnd rejection/panic.** Runtime confirmed 31 entrypoints retargeted (4 non-trampoline setters left on virt). | Y | Y | Y | -| 12 | NOP `-[_LSDModifyClient clientIsEntitledForEmbeddedRegistrationOperations]` entitlement gate + per-page re-attest — **iOS 27.0** | DSC `CoreServices` (LaunchServices) | **iOS-27 app-registration fix.** lsd gates `-[_LSDModifyClient performPostInstallationRegistration:operationUUID:reply:]` (and the containerized/rebuild registration paths) behind `clientIsEntitledForEmbeddedRegistrationOperations`, which does `xpc_connection_copy_entitlement_value` on the XPC peer for any of `com.apple.private.coreservices.lsaw` / `com.apple.private.installcoordinationd.daemon` / `com.apple.private.coreservices.can-register-install-results`. A client without one gets `NSOSStatusErrorDomain -54` (permErr, `LSDModifyService.mm:1639`), so `registerApplicationDictionary:` / `registerContainerizedApplicationWithInfoDictionaries:` fail and no app can (re)register — blocking vphoned's installer, TrollStore, and uicache/Sileo alike. The entitlement route is a dead end even for a launchd platform daemon (vphoned) whose validated csblob (`csops CS_OPS_ENTITLEMENTS_BLOB`) contains all three: LS registration is proxied, so the XPC peer lsd inspects is not the registering process. Fix: NOP the final `cbz w0, ` (the conditional branch whose fall-through sets the `mov w,#1` result) so the method always returns YES. Fully dynamic: method resolved via the DSC's own `.symbols` in-image local-symbol table (ipsw `symaddr -a`/`a2s` time out on this cache), gate located by control-flow shape in Capstone, NOP from Keystone `asm("nop")`, modified 16 KiB page re-attested (`cfw_dsc_codesign.py`; TXM enforces per-page). The resulting CDHash change is accepted by the JB always-true AMFI cdhash-trust patch. Self-gating (`patch-lsd-embedded-reg`): no-op on pre-iOS-27 userlands where the method is absent. **Pairs with vphoned's `vp_register_path` containerized-registration fallback** (`registerContainerizedApplicationWithInfoDictionaries:...:registrationError:`, treating a nil `registrationError` as success since it returns NO even when it registers). Also paired with **`/cores/vpregister`** (built + deployed by `cfw_install_jb.sh` / `cfw_install_exp.sh`, invoked by `vphone_jb_setup.sh` at first boot): it registers JB app bundles (Sileo) via the same containerized API, because `uicache -a`'s `registerApplicationDictionary:` is a deprecated no-op on iOS 27 (lsd logs *"you cannot use ... to register applications anymore. These interfaces have been deprecated for years."*). **VALIDATED (2026-07-17, `17,3_27.0_24A5380h` + cloudOS 26.4, JB): -54 gone; Sileo registers (`uicache -l` 0→1) via `vpregister`; vphoned installs+registers a test IPA (`com.vphone.vptest`) to `/var/containers/Bundle/Application/` end-to-end. Clean boot (re-attest correct; no CoreServices page rejection).** See `scripts/patchers/cfw_patch_lsd_embedded_reg.py` and `scripts/vphoned/vpregister.m`. | Y | Y | Y | +| 12 | NOP `-[_LSDModifyClient clientIsEntitledForEmbeddedRegistrationOperations]` entitlement gate + per-page re-attest — **iOS 27.0** | DSC `CoreServices` (LaunchServices) | **iOS-27 app-registration fix.** lsd gates `-[_LSDModifyClient performPostInstallationRegistration:operationUUID:reply:]` (and the containerized/rebuild registration paths) behind `clientIsEntitledForEmbeddedRegistrationOperations`, which does `xpc_connection_copy_entitlement_value` on the XPC peer for any of `com.apple.private.coreservices.lsaw` / `com.apple.private.installcoordinationd.daemon` / `com.apple.private.coreservices.can-register-install-results`. A client without one gets `NSOSStatusErrorDomain -54` (permErr, `LSDModifyService.mm:1639`), so `registerApplicationDictionary:` / `registerContainerizedApplicationWithInfoDictionaries:` fail and no app can (re)register — blocking vphoned's installer, TrollStore, and uicache/Sileo alike. The entitlement route is a dead end even for a launchd platform daemon (vphoned) whose validated csblob (`csops CS_OPS_ENTITLEMENTS_BLOB`) contains all three: LS registration is proxied, so the XPC peer lsd inspects is not the registering process. Fix: NOP the final `cbz w0, ` (the conditional branch whose fall-through sets the `mov w,#1` result) so the method always returns YES. Fully dynamic: method resolved via the DSC's own `.symbols` in-image local-symbol table (ipsw `symaddr -a`/`a2s` time out on this cache), gate located by control-flow shape in Capstone, NOP from Keystone `asm("nop")`, modified 16 KiB page re-attested (`cfw_dsc_codesign.py`; TXM enforces per-page). The resulting CDHash change is accepted by the JB always-true AMFI cdhash-trust patch. Install gate: **`27.*`** (hard-gated in `cfw_install.sh` as of 2026-07-20 — an 18.x/26.x base does not apply it). The patcher additionally self-gates (`patch-lsd-embedded-reg`): no-op on pre-iOS-27 userlands where the method is absent. **Pairs with vphoned's `vp_register_path` containerized-registration fallback** (`registerContainerizedApplicationWithInfoDictionaries:...:registrationError:`, treating a nil `registrationError` as success since it returns NO even when it registers). Also paired with **`/cores/vpregister`** (built + deployed by `cfw_install_jb.sh` / `cfw_install_exp.sh`, invoked by `vphone_jb_setup.sh` at first boot — 27-gated by DEPLOYMENT: `cfw_install_jb.sh`/`cfw_install_exp.sh` copy `/cores/vpregister` only when the mounted rootfs `SystemVersion.plist` is `27.*`, and the setup script's `[ -x /cores/vpregister ]` presence check is the runtime gate. Do NOT gate the invocation on a guest `sw_vers` check — the hybrid guest does not reliably report the 27 userland version at first boot, which silently skipped registration): it registers JB app bundles (Sileo) via the same containerized API, because `uicache -a`'s `registerApplicationDictionary:` is a deprecated no-op on iOS 27 (lsd logs *"you cannot use ... to register applications anymore. These interfaces have been deprecated for years."*). **VALIDATED (2026-07-17, `17,3_27.0_24A5380h` + cloudOS 26.4, JB): -54 gone; Sileo registers (`uicache -l` 0→1) via `vpregister`; vphoned installs+registers a test IPA (`com.vphone.vptest`) to `/var/containers/Bundle/Application/` end-to-end. Clean boot (re-attest correct; no CoreServices page rejection).** See `scripts/patchers/cfw_patch_lsd_embedded_reg.py` and `scripts/vphoned/vpregister.m`. | Y | Y | Y | +| 13 | `mov x0,#1; ret` on `-[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:]` — **iOS 27.0** | `diskimagesiod` | **iOS-27 DDI (`/System/Developer`) auto-mount — mount-gate.** After the personalized DDI attaches (kernel side: JB-28 + JB-09), MobileStorageMounter waits on diskimagesiod's `-[DIDiskArb waitForDAMountWithExpectedCount:diskTracker:]` before it does the real (nobrowse) mount at `/System/Developer`. That wait loops until `isMountComplete` (= `callbackReached \|\| (appearedDiskCount>=expectedCount && mountedDiskCount>=mountableDiskCount)`) is YES; on the 26.4-kernel / 27-userland hybrid it never becomes true (not all of the DMG's IOMedia "appear" to diskimagesiod's DiskArbitration session, and diskarbitrationd never auto-mounts the volume), so the wait hangs and pmd3 times out. diskimagesiod itself does NOT mount the DDI (its `-[DIDiskArb mountWithDeviceName:...]` is dead code) — it only gates MobileStorageMounter. Forcing `isMountComplete` → YES lets the wait return so MobileStorageMounter proceeds. IMP resolved via LC_SYMTAB or ObjC metadata (selector → `__objc_selrefs` → `__TEXT,__objc_methlist` relative method list → IMP); prologue overwritten `mov x0,#1 ; ret` (safe — the method returns to the caller's unsigned LR without pushing a frame). **Gated to 27.\*** in `cfw_install.sh` (same `$IOS_VERSION` as the DSC patches): on a version-matched userland the native wait completes correctly and forcing it early could race the real mount, so it is NOT applied there. Embedded sandbox profile + private DA/apfs entitlements preserved on re-sign (`ldid_sign_ent`). **Validated on `c0ecdb4b` 26.4 + 27.0 userland: `pmd3 mounter auto-mount` → rc=0, DDI at `/System/Developer`, idempotent across fresh boots.** See `scripts/patchers/cfw_patch_diskimagesiod.py`. | Y | Y | Y | ### Installed Components @@ -663,8 +667,8 @@ cache rebuild. | Procursus bootstrap deployment | - | - | Y (JB-2) | Y (JB-2) | | BaseBin hook deployment (`*.dylib` -> `/mnt1/cores`) | - | - | Y (JB-3) | Y (JB-3) | | First-boot JB finalization (`vphone_jb_setup.sh`) | - | - | Y (post-boot) | Y (post-boot) | -| IOMobileFramebuffer SwapEnd payload-size patch (`26.0/26.0.1`,`18.x` -> 0x560 / 26.1 base; `27.0` -> 0x588 / 26.4 base) | Y | Y | Y (inherited from base run) | Y (inherited from base run) | -| dyld cache `maxSlide` zero (`patch-dsc-maxslide`; only when cache overflows the 6 GiB region, e.g. iOS 27.0) | Y | Y | Y (inherited from base run) | Y (inherited from base run) | +| IOMobileFramebuffer SwapEnd payload-size patch (install-gated `26.0*`/`18.*` -> 0x560 / 26.1 base; **27.0 does NOT use this — it uses force-kern, next section**) | Y | Y | Y (inherited from base run) | Y (inherited from base run) | +| dyld cache `maxSlide` zero (`patch-dsc-maxslide`; **install-gated `27.*`** as of 2026-07-20 — iOS 27's cache overflows the 6 GiB region; 18.x/26.x skip it) | Y | Y | Y (inherited from base run) | Y (inherited from base run) | | DSC pre-patch (`kern.hv_vmm_present` byte-5 mangle + slot reattest) | - | - | - | Y (pre-step, before base CFW) | | DSC camera patches (12 patches across CMCapture / CoreMediaIO / AVFCapture / libMobileGestalt) | - | - | - | Y (pre-step, same cryptex mount as hv_vmm) | | `watchdogd` surgical 2-insn patch + slot reattest | - | - | - | Y (EXP-JB-3.5) | diff --git a/scripts/cfw_install.sh b/scripts/cfw_install.sh index 2beaaa7..d319ee1 100755 --- a/scripts/cfw_install.sh +++ b/scripts/cfw_install.sh @@ -71,6 +71,16 @@ ldid_sign() { ldid "${args[@]}" "$file" } +# Like ldid_sign but re-applies an entitlements plist (for binaries whose +# entitlements must survive the re-sign, e.g. diskimagesiod's embedded sandbox +# profile + private DA/apfs entitlements). +ldid_sign_ent() { + local file="$1" ent="$2" bundle_id="${3:-}" + local args=("-S$ent" -M "-K$VM_DIR/$CFW_INPUT/signcert.p12") + [[ -n "$bundle_id" ]] && args+=("-I$bundle_id") + ldid "${args[@]}" "$file" +} + host_hdiutil() { local rc # SUDO_PASSWORD flow exports SUDO_ASKPASS: go straight to sudo -A so @@ -367,6 +377,31 @@ ldid_sign "$TEMP_DIR/seputil" "com.apple.seputil" cp -R "$TEMP_DIR/seputil" "$MNT1/usr/libexec/seputil" /bin/chmod 0755 $MNT1/usr/libexec/seputil +# ── DDI (/System/Developer) auto-mount — diskimagesiod (iOS 27 only) ── +# Force -[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:] → YES so +# MobileStorageMounter proceeds to mount the iOS-27 personalized DDI (its +# waitForDAMount otherwise hangs forever on the 26.4 vphone600 hybrid: only some +# IOMedia appear to diskimagesiod's DA session + DA never auto-mounts). Pairs +# with the DiskImages2 ABI + sandbox mac_policy_ops[124] JB kernel patches. +# Entitlements (embedded sandbox profile + private DA/apfs) preserved on re-sign. +# Gated to 27.*: on a version-matched userland the native waitForDAMount completes +# correctly, and forcing the wait to return early could race the real mount — so +# it is NOT applied there (uses the same $IOS_VERSION as the DSC patches above). +case "$IOS_VERSION" in + 27.*) + echo " Patching diskimagesiod (DDI auto-mount, iOS $IOS_VERSION)..." + if ! [[ -e "$MNT1/usr/libexec/diskimagesiod.bak" ]]; then + /bin/cp "$MNT1/usr/libexec/diskimagesiod" "$MNT1/usr/libexec/diskimagesiod.bak" + fi + ldid -e "$MNT1/usr/libexec/diskimagesiod.bak" > "$TEMP_DIR/diskimagesiod.ent.plist" + cp "$MNT1/usr/libexec/diskimagesiod.bak" "$TEMP_DIR/diskimagesiod" + "$PYTHON3" "$SCRIPT_DIR/patchers/cfw.py" patch-diskimagesiod "$TEMP_DIR/diskimagesiod" + ldid_sign_ent "$TEMP_DIR/diskimagesiod" "$TEMP_DIR/diskimagesiod.ent.plist" "com.apple.diskimagesiod" + cp -R "$TEMP_DIR/diskimagesiod" "$MNT1/usr/libexec/diskimagesiod" + /bin/chmod 0755 "$MNT1/usr/libexec/diskimagesiod" + ;; +esac + # Rename gigalocker (mv to same name is fine on re-run) echo " Renaming gigalocker..." mount_vol s3 "$MNT3" diff --git a/scripts/patchers/cfw.py b/scripts/patchers/cfw.py index 23c327e..5e678e8 100755 --- a/scripts/patchers/cfw.py +++ b/scripts/patchers/cfw.py @@ -77,6 +77,13 @@ Commands: escalates to a kernel panic. Also recomputes the affected CodeDirectory slot hash via cfw_macho_codesign. + patch-diskimagesiod + Force -[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:] to return + YES so MobileStorageMounter proceeds to mount the iOS-27 personalized DDI at + /System/Developer (its waitForDAMount otherwise hangs forever on the 26.4 + vphone600 hybrid). Pairs with the DiskImages2 ABI + sandbox + mac_policy_ops[124] JB kernel patches. No-op-in-effect on matched userlands. + inject-daemons Inject bash/dropbear/trollvnc into launchd.plist. @@ -111,6 +118,7 @@ if __name__ == "__main__": from patchers.cfw_patch_lsd_embedded_reg import patch_lsd_embedded_reg from patchers.cfw_patch_camera_dsc import apply_all_camera_patches from patchers.cfw_patch_watchdogd import patch_watchdogd + from patchers.cfw_patch_diskimagesiod import patch_diskimagesiod from patchers.cfw_daemons import parse_cryptex_paths, inject_daemons, patch_dropbear_plist else: from .cfw_patch_seputil import patch_seputil @@ -124,6 +132,7 @@ else: from .cfw_patch_lsd_embedded_reg import patch_lsd_embedded_reg from .cfw_patch_camera_dsc import apply_all_camera_patches from .cfw_patch_watchdogd import patch_watchdogd + from .cfw_patch_diskimagesiod import patch_diskimagesiod from .cfw_daemons import parse_cryptex_paths, inject_daemons, patch_dropbear_plist @@ -247,6 +256,13 @@ def main(): # exception (unparseable binary / no anchor) is fatal. sys.exit(0) + elif cmd == "patch-diskimagesiod": + if len(sys.argv) < 3: + print("Usage: patch_cfw.py patch-diskimagesiod ") + sys.exit(1) + if not patch_diskimagesiod(sys.argv[2]): + sys.exit(1) + elif cmd == "inject-daemons": if len(sys.argv) < 4: print("Usage: patch_cfw.py inject-daemons ") @@ -285,7 +301,7 @@ def main(): print("Commands: cryptex-paths, patch-seputil, patch-launchd-cache-loader, patch-camera-dsc,") print(" patch-mobileactivationd, patch-launchd-jetsam,") print(" patch-hv-vmm-dsc, patch-iomfb-swapend, patch-iomfb-force-kern, patch-dsc-maxslide, patch-lsd-embedded-reg, patch-watchdogd,") - print(" inject-daemons, patch-dropbear-plist, inject-dylib") + print(" patch-diskimagesiod, inject-daemons, patch-dropbear-plist, inject-dylib") sys.exit(1) diff --git a/scripts/patchers/cfw_patch_diskimagesiod.py b/scripts/patchers/cfw_patch_diskimagesiod.py new file mode 100644 index 0000000..1984409 --- /dev/null +++ b/scripts/patchers/cfw_patch_diskimagesiod.py @@ -0,0 +1,155 @@ +"""diskimagesiod patch module — iOS 27 DDI (/System/Developer) auto-mount fix. + +Forces -[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:] to return YES. + +Why: `pymobiledevice3 mounter auto-mount` attaches the personalized DDI, then +MobileStorageMounter waits on diskimagesiod's -[DIDiskArb +waitForDAMountWithExpectedCount:diskTracker:] before it performs the real +(nobrowse) mount of the DDI volume at /System/Developer. That wait loops until +`isMountComplete` returns YES, which is +`callbackReached || (appearedDiskCount >= expectedCount && mountedDiskCount >= mountableDiskCount)`. +On the iOS-27-userland / 26.4-vphone600-kernel hybrid this never becomes true: +only some of the DMG's IOMedia ever "appear" to diskimagesiod's DiskArbitration +session (appearedDiskCount < expectedCount), and diskarbitrationd never +auto-mounts the volume (mountedDiskCount stays 0), so the wait hangs forever and +pmd3 times out. diskimagesiod itself does NOT mount the DDI (its +-[DIDiskArb mountWithDeviceName:...] is dead code); it only gates +MobileStorageMounter. Forcing isMountComplete to YES lets the wait return +immediately so MobileStorageMounter proceeds and mounts the DDI. + +Pairs with the JB kernel patches that make the DDI attachable + mountable: + - DiskImages2 ABI pokes (KernelJBPatchDiskImages2*) — the v9-kernel accepts the + v11 userland attach. + - Sandbox mac_policy_ops[124] (mpo_proc_check_syscall_unix) → allow stub + (KernelJBPatchSandboxExtended) — lets MobileStorageMounter's mount_apfs make + the mount(2) syscall (unix 167), otherwise the kernel Sandbox denies it + ("Protobox: mount_apfs deny(1) syscall-unix 167"). + +No-op-in-effect on version-matched userlands (there the wait completes on its +own, so returning YES early changes nothing observable). +""" + +from .cfw_asm import * +from .cfw_asm import _log_asm + +_SELECTOR = "isMountCompleteWithExpectedCount:diskTracker:" + + +def _find_imp_via_objc_metadata(data): + """Resolve the method IMP through ObjC runtime metadata (relative method lists). + + selector cstring -> __objc_selrefs entry -> relative method-list entry -> IMP. + """ + sections = parse_macho_sections(data) + + sel_foff = data.find(_SELECTOR.encode() + b"\x00") + if sel_foff < 0: + print(f" [-] Selector '{_SELECTOR}' not found in binary") + return -1 + + sel_va = -1 + for _sec_name, (sva, ssz, sfoff) in sections.items(): + if sfoff <= sel_foff < sfoff + ssz: + sel_va = sva + (sel_foff - sfoff) + break + if sel_va < 0: + print(f" [-] Could not compute VA for selector at foff:0x{sel_foff:X}") + return -1 + print(f" Selector at foff:0x{sel_foff:X} va:0x{sel_va:X}") + + selrefs = find_section( + sections, + "__DATA_CONST,__objc_selrefs", + "__DATA,__objc_selrefs", + "__AUTH_CONST,__objc_selrefs", + ) + selref_va = -1 + if selrefs: + sr_va, sr_size, sr_foff = selrefs + for i in range(0, sr_size, 8): + ptr = struct.unpack_from("= 0: + print(f" Found via relative method list: IMP va:0x{imp_va:X} foff:0x{imp_foff:X}") + return imp_foff + print(f" [!] IMP va:0x{imp_va:X} could not be mapped to file offset") + return -1 + + +def patch_diskimagesiod(filepath): + """Force -[DIDiskArb isMountCompleteWithExpectedCount:diskTracker:] → YES. + + Anchor strategies (in order): + 1. LC_SYMTAB symbol containing "isMountCompleteWithExpectedCount". + 2. ObjC metadata: selector -> selref -> relative method list -> IMP. + Then overwrite the method prologue with `mov x0,#1 ; ret`. + """ + data = bytearray(open(filepath, "rb").read()) + + imp_foff = -1 + imp_va = find_symbol_va(bytes(data), "isMountCompleteWithExpectedCount") + if imp_va > 0: + imp_foff = va_to_foff(bytes(data), imp_va) + if imp_foff >= 0: + print(f" Found via symtab: va:0x{imp_va:X} -> foff:0x{imp_foff:X}") + + if imp_foff < 0: + imp_foff = _find_imp_via_objc_metadata(data) + + if imp_foff < 0: + print(" [-] Dynamic anchor not found — all strategies exhausted") + return False + + if imp_foff + 8 > len(data): + print(f" [-] IMP offset 0x{imp_foff:X} out of bounds") + return False + + print(" Before:") + _log_asm(data, imp_foff, 4, imp_foff) + + # Return YES immediately. Overwriting the prologue (pacibsp; stp...) is safe: + # the function returns to the caller's (unsigned) LR without ever pushing a + # frame. Mirrors patch_mobileactivationd. + data[imp_foff:imp_foff + 4] = MOV_X0_1 + data[imp_foff + 4:imp_foff + 8] = RET + + print(" After:") + _log_asm(data, imp_foff, 4, imp_foff) + + open(filepath, "wb").write(data) + print(f" [+] Patched isMountComplete at 0x{imp_foff:X}: mov x0, #1; ret") + return True diff --git a/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchDiskImages2.swift b/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchDiskImages2.swift new file mode 100644 index 0000000..a5ec920 --- /dev/null +++ b/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchDiskImages2.swift @@ -0,0 +1,240 @@ +// KernelJBPatchDiskImages2.swift — JB kernel patch: DiskImages2 ABI acceptance. +// +// Productionizes the known-good DiskImages2 pokes that make the 26.4 vphone600 +// kernel accept the iOS-27 userland's DiskImages2 client (kernel driver ABI v9 vs +// daemon/controller ABI v11) and fix a RegisterNotificationPort off-by-one. Without +// these the iOS-27 personalized DDI never attaches (kernel: +// "DIDeviceCreatorUserClient::CreateDevice: Incompatible client: expected ABI +// version 9 actual 11"), so `pymobiledevice3 mounter auto-mount` fails at attach. +// +// Pairs with the sandbox mac_policy_ops[124] allow (KernelJBPatchSandboxExtended) +// and the diskimagesiod isMountComplete→YES userland patch (cfw_install) that +// together let the attached DDI actually mount at /System/Developer. +// +// All sites are in the com.apple.driver.AppleDiskImages2 kext inside the MH_FILESET +// kernelcache. The existing JB patches scan the whole kernel text, which covers the +// kext; every anchor here is a globally-unique C++ signature / AssertMacros cstring, +// or a shape made unique by first pinning the enclosing function — so no DI2-range +// restriction is required. +// +// Layers: +// - GATE1/GATE2b (ABI reject NOP): version-robust — anchored on the C++ signature +// cstring + a unique `cmp #9 ; b.ne`. No-op-in-effect on version-matched userlands +// (ABI 9 == 9, so the b.ne isn't taken anyway). +// - GATE2 (notification-ports array + bound-check widen): the off-by-one is specific +// to the 26.4-kernel / iOS-27-userland hybrid, and its instruction shapes vary by +// kernel build. Applied ALL-OR-NOTHING (widening the bound checks without also +// widening the backing array would let RegisterNotificationPort write past the +// array); on builds whose codegen doesn't present all three sites the whole gate +// is skipped (logged) — the ABI gates above remain the essential attach fix. +// +// Guardrails: no hardcoded offsets/VAs/bytes in patch logic — matching is from +// Capstone decode, replacement bytes from the Keystone-backed ARM64/ARM64Encoder +// helpers. + +import Capstone +import Foundation + +extension KernelJBPatcher { + /// Apply all DiskImages2 ABI pokes. Wired into KernelJBPatcher.findAll(). + @discardableResult + func patchDiskImages2ClientAbi() -> Bool { + var ok = true + ok = patchDiskImages2CreateDeviceAbi() && ok + ok = patchDiskImages2ConnectAbi() && ok + ok = patchDiskImages2NotificationPortArray() && ok + return ok + } + + // MARK: - GATE 1 / GATE 2b: ABI-version reject b.ne → NOP + + /// `DIDeviceCreatorUserClient::CreateDevice` rejects a client whose controller ABI + /// (`cmp wN,#9 ; b.ne `) != 9. NOP the b.ne so the ABI-11 iOS-27 client is + /// accepted. Function pinned by its unique C++ signature cstring. + @discardableResult + func patchDiskImages2CreateDeviceAbi() -> Bool { + log("\n[JB] DiskImages2 GATE1: CreateDevice controller-ABI b.ne -> nop") + return nopAbiVersionGate( + funcSig: "static IOReturn DIDeviceCreatorUserClient::CreateDevice(OSObject *, void *, IOExternalMethodArguments *)", + patchID: "di2_createdevice_abi", + desc: "nop [DI2 CreateDevice controller-ABI cmp#9/b.ne gate]" + ) + } + + /// `DIDeviceIOUserClient::Connect` rejects a client whose daemon ABI + /// (`cmp wN,#9 ; b.ne `) != 9. NOP the b.ne. Same shape, different function. + @discardableResult + func patchDiskImages2ConnectAbi() -> Bool { + log("\n[JB] DiskImages2 GATE2b: Connect daemon-ABI b.ne -> nop") + return nopAbiVersionGate( + funcSig: "static IOReturn DIDeviceIOUserClient::Connect(OSObject *, void *, IOExternalMethodArguments *)", + patchID: "di2_connect_abi", + desc: "nop [DI2 Connect daemon-ABI cmp#9/b.ne gate]" + ) + } + + /// Pin the function via its unique signature cstring, then NOP the unique + /// `cmp wN,#9 ; b.ne` inside it. + private func nopAbiVersionGate(funcSig: String, patchID: String, desc: String) -> Bool { + guard let sigOff = buffer.findString(funcSig) else { + log(" [-] signature string not found: \(funcSig.prefix(48))…") + return false + } + let refs = findStringRefs(sigOff) + guard let ref = refs.first, let funcStart = findFunctionStart(ref.adrpOff) else { + log(" [-] no xref/function for signature string") + return false + } + let funcEnd = findFuncEnd(funcStart, maxSize: 0x2000) + + // CMP Wn,#9 (== SUBS WZR,Wn,#9) immediately followed by B.NE. + var hits: [Int] = [] + var off = funcStart + while off + 8 <= funcEnd { + defer { off += 4 } + guard let cmp = disasAt(off), cmp.mnemonic == "cmp", + let ops = cmp.aarch64?.operands, ops.count == 2, + ops[0].type == AARCH64_OP_REG, + (disasm.firstRegisterName(cmp)?.hasPrefix("w") ?? false), + ops[1].type == AARCH64_OP_IMM, ops[1].imm == 9 + else { continue } + guard let nxt = disasAt(off + 4), nxt.mnemonic == "b.ne" else { continue } + hits.append(off + 4) // the b.ne + } + + guard hits.count == 1 else { + log(" [-] expected 1 cmp#9/b.ne gate, found \(hits.count)") + return false + } + let bneOff = hits[0] + emit(bneOff, ARM64.nop, patchID: patchID, virtualAddress: fileOffsetToVA(bneOff), description: desc) + return true + } + + // MARK: - GATE 2 (a/b/c): notification-ports array + bound checks widen + + /// Widen the notification-ports array allocation AND both bound-check fields to + /// 0x800 entries, fixing the RegisterNotificationPort off-by-one (userland + /// registers at index == maxPorts, one past the array). + /// + /// ALL-OR-NOTHING: widening the bound checks (type < 0x800) without also widening + /// the backing array would let RegisterNotificationPort write past the array + /// (memory corruption). So all three sites are located first and patched only if + /// all are present. On kernel builds whose notif-port codegen differs (the + /// off-by-one is specific to the 26.4-kernel / iOS-27-userland hybrid), the whole + /// gate is skipped — the version-robust ABI gates (GATE1/GATE2b) are the essential + /// attach fix. + @discardableResult + func patchDiskImages2NotificationPortArray() -> Bool { + log("\n[JB] DiskImages2 GATE2: widen notification-ports array + bound checks") + + guard let allocSite = findDI2AllocPortsSizeSite() else { + log(" [~] AllocPortsArray size-shift not present on this kernel — skipping GATE2 (build-specific notif-port codegen; GATE1/GATE2b are the essential fix)") + return true + } + guard let (rnpStart, rnpEnd) = findDI2RegisterNotifFunc(), + let f1 = findUniqueFieldLoad(funcStart: rnpStart, funcEnd: rnpEnd, mnemonic: "ldrh", disp: 0xD8, requireWDest: false), + let f2 = findUniqueFieldLoad(funcStart: rnpStart, funcEnd: rnpEnd, mnemonic: "ldr", disp: 0xE8, requireWDest: true) + else { + log(" [~] notification-port bound-check loads not both present — skipping GATE2 (all-or-nothing)") + return true + } + + // All three located — apply together. + var ok = applyDI2AllocPortsSize(at: allocSite) + ok = applyFieldLoadMov800(at: f1, patchID: "di2_notif_boundcheck_d8", + desc: "mov wD,#0x800 [DI2 RegisterNotificationPort bound-check field1 @+0xd8]") && ok + ok = applyFieldLoadMov800(at: f2, patchID: "di2_notif_boundcheck_e8", + desc: "mov wD,#0x800 [DI2 RegisterNotificationPort bound-check field2 @+0xe8]") && ok + return ok + } + + /// Locate the AllocPortsArray allocator size arg `lsl x1, xN, #3` (count << 3 == + /// count * 8), unique within the function. Function pinned by its C++ signature. + private func findDI2AllocPortsSizeSite() -> Int? { + guard let sigOff = buffer.findString( + "static IOReturn DIDeviceIOUserClient::AllocPortsArray(OSObject *, void *, IOExternalMethodArguments *)" + ) else { return nil } + let refs = findStringRefs(sigOff) + guard let ref = refs.first, let funcStart = findFunctionStart(ref.adrpOff) else { return nil } + let funcEnd = findFuncEnd(funcStart, maxSize: 0x1000) + + // The allocator size arg `lsl x1, xN, #3` (count << 3 == count * 8). Capstone on + // this toolchain decodes the lsl-immediate (a UBFM alias) as 2 operands (not the + // xd,xn,#imm 3-operand shape), so match on mnemonic + destination x1 — the only + // lsl that writes the size register, unique within AllocPortsArray. Replacement is + // a fixed `mov x1,#0x4000`, so the original shift amount is irrelevant. + var hits: [Int] = [] + var off = funcStart + while off + 4 <= funcEnd { + defer { off += 4 } + guard let lsl = disasAt(off), lsl.mnemonic == "lsl", + disasm.firstRegisterName(lsl) == "x1" + else { continue } + hits.append(off) + } + return hits.count == 1 ? hits[0] : nil + } + + /// Pin the RegisterNotificationPort function via its unique AssertMacros cstring. + private func findDI2RegisterNotifFunc() -> (Int, Int)? { + guard let sOff = buffer.findString("!notification_ports[ type ]") else { return nil } + let refs = findStringRefs(sOff) + guard let ref = refs.first, let funcStart = findFunctionStart(ref.adrpOff) else { return nil } + return (funcStart, findFuncEnd(funcStart, maxSize: 0x400)) + } + + /// Locate a unique ` wD,[xB,#disp]` field load in [funcStart,funcEnd). + private func findUniqueFieldLoad(funcStart: Int, funcEnd: Int, mnemonic: String, disp: Int64, requireWDest: Bool) -> Int? { + var hits: [Int] = [] + var off = funcStart + while off + 4 <= funcEnd { + defer { off += 4 } + guard let ins = disasAt(off), ins.mnemonic == mnemonic, + let ops = ins.aarch64?.operands, ops.count == 2, + ops[0].type == AARCH64_OP_REG, + ops[1].type == AARCH64_OP_MEM, ops[1].mem.disp == disp + else { continue } + if requireWDest, !(disasm.firstRegisterName(ins)?.hasPrefix("w") ?? false) { continue } + hits.append(off) + } + return hits.count == 1 ? hits[0] : nil + } + + /// Rewrite the AllocPortsArray size shift to `mov x1,#0x4000` (0x800 entries * 8). + private func applyDI2AllocPortsSize(at lslOff: Int) -> Bool { + guard let name = disasm.firstRegisterName(disasAt(lslOff)!), + let dst = xRegIndex(name), + let bytes = ARM64Encoder.encodeMovzX(rd: dst, imm16: 0x4000, shift: 0) + else { log(" [-] could not encode mov x1,#0x4000"); return false } + emit(lslOff, bytes, + patchID: "di2_allocports_size", + virtualAddress: fileOffsetToVA(lslOff), + description: "mov x1,#0x4000 [DI2 AllocPortsArray widen notif-ports alloc to 0x800 entries]") + return true + } + + /// Rewrite a bound-check field load to `mov wD,#0x800`, deriving wD from the decode. + private func applyFieldLoadMov800(at ldOff: Int, patchID: String, desc: String) -> Bool { + guard let name = disasm.firstRegisterName(disasAt(ldOff)!), + let dst = wRegIndex(name), + let bytes = ARM64Encoder.encodeMovzW(rd: dst, imm16: 0x800, shift: 0) + else { log(" [-] could not encode mov wD,#0x800"); return false } + emit(ldOff, bytes, patchID: patchID, virtualAddress: fileOffsetToVA(ldOff), description: desc) + return true + } + + // MARK: - Register-name → index helpers + + private func wRegIndex(_ name: String) -> UInt32? { + if name == "wzr" { return 31 } + guard name.hasPrefix("w"), let n = UInt32(name.dropFirst()), n < 31 else { return nil } + return n + } + + private func xRegIndex(_ name: String) -> UInt32? { + if name == "xzr" { return 31 } + guard name.hasPrefix("x"), let n = UInt32(name.dropFirst()), n < 31 else { return nil } + return n + } +} diff --git a/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchSandboxExtended.swift b/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchSandboxExtended.swift index bbbf9df..ee33dfa 100644 --- a/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchSandboxExtended.swift +++ b/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchSandboxExtended.swift @@ -29,8 +29,10 @@ extension KernelJBPatcher { return false } - // Extended hook index table (name → ops slot index). - let hookIndices: [(String, Int)] = [ + // Extended hook index table (name → ops slot index). Entries 201..316 are the + // base JB sandbox bypass and apply on every JB base; the iOS-27-only + // syscall-unix entry (124) is appended below, gated on applyIOS27. + var hookIndices: [(String, Int)] = [ ("iokit_check_201", 201), ("iokit_check_202", 202), ("iokit_check_203", 203), @@ -69,6 +71,17 @@ extension KernelJBPatcher { ("vnode_check_fsgetpath", 316), ] + // iOS-27-only: mpo_proc_check_syscall_unix[124] → allow lets the mount_apfs that + // MobileStorageMounter spawns make the mount(2) syscall (unix 167) for the iOS-27 + // personalized DDI (/System/Developer). Gated so a 26.x base keeps its syscall-unix + // MAC filter intact (else kernel Sandbox: "Protobox: mount_apfs deny(1) syscall-unix + // 167"). Index 124 is calibrated against this kernel's mac_policy_ops layout: the + // vnode_check_open==267 / vnode_check_fsgetpath==316 entries above match the + // reference XNU struct order exactly, so mpo_proc_check_syscall_unix==124 holds. + if applyIOS27 { + hookIndices.append(("proc_check_syscall_unix", 124)) + } + var patched = 0 for (hookName, idx) in hookIndices { let entryOff = opsTable + idx * 8 diff --git a/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift b/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift index 7f65c5d..9b864ed 100644 --- a/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift +++ b/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift @@ -33,13 +33,21 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher { patchTaskConversionEvalInternal() patchSandboxHooksExtended() patchIoucFailedMacf() - // iOS 27 userland on the 26.4 kernel: the IOKit user-client open path's - // Sandbox gate (separate from the MACF gate above) spuriously denies the - // render server (backboardd) its IOMobileFramebuffer/IOSurface/HID user - // clients → no present (no Apple logo) + nil main display (SpringBoard - // crash-loop). Bypass it, mirroring the MACF gate. No-op where the gate - // already allows (native 26.x userlands). - patchIoucFailedSandbox() + + // iOS-27-only (gated — a 26.x base skips these entirely). Both target a 27 + // userland on the 26.4 kernel: + // - IOUC sandbox gate bypass: the IOKit user-client open path's Sandbox gate + // (separate from the MACF gate above) spuriously denies backboardd its + // IOMFB/IOSurface/HID user clients → no present + nil main display → + // SpringBoard crash-loop. Mirrors the MACF gate. + // - DiskImages2 DDI ABI (kernel driver v9 vs iOS-27 controller/daemon v11) + + // RegisterNotificationPort off-by-one, so the personalized DDI attaches + // (/System/Developer auto-mount). Pairs with the sandbox ops[124] allow + // and the diskimagesiod isMountComplete→YES userland patch (cfw_install). + if applyIOS27 { + patchIoucFailedSandbox() + patchDiskImages2ClientAbi() + } // Group B patchPostValidationAdditional()