PatchRecord 记账系统:字节级补丁日志如何守住可审计底线
【免费下载链接】vphone-cli项目地址: https://gitcode.com/GitHub_Trending/vp/vphone-cli
在 macOS 上把一台真实的 iPhone 系统跑成虚拟机,意味着要在一夜之间改掉引导链上的每一个环节——AVPBooter、iBSS、iBEC、LLB、TXM、内核缓存、设备树,再到用户态共享缓存里的某个函数。这不是「把某几个字节 NOP 掉」的体力活,而是一个随时可能踩爆签名校验、PAC 指针和 TXM 逐页哈希的系统工程。任何一个错误字节的后果都是「虚拟机直接无法启动」,没有任何调试 shell 可进。vphone-cli 能持续迭代至今,靠的正是底层那套被称为 PatchRecord 的补丁记账系统——它把每一次字节写入都变成一条可审计、可对账、可复现的记录。
社区对 vphone-cli 的关注大多停留在「虚拟 iPhone 能不能跑」的层面,但真正支撑其工程可信度的,是这套字节级补丁日志设计:字节偏移追踪、双模记录、三阶段补丁流程、跨语言对账与 JSON 审计导出。本文从仓库源码出发,拆解这套记账系统如何在每一处细节上守住「可审计」的底线。
字节偏移追踪:fileOffset 与 virtualAddress 双地址
补丁日志的第一道门槛,是把「改了什么」这件事说得足够精确。在 PatchRecord.swift 中,每一条补丁记录被定义为一个 Codable 结构体,字段设计几乎就是一个「审计清单」:
public struct PatchRecord: Codable, Equatable, Sendable { /// Unique patch identifier (e.g., "kernel-boot-bsd_init_rootvp"). public let patchID: String /// Component being patched (e.g., "kernelcache", "ibss", "txm"). public let component: String /// File offset where the patch is applied. public let fileOffset: Int /// Virtual address (if applicable, nil for raw binaries). public let virtualAddress: UInt64? /// Original bytes before patching. public let originalBytes: Data /// Replacement bytes after patching. public let patchedBytes: Data /// Capstone disassembly of original bytes. public let beforeDisasm: String /// Capstone disassembly of patched bytes. public let afterDisasm: String /// Human-readable description of what this patch does. public let patchDescription: String }这里最值得注意的,是fileOffset与virtualAddress这对双地址设计。固件二进制与 Mach-O 的差异在于:文件偏移是「补丁落纸面」的位置,虚拟地址是「补丁在运行期」的位置,两者由段表换算。在 KernelPatcherBase.swift 中可以看到完整的换算工具:
/// Convert file offset to virtual address. public func fileOffsetToVA(_ offset: Int) -> UInt64? { for seg in segments { let segStart = Int(seg.fileOffset) let segEnd = segStart + Int(seg.fileSize) if offset >= segStart, offset < segEnd { return seg.vmAddr + UInt64(offset - segStart) } } return nil }PatchRecord的description输出同时呈现两个坐标,让审计者既能在磁盘上定位字节,也能在调试器中对照虚拟地址:
let addr = virtualAddress.map { String(format: " (VA 0x%llX)", $0) } ?? "" return String( format: " 0x%06X%@: %@ → %@ [%@]", fileOffset, addr, beforeDisasm.isEmpty ? originalBytes.hex : beforeDisasm, afterDisasm.isEmpty ? patchedBytes.hex : afterDisasm, patchID, )fileOffset: Int与virtualAddress: UInt64?的分工也照顾了两类二进制:裸的 AVPBooter/iBSS 没有虚拟地址概念,virtualAddress取 nil;kernelcache 这类 Mach-O 则两者都填。这比「只记偏移」或「只记符号」都更经得起推敲——符号可能因编译变化漂移,字节偏移是唯一不可伪造的坐标。
双模记录:原始字节 + Capstone 反汇编
仅仅记下字节,还不足以回答审计者最关心的两个问题:这里原来是什么、改完变成什么语义。PatchRecord 为此同时保存了字节与反汇编两套视图——originalBytes/patchedBytes提供机器级的精确指纹,beforeDisasm/afterDisasm提供人可读的指令级解释。
反汇编来自 PatchKit 内封装的 Capstone AArch64 反汇编器。在 ARM64Disassembler.swift 中,disassembleOne(in:at:)从缓冲区按文件偏移取出 4 字节指令并反汇编:
public func disassembleOne(in buffer: Data, at offset: Int, address: UInt64? = nil) -> ARM64Instruction? { guard offset >= 0, offset + 4 <= buffer.count else { return nil } let slice = buffer[offset ..< offset + 4] let addr = address ?? UInt64(offset) return disassembleOne(Data(slice), at: addr) }KernelPatcherBase 的emit()在写入前、写入后各做一次反汇编,把两条指令都封进记录:
let beforeInsn = disasm.disassembleOne(in: buffer.original, at: offset) let afterInsn = disasm.disassembleOne(patchBytes, at: UInt64(offset)) let beforeStr = beforeInsn.map { "\($0.mnemonic) \($0.operandString)" } ?? "???" let afterStr = afterInsn.map { "\($0.mnemonic) \($0.operandString)" } ?? "???"字节与反汇编的对照,让「改了什么」一目了然:bl hash_cmp → mov x0, #0、tbnz w8, #5 → nop、mov w6, #0x201 → mov w6, #0x1。字节负责精确,反汇编负责语义,两者互为校验——这正是「双模记录」的价值。
值得一提的是ARM64Disassembler启用了CS_OPT_DETAIL,指令的操作数类型(寄存器/立即数/内存)随记录一起解码,审计时可以据此判断一条补丁到底是改寄存器宽度、改条件分支还是改内存访问——这对核对「语义等价」的补丁尤为关键。
三阶段补丁流程:探测 / 执行 / 回写
PatchRecord 不只是「记日志」,它嵌入了补丁执行的完整流程。Patcher协议把每个补丁器约束为两个正交的阶段(见 PatcherProtocol.swift):
public protocol Patcher { /// Find all patch sites and return patch records (dry-run mode). func findAll() throws -> [PatchRecord] /// Apply all patches to the buffer. Returns the number of patches applied. @discardableResult func apply() throws -> Int }findAll()是纯探测:它跑完全部锚点扫描逻辑,但不写任何字节,只产出 PatchRecord 列表——这是天然的 dry-run 模式。apply()才真正执行写入。而在流水线层面,FirmwarePipeline.swift 把流程组织成了「加载原始文件 → 逐组件探测 → 逐组件执行 → 回写保存」:
let records = try patcher.findAll() guard !records.isEmpty else { guard !expectsPatches else { throw PatcherError.patchSiteNotFound("\(componentName): no patches found") } ... } let count = try patcher.apply()三阶段的精髓在于每一条记录都是执行前的产物。KernelPatcherBase.emit()的注释把这一点说得很清楚:
// The gate is consulted before the write, not after: a record dropped // afterwards would leave the bytes patched and nothing saying so. guard gate.allowsReporting(record: patchID, component: "kernelcache", verbose: verbose) else { return }先记后写、先探测后执行,意味着审计与补丁永远同源:不存在「补丁写进去了但日志里没有」的状态。一个补丁器如果探测不到站点,流水线直接抛出patchSiteNotFound拒绝执行——探测阶段就是执行阶段的守门人。
统一记账入口 emit():先记后写
在KernelPatcherBase中,所有内核补丁的写入都收敛到唯一的emit()入口。这个「统一记账」的设计消除了补丁器各行其是的可能——任何一处写入都必须先经过记账:
public func emit( _ offset: Int, _ patchBytes: Data, patchID: String, virtualAddress: UInt64? = nil, description: String, ) { guard gate.allowsReporting(record: patchID, component: "kernelcache", verbose: verbose) else { return } let originalBytes = buffer.readBytes(at: offset, count: patchBytes.count) // Disassemble before/after let beforeInsn = disasm.disassembleOne(in: buffer.original, at: offset) let afterInsn = disasm.disassembleOne(patchBytes, at: UInt64(offset)) ... let record = PatchRecord(...) patches.append(record) buffer.writeBytes(at: offset, bytes: patchBytes) ... }注意写入顺序:先读原始字节、反汇编、构造记录、追加到patches数组,最后才writeBytes。这样即使后续流程抛出异常,已记录的内容也与缓冲区实际状态一致。
统一入口还带来一个副产物:patches数组本身就是「本组件本次运行改了什么」的权威清单。applyPatches()甚至不用再写任何东西——emit()已经把补丁写进了缓冲区,它只需返回记录数量:
/// Number of collected patches. `emit()` already wrote every record through to /// `buffer.data`, so there is nothing left to apply here. public func applyPatches() -> Int { patches.count }TXM、iBSS 等组件的补丁器虽然各自实现emit(),但记账契约完全一致,最终都汇入[PatchRecord]。
patchAll() 全链路汇总:一次运行一份完整账单
真正体现「可审计」的,是流水线把整条引导链的补丁汇聚成一份账单的能力。FirmwarePipeline.patchAll()按固定顺序跑完全部组件,把每个组件的 PatchRecord 合并返回:
public func patchAll() throws -> [PatchRecord] { ... let allRecords = try patchComponents(components, restoreDir: restoreDir, plan: plan) log("\n\(String(repeating: "=", count: 60))") log(" All \(components.count) components processed successfully! (\(allRecords.count) total patches)") return allRecords }管道顺序为AVPBooter → iBSS → iBEC → LLB → TXM → Kernel → DeviceTree,每一环的探测记录按序汇入。patchComponents内部对每个组件执行「加载 → 探测 → 执行 → 回写」:
let rawData = try loader.load(from: sourceURL) log(" format: \(rawData.count) bytes") let (currentData, componentRecords) = try patchData(...) ... allRecords.append(contentsOf: componentRecords)这份全链路账单既可用于人工审计,也可作为自动化验证的输入——一次fw patch运行下来,patchAll()返回的就是覆盖所有组件的完整补丁清单。仓库的集成记录里提到,一次完整的fw patch会产出 191 条记录,覆盖 7 条 EXP 内核补丁与 23 条设备树补丁,这就是这份账单的真实规模。
跨语言对账:Swift 输出 vs Python 参考
PatchRecord 最硬核的用途,是支撑跨语言字节级对账。vphone-cli 的固件补丁器经历了从 Python 到 Swift 的迁移,而迁移的正确性验证不靠「跑得通」,而是靠字节级比对:PatchComparisonTests.swift 把 Swift 补丁器的输出与 Python 时代留下的参考 JSON 逐条比对:
private struct ReferencePatch: Decodable { let file_offset: Int let patch_bytes: String let patch_size: Int let description: String let component: String } if s.fileOffset != r.file_offset || swiftHex != r.patch_bytes { mismatches += 1 print(" ✗ \(component) patch \(i): Swift=0x\(String(format: "%X", s.fileOffset)):\(swiftHex) vs Python=0x\(String(format: "%X", r.file_offset)):\(r.patch_bytes) [\(r.description)]") } #expect(s.fileOffset == r.file_offset, ...) #expect(swiftHex == r.patch_bytes, ...)对账基准是file_offset+patch_bytes——字节偏移与十六进制字节串双重一致,排序后逐条比对。这就是「双模记录」中字节视图的用武之地:反汇编可能因 Capstone 版本差异而措辞不同(如 Capstone 6 省略立即数前的#),但字节永远不会说谎。
参考快照本身也由同一套记账理念管理。patch_reference_capture.md记录了 Python 时代的--emit-records采集机制:捕获是纯观察者,开启时输出与关闭时字节一致;每条记录的站点键是(patchID, component, fileOffset, 源文件路径),重跑捕获会被拒收,因为二次运行会把「自己的输出」误记为「原始字节」。
JSON 审计导出:可机器消费的完整审计轨迹
记账系统的最后一环,是让账单能够导出、存档、比对。CLI 层提供了专门的导出通道:VPhoneCommand.swift 中的patch-firmware和patch-component子命令都支持--records-out参数:
@Option( name: .customLong("records-out"), help: "Optional path to write emitted PatchRecord JSON.", ) var recordsOut: String? mutating func run() throws { ... let records = try pipeline.patchAll() if let recordsOut { let url = URL(fileURLWithPath: recordsOut) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] try encoder.encode(records).write(to: url) print("[patch-firmware] wrote \(records.count) patch records to \(url.path)") } }PatchRecord的Codable一致性在这里兑现:Data字段按 base64 编码,JSONEncoder输出漂亮打印 + 排序键的 JSON 文件。这份 JSON 是机器可消费的完整审计轨迹——每个字段(patchID、component、fileOffset、virtualAddress、originalBytes、patchedBytes、beforeDisasm、afterDisasm、patchDescription)都被保留,既可用于离线审查,也可被 CI 或 Launchpad 的补丁编辑器直接读取。
幂等性与版本门控:可审计的延伸
可审计不等于「每次都能成功复现」——真正的底线是重复运行产生相同结果。PatchRecord 体系把幂等性也纳入了记账范畴。
CustomFirmwareJetsam.swift 记录了一个从 Python 参考继承来的经典教训:Python 的patch_launchd_jetsam并非幂等——二次运行会越过第一次改写过的条件分支,改到返回块里的另一个分支。Swift 端通过「在扫描内识别自己写过的无条件分支」修复了这个问题:
// The fix has to live inside the scan, because on a re-run neither // implementation picks the site it patched before. So the scan collects // unconditional `b`s into a return block as well, and an earlier one of // those means a previous run already did the work.与幂等性并列的是版本门控。补丁声明(VPhonePatchDeclaration.swift)与门控(VPhonePatchGate.swift)把「这条补丁属于哪个组件、适用于哪些 OS 版本」变成结构化数据:
public func covers(recordIdentifier record: String) -> Bool { record == identifier || record.hasPrefix(identifier + ".") }一个声明覆盖其下的多条记录——kernel-boot-kcall10.sy_call及其三个兄弟记录统一受控,既保证「半套补丁无法导致无法启动」,也保证一条记录不会漏过审计。FirmwarePipeline.resolvePlan在写任何字节之前完成 preset 解析与版本门控,把「不适用版本的补丁」挡在流水线之外——这同样是对审计底线的守护:账单里不该出现根本不该存在的记录。
结语
回看 PatchRecord 这套体系,它守住可审计底线的逻辑是层层递进的:双地址坐标让每条补丁在磁盘与内存两个维度上都被唯一锁定;字节 + 反汇编双模记录让改动既精确又可读;探测/执行/回写三阶段让审计先于写入、补丁与日志永远同源;emit() 统一记账消除了绕过记账的旁路;跨语言对账用字节级比对为 Swift 迁移背书;JSON 导出让整条引导链的改动成为可存档、可机器消费的审计轨迹;而幂等性与版本门控则保证每次运行都产生可预期的账本。
对于任何涉及二进制补丁、签名固件或安全敏感的工程而言,这套「先记账、后写入、可对账、可导出」的设计都是值得借鉴的范式——vphone-cli 能够在虚拟 iPhone 这条高危赛道上持续演进,PatchRecord 正是其工程可信度的基石。
【免费下载链接】vphone-cli项目地址: https://gitcode.com/GitHub_Trending/vp/vphone-cli
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考