From 74b6076557ec0b5fc5579b7dc4eaadbdcf480323 Mon Sep 17 00:00:00 2001 From: zqxwce Date: Tue, 11 Aug 2026 17:24:27 +0300 Subject: [PATCH] cli: Wire --frida through the pipeline, gated to cloudOS 26.4+ Expose --frida on patch-firmware, patch-component, fw patch, and vm create (plus `make fw_patch_jb/exp FRIDA=1`), threading it into KernelJBPatcher.applyFrida via FirmwarePipeline. The kernel patches apply only when the cloudOS kernel is 26.4+ (the versions where the shapes were validated); older kernels are left untouched. Baseline JB/EXP output is byte-identical without --frida (26.4 emits 83 records, 87 with). Co-Authored-By: Claude Fable 5 --- Makefile | 14 ++++-- .../Pipeline/FirmwarePipeline.swift | 48 ++++++++++++++++--- sources/vphone-cli/VPhoneCLI.swift | 16 ++++++- .../vphone-cli/VPhoneCreateOrchestrator.swift | 14 ++++-- sources/vphone-cli/VPhoneFWCLI.swift | 4 +- sources/vphone-cli/VPhoneVMCreateCLI.swift | 3 +- 6 files changed, 82 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 89c9b57..7159de5 100644 --- a/Makefile +++ b/Makefile @@ -112,8 +112,10 @@ help: @echo " make fw_patch_dev Patch boot chain with Swift pipeline (dev mode TXM patches)" @echo " make fw_patch_jb Patch boot chain with Swift pipeline (dev + JB extensions)" @echo " Options: FORCE_EXC_GUARD=1 (see fw_patch above)" + @echo " FRIDA=1 Opt in to the Frida Stalker kernel relaxations" @echo " make fw_patch_exp Patch boot chain with Swift pipeline (JB + EXP experimental)" @echo " Options: FORCE_EXC_GUARD=1 (see fw_patch above)" + @echo " FRIDA=1 Opt in to the Frida Stalker kernel relaxations" @echo "" @echo "Testing:" @echo " make test_jb_patches Run all JB kernel patches (incl. Sandbox) over every supported cloudOS kernel" @@ -369,11 +371,13 @@ fw_patch_dev: patcher_build fw_patch_jb: patcher_build "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(VM_DIR_ABS)" --variant jb \ - $(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,) + $(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,) \ + $(if $(filter 1 true yes YES TRUE,$(FRIDA)),--frida,) fw_patch_exp: patcher_build "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(VM_DIR_ABS)" --variant exp \ - $(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,) + $(if $(filter 1 true yes YES TRUE,$(FORCE_EXC_GUARD)),--force-exc-guard,) \ + $(if $(filter 1 true yes YES TRUE,$(FRIDA)),--frida,) .PHONY: test_jb_patches @@ -481,13 +485,13 @@ cfw_install_dev: $(MAKE) cfw_install_host VARIANT=dev cfw_install_jb: - $(MAKE) cfw_install_host VARIANT=jb + $(MAKE) cfw_install_host VARIANT=jb FRIDA="$(FRIDA)" cfw_install_exp: - $(MAKE) cfw_install_host VARIANT=exp SPOOF_BUILD="$(SPOOF_BUILD)" + $(MAKE) cfw_install_host VARIANT=exp SPOOF_BUILD="$(SPOOF_BUILD)" FRIDA="$(FRIDA)" # CFW install: place files via host mount + flip the boot snapshot offline. # VM must be off; re-execs under sudo. # Options: VARIANT=regular|dev|jb|exp (default exp) SPOOF_BUILD= (exp) cfw_install_host: - $(if $(SPOOF_BUILD),SPOOF_BUILD="$(SPOOF_BUILD)") zsh "$(CURDIR)/$(SCRIPTS)/cfw_install_host.sh" --variant $(if $(VARIANT),$(VARIANT),exp) "$(VM_DIR_ABS)" + $(if $(SPOOF_BUILD),SPOOF_BUILD="$(SPOOF_BUILD)") $(if $(filter 1 true yes YES TRUE,$(FRIDA)),VPHONE_FRIDA=1) zsh "$(CURDIR)/$(SCRIPTS)/cfw_install_host.sh" --variant $(if $(VARIANT),$(VARIANT),exp) "$(VM_DIR_ABS)" diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index 431f749..8a53d95 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -81,6 +81,7 @@ public final class FirmwarePipeline { let noBinpack: Bool let noVphoned: Bool let forceExcGuard: Bool + let enableFrida: Bool let loader: any FirmwareLoader /// Set when the iPhone base is iOS 18.x (read from iPhone-BuildManifest.plist). @@ -93,6 +94,9 @@ public final class FirmwarePipeline { /// byte-identical to pre-branch. Computed in `patchAll()` alongside iosBaseIs18. private var iosBaseIs27 = false + /// Set when the cloudOS kernel is 26.4+; gates the opt-in Frida kernel patches. + private var cloudOSIsFridaCapable = false + // MARK: - Init public init( @@ -102,6 +106,7 @@ public final class FirmwarePipeline { noBinpack: Bool = false, noVphoned: Bool = false, forceExcGuard: Bool = false, + enableFrida: Bool = false, loader: (any FirmwareLoader)? = nil ) { self.vmDirectory = vmDirectory @@ -110,6 +115,7 @@ public final class FirmwarePipeline { self.noBinpack = noBinpack self.noVphoned = noVphoned self.forceExcGuard = forceExcGuard + self.enableFrida = enableFrida self.loader = loader ?? ContainerFirmwareLoader() } @@ -135,6 +141,17 @@ public final class FirmwarePipeline { : iosBaseIs27 ? " (enabling iOS-27 JB kernel patches)" : "" log("[*] iPhone base iOS: \(baseVersion ?? "unknown")\(baseGateNote)") + // Frida Stalker kernel patches only apply on cloudOS 26.4+ (where the shapes + // were validated); older kernels are left untouched. The Frida deb install is + // separate and version-independent. + let cloudOSVersion = Self.readCloudOSProductVersion(restoreDir) + cloudOSIsFridaCapable = Self.productVersionAtLeast(cloudOSVersion, 26, 4) + if enableFrida { + log("[*] cloudOS kernel: \(cloudOSVersion ?? "unknown")" + + (cloudOSIsFridaCapable ? " (Frida kernel patches enabled)" + : " (< 26.4 — Frida kernel patches skipped)")) + } + let components = buildComponentList() log("[*] Patching \(components.count) boot-chain components ...") @@ -220,6 +237,9 @@ public final class FirmwarePipeline { // JB kernel patches so 18.x/26.x bases apply none of them. let applyIOS27 = iosBaseIs27 + // Opt-in Frida Stalker kernel relaxations (--frida), gated to cloudOS 26.4+. + let applyFrida = enableFrida && cloudOSIsFridaCapable + // 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 @@ -341,6 +361,7 @@ public final class FirmwarePipeline { { data, verbose in let p = KernelJBPatcher(data: data, verbose: verbose) p.applyIOS27 = applyIOS27 + p.applyFrida = applyFrida return p }, ] @@ -352,6 +373,7 @@ public final class FirmwarePipeline { { data, verbose in let p = KernelJBPatcher(data: data, verbose: verbose) p.applyIOS27 = applyIOS27 + p.applyFrida = applyFrida return p }, { data, verbose in @@ -436,12 +458,9 @@ public final class FirmwarePipeline { return restoreDir } - /// Read the iPhone base `ProductVersion` from `iPhone-BuildManifest.plist` - /// (preserved by fw_prepare before the hybrid manifest overwrites - /// BuildManifest.plist). Returns nil if absent/unreadable — callers then - /// treat the base as non-iOS-18 (conservative). - static func readBaseProductVersion(_ restoreDir: URL) -> String? { - let url = restoreDir.appendingPathComponent("iPhone-BuildManifest.plist") + /// `ProductVersion` from a manifest in `restoreDir`, or nil if absent/unreadable. + static func readProductVersion(_ restoreDir: URL, manifest: String) -> String? { + let url = restoreDir.appendingPathComponent(manifest) guard let data = try? Data(contentsOf: url), let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), let dict = plist as? [String: Any], @@ -450,6 +469,23 @@ public final class FirmwarePipeline { return version } + /// iPhone base version (`iPhone-BuildManifest.plist`, preserved by fw_prepare). + static func readBaseProductVersion(_ restoreDir: URL) -> String? { + readProductVersion(restoreDir, manifest: "iPhone-BuildManifest.plist") + } + + /// cloudOS/kernel version (the live `BuildManifest.plist`). + static func readCloudOSProductVersion(_ restoreDir: URL) -> String? { + readProductVersion(restoreDir, manifest: "BuildManifest.plist") + } + + /// Dotted `ProductVersion` >= major.minor, compared numerically. nil is false. + static func productVersionAtLeast(_ version: String?, _ major: Int, _ minor: Int) -> Bool { + guard let parts = version?.split(separator: ".").compactMap({ Int($0) }), + let vMajor = parts.first else { return false } + return vMajor != major ? vMajor > major : (parts.count > 1 ? parts[1] : 0) >= minor + } + private func compareRestoreDirectories(_ lhs: URL, _ rhs: URL) -> Bool { let leftName = lhs.lastPathComponent let rightName = rhs.lastPathComponent diff --git a/sources/vphone-cli/VPhoneCLI.swift b/sources/vphone-cli/VPhoneCLI.swift index cfc0eb6..166379d 100644 --- a/sources/vphone-cli/VPhoneCLI.swift +++ b/sources/vphone-cli/VPhoneCLI.swift @@ -187,6 +187,12 @@ struct PatchFirmwareCLI: ParsableCommand { ) var forceExcGuard: Bool = false + @Flag( + name: .customLong("frida"), + help: "Opt in to Frida Stalker kernel relaxations (existing-thread follow + repeated VM_PROT_COPY). jb/exp only." + ) + var frida: Bool = false + mutating func run() throws { let pipeline = FirmwarePipeline( vmDirectory: vmDirectory, @@ -194,7 +200,8 @@ struct PatchFirmwareCLI: ParsableCommand { verbose: !quiet, noBinpack: noBinpack, noVphoned: noVphoned, - forceExcGuard: forceExcGuard + forceExcGuard: forceExcGuard, + enableFrida: frida ) let records = try pipeline.patchAll() @@ -259,6 +266,12 @@ struct PatchComponentCLI: ParsableCommand { ) var targetOS: String? + @Flag( + name: .customLong("frida"), + help: "kernel-jb only: opt in to the Frida Stalker kernel relaxations." + ) + var frida: Bool = false + mutating func run() throws { let payload = try IM4PHandler.load(contentsOf: input).payload let count: Int @@ -288,6 +301,7 @@ struct PatchComponentCLI: ParsableCommand { // --target-os, default to applying them so the dev/test tool exercises the // full set. patcher.applyIOS27 = targetOS.map { $0.hasPrefix("27.") } ?? true + patcher.applyFrida = frida count = try patcher.apply() patchedData = patcher.buffer.data records = patcher.patches diff --git a/sources/vphone-cli/VPhoneCreateOrchestrator.swift b/sources/vphone-cli/VPhoneCreateOrchestrator.swift index dbe10a1..da0e77f 100644 --- a/sources/vphone-cli/VPhoneCreateOrchestrator.swift +++ b/sources/vphone-cli/VPhoneCreateOrchestrator.swift @@ -99,6 +99,7 @@ public struct VPhoneCreateOrchestrator { public var sudoPassword: String? public var spoofBuild: String? public var forceDSCMaxSlide: Bool + public var enableFrida: Bool public var rootPopup: Bool public var interactive: Bool public var cpuCount: UInt @@ -115,6 +116,7 @@ public struct VPhoneCreateOrchestrator { sudoPassword: String? = nil, spoofBuild: String? = nil, forceDSCMaxSlide: Bool = false, + enableFrida: Bool = false, rootPopup: Bool = false, interactive: Bool = false, cpuCount: UInt = 8, @@ -130,6 +132,7 @@ public struct VPhoneCreateOrchestrator { self.sudoPassword = sudoPassword self.spoofBuild = spoofBuild self.forceDSCMaxSlide = forceDSCMaxSlide + self.enableFrida = enableFrida self.rootPopup = rootPopup self.interactive = interactive self.cpuCount = cpuCount @@ -209,7 +212,9 @@ public struct VPhoneCreateOrchestrator { try runFWPrepare(options: options, isLess: isLess, bundleURL: bundleURL) print("\n=== fw patch ===") - try runFWPatch(variant: variantOption, isLess: isLess, bundleURL: bundleURL, verbosity: v) + try runFWPatch( + variant: variantOption, isLess: isLess, enableFrida: options.enableFrida, + bundleURL: bundleURL, verbosity: v) print("\n=== Restore phase ===") try runRestorePhase(bundleURL: bundleURL, verbosity: v) @@ -326,7 +331,8 @@ public struct VPhoneCreateOrchestrator { } private func runFWPatch( - variant: PatchFirmwareCLI.VariantOption, isLess: Bool, bundleURL: URL, verbosity v: VPhoneVerbosity + variant: PatchFirmwareCLI.VariantOption, isLess: Bool, enableFrida: Bool, + bundleURL: URL, verbosity v: VPhoneVerbosity ) throws { // Mirrors the Makefile's `ifeq ($(UID),0)` gate on `fw_patch_less` — // only the `less` variant requires root. @@ -343,7 +349,8 @@ public struct VPhoneCreateOrchestrator { trace("in-process FirmwarePipeline.patchAll variant=\(variant.rawValue)", v) let pipeline = FirmwarePipeline( vmDirectory: bundleURL, variant: variant.pipelineVariant, verbose: v.showsToolDetail, - noBinpack: false, noVphoned: false, forceExcGuard: false) + noBinpack: false, noVphoned: false, forceExcGuard: false, + enableFrida: enableFrida) let records = try pipeline.patchAll() print("[fw patch] applied \(records.count) patches for \(variant.rawValue)") } @@ -490,6 +497,7 @@ public struct VPhoneCreateOrchestrator { ] if let spoofBuild = options.spoofBuild { scriptEnv["SPOOF_BUILD"] = spoofBuild } if options.forceDSCMaxSlide { scriptEnv["FORCE_DSC_MAXSLIDE"] = "1" } + if options.enableFrida { scriptEnv["VPHONE_FRIDA"] = "1" } if options.keepArtifacts { scriptEnv["VPHONE_KEEP_ARTIFACTS"] = "1" } let args = [resources.cfwInstallHostScript.path, "--variant", options.variant, bundleURL.path] diff --git a/sources/vphone-cli/VPhoneFWCLI.swift b/sources/vphone-cli/VPhoneFWCLI.swift index 3588778..c33c7ac 100644 --- a/sources/vphone-cli/VPhoneFWCLI.swift +++ b/sources/vphone-cli/VPhoneFWCLI.swift @@ -95,6 +95,7 @@ struct VPhoneFWPatchCommand: ParsableCommand { @Argument(help: "VM name") var name: String? @Option(name: [.customShort("V"), .long], help: "variant: regular | dev | jb | exp | less") var variant: PatchFirmwareCLI.VariantOption = .regular @Flag(name: .customLong("force-exc-guard"), help: "Force the EXC_GUARD disable patch") var forceExcGuard = false + @Flag(name: .customLong("frida"), help: "Opt in to Frida Stalker kernel relaxations (jb/exp only)") var frida = false @Flag(name: .shortAndLong, help: "Suppress per-component progress") var quiet = false func run() throws { @@ -114,7 +115,8 @@ struct VPhoneFWPatchCommand: ParsableCommand { verbose: !quiet, noBinpack: false, noVphoned: false, - forceExcGuard: forceExcGuard) + forceExcGuard: forceExcGuard, + enableFrida: frida) let records = try pipeline.patchAll() print("[fw patch] applied \(records.count) patches for \(variant.rawValue)") } diff --git a/sources/vphone-cli/VPhoneVMCreateCLI.swift b/sources/vphone-cli/VPhoneVMCreateCLI.swift index 1fe8758..029e305 100644 --- a/sources/vphone-cli/VPhoneVMCreateCLI.swift +++ b/sources/vphone-cli/VPhoneVMCreateCLI.swift @@ -21,6 +21,7 @@ struct VPhoneVMCreateCommand: ParsableCommand { var sudoPassword: String? @Option(name: [.customShort("b"), .long], help: "(exp only) rewrite ProductBuildVersion to this build id") var spoofBuild: String? @Flag(name: .customLong("force-dsc-maxslide"), help: "Zero the dyld cache maxSlide on non-27 bases (opt-in DSC-map fit)") var forceDSCMaxSlide = false + @Flag(name: .customLong("frida"), help: "Opt in to Frida Stalker support: install re.frida.server (latest GitHub release) + jb/exp kernel relaxations") var frida = false @Flag(name: .customLong("root-popup"), help: "Elevate the CFW host-mount via macOS's native authentication dialog (osascript) instead of a sudo prompt") var rootPopup = false @Flag(help: "Prompt at first-boot stages instead of running non-interactively") var interactive = false @Flag(name: .customLong("keep-artifacts"), help: "Keep intermediate build artifacts (built restore firmware, extracted base-IPSW caches, extracted CFW input dirs) instead of removing them after use. Source archives (.ipsw / .tar.zst) are always kept.") @@ -41,7 +42,7 @@ struct VPhoneVMCreateCommand: ParsableCommand { name: name, variant: variant, iphoneSource: sources.iphoneSource, cloudosSource: sources.cloudosSource, sudoPassword: sudoPassword, spoofBuild: spoofBuild, forceDSCMaxSlide: forceDSCMaxSlide, - rootPopup: rootPopup, + enableFrida: frida, rootPopup: rootPopup, interactive: interactive, diskSizeGB: diskSize, verbosity: VPhoneVerbosity(count: verboseCount), keepArtifacts: keepArtifacts))