mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
tests: Add Frida encoder/version-gate tests
- ARM64Encoder round-trips (TBZ/TBNZ bit retarget, movz flag clear) and the cloudOS 26.4 version-gate compare. - Reveal procedures and static/runtime validation for both kernel patches; patch-comparison table rows JB-23b / JB-25c and the opt-in note. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
committed by
zqxwce
co-authored by
Claude Fable 5
parent
ae29e6de84
commit
ceccc126c8
@@ -124,6 +124,8 @@
|
|||||||
|
|
||||||
> **⚠ iOS-27 hard-gate (2026-07-20).** Every patch below whose Purpose cites "iOS 27" / "27.0 on the 26.4 kernel" is **hard-gated to a 27.x base** via `KernelJBPatcher.applyIOS27` — set by `FirmwarePipeline` from the iPhone base `ProductVersion` (mirroring the `applyExcGuard`/iOS-18 mechanism at patch 27 above; standalone `patch-component` defaults it true, override with `--target-os`). Gated set: **JB-02b, JB-02d, JB-09 (the `ops[124]` add, plus on 27 the `ops[267]` removal — handed to JB-29; the other 201..316 hooks stay), JB-10b, JB-26, JB-27, JB-28, JB-29**. On an 18.x/26.x base **none** of them run. This supersedes the per-row "No-op-in-effect for version-matched userlands" notes: those describe the effect *if applied*, but **JB-27 is NOT a no-op on 26.x** — its `cmp 0x588→0x6e0` retarget makes the 26.x userclient reject its own native 0x588 SwapEnd → dead display (the 26.5 regression that motivated the hard-gate). **Verified 2026-07-20 (JB-29 addendum 2026-07-23):** a 26.5 base produces a **byte-identical** kernelcache to pre-branch `main` (`patch-component --component kernel-jb --target-os 26.5` vs main's output — `cmp` clean, 83 records each; re-confirmed unchanged after JB-29 by `sha256`, since `ops[267]` stays blanket-neutered on 26.5), and a 27.0 base emits **95** records on the `c0ecdb4b` deployment kernel: the 11 gated records above **plus JB-29's 2** (`jb.fpfs_scoped_open.{ops_retarget,cave}`), with `sandbox_ext_267` suppressed (JB-29 owns `ops[267]` on 27 → the FileProvider-scoped trampoline instead of the blanket allow-stub).
|
> **⚠ iOS-27 hard-gate (2026-07-20).** Every patch below whose Purpose cites "iOS 27" / "27.0 on the 26.4 kernel" is **hard-gated to a 27.x base** via `KernelJBPatcher.applyIOS27` — set by `FirmwarePipeline` from the iPhone base `ProductVersion` (mirroring the `applyExcGuard`/iOS-18 mechanism at patch 27 above; standalone `patch-component` defaults it true, override with `--target-os`). Gated set: **JB-02b, JB-02d, JB-09 (the `ops[124]` add, plus on 27 the `ops[267]` removal — handed to JB-29; the other 201..316 hooks stay), JB-10b, JB-26, JB-27, JB-28, JB-29**. On an 18.x/26.x base **none** of them run. This supersedes the per-row "No-op-in-effect for version-matched userlands" notes: those describe the effect *if applied*, but **JB-27 is NOT a no-op on 26.x** — its `cmp 0x588→0x6e0` retarget makes the 26.x userclient reject its own native 0x588 SwapEnd → dead display (the 26.5 regression that motivated the hard-gate). **Verified 2026-07-20 (JB-29 addendum 2026-07-23):** a 26.5 base produces a **byte-identical** kernelcache to pre-branch `main` (`patch-component --component kernel-jb --target-os 26.5` vs main's output — `cmp` clean, 83 records each; re-confirmed unchanged after JB-29 by `sha256`, since `ops[267]` stays blanket-neutered on 26.5), and a 27.0 base emits **95** records on the `c0ecdb4b` deployment kernel: the 11 gated records above **plus JB-29's 2** (`jb.fpfs_scoped_open.{ops_retarget,cave}`), with `sandbox_ext_267` suppressed (JB-29 owns `ops[267]` on 27 → the FileProvider-scoped trampoline instead of the blanket allow-stub).
|
||||||
|
|
||||||
|
> **Frida Stalker support is opt-in (`--frida`).** JB-23b and JB-25c run only when firmware patching is invoked with `--frida` (`vphone-cli vm create … --frida`, `vphone-cli fw patch <vm> -V jb --frida`, `patch-firmware … --frida`, `patch-component --component kernel-jb --frida`, or `make fw_patch_jb FRIDA=1`), gated by `KernelJBPatcher.applyFrida` (set by `FirmwarePipeline` from `enableFrida`). Baseline JB/EXP output is **byte-identical** when off (26.4 emits 83 records without `--frida`, 87 with — the 4 being JB-23b's 2 thread_set_state setters and JB-25c's 2 vm_map_delete gates). Frida itself is installed through the existing extra-debs mechanism: on a `--frida` create the orchestrator sets `VPHONE_FRIDA=1`, `fetch_debs.sh` resolves the latest `frida_<ver>_iphoneos-arm64.deb` (== `re.frida.server`: no `Depends`, rootless `/var/jb` layout) from the Frida GitHub releases into the debs cache, `cfw_install_{jb,exp}.sh` stage it, and the first-boot "5b/8 INSTALL EXTRA DEBS" step `dpkg -i`'s it — no APT source, marker, or dependency resolution.
|
||||||
|
|
||||||
| # | Group | Method | Function | Purpose | JB Enabled |
|
| # | Group | Method | Function | Purpose | JB Enabled |
|
||||||
| ----- | ----- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------: |
|
| ----- | ----- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------: |
|
||||||
| JB-01 | A | `patch_amfi_cdhash_in_trustcache` | `AMFIIsCDHashInTrustCache` | Always return true + store hash | Y |
|
| JB-01 | A | `patch_amfi_cdhash_in_trustcache` | `AMFIIsCDHashInTrustCache` | Always return true + store hash | Y |
|
||||||
@@ -152,8 +154,10 @@
|
|||||||
| JB-21 | B | `patch_spawn_validate_persona` | `_spawn_validate_persona` | Upstream dual-`cbz` persona helper bypass | Y |
|
| JB-21 | B | `patch_spawn_validate_persona` | `_spawn_validate_persona` | Upstream dual-`cbz` persona helper bypass | Y |
|
||||||
| JB-22 | B | `patch_task_for_pid` | `_task_for_pid` | Allow task_for_pid via upstream early `pid == 0` gate NOP | Y |
|
| JB-22 | B | `patch_task_for_pid` | `_task_for_pid` | Allow task_for_pid via upstream early `pid == 0` gate NOP | Y |
|
||||||
| JB-23 | B | `patch_thid_should_crash` | `_thid_should_crash` | Prevent GUARD_TYPE_MACH_PORT crash | Y |
|
| JB-23 | B | `patch_thid_should_crash` | `_thid_should_crash` | Prevent GUARD_TYPE_MACH_PORT crash | Y |
|
||||||
|
| JB-23b| B | `patchThreadSetStateEntitlementFlag` | `thread_set_state_from_user` / inlined `act_set_state_from_user` flags materialization | **Frida Stalker existing-thread support (opt-in `--frida`).** Stalker updates an existing thread's core registers via `thread_set_state_from_user`, which passes `flags = TSSF_TRANSLATE_TO_USER \| TSSF_CHECK_ENTITLEMENT` (0x201) into `thread_set_state_internal`; the inlined `thread_set_state_allowed()` then demands `com.apple.private.thread-set-state` (which the target lacks) → `GUARD_TYPE_MACH_PORT / THREAD_SET_STATE`. Rather than NOP the entitlement check, clear TSSF_CHECK_ENTITLEMENT (bit 9) in the flags the user setters pass: rewrite `mov w6, #0x201` → `mov w6, #0x1`. This preserves TSSF_TRANSLATE_TO_USER (user-pointer translation) and leaves the independent `TH_IN_MACH_EXCEPTION` guard enforced — it only stops user-initiated `thread_set_state` from being entitlement-gated. Anchor: entitlement-string xref cluster → the single containing function (thread_set_state_internal); then its direct `b`/`bl` callers that set `w6` (the 7th-arg = flags, a calling-convention anchor, not an allocation guess) to 0x201. Both setters (`thread_set_state_from_user` + inlined `act_set_state_from_user`) are patched. No offsets/VAs/registers/bytes hardcoded; replacement from the Keystone-backed `ARM64Encoder.encodeMovzW`, Capstone-verified. Kernels without the shape are skipped (fail-open no-op). Verified on the `c0ecdb4b` 26.4 kernel (UUID `BCD06230-CCBE-8E48-50FF-D9C166D83CD5`): exactly two records at file-off `0x1D95720`/`0x1D9594C`. | `--frida` |
|
||||||
| JB-24 | B | `patch_vm_fault_enter_prepare` | `_vm_fault_enter_prepare` | Force `cs_bypass` fast path in runtime fault validation | Y |
|
| JB-24 | B | `patch_vm_fault_enter_prepare` | `_vm_fault_enter_prepare` | Force `cs_bypass` fast path in runtime fault validation | Y |
|
||||||
| JB-25 | B | `patch_vm_map_protect` | `_vm_map_protect` | Skip upstream write-downgrade gate. Shape A (26.1–26.4) active; **Shape B (26.5) disabled 2026-07-05** — widened the `~VM_PROT_WRITE` COW strip (vm_map.c:6202) instead of the RWX gate (vm_map.c:5997), breaking COW and crashing the debugger (SPTM `VIOLATION_ILLEGAL_MAP`). Retired on 26.5+: SPTM code-mod (debugger + Substrate tweaks) uses write-then-flip via `vm_protect(VM_PROT_COPY)` → `XNU_USER_DEBUG`, so no RWX patch is needed. | Y/N |
|
| JB-25 | B | `patch_vm_map_protect` | `_vm_map_protect` | Skip upstream write-downgrade gate. Shape A (26.1–26.4) active; **Shape B (26.5) disabled 2026-07-05** — widened the `~VM_PROT_WRITE` COW strip (vm_map.c:6202) instead of the RWX gate (vm_map.c:5997), breaking COW and crashing the debugger (SPTM `VIOLATION_ILLEGAL_MAP`). Retired on 26.5+: SPTM code-mod (debugger + Substrate tweaks) uses write-then-flip via `vm_protect(VM_PROT_COPY)` → `XNU_USER_DEBUG`, so no RWX patch is needed. | Y/N |
|
||||||
|
| JB-25c| B | `patchVmMapDeleteImmutableCode` | `_vm_map_delete` permanent-entry immutable-code exception (vm_map.c:8855) | **Frida Stalker repeated-`VM_PROT_COPY` fix (opt-in `--frida`).** Stalker's write-then-flip leaves a CSM-associated permanent entry at current `RW` / max `RWX`; XNU's "debugger may undo executable mappings" exception tests `entry->protection & VM_PROT_EXECUTE` (current, bit 9), which is clear, so the entry stays permanent and the next fixed overwrite returns `KERN_PROTECTION_FAILURE`. Retarget the execute test to the packed `max_protection` bit (bit 9 → bit 13; `protection:3`@7..9, `max_protection:4`@11..14 in the `[entry,#0x38]` flags word). Semantic matcher: packed-flags load + `vme_permanent` (bit 19) + the inlined `developer_mode_state()` byte-bit-0 read + the current-X test bound to the immutable-code cluster (Shape A: shares the remove-flags fallback target; Shape B: branches to the permanent-continuation target). The remove-flags bit is matched structurally (a test of a non-entry register), not by source constant (VM_MAP_REMOVE_* bit numbers drift across XNU versions). The later CSM current-X `#9` test in the same window is deliberately excluded (different branch target). Exactly two gates or fail closed; branch bytes from the Keystone-backed `ARM64Encoder.encodeTestBitBranch`, Capstone round-trip verified (sense/bit/target). Verified on the `c0ecdb4b` 26.4 kernel: two records at file-off `0x1DBE14C` (`tbz w8,#9→#0xd`) and `0x1DBE828` (`tbnz w8,#9→#0xd`). | `--frida` |
|
||||||
| JB-26 | B | `patch_iomfb_swapend_variable_size` | IOMFB userclient method-5 (SwapEnd) `__DATA_CONST` dispatch entry (`checkStructureInputSize`) | **iOS-27 VZ-view fix, kernel half — paired with DSC force-kern (DSC-patch item 11).** The 26.4 userclient's method-5 dispatch entry hard-checks `checkStructureInputSize == 0x588`; forced-kern iOS 27 sends its native `0x6e0`. Rewrite the size field to `kIOUCVariableStructureSize (0xFFFFFFFF)` so `IOUserClient::externalMethod` accepts 27's struct and reaches the handler. Anchor (structural): the sole `__DATA_CONST` entry `{ptr(ptrauth, top-byte≥0x80), scalarIn=0, structIn=0x588, scalarOut=0, structOut=0}` (verified unique; decompressed file-off 0x9c7228). No-op-in-effect for version-matched 26.x (sends 0x588). Re-enabled 2026-07-15 (was disabled when 27 present-path was still unknown). | Y |
|
| JB-26 | B | `patch_iomfb_swapend_variable_size` | IOMFB userclient method-5 (SwapEnd) `__DATA_CONST` dispatch entry (`checkStructureInputSize`) | **iOS-27 VZ-view fix, kernel half — paired with DSC force-kern (DSC-patch item 11).** The 26.4 userclient's method-5 dispatch entry hard-checks `checkStructureInputSize == 0x588`; forced-kern iOS 27 sends its native `0x6e0`. Rewrite the size field to `kIOUCVariableStructureSize (0xFFFFFFFF)` so `IOUserClient::externalMethod` accepts 27's struct and reaches the handler. Anchor (structural): the sole `__DATA_CONST` entry `{ptr(ptrauth, top-byte≥0x80), scalarIn=0, structIn=0x588, scalarOut=0, structOut=0}` (verified unique; decompressed file-off 0x9c7228). No-op-in-effect for version-matched 26.x (sends 0x588). Re-enabled 2026-07-15 (was disabled when 27 present-path was still unknown). | Y |
|
||||||
| JB-27 | B | `patch_iomfb_swapend_handler_size` | method-5 handler internal size gate (`cmp w2,#0x588 ; b.ne <err>`) | Companion to JB-26: beyond the dispatch-table check the handler re-checks the struct size (`cmp w2,#0x588 ; b.ne <kIOReturnBadArgument>`; verified unique at decompressed file-off 0x16ae22c; success path forwards the raw struct ptr to a `vtable+0x590` paravirt swap method). Retarget the `cmp` immediate to `0x6e0` so forced-kern iOS 27's native SwapEnd reaches real swap processing (27's IOMFBSwapRec prefix matches 26.x → handler reads valid fields). Semantic anchor (`cmp w2,#imm` word + following `b.ne` decode). Enabled together with JB-26 + DSC force-kern for iOS 27. | Y |
|
| JB-27 | B | `patch_iomfb_swapend_handler_size` | method-5 handler internal size gate (`cmp w2,#0x588 ; b.ne <err>`) | Companion to JB-26: beyond the dispatch-table check the handler re-checks the struct size (`cmp w2,#0x588 ; b.ne <kIOReturnBadArgument>`; verified unique at decompressed file-off 0x16ae22c; success path forwards the raw struct ptr to a `vtable+0x590` paravirt swap method). Retarget the `cmp` immediate to `0x6e0` so forced-kern iOS 27's native SwapEnd reaches real swap processing (27's IOMFBSwapRec prefix matches 26.x → handler reads valid fields). Semantic anchor (`cmp w2,#imm` word + following `b.ne` decode). Enabled together with JB-26 + DSC force-kern for iOS 27. | Y |
|
||||||
| JB-28 | A | `patch_disk_images2_client_abi` | `com.apple.driver.AppleDiskImages2` kext: `DIDeviceCreatorUserClient::CreateDevice` + `DIDeviceIOUserClient::Connect` ABI-version gates, and the `AllocPortsArray`/`RegisterNotificationPort` notif-port sizing | **iOS-27 DDI (`/System/Developer`) auto-mount — attach layer.** `pymobiledevice3 mounter auto-mount` on the 26.4-kernel / 27-userland hybrid fails at attach: the kernel DiskImages2 driver is ABI v9, the 27 userland's DiskImages2 controller/daemon is ABI v11 (`DIDeviceCreatorUserClient::CreateDevice: Incompatible client: expected ABI version 9 actual 11`). Three layers: **GATE1** NOPs the CreateDevice controller-ABI `cmp #9 ; b.ne` reject; **GATE2b** NOPs the Connect daemon-ABI `cmp #9 ; b.ne` reject (both anchored on the C++ signature cstring → unique `cmp #9`/`b.ne`; version-robust; no-op-in-effect on version-matched userlands where ABI 9==9). **GATE2** (array + 2 bound checks) fixes a RegisterNotificationPort off-by-one (userland registers at index==maxPorts, one past the array) by widening the AllocPortsArray allocation (`lsl x1,xN,#3` → `mov x1,#0x4000`) AND both bound-check field loads (`ldrh [.,#0xd8]`/`ldr [.,#0xe8]` → `mov wD,#0x800`); applied **all-or-nothing** (widening the bound checks without the backing array would let RegisterNotificationPort write past it → corruption) and skipped/logged on builds whose notif-port codegen differs (the off-by-one is 26.4-hybrid-specific). Pairs with the sandbox ops[124] allow (JB-09) and the diskimagesiod userland patch (CFW binary-patch #13). Anchors structural (Capstone decode of the pinned function's instructions); replacement bytes from the Keystone-backed `ARM64Encoder`. **GATE2a anchor note:** the AllocPortsArray size-shift is matched on `lsl` mnemonic + destination `x1` (the unique size-writing lsl in the function) — NOT a 3-operand `lsl xd,xn,#imm` shape, because Capstone on this toolchain decodes the lsl-immediate (a UBFM alias) as **2 operands**; requiring 3 operands makes GATE2 silently skip (the all-or-nothing returns a harmless no-op). Verified on the `c0ecdb4b` 26.4 deployment kernel via `patch-component --component kernel-jb --records-out`: all five di2 records emit (`di2_createdevice_abi`, `di2_connect_abi`, `di2_allocports_size`, `di2_notif_boundcheck_d8`, `di2_notif_boundcheck_e8`) — always verify the di2 records EMIT, not merely that the JB suite reports "no failures". No-op-in-effect for version-matched userlands. | Y |
|
| 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 |
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# JB-23b `patchThreadSetStateEntitlementFlag`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Opt-in Frida Stalker patch. Emitted only when firmware patching uses `--frida`
|
||||||
|
(`KernelJBPatcher.applyFrida`). Baseline JB/EXP firmware is byte-identical when
|
||||||
|
off (26.4 emits 83 kernel-jb records without `--frida`, 85 with).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Frida Stalker follows an existing thread by rewriting its core CPU registers via
|
||||||
|
the `thread_set_state` MIG routine, which lands in `thread_set_state_from_user()`.
|
||||||
|
That path passes:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// osfmk/kern/thread_act.c
|
||||||
|
thread_set_state_from_user(...)
|
||||||
|
-> thread_set_state_internal(..., TSSF_TRANSLATE_TO_USER | TSSF_CHECK_ENTITLEMENT); // 0x1 | 0x200 = 0x201
|
||||||
|
```
|
||||||
|
|
||||||
|
`thread_set_state_internal()` (with `thread_set_state_allowed()` inlined) then, on
|
||||||
|
any flags carrying `TSSF_CHECK_ENTITLEMENT`, requires the caller task to hold
|
||||||
|
`com.apple.private.thread-set-state`. Frida's target does not, so the kernel
|
||||||
|
raises `GUARD_TYPE_MACH_PORT / THREAD_SET_STATE` and terminates it.
|
||||||
|
|
||||||
|
## Approach — clear the flag, don't NOP the check
|
||||||
|
|
||||||
|
Instead of NOPing an entitlement-failure branch inside `thread_set_state_allowed()`,
|
||||||
|
clear `TSSF_CHECK_ENTITLEMENT` (bit 9, 0x200) in the flags the *user setters* pass:
|
||||||
|
|
||||||
|
```asm
|
||||||
|
mov w6, #0x201 // before (TSSF_TRANSLATE_TO_USER | TSSF_CHECK_ENTITLEMENT)
|
||||||
|
mov w6, #0x1 // after (TSSF_TRANSLATE_TO_USER only)
|
||||||
|
```
|
||||||
|
|
||||||
|
`w6` is the 7th argument to `thread_set_state_internal` (`flags`) by the AArch64
|
||||||
|
calling convention. Clearing bit 9:
|
||||||
|
|
||||||
|
- `TSSF_TRANSLATE_TO_USER` (0x1) is preserved, so user-pointer translation on the
|
||||||
|
`from_user` path is unchanged.
|
||||||
|
- Both entitlement-gated branches in `thread_set_state_allowed()` (the
|
||||||
|
core-register and fatal-PAC-debug clauses, each `flags & TSSF_CHECK_ENTITLEMENT`)
|
||||||
|
fall through to "allowed" — the function's first test is `tbnz w6, #9`, which is
|
||||||
|
now not taken, so a non-mach-exception thread returns allowed immediately.
|
||||||
|
- The `TH_IN_MACH_EXCEPTION` guard (independent of this flag) stays enforced.
|
||||||
|
|
||||||
|
This is narrower and more source-faithful than editing the check: it disables the
|
||||||
|
entitlement requirement only for user-initiated `thread_set_state`, at the exact
|
||||||
|
call sites that request it.
|
||||||
|
|
||||||
|
## Reveal Procedure
|
||||||
|
|
||||||
|
1. `findString("com.apple.private.thread-set-state")`.
|
||||||
|
2. `findStringRefs` → all ADRP+ADD xrefs; group by `findFunctionStart`. Require
|
||||||
|
they resolve to a single function — `thread_set_state_internal` (the entitlement
|
||||||
|
checks are inlined there). Recover `[fnStart, fnEnd)` via `findFuncEnd`.
|
||||||
|
3. Scan code for direct `b`/`bl` whose target lands in `[fnStart - 0x10, fnEnd)`
|
||||||
|
(the internal function's entry, allowing a small landing-pad lead-in).
|
||||||
|
4. For each such call, scan back up to 8 instructions for `mov w6, #0x201`
|
||||||
|
(`w6` = flags; abandon if `w6` is otherwise written first).
|
||||||
|
5. Patch each such setter to `mov w6, #0x1` via `ARM64Encoder.encodeMovzW`,
|
||||||
|
Capstone-verifying the re-encode decodes to `mov/movz w6, #1`.
|
||||||
|
|
||||||
|
No file offsets, VAs, register numbers, or preassembled bytes are hardcoded.
|
||||||
|
Kernels without the shape are skipped without changing bytes (fail-open no-op),
|
||||||
|
and the patch only runs under `--frida`.
|
||||||
|
|
||||||
|
## Static Validation — 26.4
|
||||||
|
|
||||||
|
Kernel: `ipsws/c0ecdb4b…/kernelcache.research.vphone600`, UUID
|
||||||
|
`BCD06230-CCBE-8E48-50FF-D9C166D83CD5`.
|
||||||
|
|
||||||
|
`patch-component --component kernel-jb --target-os 26.4 --frida` emits exactly two
|
||||||
|
`kernelcache_frida.thread_set_state_entitlement_flag` records:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0x01D95720: mov w6, #0x201 -> mov w6, #0x1
|
||||||
|
0x01D9594C: mov w6, #0x201 -> mov w6, #0x1
|
||||||
|
```
|
||||||
|
|
||||||
|
(VA `0xfffffe0008d99720` / `0xfffffe0008d9994c` — the `thread_set_state_from_user`
|
||||||
|
setter and the inlined `act_set_state_from_user` setter, both feeding the same
|
||||||
|
`thread_set_state_internal` at `0xfffffe0008d5c170`.) Without `--frida`, zero such
|
||||||
|
records are emitted.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The 26.4 research kernel has no `tss_should_crash` early-out in the compiled
|
||||||
|
`thread_set_state_allowed()` (it goes straight to `tbnz w6, #9`), so the
|
||||||
|
DEVELOPMENT boot-arg bypass is not available — a code patch is required.
|
||||||
|
- Companion: Frida Stalker's repeated `VM_PROT_COPY` overwrite also needs the
|
||||||
|
`vm_map_delete` immutable-code fix — JB-25c
|
||||||
|
(`patch_vm_map_delete_immutable_code.md`), applied together under `--frida`.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# JB-25c `patchVmMapDeleteImmutableCode`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Opt-in Frida Stalker patch. Emitted only under `--frida`
|
||||||
|
(`KernelJBPatcher.applyFrida`). Companion to JB-23b (thread_set_state); together
|
||||||
|
they give Frida Stalker existing-thread following and repeated re-instrumentation.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Frida Stalker instruments code by a write-then-flip: allocate, write RW, then
|
||||||
|
`vm_protect(VM_PROT_COPY)` to executable. On a CSM device this leaves a
|
||||||
|
CSM-associated **permanent** `vm_map_entry` at **current protection RW, maximum
|
||||||
|
protection RWX**. When Stalker later overwrites that region (re-instrumentation),
|
||||||
|
the fixed-overwrite path calls `vm_map_delete` on the old entry, whose
|
||||||
|
permanent-entry handler has a debugger exception (`osfmk/vm/vm_map.c:8855`):
|
||||||
|
|
||||||
|
```c
|
||||||
|
} else if ((flags & VM_MAP_REMOVE_IMMUTABLE_CODE) &&
|
||||||
|
(entry->protection & VM_PROT_EXECUTE) && // CURRENT protection
|
||||||
|
developer_mode_state()) {
|
||||||
|
entry->vme_permanent = FALSE; // allow the debugger to undo it
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The entry is current-RW, so `entry->protection & VM_PROT_EXECUTE` is false, the
|
||||||
|
exception is skipped, the entry stays permanent, and the overwrite returns
|
||||||
|
`KERN_PROTECTION_FAILURE`.
|
||||||
|
|
||||||
|
## Approach — test max protection instead of current
|
||||||
|
|
||||||
|
Retarget the execute test from current protection to maximum protection. The
|
||||||
|
packed flags word at `[entry, #0x38]` (see `vm_map_xnu.h`: `VME_ALIAS_BITS=12` +
|
||||||
|
`VME_OFFSET_BITS=52` fill qword0, so `protection:3`/`max_protection:4` land in
|
||||||
|
qword1's low half) places:
|
||||||
|
|
||||||
|
- current protection EXECUTE = **bit 9**
|
||||||
|
- max protection EXECUTE = **bit 13**
|
||||||
|
|
||||||
|
So the fix is `#9 → #13` on the immutable-code execute test — "allow a debugger to
|
||||||
|
undo a mapping that is *capable of* execution," which is exactly Frida's RW/max-RWX
|
||||||
|
entry. This is strictly narrower than converting every `KERN_PROTECTION_FAILURE`
|
||||||
|
to success.
|
||||||
|
|
||||||
|
## Semantic Reveal Procedure
|
||||||
|
|
||||||
|
No offsets, VAs, registers, or bytes are hardcoded. For each candidate:
|
||||||
|
|
||||||
|
1. Pre-filter for `ldr wF, [xE, #0x38]` (the packed `vm_map_entry` flags word).
|
||||||
|
2. Require `tbz wF, #19` immediately after (`vme_permanent`).
|
||||||
|
3. Require the inlined `developer_mode_state()` read in the window: a byte load
|
||||||
|
whose bit 0 is then tested (`ldrb wD,[…] ; … ; tbz/tbnz wD,#0`). This ties the
|
||||||
|
match to the immutable-code gate rather than any packed-flags load.
|
||||||
|
4. Identify the current-X test (`wF`, bit 9) bound to the cluster:
|
||||||
|
- **Shape A**: `tbz wF,#9,T` immediately following a remove-flags argument test
|
||||||
|
`tbz wArg,#b,T` (different register, **same** fallback target `T`).
|
||||||
|
- **Shape B**: `tbnz wF,#9,P` (after the developer-mode gate) whose target `P`
|
||||||
|
equals the `vme_permanent` test's target (the permanent-continuation path).
|
||||||
|
5. Retarget bit 9 → 13, preserving sense (`tbz`/`tbnz`), source register, and
|
||||||
|
target, via `ARM64Encoder.encodeTestBitBranch`; Capstone round-trip verify the
|
||||||
|
re-encode's mnemonic, bit (13), and target before emitting.
|
||||||
|
|
||||||
|
Exactly two gates must be found (the compiler outlines the two source paths); any
|
||||||
|
other count fails closed. The **later CSM current-X `#9` test** in the same window
|
||||||
|
is excluded because its branch target is neither the remove-flags fallback nor the
|
||||||
|
permanent-continuation target.
|
||||||
|
|
||||||
|
Robustness notes vs. a naive scan:
|
||||||
|
- The remove-flags bit is matched **structurally** (a test of a register other than
|
||||||
|
the entry-flags register), never by a source constant — `VM_MAP_REMOVE_*` bit
|
||||||
|
numbers drift across XNU versions (this kernel tests bit 6; the reference source
|
||||||
|
defines `VM_MAP_REMOVE_IMMUTABLE_CODE = 0x080`).
|
||||||
|
- Bits 9/13/19 are protection/permanent **struct** bits, stable across versions.
|
||||||
|
|
||||||
|
## 26.4 Static Validation
|
||||||
|
|
||||||
|
Kernel: `ipsws/c0ecdb4b…/kernelcache.research.vphone600`.
|
||||||
|
`patch-component --component kernel-jb --target-os 26.4 --frida` emits exactly two
|
||||||
|
`kernelcache_frida.vm_map_delete_immutable_code` records:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0x01DBE14C: tbz w8, #9, 0x1dbe16c -> tbz w8, #0xd, 0x1dbe16c (shape-A)
|
||||||
|
0x01DBE828: tbnz w8, #9, 0x1dbe958 -> tbnz w8, #0xd, 0x1dbe958 (shape-B)
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch targets are unchanged; only the tested bit index differs. Without `--frida`,
|
||||||
|
zero such records are emitted (baseline 83; `--frida` 87 = 83 + 2 thread_set_state
|
||||||
|
+ 2 vm_map_delete).
|
||||||
|
|
||||||
|
## Validation Requirements
|
||||||
|
|
||||||
|
- `swift test --filter ARM64EncoderTests` passes (round-trip of the bit-13 encode).
|
||||||
|
- 26.4 dry-run emits exactly two `vm_map_delete_immutable_code` records.
|
||||||
|
- Before/after disassembly differs only in the tested bit index (9 → 13).
|
||||||
|
- If the semantic candidate count is not exactly two, the patch fails closed.
|
||||||
@@ -156,6 +156,41 @@ struct ARM64EncoderTests {
|
|||||||
// `mov x0, x20` matches the project's preverified ARM64.movX0X20 constant.
|
// `mov x0, x20` matches the project's preverified ARM64.movX0X20 constant.
|
||||||
#expect(ARM64Encoder.encodeMovX(rd: 0, rm: 20) == ARM64.movX0X20)
|
#expect(ARM64Encoder.encodeMovX(rd: 0, rm: 20) == ARM64.movX0X20)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func encodeTestBitBranchRoundTrips() throws {
|
||||||
|
// The vm_map_delete --frida patch retargets `tbz/tbnz w8,#9` to bit 13
|
||||||
|
// (current-protection.X → max_protection.X), preserving sense and target.
|
||||||
|
let tbz = try #require(ARM64Encoder.encodeTestBitBranch(
|
||||||
|
nonzero: false, register: 8, bit: 13, from: 0x1000, to: 0x1020))
|
||||||
|
let tbzI = try #require(disasm.disassembleOne(tbz, at: 0x1000))
|
||||||
|
#expect(tbzI.mnemonic == "tbz")
|
||||||
|
#expect(tbzI.operandString.contains("w8"))
|
||||||
|
#expect(tbzI.operandString.contains("#0xd"))
|
||||||
|
#expect(tbzI.operandString.contains("0x1020"))
|
||||||
|
|
||||||
|
let tbnz = try #require(ARM64Encoder.encodeTestBitBranch(
|
||||||
|
nonzero: true, register: 8, bit: 13, from: 0x2000, to: 0x1f00))
|
||||||
|
let tbnzI = try #require(disasm.disassembleOne(tbnz, at: 0x2000))
|
||||||
|
#expect(tbnzI.mnemonic == "tbnz")
|
||||||
|
#expect(tbnzI.operandString.contains("#0xd"))
|
||||||
|
#expect(tbnzI.operandString.contains("0x1f00"))
|
||||||
|
|
||||||
|
// Rejects bad register / bit / out-of-range target.
|
||||||
|
#expect(ARM64Encoder.encodeTestBitBranch(nonzero: false, register: 32, bit: 13, from: 0, to: 4) == nil)
|
||||||
|
#expect(ARM64Encoder.encodeTestBitBranch(nonzero: false, register: 8, bit: 64, from: 0, to: 4) == nil)
|
||||||
|
#expect(ARM64Encoder.encodeTestBitBranch(nonzero: false, register: 8, bit: 13, from: 0, to: 0x8000) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func encodeMovzWClearsTSSFCheckEntitlement() throws {
|
||||||
|
// The thread_set_state --frida patch rewrites `mov w6, #0x201`
|
||||||
|
// (TSSF_TRANSLATE_TO_USER | TSSF_CHECK_ENTITLEMENT) to `mov w6, #0x1`,
|
||||||
|
// clearing only the entitlement bit while preserving user translation.
|
||||||
|
let bytes = try #require(ARM64Encoder.encodeMovzW(rd: 6, imm16: 0x1))
|
||||||
|
let insn = try #require(disasm.disassembleOne(bytes, at: 0))
|
||||||
|
#expect(insn.mnemonic == "mov" || insn.mnemonic == "movz")
|
||||||
|
#expect(insn.operandString.contains("w6"))
|
||||||
|
#expect(insn.operandString.contains("#1") || insn.operandString.contains("#0x1"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Round-trip coverage for the shared raw-instruction predicates in `ARM64Inst`,
|
/// Round-trip coverage for the shared raw-instruction predicates in `ARM64Inst`,
|
||||||
@@ -476,3 +511,16 @@ struct FirmwarePipelineTests {
|
|||||||
#expect(found == target)
|
#expect(found == target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FridaGatingTests {
|
||||||
|
@Test func cloudOSVersionGate() {
|
||||||
|
// Frida kernel patches apply on cloudOS 26.4+ only.
|
||||||
|
#expect(FirmwarePipeline.productVersionAtLeast("26.4", 26, 4))
|
||||||
|
#expect(FirmwarePipeline.productVersionAtLeast("26.5", 26, 4))
|
||||||
|
#expect(FirmwarePipeline.productVersionAtLeast("26.10", 26, 4))
|
||||||
|
#expect(FirmwarePipeline.productVersionAtLeast("27.0", 26, 4))
|
||||||
|
#expect(!FirmwarePipeline.productVersionAtLeast("26.3", 26, 4))
|
||||||
|
#expect(!FirmwarePipeline.productVersionAtLeast("18.5", 26, 4))
|
||||||
|
#expect(!FirmwarePipeline.productVersionAtLeast(nil, 26, 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user