kernel-jb: Add opt-in Frida Stalker kernel patches

Two narrowly-scoped patches, emitted only under the Frida opt-in:

- thread_set_state: clear TSSF_CHECK_ENTITLEMENT in the thread_set_state user
  setters (mov w6,#0x201 -> #0x1) so Frida can follow an existing thread without
  a GUARD_TYPE_MACH_PORT kill, while preserving TSSF_TRANSLATE_TO_USER and the
  TH_IN_MACH_EXCEPTION guard.
- vm_map_delete: retarget the immutable-code exception from current-protection
  execute (bit 9) to max-protection execute (bit 13) so a debugger-created
  RW/max-RWX permanent mapping survives repeated VM_PROT_COPY re-instrumentation
  instead of returning KERN_PROTECTION_FAILURE.

Both matchers are semantic (entitlement-string / developer-mode / call-flow
anchored, no hardcoded offsets/VAs/registers/bytes) and fail closed; replacement
bytes come from ARM64Encoder and are Capstone-verified.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
zqxwce
2026-08-12 11:40:24 +03:00
committed by zqxwce
co-authored by Claude Fable 5
parent d3b873c777
commit fdf9487bfd
4 changed files with 346 additions and 4 deletions
@@ -34,6 +34,32 @@ public enum ARM64Encoder {
return ARM64.encodeU32(insn)
}
/// Encode TBZ/TBNZ (test bit and branch). Target must be 4-byte aligned and
/// within the signed 14-bit range (+/-32 KB).
///
/// Format: `[31] = b5`, `[30:24] = 0110110 (TBZ) / 0110111 (TBNZ)`,
/// `[23:19] = b40`, `[18:5] = imm14`, `[4:0] = Rt`
public static func encodeTestBitBranch(
nonzero: Bool,
register: UInt32,
bit: UInt32,
from pc: Int,
to target: Int
) -> Data? {
guard register < 32, bit < 64 else { return nil }
let delta = target - pc
guard delta & 0x3 == 0 else { return nil }
let imm14 = delta >> 2
guard imm14 >= -(1 << 13), imm14 < (1 << 13) else { return nil }
var insn: UInt32 = nonzero ? 0x3700_0000 : 0x3600_0000
insn |= (bit & 0x20) << 26
insn |= (bit & 0x1F) << 19
insn |= (UInt32(bitPattern: Int32(imm14)) & 0x3FFF) << 5
insn |= register & 0x1F
return ARM64.encodeU32(insn)
}
// MARK: - ADRP / ADD Encoding
/// Encode ADRP instruction.
@@ -0,0 +1,114 @@
// KernelJBPatchThreadSetState.swift optional Frida Stalker support (--frida).
//
// Frida follows an existing thread via thread_set_state_from_user, whose flags
// carry TSSF_CHECK_ENTITLEMENT and trip GUARD_TYPE_MACH_PORT. Clear that bit in the
// user setters (`mov w6,#0x201` `mov w6,#0x1`) rather than the check itself.
// Reveal + validation: research/kernel_patch_jb/patch_thread_set_state.md.
import Capstone
import Foundation
extension KernelJBPatcher {
private static let tssEntitlement = "com.apple.private.thread-set-state"
// TSSF_TRANSLATE_TO_USER (0x1) | TSSF_CHECK_ENTITLEMENT (0x200).
private static let tssFlagsFromUser: Int64 = 0x201
private static let tssFlagsCleared: UInt16 = 0x1
/// Clear TSSF_CHECK_ENTITLEMENT in the flags passed by the thread_set_state
/// user setters so Frida Stalker can update an existing thread's registers.
@discardableResult
func patchThreadSetStateEntitlementFlag() -> Bool {
log("\n[FRIDA] thread_set_state: clear TSSF_CHECK_ENTITLEMENT in user setters")
guard let strOff = buffer.findString(Self.tssEntitlement) else {
log(" [~] thread-set-state entitlement string absent; skipping")
return true
}
// All entitlement-string refs land in one function (thread_set_state_internal).
let refs = findStringRefs(strOff)
let starts = Set(refs.compactMap { findFunctionStart($0.adrpOff) })
guard starts.count == 1, let fnStart = starts.first else {
log(" [~] entitlement checks not in a single recovered function (\(starts.count)); skipping")
return true
}
let fnEnd = findFuncEnd(fnStart, maxSize: 0x1000)
// `mov w6,#0x201` (w6 = 7th arg = flags) feeding a direct branch into it.
var setterOffsets: [Int] = []
for range in codeRanges {
var off = range.start
while off + 4 <= min(range.end, buffer.count) {
defer { off += 4 }
guard let branch = disasAt(off),
branch.mnemonic == "b" || branch.mnemonic == "bl",
let target = branchTargetFileOffset(branch),
target >= fnStart - 0x10, target < fnEnd
else { continue }
if let setter = findFlagSetterBefore(off, funcFloor: range.start) {
setterOffsets.append(setter)
}
}
}
let unique = Array(Set(setterOffsets)).sorted()
guard !unique.isEmpty else {
log(" [~] no TSSF_CHECK_ENTITLEMENT setter reaches thread_set_state; skipping")
return true
}
for setterOff in unique {
guard let orig = disasAt(setterOff),
let rd = wRegisterNumber(orig),
let bytes = ARM64Encoder.encodeMovzW(rd: rd, imm16: Self.tssFlagsCleared),
let check = disasm.disassembleOne(bytes, at: UInt64(setterOff)),
(check.mnemonic == "mov" || check.mnemonic == "movz"),
let ops = check.aarch64?.operands, ops.count == 2,
ops[1].type == AARCH64_OP_IMM, ops[1].imm == Int64(Self.tssFlagsCleared)
else {
log(" [-] failed to assemble/verify cleared flags at 0x\(String(format: "%X", setterOff))")
return false
}
emit(setterOff, bytes,
patchID: "kernelcache_frida.thread_set_state_entitlement_flag",
virtualAddress: fileOffsetToVA(setterOff),
description: "clear TSSF_CHECK_ENTITLEMENT (0x201 -> 0x1) [thread_set_state user setter, --frida]")
}
return true
}
// MARK: - Helpers
/// Direct B/BL target (disassembly runs in file-offset space).
private func branchTargetFileOffset(_ insn: Instruction) -> Int? {
guard let ops = insn.aarch64?.operands, ops.count == 1,
ops[0].type == AARCH64_OP_IMM
else { return nil }
return Int(ops[0].imm)
}
/// Scan back up to 8 instructions for `mov w6, #0x201`, abandoning if w6 is
/// otherwise written first. Returns the setter's file offset.
private func findFlagSetterBefore(_ branchOff: Int, funcFloor: Int) -> Int? {
var off = branchOff - 4
var steps = 0
while off >= funcFloor, steps < 8 {
defer { off -= 4; steps += 1 }
guard let insn = disasAt(off) else { continue }
guard insn.mnemonic == "mov" || insn.mnemonic == "movz" else { continue }
guard let ops = insn.aarch64?.operands, ops.count == 2,
ops[0].type == AARCH64_OP_REG, ops[1].type == AARCH64_OP_IMM,
disasm.firstRegisterName(insn) == "w6"
else { continue }
return ops[1].imm == Self.tssFlagsFromUser ? off : nil
}
return nil
}
private func wRegisterNumber(_ insn: Instruction) -> UInt32? {
guard let name = disasm.firstRegisterName(insn), name.hasPrefix("w"),
let value = UInt32(name.dropFirst()), value < 32
else { return nil }
return value
}
}
@@ -0,0 +1,189 @@
// KernelJBPatchVmMapDelete.swift optional Frida Stalker support (--frida).
//
// Frida's write-then-flip leaves a permanent CSM mapping at current RW / max RWX;
// vm_map_delete's immutable-code exception tests current-protection EXECUTE, which
// is clear, so re-instrumentation fails with KERN_PROTECTION_FAILURE. Retarget the
// test from current-X (packed [entry,#0x38] bit 9) to max-X (bit 13).
// Reveal + validation: research/kernel_patch_jb/patch_vm_map_delete_immutable_code.md.
import Capstone
import Foundation
extension KernelJBPatcher {
private struct VmMapDeleteGate {
let offset: Int
let register: UInt32
let nonzero: Bool
let target: Int
let shape: String
}
@discardableResult
func patchVmMapDeleteImmutableCode() -> Bool {
log("\n[FRIDA] _vm_map_delete: allow debugger overwrite of RW/max-RWX permanent code")
let gates = findVmMapDeleteImmutableCodeGates()
if gates.isEmpty {
// Older kernels predate this compiled CSM/permanent-entry shape.
log(" [~] immutable-code current-protection gates not present; skipping")
return true
}
guard gates.count == 2 else {
log(" [-] expected 2 immutable-code execute gates, found \(gates.count); failing closed")
return false
}
// Each gate must live inside a recovered function (the compiler may outline
// the two source paths into separate local helpers).
for gate in gates where findFunctionStart(gate.offset) == nil {
log(" [-] could not recover function containing gate at 0x\(String(format: "%X", gate.offset))")
return false
}
var replacements: [(VmMapDeleteGate, Data)] = []
for gate in gates.sorted(by: { $0.offset < $1.offset }) {
guard let bytes = ARM64Encoder.encodeTestBitBranch(
nonzero: gate.nonzero, register: gate.register, bit: 13,
from: gate.offset, to: gate.target
),
let decoded = disasm.disassembleOne(bytes, at: UInt64(gate.offset)),
decoded.mnemonic == (gate.nonzero ? "tbnz" : "tbz"),
let ops = decoded.aarch64?.operands, ops.count == 3,
ops[1].type == AARCH64_OP_IMM, ops[1].imm == 13,
ops[2].type == AARCH64_OP_IMM, Int(ops[2].imm) == gate.target
else {
log(" [-] failed to assemble/verify max-X gate at 0x\(String(format: "%X", gate.offset))")
return false
}
replacements.append((gate, bytes))
}
for (gate, bytes) in replacements {
emit(gate.offset, bytes,
patchID: "kernelcache_frida.vm_map_delete_immutable_code",
virtualAddress: fileOffsetToVA(gate.offset),
description: "\(gate.nonzero ? "tbnz" : "tbz") entry max_protection.X [vm_map_delete immutable-code \(gate.shape), --frida]")
}
return true
}
// MARK: - Semantic matcher
private func findVmMapDeleteImmutableCodeGates() -> [VmMapDeleteGate] {
var hits: [VmMapDeleteGate] = []
for range in codeRanges {
var off = range.start
while off + 4 <= min(range.end, buffer.count) {
defer { off += 4 }
// Cheap pre-filter: 32-bit `ldr wRt, [xN, #0x38]`.
let word = buffer.readU32(at: off)
guard word & 0xFFC0_0000 == 0xB940_0000,
((word >> 10) & 0xFFF) * 4 == 0x38
else { continue }
if let gate = matchGate(at: off) {
hits.append(gate)
}
}
}
var seen = Set<Int>()
return hits.filter { seen.insert($0.offset).inserted }
}
/// The window rooted at `ldr wF,[entry,#0x38] ; tbz wF,#19` (vme_permanent),
/// carrying the inlined developer_mode_state() read and the immutable-code
/// current-X test to retarget.
private func matchGate(at ldrOff: Int) -> VmMapDeleteGate? {
let insns = disasm.disassemble(in: buffer.data, at: ldrOff, count: 16)
guard insns.count >= 8,
let flagsReg = destRegister(insns[0]),
bitBranch(insns[1], mnemonic: "tbz", register: flagsReg, bit: 19) != nil
else { return nil }
// Require the inlined developer_mode_state() read somewhere in the window:
// `ldrb wD,[...] ; ... ; tbz/tbnz wD,#0`. Gating anchor for these gates.
guard developerModeGatePresent(insns) else { return nil }
// Shape A: the current-X test immediately follows a remove-flags argument
// test and shares its fallback target.
// tbz wArg,#b, T
// tbz wF, #9, T <- retarget
for i in 2 ..< (insns.count - 1) {
guard let argTarget = bitBranchAnyBit(insns[i], mnemonic: "tbz"),
destRegister(insns[i]) != flagsReg,
let exec = bitBranch(insns[i + 1], mnemonic: "tbz", register: flagsReg, bit: 9),
exec == argTarget.target
else { continue }
return VmMapDeleteGate(
offset: Int(insns[i + 1].address), register: flagsReg,
nonzero: false, target: exec, shape: "shape-A")
}
// Shape B: developer mode is checked first, then the current-X test branches
// to the same permanent-continuation target as the vme_permanent test.
// tbz wF,#19, P
// ... developer-mode gate ...
// tbnz wF,#9, P <- retarget
let permTarget = bitBranch(insns[1], mnemonic: "tbz", register: flagsReg, bit: 19)!
for i in 3 ..< insns.count {
guard let exec = bitBranch(insns[i], mnemonic: "tbnz", register: flagsReg, bit: 9),
exec == permTarget
else { continue }
return VmMapDeleteGate(
offset: Int(insns[i].address), register: flagsReg,
nonzero: true, target: exec, shape: "shape-B")
}
return nil
}
// MARK: - Instruction helpers
/// The instruction's first operand as a W register number, if it is one.
private func destRegister(_ insn: Instruction) -> UInt32? {
guard let name = disasm.firstRegisterName(insn), name.hasPrefix("w"),
let value = UInt32(name.dropFirst()), value < 32
else { return nil }
return value
}
/// A `tbz`/`tbnz wReg,#bit,target` matching the given mnemonic, register, and
/// bit; returns the branch target file offset.
private func bitBranch(_ insn: Instruction, mnemonic: String, register: UInt32, bit: Int64) -> Int? {
guard insn.mnemonic == mnemonic,
let ops = insn.aarch64?.operands, ops.count == 3,
ops[0].type == AARCH64_OP_REG, destRegister(insn) == register,
ops[1].type == AARCH64_OP_IMM, ops[1].imm == bit,
ops[2].type == AARCH64_OP_IMM
else { return nil }
return Int(ops[2].imm)
}
/// Any `tbz`/`tbnz wReg,#bit,target` of the given mnemonic; returns bit + target.
private func bitBranchAnyBit(_ insn: Instruction, mnemonic: String) -> (bit: Int64, target: Int)? {
guard insn.mnemonic == mnemonic,
let ops = insn.aarch64?.operands, ops.count == 3,
ops[0].type == AARCH64_OP_REG,
ops[1].type == AARCH64_OP_IMM, ops[2].type == AARCH64_OP_IMM
else { return nil }
return (ops[1].imm, Int(ops[2].imm))
}
/// The inlined `developer_mode_state()`: a byte load whose bit 0 is then tested
/// (`ldrb wD,[...] ; ; tbz/tbnz wD,#0`).
private func developerModeGatePresent(_ insns: [Instruction]) -> Bool {
for i in 0 ..< insns.count {
guard insns[i].mnemonic == "ldrb", let devReg = destRegister(insns[i]) else { continue }
for j in (i + 1) ..< min(insns.count, i + 4) {
let m = insns[j].mnemonic
if (m == "tbz" || m == "tbnz"),
bitBranch(insns[j], mnemonic: m, register: devReg, bit: 0) != nil {
return true
}
}
}
return false
}
}
@@ -4,11 +4,13 @@
import Foundation
/// JB kernel patcher: 84 patches across 3 groups.
/// JB kernel patcher across 3 groups. Variant- and feature-gated methods can
/// change the emitted record count; iOS-27-only patches are gated by `applyIOS27`
/// and Frida Stalker relaxations by `applyFrida` (opt-in `--frida`).
///
/// Group A: Core gate-bypass methods (5 patches)
/// Group B: Pattern/string anchored methods (16 patches)
/// Group C: Shellcode/trampoline heavy methods (4 patches)
/// Group A: Core gate-bypass methods
/// Group B: Pattern/string anchored methods
/// Group C: Shellcode/trampoline heavy methods
public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
public let component = "kernelcache_jb"
@@ -21,6 +23,10 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
/// set (override with --target-os).
public var applyIOS27 = false
/// Opt-in Frida Stalker kernel relaxations (exposed as `--frida`). Baseline
/// JB/EXP firmware is byte-identical when false.
public var applyFrida = false
public func findAll() throws -> [PatchRecord] {
try parseMachO()
buildADRPIndex()
@@ -67,6 +73,13 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher {
patchVmFaultEnterPrepare()
patchVmMapProtect()
// Opt-in Frida Stalker support (--frida): existing-thread follow
// (thread_set_state) + repeated VM_PROT_COPY overwrite (vm_map_delete).
if applyFrida {
patchThreadSetStateEntitlementFlag()
patchVmMapDeleteImmutableCode()
}
// Group C
patchCredLabelUpdateExecve()
patchHookCredLabelUpdateExecve()