From ceccc126c8556aedc16e58e7544fb13d50485e0d Mon Sep 17 00:00:00 2001 From: zqxwce Date: Tue, 11 Aug 2026 17:24:27 +0300 Subject: [PATCH] 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 --- research/0_binary_patch_comparison.md | 4 + .../kernel_patch_jb/patch_thread_set_state.md | 93 ++++++++++++++++++ .../patch_vm_map_delete_immutable_code.md | 95 +++++++++++++++++++ .../FirmwarePatcherTests.swift | 48 ++++++++++ 4 files changed, 240 insertions(+) create mode 100644 research/kernel_patch_jb/patch_thread_set_state.md create mode 100644 research/kernel_patch_jb/patch_vm_map_delete_immutable_code.md diff --git a/research/0_binary_patch_comparison.md b/research/0_binary_patch_comparison.md index ade779b..055a56f 100644 --- a/research/0_binary_patch_comparison.md +++ b/research/0_binary_patch_comparison.md @@ -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). +> **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 -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__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 | | ----- | ----- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------: | | 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-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-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-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-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 | diff --git a/research/kernel_patch_jb/patch_thread_set_state.md b/research/kernel_patch_jb/patch_thread_set_state.md new file mode 100644 index 0000000..ec4002a --- /dev/null +++ b/research/kernel_patch_jb/patch_thread_set_state.md @@ -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`. diff --git a/research/kernel_patch_jb/patch_vm_map_delete_immutable_code.md b/research/kernel_patch_jb/patch_vm_map_delete_immutable_code.md new file mode 100644 index 0000000..974b5af --- /dev/null +++ b/research/kernel_patch_jb/patch_vm_map_delete_immutable_code.md @@ -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. diff --git a/tests/FirmwarePatcherTests/FirmwarePatcherTests.swift b/tests/FirmwarePatcherTests/FirmwarePatcherTests.swift index 7fee368..f1e5a52 100644 --- a/tests/FirmwarePatcherTests/FirmwarePatcherTests.swift +++ b/tests/FirmwarePatcherTests/FirmwarePatcherTests.swift @@ -156,6 +156,41 @@ struct ARM64EncoderTests { // `mov x0, x20` matches the project's preverified ARM64.movX0X20 constant. #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`, @@ -476,3 +511,16 @@ struct FirmwarePipelineTests { #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)) + } +}