mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
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:
committed by
zqxwce
co-authored by
Claude Fable 5
parent
d3b873c777
commit
fdf9487bfd
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user