Files
vphone-cli/VPhoneExecutable/VPhoneCommand/FirmwarePatcher/DyldSharedCache/Patchers/DyldSharedCacheLSDEmbeddedRegPatcher.swift
T
LakrandClaude Opus 5.5 ae5e4531a5 Name every patch {component}-{effect}-{name}
The 117 bundled patch identifiers had grown five naming schemes
(kernel.x, jb.x, kernelcache_jb.x, txm_dev.x, bare names). Each one is now
{component}-{effect}-{name}:

- component: avpbooter, ibss, ibec, llb, txm, kernel, devicetree, dyld,
  preboot, or system-<binary> for a guest binary or file.
- effect: boot when the patch is boot-essential, exp when the standard
  preset leaves it off, cfw otherwise. A catalog test enforces this.
- name: snake_case, no hyphen, so the identifier splits from the right.

Record sites are now always <identifier>.<site>. The underscore-prefix
rule in covers(recordIdentifier:) and in the gate is gone: the new names
contain underscores, so kernel-boot-post_validation would otherwise have
covered kernel-boot-post_validation_unsigned. The 25 records that relied
on it (amfi_trustcache_1, launch_constraints_mov, sandbox_ext_N, ...) now
use a dot.

Old identifiers are not migrated. A VM whose PatchPlan or PatchSelection
names one must be patched again. The bundle becomes 2.2.0 and Launchpad
requires 2.2.0, so it never meets an old identifier from a bundle.

Launchpad's patch table shows Component, Effect and Name columns in place
of Identifier and Patch Set; the set moves to the detail line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:20:20 +09:00

366 lines
16 KiB
Swift

