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):
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:
- Pre-filter for
ldr wF, [xE, #0x38](the packedvm_map_entryflags word). - Require
tbz wF, #19immediately after (vme_permanent). - 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. - Identify the current-X test (
wF, bit 9) bound to the cluster:- Shape A:
tbz wF,#9,Timmediately following a remove-flags argument testtbz wArg,#b,T(different register, same fallback targetT). - Shape B:
tbnz wF,#9,P(after the developer-mode gate) whose targetPequals thevme_permanenttest's target (the permanent-continuation path).
- Shape A:
- Retarget bit 9 → 13, preserving sense (
tbz/tbnz), source register, and target, viaARM64Encoder.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 definesVM_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:
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 ARM64EncoderTestspasses (round-trip of the bit-13 encode).- 26.4 dry-run emits exactly two
vm_map_delete_immutable_coderecords. - 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.