diff --git a/research/0_binary_patch_comparison.md b/research/0_binary_patch_comparison.md index d0de526..face339 100644 --- a/research/0_binary_patch_comparison.md +++ b/research/0_binary_patch_comparison.md @@ -126,6 +126,7 @@ | ----- | ----- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------: | | JB-01 | A | `patch_amfi_cdhash_in_trustcache` | `AMFIIsCDHashInTrustCache` | Always return true + store hash | Y | | JB-02 | A | `patch_amfi_execve_kill_path` | AMFI execve kill return site | Convert shared kill return from deny to allow (superseded by C21; standalone only) | N | +| JB-02b| C | `patch_exec_security_policy_kill` | XNU exec `imgp->ip_mac_return` gate (kern_exec, `os_reason_create(OS_REASON_EXEC, EXEC_EXIT_REASON_SECURITY_POLICY)` site) | Flip `cbz wN, ` → unconditional `b ` so the exec-time MAC-verdict `SECURITY_POLICY` kill is unreachable. **Needed to run a userland NEWER than the kernel (iOS 27.0 on the 26.4 kernel):** AMFI's exec hooks reject the newer binaries' code-sign validation category, setting `ip_mac_return != 0` → core daemons (backboardd/cfprefsd/containermanagerd/…) die at exec (`namespace 9 / code 0x8`) → boot deadlock. Validated on 27.0/26.4: 0 SECURITY_POLICY kills, daemons launch, networking + SSH come up. No-op-in-effect for version-matched userlands (ip_mac_return == 0 there, so the original cbz already skips). | Y | | JB-03 | C | `patch_cred_label_update_execve` | `_cred_label_update_execve` | Reworked C21-v3: C21-v1 already boots; v3 keeps split late exits and additionally ORs success-only helper bits `0xC` after clearing `0x3F00`; still disabled pending boot validation | N | | JB-04 | C | `patch_hook_cred_label_update_execve` | sandbox `mpo_cred_label_update_execve` wrapper (`ops[18]` -> `sub_FFFFFE00093BDB64`) | Faithful upstream C23 trampoline: copy `VSUID`/`VSGID` owner state into pending cred, set `P_SUGID`, then branch back to wrapper | Y | | JB-05 | C | `patch_kcall10` | `sysent[439]` (`SYS_kas_info` replacement) | Rebuilt ABI-correct kcall cave: `target + 7 args -> uint64 x0`; re-enabled after focused dry-run validation | Y | diff --git a/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchExecPolicyKill.swift b/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchExecPolicyKill.swift new file mode 100644 index 0000000..fb21162 --- /dev/null +++ b/sources/FirmwarePatcher/Kernel/JBPatches/KernelJBPatchExecPolicyKill.swift @@ -0,0 +1,101 @@ +// KernelJBPatchExecPolicyKill.swift — JB kernel patch: neutralize the exec-time +// MAC-verdict (ip_mac_return) security-policy kill. +// +// After the MAC exec hooks run, XNU's exec path (kern_exec.c) checks: +// +// if (imgp->ip_mac_return != 0) { +// ... os_reason_create(OS_REASON_EXEC, EXEC_EXIT_REASON_SECURITY_POLICY); +// error = imgp->ip_mac_return; +// goto done; // SIGKILL the new process at exec +// } +// +// When running a userland NEWER than the vphone600 kernel (e.g. iOS 27.0 on the +// 26.4 kernel), AMFI's exec hooks reject the newer binaries' code-sign validation +// category, setting ip_mac_return != 0. Every core platform daemon (backboardd, +// cfprefsd, containermanagerd, ...) then dies at exec with +// EXEC_EXIT_REASON_SECURITY_POLICY (namespace 9 / code 0x8), launchd throttles the +// respawns, and the boot deadlocks (all CPUs idle) before SpringBoard/UI. +// +// Flip the `cbz wN, ` guard immediately preceding the reason-create call to +// an unconditional `b `, so the kill block is unreachable. Safe for +// version-matched userlands too: there ip_mac_return is 0, so the original cbz +// already branches to — the unconditional b is behaviourally identical. +// +// Anchor (structural, no hardcoded offsets): the +// `os_reason_create(OS_REASON_EXEC=9, EXEC_EXIT_REASON_SECURITY_POLICY=8)` call — +// two adjacent `movz w0,#9 ; movz w1,#8` — preceded by +// `ldr wN,[xM,#imm] ; cbz wN, `. The ip_mac_return site uses a W-register +// cbz (distinguishing it from the sibling subsystem-root reject site, which cbz's +// an X register). + +import Foundation + +extension KernelJBPatcher { + @discardableResult + func patchExecSecurityPolicyKill() -> Bool { + log("\n[JB] exec ip_mac_return SECURITY_POLICY kill: cbz -> b (allow)") + + guard let (ks, ke) = kernTextRange else { + log(" [-] no kernel text range") + return false + } + + let movzW0_9: UInt32 = 0x5280_0120 // movz w0, #9 (OS_REASON_EXEC) + let movzW1_8: UInt32 = 0x5280_0101 // movz w1, #8 (EXEC_EXIT_REASON_SECURITY_POLICY) + + var hits: [Int] = [] + var off = ks + while off + 8 <= ke { + if buffer.readU32(at: off) == movzW0_9, buffer.readU32(at: off + 4) == movzW1_8 { + let cbzOff = off - 4 + let ldrOff = off - 8 + if cbzOff >= ks, + let cbz = disasAt(cbzOff), cbz.mnemonic == "cbz", + let ldr = disasAt(ldrOff), ldr.mnemonic == "ldr", + // W-register cbz == the ip_mac_return site (not the X-register + // subsystem-root sibling). + cbz.operandString.hasPrefix("w"), + ldr.operandString.hasPrefix("w") + { + // Decode the cbz's forward branch target (imm19 << 2). + let word = buffer.readU32(at: cbzOff) + let imm19 = Int((word >> 5) & 0x7FFFF) + let signed = imm19 >= (1 << 18) ? imm19 - (1 << 19) : imm19 + let target = cbzOff + signed * 4 + // Must be a forward branch that skips the reason-create/kill block. + if target > off + 8 { + hits.append(cbzOff) + } + } + } + off += 4 + } + + guard hits.count == 1 else { + log(" [-] exec ip_mac_return kill guard not found uniquely (found \(hits.count))") + return false + } + + let cbzOff = hits[0] + // Re-decode the target for the emitted branch. + let word = buffer.readU32(at: cbzOff) + let imm19 = Int((word >> 5) & 0x7FFFF) + let signed = imm19 >= (1 << 18) ? imm19 - (1 << 19) : imm19 + let target = cbzOff + signed * 4 + + guard let bBytes = ARM64Encoder.encodeB(from: cbzOff, to: target) else { + log(" [-] failed to encode B to 0x\(String(target, radix: 16))") + return false + } + + let va = fileOffsetToVA(cbzOff) + emit( + cbzOff, + bBytes, + patchID: "exec_security_policy_kill", + virtualAddress: va, + description: "cbz -> b [exec ip_mac_return SECURITY_POLICY kill bypass]" + ) + return true + } +} diff --git a/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift b/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift index b2dff04..a56814e 100644 --- a/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift +++ b/sources/FirmwarePatcher/Kernel/KernelJBPatcher.swift @@ -12,6 +12,15 @@ import Foundation public final class KernelJBPatcher: KernelJBPatcherBase, Patcher { public let component = "kernelcache_jb" + /// Gates the iOS-27-only kernel patches. These target an iOS-27 userland running + /// on the 26.4 kernel; on a 26.x base they are unnecessary and some are actively + /// harmful (e.g. the IOMFB SwapEnd size gate would reject 26.x's native 0x588 swap + /// struct → dead display, the 26.5 regression). The pipeline sets this from the + /// iPhone base ProductVersion (false for 18.x/26.x → byte-identical to pre-branch); + /// standalone patch-component defaults it true so the dev tool exercises the full + /// set (override with --target-os). + public var applyIOS27 = false + public func findAll() throws -> [PatchRecord] { try parseMachO() buildADRPIndex() @@ -49,6 +58,13 @@ public final class KernelJBPatcher: KernelJBPatcherBase, Patcher { patchKcall10() patchSyscallmaskApplyToProc() + // Neutralize the exec-time ip_mac_return SECURITY_POLICY kill so a userland + // newer than the kernel (iOS 27 on the 26.4 kernel) can launch: AMFI's exec + // hooks reject the newer binaries' validation category → ip_mac_return != 0 → + // core daemons (backboardd, cfprefsd, ...) die at exec → boot deadlock. + // No-op-in-effect for version-matched userlands (ip_mac_return == 0 there). + patchExecSecurityPolicyKill() + return patches } diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index a4dc0fd..63da029 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -87,6 +87,11 @@ public final class FirmwarePipeline { /// Computed in `patchAll()` before `buildComponentList()` runs. private var iosBaseIs18 = false + /// Set when the iPhone base is iOS 27.x. Gates the iOS-27-only JB kernel patches + /// (KernelJBPatcher.applyIOS27); false for 18.x/26.x so those bases are + /// byte-identical to pre-branch. Computed in `patchAll()` alongside iosBaseIs18. + private var iosBaseIs27 = false + // MARK: - Init public init( @@ -122,7 +127,10 @@ public final class FirmwarePipeline { // version, not the base). iOS 18 bases need the EXC_GUARD patch. let baseVersion = Self.readBaseProductVersion(restoreDir) iosBaseIs18 = baseVersion?.hasPrefix("18.") ?? false - log("[*] iPhone base iOS: \(baseVersion ?? "unknown")\(iosBaseIs18 ? " (enabling iOS-18 EXC_GUARD kernel patch)" : "")") + iosBaseIs27 = baseVersion?.hasPrefix("27.") ?? false + let baseGateNote = iosBaseIs18 ? " (enabling iOS-18 EXC_GUARD kernel patch)" + : iosBaseIs27 ? " (enabling iOS-27 JB kernel patches)" : "" + log("[*] iPhone base iOS: \(baseVersion ?? "unknown")\(baseGateNote)") let components = buildComponentList() log("[*] Patching \(components.count) boot-chain components ...") @@ -196,6 +204,10 @@ public final class FirmwarePipeline { // capturing self). True only for iOS 18 bases; gates the EXC_GUARD patch. let applyExcGuard = iosBaseIs18 + // Same capture-by-value; true only for iOS 27 bases. Gates the iOS-27-only + // JB kernel patches so 18.x/26.x bases apply none of them. + let applyIOS27 = iosBaseIs27 + // iOS 18 bases: disable the skywalk flowswitch netagents via boot-arg so // Network.framework uses the BSD path (the 26.1-kernel skywalk // channel-create traps in the 18.x Network.framework and crash-loops @@ -315,7 +327,9 @@ public final class FirmwarePipeline { KernelPatcher(data: data, verbose: verbose, isDev: false, applyExcGuard: applyExcGuard) }, { data, verbose in - KernelJBPatcher(data: data, verbose: verbose) + let p = KernelJBPatcher(data: data, verbose: verbose) + p.applyIOS27 = applyIOS27 + return p }, ] case .exp: @@ -324,7 +338,9 @@ public final class FirmwarePipeline { KernelPatcher(data: data, verbose: verbose, isDev: false, applyExcGuard: applyExcGuard) }, { data, verbose in - KernelJBPatcher(data: data, verbose: verbose) + let p = KernelJBPatcher(data: data, verbose: verbose) + p.applyIOS27 = applyIOS27 + return p }, { data, verbose in KernelEXPPatcher(data: data, verbose: verbose) diff --git a/sources/vphone-cli/VPhoneCLI.swift b/sources/vphone-cli/VPhoneCLI.swift index 0468f56..9a5cff7 100644 --- a/sources/vphone-cli/VPhoneCLI.swift +++ b/sources/vphone-cli/VPhoneCLI.swift @@ -238,6 +238,12 @@ struct PatchComponentCLI: ParsableCommand { ) var recordsOut: String? + @Option( + name: .customLong("target-os"), + help: "kernel-jb only: base iOS version the kernel will run under (e.g. 27.0). Gates the iOS-27-only JB patches exactly as the pipeline does. Omit to apply the full set (dev/test default)." + ) + var targetOS: String? + mutating func run() throws { let payload = try IM4PHandler.load(contentsOf: input).payload let count: Int @@ -262,6 +268,11 @@ struct PatchComponentCLI: ParsableCommand { // KernelJBPatcher standalone faithfully reproduces JB hook behavior // without the base patcher or the rest of the boot chain. let patcher = KernelJBPatcher(data: payload, verbose: !quiet) + // Mirror the pipeline's per-base gating: apply the iOS-27-only patches when + // --target-os is 27.x, skip them for an explicit non-27 target. With no + // --target-os, default to applying them so the dev/test tool exercises the + // full set. + patcher.applyIOS27 = targetOS.map { $0.hasPrefix("27.") } ?? true count = try patcher.apply() patchedData = patcher.buffer.data records = patcher.patches