// DyldSharedCacheLSDEmbeddedRegPatcher.swift — open lsd's embedded-registration gate.
//
// Swift port of `scripts/patchers/cfw_patch_lsd_embedded_reg.py`, driven by
// `cfw.py patch-lsd-embedded-reg <chunks_dir> [--dry-run]`.
//
// iOS 27's lsd gates `-[_LSDModifyClient performPostInstallationRegistration:…]`
// — and its containerized/rebuild siblings — behind
// `-[_LSDModifyClient clientIsEntitledForEmbeddedRegistrationOperations]`, which
// calls `xpc_connection_copy_entitlement_value` on the XPC *peer* for any of
// three privileged entitlements (`com.apple.private.coreservices.lsaw`,
// `com.apple.private.installcoordinationd.daemon`,
// `com.apple.private.coreservices.can-register-install-results`). A client
// without one gets `NSOSStatusErrorDomain -54` (permErr) out of
// `LSDModifyService.mm`, so `registerApplicationDictionary:` returns NO. That
// blocks JB app registration (uicache/Sileo), vphoned's IPA installer and
// TrollStore alike.
//
// The entitlement route is a dead end on this stack: even a launchd-spawned
// platform daemon (vphoned) carrying all three in its *validated* csblob
// (`csops CS_OPS_ENTITLEMENTS_BLOB` confirms) is still refused, because LS
// registration is proxied — the XPC peer lsd inspects is not the registering
// process.
//
// So the check is forced to succeed. The method ORs three entitlement probes:
//
// bl <check ent1> ; cbnz w0, entitled
// bl <check ent2> ; cbnz w0, entitled
// bl <check ent3> ; cbz w0, not_entitled <- the gate; this is NOP'd
// entitled:
// mov w20, #1 <- the YES result
// … ; mov x0, x20 ; retab
// not_entitled:
// mov w20, #0
//
// NOP'ing that final `cbz w0, <not_entitled>` — the conditional branch whose
// fall-through is the `mov w<reg>, #1` that becomes the return value — makes
// every path reach YES. One instruction, one site.
//
// Nothing is hardcoded. The method is resolved from the cache's own
// `.symbols` local-symbol table, disassembled with Capstone, and the gate is
// found by control-flow shape rather than by address. The replacement is
// `ARM64.nop`. Writing a cache page invalidates its 16 KiB code slot, so the
// page is re-attested (TXM enforces per-page); the CDHash change that follows
// is accepted by the JB's always-true AMFI cdhash-trust patch.
import Foundation
import VPhonePatchKit
/// Forces `-[_LSDModifyClient clientIsEntitledForEmbeddedRegistrationOperations]`
/// to return YES, by NOP'ing the branch that skips its YES result.
public enum DyldSharedCacheLSDEmbeddedRegPatcher {
/// The image the method lives in. Reported only — the symbol is resolved
/// through the cache-wide local table, exactly as the Python does, because
/// `ipsw dyld symaddr -a` times out on a cache this size.
public static let image =
"/System/Library/Frameworks/CoreServices.framework/CoreServices"
/// The ObjC method whose entitlement check is opened.
public static let method =
"-[_LSDModifyClient clientIsEntitledForEmbeddedRegistrationOperations]"
/// Record identity, matching the Python's `records.next_site` label so a
/// captured reference and this port sort together.
public static let patchID = "dyld-boot-lsd_embedded_reg"
/// How far into the method to look. The gate sits within the first handful
/// of basic blocks; 96 instructions is the Python's window and is ample.
static let maxInstructions = 96
// MARK: - Results
/// The conditional branch (or the NOP that already replaced it) that guards
/// the method's YES result.
public struct Gate: Sendable, Equatable {
/// Address of the gating instruction.
public let vma: UInt64
/// Its mnemonic as Capstone decoded it — `cbz`, `cbnz`, or `nop` when
/// a previous run already patched this cache.
public let mnemonic: String
/// Its operand text, for logging.
public let operandString: String
/// The `w` register the fall-through sets to 1 — the YES value that
/// later becomes the method's return.
public let resultRegister: String
/// True when the site already holds this patch's own output.
public var wasAlreadyNOP: Bool {
mnemonic == "nop"
}
/// How the gate reads in disassembly.
public var text: String {
operandString.isEmpty ? mnemonic : "\(mnemonic) \(operandString)"
}
}
/// What a run did.
public enum Outcome: String, Sendable, Equatable {
/// The method is not in this cache — a pre-iOS-27 userland, where the
/// gate does not exist and there is nothing to open.
case methodAbsent
/// The gate already held a NOP; the page was re-attested and nothing
/// else changed.
case alreadyPatched
/// `dryRun` was set, so the site was located and reported only.
case wouldPatch
/// The branch was replaced with a NOP and its page re-attested.
case patched
}
/// The outcome of one run, and the site it acted on.
public struct Report: Sendable {
public let outcome: Outcome
/// The gate, when the method was present.
public let gate: Gate?
/// The write, in the shape the Python's reference capture records it.
/// `nil` unless bytes actually changed.
public let record: PatchRecord?
/// Sites whose bytes this run changed. The parity number: the Python
/// writes exactly one, and so must this.
public var sitesWritten: Int {
record == nil ? 0 : 1
}
/// Whether the gate method exists in this cache at all. Mirrors the
/// Python's return value, which is 0 when absent and 1 otherwise.
public var methodIsPresent: Bool {
outcome != .methodAbsent
}
}
/// Where progress goes when the caller does not say. The Python prints to
/// stdout and `cfw_install*.sh` captures that, so this does too.
public static let stdoutLog: @Sendable (String) -> Void = { print($0) }
// MARK: - Patching
/// Open the gate in the cache under `chunksDirectory`.
///
/// Self-gating, like the Python: the method exists only on iOS 27+
/// LaunchServices, so a cache without it reports `.methodAbsent` and
/// changes nothing. A cache whose `.symbols` side file is *missing*, by
/// contrast, throws — "there is nowhere to look" must not be reported as
/// "this userland predates the gate", or an install against a stripped
/// cache would silently skip the patch and boot into a guest where no app
/// can register.
///
/// - Throws: ``PatcherError/patchSiteNotFound(_:)`` when the method is
/// present but its control-flow shape no longer matches — a LaunchServices
/// rewrite, which has to stop the install rather than be guessed at.
@discardableResult
public static func patch(
chunksDirectory: URL,
dryRun: Bool = false,
log: ((String) -> Void)? = stdoutLog,
) throws -> Report {
let chunks = try DyldSharedCacheChunkSet(directory: chunksDirectory)
log?(" [.] \(chunksDirectory.path): \(chunks.chunkURLs.count) chunk(s), "
+ "\(chunks.mappings.count) mapping(s)")
guard let located = try locateGate(in: chunks) else {
log?(" [=] \(method) not present (pre-iOS-27 userland); nothing to patch")
return Report(outcome: .methodAbsent, gate: nil, record: nil)
}
log?(" [.] \(method) @ 0x\(hex(located.functionVMA))")
let gate = located.gate
log?(" [.] gate: \(gate.text) @ 0x\(hex(gate.vma)) "
+ "(fall-through sets \(gate.resultRegister)=1)")
let nop = ARM64.nop
let original = try chunks.bytesAtVMA(gate.vma, length: nop.count)
let alreadyNOP = original == nop
if alreadyNOP {
log?(" [=] already NOP at 0x\(hex(gate.vma)); re-attesting page only")
} else {
log?(" [+] \(dryRun ? "would NOP" : "NOP'd") gate \(gate.mnemonic) -> nop "
+ "at 0x\(hex(gate.vma)) (bytes \(original.hex) -> \(nop.hex))")
}
guard !dryRun else {
// The Python signs off with "patch complete" here too. This does
// not: a dry run patched nothing, and a log line that says
// otherwise is the one an operator would quote back later.
log?(" [.] dry-run: would re-attest page for 0x\(hex(gate.vma))")
return Report(outcome: .wouldPatch, gate: gate, record: nil)
}
// Written unconditionally, including when the bytes already match. The
// Python re-attests the gate's page on every non-dry run, patched or
// not, and re-attestation here consumes the chunk set's own write log
// rather than an address a caller hands it — so the idempotent case has
// to go through the same door. The bytes on disk are identical either
// way, and `DyldSharedCacheCodeSignature` leaves a page whose stored slot already
// matches alone.
try chunks.write(at: gate.vma, nop)
log?(" [.] re-attesting modified page...")
try DyldSharedCacheCodeSignature.reattestRecordedWrites(in: chunks, log: log)
let written = try chunks.bytesAtVMA(gate.vma, length: nop.count)
guard written == nop else {
throw PatcherError.patchVerificationFailed(
"\(method): gate at 0x\(hex(gate.vma)) reads \(written.hex) after write",
)
}
log?(" [+] lsd embedded-registration gate patch complete")
guard !alreadyNOP else {
return Report(outcome: .alreadyPatched, gate: gate, record: nil)
}
return try Report(
outcome: .patched,
gate: gate,
record: record(for: gate, in: chunks, original: original, patched: nop),
)
}
// MARK: - Locating the gate
/// Find the entitlement gate without touching the cache.
///
/// Returns `nil` when the method is absent, which is the pre-iOS-27 no-op
/// case rather than an error.
public static func locateGate(
in chunks: DyldSharedCacheChunkSet,
) throws -> (functionVMA: UInt64, gate: Gate)? {
guard let functionVMA = try chunks.resolveLocalSymbol(method) else { return nil }
let instructions = try disassembleFunction(in: chunks, at: functionVMA)
guard let gate = findGate(in: instructions) else {
throw PatcherError.patchSiteNotFound(
"\(method): entitled-result gate (cbz/cbnz w0 -> mov w<reg>,#1) not found",
)
}
return (functionVMA, gate)
}
/// Disassemble from `vma` up to the first `ret`/`retab`, or
/// ``maxInstructions``, whichever comes first.
///
/// `allowShort` because the window is a fixed instruction count, not a
/// claim about what is mapped: a method that ends near the tail of its
/// mapping must not turn into an unmapped-span error.
static func disassembleFunction(
in chunks: DyldSharedCacheChunkSet,
at vma: UInt64,
) throws -> [ARM64Instruction] {
let buffer = try chunks.readAtVMA(
vma,
length: maxInstructions * 4,
allowShort: true,
)
let decoded = ARM64Disassembler().disassemble(buffer, at: vma)
var result: [ARM64Instruction] = []
for instruction in decoded {
// The shared disassembler has skip-data on, so a word it cannot
// decode arrives as a data pseudo-instruction rather than ending the
// stream. The Python's `cs.disasm` stops there, and so does this:
// past an undecodable word the window is no longer this function's
// instructions, and matching a "gate" in it would be matching noise.
guard instruction.isDecoded else { break }
result.append(instruction)
if instruction.mnemonic == "ret" || instruction.mnemonic == "retab" {
break
}
}
return result
}
/// The conditional branch that gates the entitled result: the `cbz`/`cbnz`
/// on `w0` whose fall-through is `mov w<reg>, #1`.
///
/// A bare `nop` in that position matches too. That is this patch's own
/// output, so a second run over an already-patched cache recognises the
/// idempotent state instead of failing to find a branch that is no longer
/// there — which is exactly how a re-run of `cfw install` used to die.
static func findGate(in instructions: [ARM64Instruction]) -> Gate? {
let disassembler = ARM64Disassembler()
guard instructions.count >= 2 else { return nil }
for index in 0 ..< (instructions.count - 1) {
let candidate = instructions[index]
switch candidate.mnemonic {
case "cbz", "cbnz":
guard disassembler.firstRegisterName(candidate) == "w0" else { continue }
case "nop":
break
default:
continue
}
guard let next = movRegisterImmediate(instructions[index + 1], disassembler),
next.immediate == 1,
next.register.hasPrefix("w")
else { continue }
return Gate(
vma: candidate.address,
mnemonic: candidate.mnemonic,
operandString: candidate.operandString,
resultRegister: next.register,
)
}
return nil
}
/// `mov <reg>, #<imm>` decomposed, or `nil` when the instruction is
/// something else.
///
/// Matched on decoded operand types — register destination, immediate
/// source — rather than on operand text.
static func movRegisterImmediate(
_ instruction: ARM64Instruction,
_ disassembler: ARM64Disassembler,
) -> (register: String, immediate: Int64)? {
guard instruction.mnemonic == "mov",
let operands = instruction.detail?.operands,
operands.count == 2,
operands[0].type == .register,
operands[1].type == .immediate,
let register = disassembler.firstRegisterName(instruction)
else { return nil }
return (register, operands[1].imm)
}
// MARK: - Recording
/// Describe the write the way `cfw_records.record_span` does: component is
/// the chunk file's basename, the offset is into that chunk, and the
/// virtual address rides along.
private static func record(
for gate: Gate,
in chunks: DyldSharedCacheChunkSet,
original: Data,
patched: Data,
) throws -> PatchRecord {
let span = DyldSharedCacheWriteSpan(vma: gate.vma, length: patched.count)
let (chunkURL, range) = try chunks.fileRange(of: span)
return PatchRecord(
patchID: patchID,
component: chunkURL.lastPathComponent,
fileOffset: range.lowerBound,
virtualAddress: gate.vma,
originalBytes: original,
patchedBytes: patched,
beforeDisasm: disassemblyText(of: original, at: gate.vma),
afterDisasm: disassemblyText(of: patched, at: gate.vma),
description: "NOP `\(gate.text)` in \(method) so the fall-through "
+ "sets \(gate.resultRegister)=1 (entitled)",
)
}
private static func disassemblyText(of bytes: Data, at vma: UInt64) -> String {
ARM64Disassembler()
.disassemble(bytes, at: vma)
.map { $0.operandString.isEmpty ? $0.mnemonic : "\($0.mnemonic) \($0.operandString)" }
.joined(separator: "; ")
}
private static func hex(_ value: UInt64) -> String {
String(value, radix: 16, uppercase: true)
}
}