kernel: jb: Bypass exec ip_mac_return SECURITY_POLICY kill for newer userlands

Running iOS 27.0 userland on the 26.4 vphone600 kernel, every core platform
daemon (backboardd, cfprefsd, containermanagerd, locationd, CommCenter, ...)
died 3-5ms after xpcproxy spawn with exit reason namespace 9 / code 0x8
(OS_REASON_EXEC / EXEC_EXIT_REASON_SECURITY_POLICY). launchd throttled the
respawns and the boot deadlocked (all CPUs idle) before SpringBoard — no UI,
no networking. Root cause: AMFI's exec MAC hooks reject the 27.0 binaries'
code-sign validation category on the 26.4 kernel, setting imgp->ip_mac_return,
and XNU's exec path (kern_exec.c) SIGKILLs on `if (imgp->ip_mac_return != 0)`.

patch_exec_security_policy_kill flips the `cbz wN, <skip>` guard preceding the
os_reason_create(9,8) call to an unconditional `b <skip>`, making the kill block
unreachable — downstream of AMFI/TXM, so it covers the validation-category
reject regardless of which hook set the verdict. Anchored structurally (movz
w0,#9 ; movz w1,#8 preceded by ldr wN,[xM,#imm] ; cbz wN,<fwd>; W-register cbz
distinguishes the ip_mac_return site from the subsystem-root sibling). No-op in
effect for version-matched userlands (ip_mac_return == 0, so the cbz already
skips). Wired into the JB Group C dispatcher.

Validated on iPhone17,3_27.0_24A5380h + cloudOS 26.4 (c0ecdb4b): 0 SECURITY_POLICY
kills, core daemons launch, networking + SSH (dropbear :22222) come up. Remaining
gate: sandbox denies backboardd the IOMobileFramebuffer/IOSurface user clients,
so SpringBoard traps in FBSDisplayMonitor init (no display) -> UI not up yet.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013pk5tsoBeuu3jhkmnRFtic
This commit is contained in:
zqxwce
2026-07-20 16:20:34 +03:00
committed by zqxwce
co-authored by Claude Opus 4.8
parent a38fd3201f
commit 679d3d0476
5 changed files with 148 additions and 3 deletions
+1
View File
@@ -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, <skip>` → unconditional `b <skip>` 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 |
@@ -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, <skip>` guard immediately preceding the reason-create call to
// an unconditional `b <skip>`, 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 <skip> 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, <forward>`. 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
}
}
@@ -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
}
@@ -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)
+11
View File
@@ -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