From cf572fd4d3f2185d32f5f1c3ed060740a8b0f552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:45:41 +0200 Subject: [PATCH 01/11] Disable patches for regular variant --- .../FirmwarePatcher/IBoot/IBootPatcher.swift | 10 ++- .../Pipeline/FirmwarePipeline.swift | 63 ++++++++++++------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/sources/FirmwarePatcher/IBoot/IBootPatcher.swift b/sources/FirmwarePatcher/IBoot/IBootPatcher.swift index 3317f6a..cc20be6 100644 --- a/sources/FirmwarePatcher/IBoot/IBootPatcher.swift +++ b/sources/FirmwarePatcher/IBoot/IBootPatcher.swift @@ -34,6 +34,7 @@ public class IBootPatcher: Patcher { public let component: String public let verbose: Bool + public let onlySerial: Bool let buffer: BinaryBuffer let mode: Mode @@ -42,11 +43,12 @@ public class IBootPatcher: Patcher { // MARK: - Init - public init(data: Data, mode: Mode, verbose: Bool = true) { + public init(data: Data, mode: Mode, verbose: Bool = true, onlySerial: Bool = false) { buffer = BinaryBuffer(data) self.mode = mode component = mode.rawValue self.verbose = verbose + self.onlySerial = onlySerial } // MARK: - Patcher Protocol @@ -55,13 +57,15 @@ public class IBootPatcher: Patcher { patches = [] patchSerialLabels() - patchImage4Callback() + if !onlySerial { + patchImage4Callback() + } if mode == .ibec || mode == .llb { patchBootArgs() } - if mode == .llb { + if !onlySerial && mode == .llb { patchRootfssBypass() patchPanicBypass() } diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index eb96e54..f029268 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -168,9 +168,16 @@ public final class FirmwarePipeline { name: "AVPBooter", inRestoreDir: false, searchPatterns: ["AVPBooter*.bin"], - patcherFactories: [{ data, verbose in - AVPBooterPatcher(data: data, verbose: verbose) - }] + patcherFactories: { + if variant != .regular { + return [ + { data, verbose in + AVPBooterPatcher(data: data, verbose: verbose) + }, + ] + } + return [] + }() )) // 2. iBSS — JB variant runs the base iBSS patcher, then the nonce-skip extension. @@ -179,8 +186,15 @@ public final class FirmwarePipeline { inRestoreDir: true, searchPatterns: ["Firmware/dfu/iBSS.vresearch101.RELEASE.im4p"], patcherFactories: { - if variant == .jb { - return [ + return switch variant { + case .regular: + [] + case .dev: + [{ data, verbose in + IBootPatcher(data: data, mode: .ibss, verbose: verbose) + }] + case .jb: + [ { data, verbose in IBootPatcher(data: data, mode: .ibss, verbose: verbose) }, @@ -189,29 +203,26 @@ public final class FirmwarePipeline { }, ] } - return [{ data, verbose in - IBootPatcher(data: data, mode: .ibss, verbose: verbose) - }] }() )) - // 3. iBEC — same for all variants + // 3. iBEC — In the regular variant, we only want to enable the serial output. components.append(ComponentDescriptor( name: "iBEC", inRestoreDir: true, searchPatterns: ["Firmware/dfu/iBEC.vresearch101.RELEASE.im4p"], patcherFactories: [{ data, verbose in - IBootPatcher(data: data, mode: .ibec, verbose: verbose) + IBootPatcher(data: data, mode: .ibec, verbose: verbose, onlySerial: self.variant == .regular) }] )) - // 4. LLB — same for all variants + // 4. LLB — In the regular variant, we only want to enable the serial output. components.append(ComponentDescriptor( name: "LLB", inRestoreDir: true, searchPatterns: ["Firmware/all_flash/LLB.vresearch101.RELEASE.im4p"], patcherFactories: [{ data, verbose in - IBootPatcher(data: data, mode: .llb, verbose: verbose) + IBootPatcher(data: data, mode: .llb, verbose: verbose, onlySerial: self.variant == .regular) }] )) @@ -220,12 +231,16 @@ public final class FirmwarePipeline { name: "TXM", inRestoreDir: true, searchPatterns: ["Firmware/txm.iphoneos.research.im4p"], - patcherFactories: [{ [variant] data, verbose in - if variant == .dev || variant == .jb { - return TXMDevPatcher(data: data, verbose: verbose) + patcherFactories: { + return switch variant { + case .regular: + [] + case .dev, .jb: + [{ data, verbose in + TXMDevPatcher(data: data, verbose: verbose) + }] } - return TXMPatcher(data: data, verbose: verbose) - }] + }() )) // 6. Kernel — JB variant runs base kernel patches first, then JB extensions. @@ -234,8 +249,15 @@ public final class FirmwarePipeline { inRestoreDir: true, searchPatterns: ["kernelcache.research.vphone600"], patcherFactories: { - if variant == .jb { - return [ + return switch variant { + case .regular: + [] + case .dev: + [{ data, verbose in + KernelPatcher(data: data, verbose: verbose) + }] + case .jb: + [ { data, verbose in KernelPatcher(data: data, verbose: verbose) }, @@ -244,9 +266,6 @@ public final class FirmwarePipeline { }, ] } - return [{ data, verbose in - KernelPatcher(data: data, verbose: verbose) - }] }() )) From 6186b093f1eccf4f28f96672db19111282275312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:45:41 +0200 Subject: [PATCH 02/11] Allow use of default / unpatched AVPBooter --- sources/vphone-cli/VPhoneAppDelegate.swift | 8 ++++---- sources/vphone-cli/VPhoneCLI.swift | 4 ++-- sources/vphone-cli/VPhoneVirtualMachine.swift | 12 ++++++++---- .../vphone-cli/VPhoneVirtualMachineManifest.swift | 4 ++-- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index 5dfd309..980a300 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -46,12 +46,12 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { private func startVirtualMachine() async throws { let options = try cli.resolveOptions() - guard FileManager.default.fileExists(atPath: options.romURL.path) else { - throw VPhoneError.romNotFound(options.romURL.path) + guard options.romURL == nil || FileManager.default.fileExists(atPath: options.romURL!.path) else { + throw VPhoneError.romNotFound(options.romURL!.path) } print("=== vphone-cli ===") - print("ROM : \(options.romURL.path)") + print("ROM : \(options.romURL?.path ?? "None")") print("Disk : \(options.diskURL.path)") print("NVRAM : \(options.nvramURL.path)") print("Config: \(options.configURL.path)") @@ -67,7 +67,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { } print("SEP : enabled") print(" storage : \(options.sepStorageURL.path)") - print(" rom : \(options.sepRomURL.path)") + print(" rom : \(options.sepRomURL?.path ?? "None")") print("") let vm = try VPhoneVirtualMachine(options: options) diff --git a/sources/vphone-cli/VPhoneCLI.swift b/sources/vphone-cli/VPhoneCLI.swift index 7c29932..18124ee 100644 --- a/sources/vphone-cli/VPhoneCLI.swift +++ b/sources/vphone-cli/VPhoneCLI.swift @@ -68,13 +68,13 @@ struct VPhoneBootCLI: ParsableCommand { return VPhoneVirtualMachine.Options( configURL: config, - romURL: manifest.resolve(path: manifest.romImages.avpBooter, in: vmDir), + romURL: manifest.romImages != nil ? manifest.resolve(path: manifest.romImages!.avpBooter, in: vmDir) : nil, nvramURL: manifest.resolve(path: manifest.nvramStorage, in: vmDir), diskURL: manifest.resolve(path: manifest.diskImage, in: vmDir), cpuCount: Int(manifest.cpuCount), memorySize: manifest.memorySize, sepStorageURL: manifest.resolve(path: manifest.sepStorage, in: vmDir), - sepRomURL: manifest.resolve(path: manifest.romImages.avpSEPBooter, in: vmDir), + sepRomURL: manifest.romImages != nil ? manifest.resolve(path: manifest.romImages!.avpSEPBooter, in: vmDir) : nil, screenWidth: manifest.screenConfig.width, screenHeight: manifest.screenConfig.height, screenPPI: manifest.screenConfig.pixelsPerInch, diff --git a/sources/vphone-cli/VPhoneVirtualMachine.swift b/sources/vphone-cli/VPhoneVirtualMachine.swift index b6d6086..fd51c17 100644 --- a/sources/vphone-cli/VPhoneVirtualMachine.swift +++ b/sources/vphone-cli/VPhoneVirtualMachine.swift @@ -15,13 +15,13 @@ class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate { struct Options { var configURL: URL - var romURL: URL + var romURL: URL? var nvramURL: URL var diskURL: URL var cpuCount: Int = 8 var memorySize: UInt64 = 8 * 1024 * 1024 * 1024 var sepStorageURL: URL - var sepRomURL: URL + var sepRomURL: URL? var screenWidth: Int = 1290 var screenHeight: Int = 2796 var screenPPI: Int = 460 @@ -134,7 +134,9 @@ class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate { // --- Boot loader with custom ROM --- let bootloader = VZMacOSBootLoader() - Dynamic(bootloader)._setROMURL(options.romURL) + if let romURL = options.romURL { + Dynamic(bootloader)._setROMURL(romURL) + } // --- VM Configuration --- let config = VZVirtualMachineConfiguration() @@ -250,7 +252,9 @@ class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate { // Coprocessors let sepConfig = Dynamic._VZSEPCoprocessorConfiguration(storageURL: options.sepStorageURL) - sepConfig.setRomBinaryURL(options.sepRomURL) + if let sepRomURL = options.sepRomURL { + sepConfig.setRomBinaryURL(sepRomURL) + } sepConfig.setDebugStub(Dynamic._VZGDBDebugStubConfiguration().asObject) if let sepObj = sepConfig.asObject { Dynamic(config)._setCoprocessors([sepObj]) diff --git a/sources/vphone-cli/VPhoneVirtualMachineManifest.swift b/sources/vphone-cli/VPhoneVirtualMachineManifest.swift index 588fa18..0d8d81b 100644 --- a/sources/vphone-cli/VPhoneVirtualMachineManifest.swift +++ b/sources/vphone-cli/VPhoneVirtualMachineManifest.swift @@ -44,7 +44,7 @@ struct VPhoneVirtualMachineManifest: Codable { // MARK: - ROMs /// ROM image paths - let romImages: ROMImages + let romImages: ROMImages? // MARK: - SEP @@ -107,7 +107,7 @@ struct VPhoneVirtualMachineManifest: Codable { networkConfig: NetworkConfig = .default, diskImage: String = "Disk.img", nvramStorage: String = "nvram.bin", - romImages: ROMImages, + romImages: ROMImages?, sepStorage: String = "SEPStorage" ) { self.platformType = platformType From 6f00aaf7ab2ed84bd9cd00af105263cf62866859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:45:41 +0200 Subject: [PATCH 03/11] Add BuildManifest Hash Update for the regular mode. --- .gitignore | 1 - .../Filesystem/CryptexFilesystemPatcher.swift | 68 ++++++++ .../Manifest/ManifestHashPatcher.swift | 150 ++++++++++++++++++ .../Pipeline/FirmwarePipeline.swift | 19 +++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift create mode 100644 sources/FirmwarePatcher/Manifest/ManifestHashPatcher.swift diff --git a/.gitignore b/.gitignore index 3d74e12..09387eb 100644 --- a/.gitignore +++ b/.gitignore @@ -117,7 +117,6 @@ share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST # PyInstaller # Usually these files are written by a python script from a template diff --git a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift new file mode 100644 index 0000000..dd914cc --- /dev/null +++ b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift @@ -0,0 +1,68 @@ + +// 1. Collect the AppOS and SystemOS Cryptex from the iPhone BuildManifest +// 2. With the OS, AppOS, and SystemOS images, attach them and copy them to a target image +// 3. Create mtree for resulting image +// 4. Download apfs_sealvolume +// 5. Generate digest.db +// 6. Join mtree and digest.db to Ap,SystemVolumeCanonicalMetadata +// 7. Create SystemVolume root_hash + +import Foundation +import CryptoKit +import Img4tool + +/// Patcher for DeviceTree payloads. +public final class CryptexFilesystemPatcher: Patcher { + public let component = "Manifest" + public let restoreDir: URL? + public let verbose: Bool + + let buffer: BinaryBuffer + var patches: [PatchRecord] = [] + var rebuiltData: Data? + + // MARK: - Init + + public init(data: Data, restoreDir: URL?, verbose: Bool = true) { + buffer = BinaryBuffer(data) + self.restoreDir = restoreDir + self.verbose = verbose + } + + // MARK: - Patcher + + public func findAll() throws -> [PatchRecord] { + rebuiltData = nil + let root = try parsePayload(buffer.data) + let newRoot = try applyPatches(buildManifest: root) + rebuiltData = try serializePayload(newRoot) + + patches = [PatchRecord( + patchID: "manifest.hash", + component: "", + fileOffset: 0, + originalBytes: Data(), + patchedBytes: Data(), + description: "Updated the file hashes according to the actual files", + )] + return patches + } + + @discardableResult + public func apply() throws -> Int { + if patches.isEmpty { + let _ = try findAll() + } + if let rebuiltData { + buffer.data = rebuiltData + } else { + throw PatcherError.patchSiteNotFound("ManifestHash") + } + return patches.count + } + + /// Get the patched data. + public var patchedData: Data { + buffer.data + } +} diff --git a/sources/FirmwarePatcher/Manifest/ManifestHashPatcher.swift b/sources/FirmwarePatcher/Manifest/ManifestHashPatcher.swift new file mode 100644 index 0000000..0c69818 --- /dev/null +++ b/sources/FirmwarePatcher/Manifest/ManifestHashPatcher.swift @@ -0,0 +1,150 @@ +// ManifestHashPatcher.swift — ManifestHashPatcher. +// +// Update the hash in the firmware manifest according to the actual hash of the corresponding files. +// + +import Foundation +import CryptoKit +import Img4tool + +/// Patcher for Manifest payloads. +public final class ManifestHashPatcher: Patcher { + public let component = "Manifest" + public let restoreDir: URL? + public let verbose: Bool + + let buffer: BinaryBuffer + var patches: [PatchRecord] = [] + var rebuiltData: Data? + + // MARK: - Init + + public init(data: Data, restoreDir: URL?, verbose: Bool = true) { + buffer = BinaryBuffer(data) + self.restoreDir = restoreDir + self.verbose = verbose + } + + // MARK: - Patcher + + public func findAll() throws -> [PatchRecord] { + rebuiltData = nil + let root = try parsePayload(buffer.data) + let newRoot = try applyPatches(buildManifest: root) + rebuiltData = try serializePayload(newRoot) + + patches = [PatchRecord( + patchID: "manifest.hash", + component: "", + fileOffset: 0, + originalBytes: Data(), + patchedBytes: Data(), + description: "Updated the file hashes according to the actual files", + )] + return patches + } + + @discardableResult + public func apply() throws -> Int { + if patches.isEmpty { + let _ = try findAll() + } + if let rebuiltData { + buffer.data = rebuiltData + } else { + throw PatcherError.patchSiteNotFound("ManifestHash") + } + return patches.count + } + + /// Get the patched data. + public var patchedData: Data { + buffer.data + } + + private func parsePayload(_ blob: Data) throws -> PlistDict { + guard let buildManifest = try PropertyListSerialization.propertyList( + from: blob, + options: [], + format: nil + ) as? PlistDict else { + throw FirmwareManifest.ManifestError.invalidPlist("") + } + return buildManifest + } + + func applyPatches(buildManifest: PlistDict) throws -> PlistDict { + var buildManifest = buildManifest + guard let restoreDir else { + throw FirmwareManifest.ManifestError.fileNotFound("Restore Directory") + } + + // We assume that FirmwareManifest has generated the manifest containing a single build identity. + guard let buildIdentities = buildManifest["BuildIdentities"] as? [Any], + buildIdentities.count == 1 else { + throw FirmwareManifest.ManifestError.missingKey("BuildIdentities in BuildManifest") + } + guard var buildIdentity = buildIdentities.first! as? PlistDict else { + throw FirmwareManifest.ManifestError.missingKey("BuildIdentity in BuildIdentities") + } + guard let identityManifest = buildIdentity["Manifest"] as? PlistDict else { + throw FirmwareManifest.ManifestError.missingKey("Manifest in BuildIdentity") + } + + var newBuildIdentityManifest = PlistDict() + for (comp, dict) in identityManifest { + guard var dict = dict as? PlistDict else { + throw FirmwareManifest.ManifestError.missingKey("component in build identity") + } + guard let info = dict["Info"] as? PlistDict else { + throw FirmwareManifest.ManifestError.missingKey("Info in build identity component") + } + guard let path = info["Path"] as? String else { + throw FirmwareManifest.ManifestError.missingKey("Path in build identity component info") + } + + let componentData = try Data(contentsOf: restoreDir.appendingPathComponent(path)) + let finalData = try patchIm4pTypeTag(comp, info["Img4PayloadType"] as? String, componentData) + let shaHash = SHA384.hash(data: finalData) + dict["Digest"] = Data(shaHash) + newBuildIdentityManifest[comp] = dict + } + + buildIdentity["Manifest"] = newBuildIdentityManifest + buildManifest["BuildIdentities"] = [buildIdentity] + return buildManifest + } + + private func serializePayload(_ buildManifest: PlistDict) throws -> Data { + return try PropertyListSerialization.data( + fromPropertyList: buildManifest, + format: .xml, + options: 0 + ) + } +} + +func patchIm4pTypeTag(_ component: String, _ declaredType: String?, _ data: Data) throws -> Data { + guard [ + "RestoreKernelCache", + "RestoreDeviceTree", + "RestoreSEP", + "RestoreLogo", + "RestoreTrustCache", + "RestoreDCP", + "Ap,RestoreDCP2", + "Ap,RestoreTMU", + "Ap,RestoreCIO", + "Ap,DCP2", + "Ap,RestoreSecureM3Firmware", + "Ap,RestoreSecurePageTableMonitor", + "Ap,RestoreTrustedExecutionMonitor", + "Ap,RestorecL4" + ].contains(component) else { + return data + } + guard let declaredType else { + throw FirmwareManifest.ManifestError.missingKey("Img4PayloadType in build identity component info") + } + return try IM4P(data).renamed(to: declaredType).data +} diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index f029268..0991c56 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -136,6 +136,8 @@ public final class FirmwarePipeline { componentRecords.append(contentsOf: records) if let deviceTreePatcher = patcher as? DeviceTreePatcher { currentData = deviceTreePatcher.patchedData + } else if let manifestPatcher = patcher as? ManifestHashPatcher { + currentData = manifestPatcher.patchedData } else { for record in records { let range = record.fileOffset ..< record.fileOffset + record.patchedBytes.count @@ -279,6 +281,23 @@ public final class FirmwarePipeline { }] )) + // 8. Firmware Manifest - Only required when excluding the img4 signature patches. + components.append(ComponentDescriptor( + name: "Manifest", + inRestoreDir: true, + searchPatterns: ["BuildManifest.plist"], + patcherFactories: { + return switch variant { + case .regular: + [{ data, verbose in + ManifestHashPatcher(data: data, restoreDir: try? self.findRestoreDirectory(), verbose: verbose) + }] + case .dev, .jb: + [] + } + }() + )) + return components } From ddf6c4fc1398b9250d38c750775d7ffad507c49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:45:41 +0200 Subject: [PATCH 04/11] Add Filesystem Patcher --- README.md | 4 +- .../Filesystem/CryptexFilesystemPatcher.swift | 795 +++++++++++++++++- .../FirmwarePatcher/IBoot/IBootPatcher.swift | 10 +- .../Pipeline/FirmwarePipeline.swift | 51 +- 4 files changed, 811 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index a78725d..7ac98b0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Three patch variants are available with increasing levels of security bypass: | Variant | Boot Chain | CFW | Make Targets | | --------------- | :---------: | :-------: | ---------------------------------- | -| **Regular** | 41 patches | 10 phases | `fw_patch` + `cfw_install` | +| **Regular** | 0 patches | 10 phases | `fw_patch` + `cfw_install` | | **Development** | 52 patches | 12 phases | `fw_patch_dev` + `cfw_install_dev` | | **Jailbreak** | 112 patches | 14 phases | `fw_patch_jb` + `cfw_install_jb` | @@ -87,7 +87,7 @@ Boot into Recovery (long press power button), open Terminal, then choose one set **Install dependencies:** ```bash -brew install aria2 ideviceinstaller wget gnu-tar openssl@3 ldid-procursus sshpass keystone autoconf automake pkg-config libtool cmake +brew install aria2 ideviceinstaller wget gnu-tar openssl@3 ldid-procursus sshpass keystone autoconf automake pkg-config libtool cmake ipsw ``` `scripts/fw_prepare.sh` prefers `aria2c` for faster multi-connection downloads and falls back to `curl` or `wget` when needed. diff --git a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift index dd914cc..18a49e2 100644 --- a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift +++ b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift @@ -1,68 +1,801 @@ - +// CryptexFilesystemPatcher.swift — CryptexFilesystemPatcher. +// +// Merge the cryptex filesystems inside the main OS filesystem. +// // 1. Collect the AppOS and SystemOS Cryptex from the iPhone BuildManifest // 2. With the OS, AppOS, and SystemOS images, attach them and copy them to a target image -// 3. Create mtree for resulting image -// 4. Download apfs_sealvolume -// 5. Generate digest.db -// 6. Join mtree and digest.db to Ap,SystemVolumeCanonicalMetadata -// 7. Create SystemVolume root_hash +// 3. Create trustcache for resulting image +// 4. Create mtree for resulting image +// 5. Download apfs_sealvolume +// 6. Generate digest.db and SystemVolume root_hash +// 7. Join mtree and digest.db to Ap,SystemVolumeCanonicalMetadata import Foundation import CryptoKit import Img4tool -/// Patcher for DeviceTree payloads. +enum ProcessError: Error { + case failed(Int32, String) +} + +extension Data { + var hexString: String { + self.map { String(format: "%02x", $0) }.joined() + } +} + +/// Patcher for the Filesystem payload. public final class CryptexFilesystemPatcher: Patcher { - public let component = "Manifest" - public let restoreDir: URL? + public let component = "Filesystem" + public let restoreDir: URL public let verbose: Bool - let buffer: BinaryBuffer - var patches: [PatchRecord] = [] + var buildManiest: Data var rebuiltData: Data? + var tmpDirectories: [URL] = [] // MARK: - Init - public init(data: Data, restoreDir: URL?, verbose: Bool = true) { - buffer = BinaryBuffer(data) + public init(buildManiest: Data, restoreDir: URL, verbose: Bool = true) { + self.buildManiest = buildManiest self.restoreDir = restoreDir self.verbose = verbose } + deinit { + for tmp in tmpDirectories { + try? FileManager.default.removeItem(at: tmp) + } + } + // MARK: - Patcher public func findAll() throws -> [PatchRecord] { - rebuiltData = nil - let root = try parsePayload(buffer.data) - let newRoot = try applyPatches(buildManifest: root) - rebuiltData = try serializePayload(newRoot) - - patches = [PatchRecord( - patchID: "manifest.hash", + return [PatchRecord( + patchID: "filesystem.cryptex.merge", component: "", fileOffset: 0, originalBytes: Data(), patchedBytes: Data(), - description: "Updated the file hashes according to the actual files", + description: "Merge the cryptex filesystems inside the OS filesystem", )] - return patches } @discardableResult public func apply() throws -> Int { - if patches.isEmpty { - let _ = try findAll() + print("Merging Filesystems") + let (unencryptedImage, aeaImage) = try mergeFilesystems() + + print("Creating Trustcache") + let trustcachePath = try createTrustcache(filesystem: unencryptedImage) + + print("Creating mtree") + try removeSpecificSystemFiles(filesystem: unencryptedImage) + let mtreePath = try createMtree(filesystem: unencryptedImage) + + print("Creating DigestDB and Root Hash") + let sealvolume = try extractSealvolume() + let (digestDbPath, rootHashPath) = try createDigestAndHash(sealvolume: sealvolume, filesystem: unencryptedImage, mtree: mtreePath) + let metadataPath = try compressCanonicalMetadata(mtree: mtreePath, digestDb: digestDbPath) + let rootHashContainer = try wrapRootHash(rootHashPath) + + // update trustcache, metadata, root_hash path + let updatedManifest = try setUpdatedComponentsInManifest(filesystem: aeaImage, trustcache: trustcachePath, metadata: metadataPath, rootHash: rootHashContainer) + rebuiltData = try serializePayload(updatedManifest) + + return 1 + } + + /// Get the patched data. + public var patchedData: Data { + rebuiltData! + } + + // mergeFilesystems merges the main OS filesystem with the Cryptexes filesystems. + // It returns the path of the merged image (plain and encrypted) + func mergeFilesystems() throws -> (URL, URL) { + let osPath = try getOSFilesystemPath() + let osDmgPath = try decryptAeaFile(self.restoreDir.appending(path: osPath)) + let tmpDir = try createTmpDir() + let newDmgPath = tmpDir.appending(path: "new-filesystem.dmg") + + print("- Converting OS image") + let targetImagePath = tmpDir.appending(path: "disk.dmg") + do { + try convertToRawImage(input: osDmgPath, output: targetImagePath) + let (targetDevice, targetMount) = try attachImage(path: targetImagePath, forceRW: true) + defer { try? detachImage(deviceNode: targetDevice) } + + print("- Merging App OS Cryptex") + try copyCryptex(targetMount: targetMount, appOS: true) + + print("- Merging System OS Cryptex") + try copyCryptex(targetMount: targetMount, systemOS: true) + + print("- Fix Dyld Cache") + try addDyldSymlinks(targetMount: targetMount) + } + + print("- Finalizing merged image") + try shrinkImage(dmg: targetImagePath) + try convertToUDRWImage(input: targetImagePath, output: newDmgPath) + let key = try getAeaKey(self.restoreDir.appending(path: osPath)) + let metadata = try getAeaMetadata(self.restoreDir.appending(path: osPath)) + let finalFile = try encryptAeaFile(newDmgPath, key: key, metadata: metadata) + let finalDestination = self.restoreDir.appending(path: finalFile.lastPathComponent) + if FileManager.default.fileExists(atPath: finalDestination.path) { + try FileManager.default.removeItem(at: finalDestination) + } + try FileManager.default.moveItem(at: finalFile, to: finalDestination) + return (newDmgPath, finalDestination) + } + + func addDyldSymlinks(targetMount: String) throws { + let target = URL.init(filePath: targetMount) + _ = try runProcess("/bin/ln", [ + "-sf", "../../../System/Cryptexes/OS/System/Library/Caches/com.apple.dyld", + target.appending(path: "/System/Library/Caches/com.apple.dyld").path + ]) + _ = try runProcess("/bin/ln", [ + "-sf", "../../../../System/Cryptexes/OS/System/DriverKit/System/Library/dyld", + target.appending(path: "/System/DriverKit/System/Library/dyld").path + ]) + } + + func wrapRootHash(_ rootHashPath: URL) throws -> URL { + let tmpDir = try createTmpDir() + let im4pPath = tmpDir.appending(path: "metadata.root_hash") + _ = try runProcess("/opt/homebrew/bin/ipsw", [ + "img4", "im4p", "create", + "--type", "isys", "--version", "0", + "-o", im4pPath.path, + rootHashPath.path + ]) + return im4pPath + } + + func wrapTrustcache(_ trustcache: URL) throws -> URL { + let tmpDir = try createTmpDir() + let im4pPath = tmpDir.appending(path: "new.filesystem") + _ = try runProcess("/opt/homebrew/bin/ipsw", [ + "img4", "im4p", "create", + "--type", "trst", "--version", "1", + "-o", im4pPath.path, + trustcache.path + ]) + return im4pPath + } + + func compressCanonicalMetadata(mtree: URL, digestDb: URL) throws -> URL { + let tmpDir = try createTmpDir() + let targetMtree = tmpDir.appending(path: mtree.lastPathComponent) + try FileManager.default.copyItem(at: mtree, to: targetMtree) + let targetDigestDb = tmpDir.appending(path: digestDb.lastPathComponent) + try FileManager.default.copyItem(at: digestDb, to: targetDigestDb) + + let archivePath = tmpDir.appending(path: "payload.aar") + _ = try runProcess("/usr/bin/aa", [ + "archive", + "-d", tmpDir.path, + "-o", archivePath.path + ]) + + let im4pPath = tmpDir.appending(path: "metadata.mtree") + _ = try runProcess("/opt/homebrew/bin/ipsw", [ + "img4", "im4p", "create", + "--type", "msys", "--version", "0", + "-o", im4pPath.path, + archivePath.path + ]) + return im4pPath + } + + func createDigestAndHash(sealvolume: URL, filesystem: URL, mtree: URL) throws -> (URL, URL) { + let (device, mount) = try attachImage(path: filesystem) + defer { try? detachImage(deviceNode: device) } + + let tmpDir = try createTmpDir() + let digestDbPath = tmpDir.appending(path: "digest.db") + let rootHashPath = tmpDir.appending(path: "root_hash") + let mtreeRemapPath = tmpDir.appending(path: "mtree_remap.xml") + + // We want to get the nanosecond timestamp of the last modification before the mtree collection. + // We know that we remove directories in /private/var in removeSpecificSystemFiles last. + // Therefore, we parse the modification time of /private/var. + let modificationTime = try parsePrivateVarTime(mtree: mtree) + let remapContent = """ + + + + + MODIFICATION + \(modificationTime) + + + """ + print("Used time: \(modificationTime)") + FileManager.default.createFile(atPath: mtreeRemapPath.path, contents: remapContent.data(using: .utf8)) + + try unmount(mount: mount) + _ = try runProcess(sealvolume.path, [ + "-R", mtreeRemapPath.path, + "-U", digestDbPath.path, // Save digest records + "-M", rootHashPath.path, // Save root hash + device + ]) + return (digestDbPath, rootHashPath) + } + + func parsePrivateVarTime(mtree: URL) throws -> String { + guard let mtreeData = FileManager.default.contents(atPath: mtree.path), + let text = String(data: mtreeData, encoding: .utf8) else { + throw FirmwareManifest.ManifestError.fileNotFound(mtree.path) + } + let lines = text.split(whereSeparator: \.isNewline).map(String.init) + + // Find the section header for /private/var + guard let sectionIndex = lines.firstIndex(of: "# ./private/var") else { + throw FirmwareManifest.ManifestError.fileNotFound("/private/var") + } + + // Look at the lines after that header until the next section header + for line in lines[(sectionIndex + 1)...] { + // Stop if we hit the next section + if line.hasPrefix("# ./") { + break + } + + // The metadata line for /private/var starts with "var " + guard line.hasPrefix("var ") else { continue } + + // Extract time=... + guard let match = line.range(of: #"time=([0-9]+(?:\.[0-9]+)?)"#, + options: .regularExpression) else { + throw FirmwareManifest.ManifestError.fileNotFound("time") + } + + let matchedText = String(line[match]) + return matchedText + .replacingOccurrences(of: "time=", with: "") + .replacingOccurrences(of: ".", with: "") + } + + throw FirmwareManifest.ManifestError.fileNotFound("metadata") + } + + func extractSealvolume() throws -> URL { + // We cannot execute iOS binaries on macOS, therefore, we have to download a macOS ramdisk + let tmpDir = try createTmpDir() + _ = try runProcess("/opt/homebrew/bin/ipsw", [ + "download", "appledb", "--os", "macOS", "--build", "25D2140", + "--pattern", "094-33864-054.dmg", + "--output", tmpDir.path + ]) + let ramdiskIm4pPath = tmpDir.appending(path: "25D2140__MacOS/094-33864-054.dmg") + let ramdiskPath = tmpDir.appending(path: "ramdisk.dmg") + try extractIm4pContainer(ramdiskIm4pPath, output: ramdiskPath) + + let (device, mount) = try attachImage(path: ramdiskPath, readonly: true) + defer { try? detachImage(deviceNode: device) } + + let sourcePath = URL.init(filePath: mount).appending(path: "System/Library/Filesystems/apfs.fs/Contents/Resources/apfs_sealvolume") + let targetPath = tmpDir.appending(path: "apfs_sealvolume") + try FileManager.default.copyItem(at: sourcePath, to: targetPath) + return targetPath + } + + func extractIm4pContainer(_ container: URL, output: URL) throws { + _ = try runProcess("/opt/homebrew/bin/ipsw", [ + "img4", "im4p", "extract", + "--output", output.path, + container.path + ]) + } + + func createMtree(filesystem: URL) throws -> URL { + let (device, mount) = try attachImage(path: filesystem, readonly: true) + defer { try? detachImage(deviceNode: device) } + + let tmpDir = try createTmpDir() + let mtreeFile = tmpDir.appending(path: "mtree.txt") + FileManager.default.createFile(atPath: mtreeFile.path, contents: nil) + _ = try runProcess("/usr/sbin/mtree", [ + "-c", + "-p", mount, + ], output: mtreeFile) + return mtreeFile + } + + func removeSpecificSystemFiles(filesystem: URL) throws { + let (device, mount) = try attachImage(path: filesystem, forceRW: true) + defer { try? detachImage(deviceNode: device) } + + let removedPaths = [ +// "/private/var/MobileAsset/PreinstalledAssets", + "/private/var/MobileAsset/PreinstalledAssetsV2", + "/private/var/staged_system_apps", + ] + for path in removedPaths { + try FileManager.default.removeItem(atPath: mount.appending(path)) + } + } + + func createTrustcache(filesystem: URL) throws -> URL { + let (device, mount) = try attachImage(path: filesystem, readonly: true) + defer { try? detachImage(deviceNode: device) } + + let oldTrustcache = try getTrustcachePath() + let oldTrustcachePath = self.restoreDir.appending(path: oldTrustcache) + let newTrustcachePath = self.restoreDir.appending(path: "Firmware/new.trustcache") + let tmpDir = try createTmpDir() + + let tcContainer = tmpDir.appending(path: "new.trustcache") + _ = try runProcess("/System/Library/SecurityResearch/usr/bin/cryptexctl", [ + "generate-trust-cache", "--type", "static", + "--base-trust-cache", oldTrustcachePath.path, + "--output-file", tcContainer.path, + mount + ]) + + if FileManager.default.fileExists(atPath: newTrustcachePath.path) { + try FileManager.default.removeItem(at: newTrustcachePath) } - if let rebuiltData { - buffer.data = rebuiltData + try FileManager.default.moveItem(at: tcContainer, to: newTrustcachePath) + + return newTrustcachePath + } + + func copyCryptex(targetMount: String, appOS: Bool = false, systemOS: Bool = false) throws { + guard (appOS || systemOS) && !(appOS && systemOS) else { + throw FirmwarePatcher.PatcherError.patchVerificationFailed("Can patch only one at a time") + } + + let osPath = if appOS { + self.restoreDir.appending(path: try getAppOsFilesystemPath()) } else { - throw PatcherError.patchSiteNotFound("ManifestHash") + try decryptAeaFile(self.restoreDir.appending(path: try getSystemOsFilesystemPath())) } - return patches.count + let (osDevice, osMount) = try attachImage(path: osPath, readonly: true) + defer { try? detachImage(deviceNode: osDevice) } + + let destination = URL.init(filePath: targetMount).appending(path: appOS ? "/System/Cryptexes/App" : "/System/Cryptexes/OS") + try FileManager.default.removeItem(at: destination) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: false) + try copyImageContents(source: URL.init(filePath: osMount), destination: destination) } - /// Get the patched data. - public var patchedData: Data { - buffer.data + func serializePayload(_ buildManifest: PlistDict) throws -> Data { + return try PropertyListSerialization.data( + fromPropertyList: buildManifest, + format: .xml, + options: 0 + ) + } + + func setUpdatedComponentsInManifest(filesystem: URL, trustcache: URL, metadata: URL, rootHash: URL) throws -> PlistDict { + var root = try parsePlist(data: buildManiest) + guard var buildIdentities = root["BuildIdentities"] as? [Any], + buildIdentities.count > 0, + var buildIdentity = buildIdentities.first! as? PlistDict else { + throw FirmwareManifest.ManifestError.missingKey("Component in BuildManifest") + } + var identityManifest = try getChildPlistDict(parent: buildIdentity, key: "Manifest") + + // We assume that the filesystem is already placed in the restore directory. + identityManifest = try updateManifestComponentPath(identityManifest: identityManifest, component: "OS", at: filesystem) + + let newTrustcachePath = self.restoreDir.appending(path: "Firmware").appending(path: trustcache.lastPathComponent) + if trustcache != newTrustcachePath && FileManager.default.fileExists(atPath: newTrustcachePath.path) { + try FileManager.default.removeItem(at: newTrustcachePath) + } + try FileManager.default.moveItem(at: trustcache, to: newTrustcachePath) + identityManifest = try updateManifestComponentPath(identityManifest: identityManifest, component: "StaticTrustCache", at: newTrustcachePath) + + let newMetadataPath = self.restoreDir.appending(path: "Firmware").appending(path: metadata.lastPathComponent) + if metadata != newMetadataPath && FileManager.default.fileExists(atPath: newMetadataPath.path) { + try FileManager.default.removeItem(at: newMetadataPath) + } + try FileManager.default.moveItem(at: metadata, to: newMetadataPath) + identityManifest = try updateManifestComponentPath(identityManifest: identityManifest, component: "Ap,SystemVolumeCanonicalMetadata", at: newMetadataPath) + + let newRootHashPath = self.restoreDir.appending(path: "Firmware").appending(path: rootHash.lastPathComponent) + if rootHash != newRootHashPath && FileManager.default.fileExists(atPath: newRootHashPath.path) { + try FileManager.default.removeItem(at: newRootHashPath) + } + try FileManager.default.moveItem(at: rootHash, to: newRootHashPath) + identityManifest = try updateManifestComponentPath(identityManifest: identityManifest, component: "SystemVolume", at: newRootHashPath) + + buildIdentity["Manifest"] = identityManifest + buildIdentities[0] = buildIdentity + root["BuildIdentities"] = buildIdentities + return root + } + + func updateManifestComponentPath(identityManifest: PlistDict, component: String, at: URL) throws -> PlistDict { + var identityManifest = identityManifest + let pathSuffix = relativePath(from: at, base: self.restoreDir.appendingPathComponent("", isDirectory: true)) + var comp = try getChildPlistDict(parent: identityManifest, key: component) + var info = try getChildPlistDict(parent: comp, key: "Info") + info["Path"] = pathSuffix + comp["Info"] = info + identityManifest[component] = comp + return identityManifest + } + + func relativePath(from child: URL, base: URL) -> String? { + let basePath = base.standardizedFileURL.pathComponents + let childPath = child.standardizedFileURL.pathComponents + + guard childPath.starts(with: basePath) else { return nil } + + let remaining = childPath.dropFirst(basePath.count) + return remaining.joined(separator: "/") + } + + func getTrustcachePath() throws -> String { + let path = self.restoreDir.appending(path: "BuildManifest-iPhone.plist") + let manifest = try getBuildIdentityManifest(path: path) + return try getComponentPath(component: "StaticTrustCache", buildManifest: manifest) + } + + func getOSFilesystemPath() throws -> String { + let path = self.restoreDir.appending(path: "BuildManifest-iPhone.plist") + let manifest = try getBuildIdentityManifest(path: path) + return try getComponentPath(component: "OS", buildManifest: manifest) + } + + func getAppOsFilesystemPath() throws -> String { + let path = self.restoreDir.appending(path: "BuildManifest-iPhone.plist") + let manifest = try getBuildIdentityManifest(path: path) + return try getComponentPath(component: "Cryptex1,AppOS", buildManifest: manifest) + } + + func getSystemOsFilesystemPath() throws -> String { + let path = self.restoreDir.appending(path: "BuildManifest-iPhone.plist") + let manifest = try getBuildIdentityManifest(path: path) + return try getComponentPath(component: "Cryptex1,SystemOS", buildManifest: manifest) + } + + func getComponentPath(component: String, buildManifest: PlistDict) throws -> String { + let comp = try getChildPlistDict(parent: buildManifest, key: component) + let info = try getChildPlistDict(parent: comp, key: "Info") + guard let path = info["Path"] as? String else { + throw FirmwareManifest.ManifestError.missingKey("component path") + } + return path + } + + func getBuildIdentityManifest(path: URL) throws -> PlistDict { + let data = try Data.init(contentsOf: path) + return try getBuildIdentityManifest(data: data) + } + + func getBuildIdentityManifest(data: Data) throws -> PlistDict { + let buildManifest = try parsePlist(data: data) + guard let buildIdentities = buildManifest["BuildIdentities"] as? [Any], + buildIdentities.count > 0, + let buildIdentity = buildIdentities.first! as? PlistDict else { + throw FirmwareManifest.ManifestError.missingKey("Component in BuildManifest") + } + return try getChildPlistDict(parent: buildIdentity, key: "Manifest") + } + + func parsePlist(data: Data) throws -> PlistDict { + guard let buildManifest = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? PlistDict else { + throw FirmwareManifest.ManifestError.invalidPlist("") + } + return buildManifest + } + + func getChildPlistDict(parent: PlistDict, key: String) throws -> PlistDict { + guard let value = parent[key] as? PlistDict else { + throw FirmwareManifest.ManifestError.missingKey(key) + } + return value + } + + func createTmpDir() throws -> URL { + let tmpDir = FileManager.default.temporaryDirectory + .appending(path: "vphone-\(UUID.init().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + self.tmpDirectories.append(tmpDir) + return tmpDir + } + + func copyImageContents(source: URL, destination: URL) throws { + // Copy everything from source volume root into destination volume root. + let sourceRoot = source.appendingPathComponent("", isDirectory: true) + let sourcePath = sourceRoot.path + let destinationRoot = destination.appendingPathComponent("", isDirectory: true) + + // We delete the files first as we want to replace symlinks with actual files. + let keys: [URLResourceKey] = [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey] + guard let enumerator = FileManager.default.enumerator(at: sourceRoot, includingPropertiesForKeys: keys) else { + throw FirmwareManifest.ManifestError.fileNotFound("enumerator") + } + for case let fileURL as URL in enumerator { + guard fileURL.path.hasPrefix(sourcePath) else { continue } + + let values = try fileURL.resourceValues(forKeys: Set(keys)) + var suffix = String(fileURL.path.dropFirst(sourcePath.count)) + if suffix.hasPrefix("/") { + suffix.removeFirst() + } + + let destinationPath = destinationRoot.appendingPathComponent(suffix) + guard let ok = try? destinationPath.checkResourceIsReachable(), ok else { + // try FileManager.default.copyItem(at: fileURL, to: destinationPath) + let result = copyfile(fileURL.path, destinationPath.path, nil, copyfile_flags_t(COPYFILE_SECURITY | COPYFILE_DATA)) + if result < 0 { + print("Failed to copy: \(destinationPath)") + } + continue + } + + let vals = try destinationPath.resourceValues(forKeys: Set(keys)) + if values.isDirectory != vals.isDirectory || + values.isRegularFile != vals.isRegularFile || + values.isSymbolicLink != vals.isSymbolicLink { + try FileManager.default.removeItem(at: destinationPath) + // try FileManager.default.copyItem(at: fileURL, to: destinationPath) + let result = copyfile(fileURL.path, destinationPath.path, nil, copyfile_flags_t(COPYFILE_SECURITY | COPYFILE_DATA)) + if result < 0 { + print("Failed to copy: \(destinationPath)") + } + } + } + } + + func getAeaKey(_ path: URL) throws -> String { + return try runProcess("/opt/homebrew/bin/ipsw", [ + "fw", "aea", + "--no-color", + "--key", + path.path, + ]).trimmingCharacters(in: ["\n"]) + } + + func encryptAeaFile(_ path: URL, key: String, metadata: [String: String]) throws -> URL { + let outputPath = path.appendingPathExtension("aea") + var arguments = [ + "encrypt", "-i", path.path, "-o", outputPath.path, + "-profile", "1", "-key-value", key, + ] + for (metaKey, metaValue) in metadata { + arguments.append("-auth-data-key") + arguments.append(metaKey) + arguments.append("-auth-data-value") + arguments.append(metaValue) + } + _ = try runProcess("/usr/bin/aea", arguments) + return outputPath + } + + func decryptAeaFile(_ path: URL) throws -> URL { + let tmpDir = try createTmpDir() + let outputPath = tmpDir.appending(path: path.appendingPathExtension("dmg").lastPathComponent) + _ = try runProcess("/opt/homebrew/bin/ipsw", [ + "fw", "aea", + "-o", outputPath.path, + path.path, + ]) + return outputPath.appending(path: path.lastPathComponent.dropLast(4)) + } + + func getAeaMetadata(_ path: URL) throws -> [String: String] { + let output = try runProcess("/opt/homebrew/bin/ipsw", [ + "fw", "aea", + "--no-color", + "--info", + path.path, + ]) + let lines = output.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).map(String.init) + + var result: [String: String] = [:] + var currentKey: String? + var bodyLines: [String] = [] + + func flushCurrentSection() { + guard let key = currentKey else { return } + result[key] = parseSectionBody(bodyLines) + } + + for rawLine in lines { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + + if let key = parseSectionHeader(trimmed) { + flushCurrentSection() + currentKey = key + bodyLines = [] + } else { + // Ignore banner lines before the first section + if currentKey != nil { + bodyLines.append(rawLine) + } + } + } + + flushCurrentSection() + return result + } + + private func parseSectionHeader(_ line: String) -> String? { + // Matches both: + // [com.apple.wkms.url]: + // [saksKey]: + guard line.hasPrefix("[") else { return nil } + guard let end = line.firstIndex(of: "]") else { return nil } + + let key = String(line[line.index(after: line.startIndex).. String { + let nonEmpty = bodyLines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + // If the section contains hex dump lines, parse and concatenate them. + let hexBytes = nonEmpty.flatMap { parseHexDumpLine($0) } + if !hexBytes.isEmpty { + let b64Encoded = Data(hexBytes).base64EncodedString() + return "hex:\(Data(b64Encoded.utf8).hexString)" + } + + // Otherwise treat it as plain text / JSON / whatever the section contains. + let text = bodyLines.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + + return "hex:\(Data(text.utf8).hexString)" + } + + private func parseHexDumpLine(_ line: String) -> [UInt8] { + // Example: + // 0000000000000000: 0a 8d 03 0a 2f c7 ... |....| + guard let colonIndex = line.firstIndex(of: ":") else { return [] } + + let afterColon = line[line.index(after: colonIndex)...] + let beforeAscii = afterColon.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false).first ?? afterColon + + let tokens = beforeAscii.split(whereSeparator: \.isWhitespace) + var bytes: [UInt8] = [] + bytes.reserveCapacity(tokens.count) + + for token in tokens { + guard token.count == 2, let b = UInt8(token, radix: 16) else { + return [] // not a hexdump line + } + bytes.append(b) + } + + return bytes + } + + func convertToRawImage(input: URL, output: URL) throws { + _ = try runProcess("/usr/sbin/diskutil", [ + "image", "create", "from", + "--format", "RAW", input.path, + output.path + ]) + + // Resize to max + let maxsize = try runProcess("/bin/sh", [ + "-c", "diskutil image resize --plist \"\(output.path)\" | plutil -extract max raw -o - -" + ]).trimmingCharacters(in: ["\n"]) + _ = try runProcess("/usr/sbin/diskutil", [ + "image", "resize", "--size", maxsize, output.path + ]) + } + + func convertToUDRWImage(input: URL, output: URL) throws { + if FileManager.default.fileExists(atPath: output.path) { + try FileManager.default.removeItem(at: output) + } + _ = try runProcess("/usr/bin/hdiutil", [ + "convert", + input.path, + "-format", "UDRW", + "-o", output.path + ]) + } + + func shrinkImage(dmg: URL) throws { + _ = try runProcess("/usr/sbin/diskutil", [ + "image", + "resize", + "--size", "min", + dmg.path + ]) + } + + func unmount(mount: String) throws { + _ = try runProcess("/usr/sbin/diskutil", ["unmount", mount]) + } + + // attachImage returns the device and mount point + func attachImage(path: URL, readonly: Bool = false, forceRW: Bool = false) throws -> (String, String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + process.arguments = if readonly {[ + "attach", + "-readonly", + "-plist", + path.path, + ]} else {[ + "attach", + "-plist", + path.path, + ]} + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + let output = String(data: data, encoding: .utf8) ?? "" + throw ProcessError.failed(process.terminationStatus, output) + } + + let root = try parsePlist(data: data) + guard let entries = root["system-entities"] as? [Any] else { + throw FirmwareManifest.ManifestError.missingKey("system-entities") + } + for entry in entries { + guard let entry = entry as? PlistDict, + let volumeKind = entry["volume-kind"] as? String, + volumeKind == "apfs" || volumeKind == "hfs" else { + continue + } + let device = entry["dev-entry"] as? String ?? "" + let mountPoint = entry["mount-point"] as? String ?? "" + + if forceRW { + _ = try runProcess("/sbin/mount", ["-u", "-w", device, mountPoint]) + } + return (device, mountPoint) + } + throw FirmwareManifest.ManifestError.missingKey("dev-entry or mount-point") + } + + func detachImage(deviceNode: String) throws { + _ = try runProcess("/usr/bin/hdiutil", ["detach", deviceNode]) + } + + func runProcess(_ launchPath: String, _ arguments: [String], sudo: Bool = false, output: URL? = nil) throws -> String { + let process = Process() + if sudo { + let whoami = try runProcess("/usr/bin/whoami", []) + if !whoami.contains("root") { + print("Please rerun as root or fix this program") + exit(42) + } + } + process.executableURL = URL(fileURLWithPath: launchPath) + process.arguments = arguments + + let outPipe = Pipe() + if let output { + let outFile = try FileHandle.init(forWritingTo: output) + process.standardOutput = outFile + process.standardError = outFile + } else { + process.standardOutput = outPipe + process.standardError = outPipe + } + + try process.run() + process.waitUntilExit() + + let output = output == nil ? String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) : nil + guard process.terminationStatus == 0 else { + throw ProcessError.failed(process.terminationStatus, output ?? "") + } + return output ?? "" } } diff --git a/sources/FirmwarePatcher/IBoot/IBootPatcher.swift b/sources/FirmwarePatcher/IBoot/IBootPatcher.swift index cc20be6..3317f6a 100644 --- a/sources/FirmwarePatcher/IBoot/IBootPatcher.swift +++ b/sources/FirmwarePatcher/IBoot/IBootPatcher.swift @@ -34,7 +34,6 @@ public class IBootPatcher: Patcher { public let component: String public let verbose: Bool - public let onlySerial: Bool let buffer: BinaryBuffer let mode: Mode @@ -43,12 +42,11 @@ public class IBootPatcher: Patcher { // MARK: - Init - public init(data: Data, mode: Mode, verbose: Bool = true, onlySerial: Bool = false) { + public init(data: Data, mode: Mode, verbose: Bool = true) { buffer = BinaryBuffer(data) self.mode = mode component = mode.rawValue self.verbose = verbose - self.onlySerial = onlySerial } // MARK: - Patcher Protocol @@ -57,15 +55,13 @@ public class IBootPatcher: Patcher { patches = [] patchSerialLabels() - if !onlySerial { - patchImage4Callback() - } + patchImage4Callback() if mode == .ibec || mode == .llb { patchBootArgs() } - if !onlySerial && mode == .llb { + if mode == .llb { patchRootfssBypass() patchPanicBypass() } diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index 0991c56..f70a9b7 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -136,6 +136,8 @@ public final class FirmwarePipeline { componentRecords.append(contentsOf: records) if let deviceTreePatcher = patcher as? DeviceTreePatcher { currentData = deviceTreePatcher.patchedData + } else if let filesystemPatcher = patcher as? CryptexFilesystemPatcher { + currentData = filesystemPatcher.patchedData } else if let manifestPatcher = patcher as? ManifestHashPatcher { currentData = manifestPatcher.patchedData } else { @@ -208,24 +210,38 @@ public final class FirmwarePipeline { }() )) - // 3. iBEC — In the regular variant, we only want to enable the serial output. + // 3. iBEC — The automatic patching is disabled for the regular variant (you can still just place a custom component there). components.append(ComponentDescriptor( name: "iBEC", inRestoreDir: true, searchPatterns: ["Firmware/dfu/iBEC.vresearch101.RELEASE.im4p"], - patcherFactories: [{ data, verbose in - IBootPatcher(data: data, mode: .ibec, verbose: verbose, onlySerial: self.variant == .regular) - }] + patcherFactories: { + return switch variant { + case .regular: + [] + case .dev, .jb: + [{ data, verbose in + IBootPatcher(data: data, mode: .ibec, verbose: verbose) + }] + } + }() )) - // 4. LLB — In the regular variant, we only want to enable the serial output. + // 4. LLB — The automatic patching is disabled for the regular variant (you can still just place a custom component there). components.append(ComponentDescriptor( name: "LLB", inRestoreDir: true, searchPatterns: ["Firmware/all_flash/LLB.vresearch101.RELEASE.im4p"], - patcherFactories: [{ data, verbose in - IBootPatcher(data: data, mode: .llb, verbose: verbose, onlySerial: self.variant == .regular) - }] + patcherFactories: { + return switch variant { + case .regular: + [] + case .dev, .jb: + [{ data, verbose in + IBootPatcher(data: data, mode: .llb, verbose: verbose) + }] + } + }() )) // 5. TXM — dev/jb variants use TXMDevPatcher (adds entitlements, debugger, dev-mode) @@ -280,8 +296,25 @@ public final class FirmwarePipeline { DeviceTreePatcher(data: data, verbose: verbose) }] )) + + // 8. Filesystem + components.append(ComponentDescriptor( + name: "Filesystem", + inRestoreDir: true, + searchPatterns: ["BuildManifest.plist"], + patcherFactories: { + return switch variant { + case .regular: + [{ data, verbose in + CryptexFilesystemPatcher(buildManiest: data, restoreDir: try! self.findRestoreDirectory(), verbose: verbose) + }] + case .dev, .jb: + [] + } + }() + )) - // 8. Firmware Manifest - Only required when excluding the img4 signature patches. + // 9. Firmware Manifest - Only required when excluding the img4 signature patches. components.append(ComponentDescriptor( name: "Manifest", inRestoreDir: true, From 181905bcb39673000ef7f01b6016d124f875e702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:45:41 +0200 Subject: [PATCH 05/11] Add GPU Driver --- .../Filesystem/CryptexFilesystemPatcher.swift | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift index 18a49e2..810032f 100644 --- a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift +++ b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift @@ -114,6 +114,9 @@ public final class CryptexFilesystemPatcher: Patcher { print("- Fix Dyld Cache") try addDyldSymlinks(targetMount: targetMount) + + print("- Fix GPU Driver") + try addGpuDriver(targetMount: targetMount) } print("- Finalizing merged image") @@ -130,6 +133,40 @@ public final class CryptexFilesystemPatcher: Patcher { return (newDmgPath, finalDestination) } + func addGpuDriver(targetMount: String) throws { + let target = URL.init(filePath: targetMount) + let tmpDir = try createTmpDir() + _ = try runProcess("/usr/bin/tar", [ + "--zstd", "-xf", "./scripts/resources/cfw_input.tar.zst", "-C", tmpDir.path + ]) + + let gpuTarPath = tmpDir.appending(path: "cfw_input/custom/AppleParavirtGPUMetalIOGPUFamily.tar") + _ = try runProcess("/usr/bin/tar", [ + "--preserve-permissions", + "-xf", gpuTarPath.path, + "-C", target.path + ]) + + let bundle = target.appending(path: "/System/Library/Extensions/AppleParavirtGPUMetalIOGPUFamily.bundle") + // Clean macOS resource fork files (._* files from tar xattrs) + _ = try? runProcess("/usr/bin/find", [bundle.path, "-name", "._*", "-delete"]) + _ = try runProcess("/usr/sbin/chown", ["-R", "0:0", bundle.path]) + for path in [ + bundle.path, + bundle.appending(path: "/libAppleParavirtCompilerPluginIOGPUFamily.dylib").path, + bundle.appending(path: "/AppleParavirtGPUMetalIOGPUFamily").path, + bundle.appending(path: "/_CodeSignature").path, + ] { + _ = try runProcess("/bin/chmod", ["0755", path]) + } + for path in [ + bundle.appending(path: "/_CodeSignature/CodeResources").path, + bundle.appending(path: "/Info.plist").path + ] { + _ = try runProcess("/bin/chmod", ["0644", path]) + } + } + func addDyldSymlinks(targetMount: String) throws { let target = URL.init(filePath: targetMount) _ = try runProcess("/bin/ln", [ From 7b1762e05b84b8e282cc2c32a59bee45c025d11d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:27:05 +0200 Subject: [PATCH 06/11] Add Mobile Activation Patch --- README.md | 2 +- .../Filesystem/CryptexFilesystemPatcher.swift | 38 +++++++++++++++---- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7ac98b0..e25c4ba 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Three patch variants are available with increasing levels of security bypass: | Variant | Boot Chain | CFW | Make Targets | | --------------- | :---------: | :-------: | ---------------------------------- | -| **Regular** | 0 patches | 10 phases | `fw_patch` + `cfw_install` | +| **Regular** | 1 patch | 10 phases | `fw_patch` + `cfw_install` | | **Development** | 52 patches | 12 phases | `fw_patch_dev` + `cfw_install_dev` | | **Jailbreak** | 112 patches | 14 phases | `fw_patch_jb` + `cfw_install_jb` | diff --git a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift index 810032f..ce14d92 100644 --- a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift +++ b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift @@ -29,6 +29,7 @@ public final class CryptexFilesystemPatcher: Patcher { public let component = "Filesystem" public let restoreDir: URL public let verbose: Bool + let vphoneCliDirectory = URL(filePath: "./") var buildManiest: Data var rebuiltData: Data? @@ -115,8 +116,17 @@ public final class CryptexFilesystemPatcher: Patcher { print("- Fix Dyld Cache") try addDyldSymlinks(targetMount: targetMount) + let cfwInputOgPath = vphoneCliDirectory.appending(path: "scripts/resources/cfw_input.tar.zst") + let cfwInputPath = try createTmpDir() + _ = try runProcess("/usr/bin/tar", [ + "--zstd", "-xf", cfwInputOgPath.path, "-C", cfwInputPath.path + ]) + print("- Fix GPU Driver") - try addGpuDriver(targetMount: targetMount) + try addGpuDriver(targetMount: targetMount, cfwInput: cfwInputPath) + + print("- Patch Mobile Activation") + try patchMobileActivation(targetMount: targetMount, cfwInput: cfwInputPath) } print("- Finalizing merged image") @@ -133,14 +143,10 @@ public final class CryptexFilesystemPatcher: Patcher { return (newDmgPath, finalDestination) } - func addGpuDriver(targetMount: String) throws { + func addGpuDriver(targetMount: String, cfwInput: URL) throws { let target = URL.init(filePath: targetMount) - let tmpDir = try createTmpDir() - _ = try runProcess("/usr/bin/tar", [ - "--zstd", "-xf", "./scripts/resources/cfw_input.tar.zst", "-C", tmpDir.path - ]) - - let gpuTarPath = tmpDir.appending(path: "cfw_input/custom/AppleParavirtGPUMetalIOGPUFamily.tar") + + let gpuTarPath = cfwInput.appending(path: "cfw_input/custom/AppleParavirtGPUMetalIOGPUFamily.tar") _ = try runProcess("/usr/bin/tar", [ "--preserve-permissions", "-xf", gpuTarPath.path, @@ -166,6 +172,22 @@ public final class CryptexFilesystemPatcher: Patcher { _ = try runProcess("/bin/chmod", ["0644", path]) } } + + func patchMobileActivation(targetMount: String, cfwInput: URL) throws { + let target = URL.init(filePath: targetMount) + let mobileActivationdPath = target.appending(path: "/usr/libexec/mobileactivationd") + _ = try runProcess("./.venv/bin/python3", [ + "./scripts/patchers/cfw.py", "patch-mobileactivationd", + mobileActivationdPath.path + ]) + _ = try runProcess("/bin/chmod", ["0755", mobileActivationdPath.path]) + + let signingCertificatePath = cfwInput.appending(path: "cfw_input/signcert.p12") + _ = try runProcess("/opt/homebrew/bin/ldid", [ + "-S", "-M", "-K\(signingCertificatePath.path)", + mobileActivationdPath.path + ]) + } func addDyldSymlinks(targetMount: String) throws { let target = URL.init(filePath: targetMount) From 247e7e77bdc8d615e5c093e7d98d03c4d1bee140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:01:07 +0200 Subject: [PATCH 07/11] Introduce Patchless Variant --- Makefile | 18 ++- README.md | 9 +- scripts/setup_machine.sh | 113 +++++++++++------- .../Pipeline/FirmwarePipeline.swift | 37 +++--- sources/vphone-cli/VPhoneCLI.swift | 2 + 5 files changed, 117 insertions(+), 62 deletions(-) diff --git a/Makefile b/Makefile index 02d7e06..6d49d39 100644 --- a/Makefile +++ b/Makefile @@ -111,8 +111,12 @@ help: .PHONY: setup_machine setup_tools setup_machine: - @if [ "$(filter 1 true yes YES TRUE,$(JB))" != "" ] && [ "$(filter 1 true yes YES TRUE,$(DEV))" != "" ]; then \ - echo "Error: JB=1 and DEV=1 are mutually exclusive"; \ + @if count=0; \ + [ -n "$(filter 1 true yes YES TRUE,$(JB))" ] && count=$$((count+1)); \ + [ -n "$(filter 1 true yes YES TRUE,$(DEV))" ] && count=$$((count+1)); \ + [ -n "$(filter 1 true yes YES TRUE,$(LESS))" ] && count=$$((count+1)); \ + [ $$count -gt 1 ]; then \ + echo "Error: JB=1, DEV=1, and LESS=1 are mutually exclusive"; \ exit 1; \ fi SUDO_PASSWORD="$(SUDO_PASSWORD)" \ @@ -120,6 +124,7 @@ setup_machine: zsh $(SCRIPTS)/setup_machine.sh \ $(if $(filter 1 true yes YES TRUE,$(JB)),--jb,) \ $(if $(filter 1 true yes YES TRUE,$(DEV)),--dev,) \ + $(if $(filter 1 true yes YES TRUE,$(LESS)),--less,) \ $(if $(filter 1 true yes YES TRUE,$(SKIP_PROJECT_SETUP)),--skip-project-setup,) setup_tools: @@ -271,7 +276,7 @@ boot_dfu: build boot_binary_check # Firmware pipeline # ═══════════════════════════════════════════════════════════════════ -.PHONY: fw_prepare fw_patch fw_patch_dev fw_patch_jb +.PHONY: fw_prepare fw_patch fw_patch_less fw_patch_dev fw_patch_jb fw_prepare: cd $(VM_DIR) && bash "$(CURDIR)/$(SCRIPTS)/fw_prepare.sh" @@ -279,6 +284,13 @@ fw_prepare: fw_patch: patcher_build "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(CURDIR)/$(VM_DIR)" --variant regular +fw_patch_less: patcher_build + @sh -c 'if [ "$$(id -u)" -ne 0 ]; then \ + echo "fw_patch_less must be run via sudo" >&2; \ + exit 1; \ + fi; \ + "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(CURDIR)/$(VM_DIR)" --variant less' + fw_patch_dev: patcher_build "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(CURDIR)/$(VM_DIR)" --variant dev diff --git a/README.md b/README.md index e25c4ba..15f5509 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ Boot a virtual iPhone (iOS 26) via Apple's Virtualization.framework using PCC re ## Firmware Variants -Three patch variants are available with increasing levels of security bypass: +Four patch variants are available with increasing levels of security bypass: | Variant | Boot Chain | CFW | Make Targets | | --------------- | :---------: | :-------: | ---------------------------------- | -| **Regular** | 1 patch | 10 phases | `fw_patch` + `cfw_install` | +| **Patchless** | 2 patches | 2 phases | `fw_patch_less` (sudo) | +| **Regular** | 41 patches | 10 phases | `fw_patch` + `cfw_install` | | **Development** | 52 patches | 12 phases | `fw_patch_dev` + `cfw_install_dev` | | **Jailbreak** | 112 patches | 14 phases | `fw_patch_jb` + `cfw_install_jb` | @@ -102,7 +103,8 @@ git clone --recurse-submodules https://github.com/Lakr233/vphone-cli.git ```bash make setup_machine # full automation through "First Boot" (includes restore/ramdisk/CFW) -# options: NONE_INTERACTIVE=1 SUDO_PASSWORD=... +# options: NONE_INTERACTIVE=1 SUDO_PASSWORD=... +# LESS=1 for patchless variant (- AMFI, SSV, Img4, TXM bypasses) # DEV=1 for dev variant (+ TXM entitlement/debug bypasses) # JB=1 for jailbreak variant (+ full security bypass) ``` @@ -116,6 +118,7 @@ make vm_new # create VM directory with manifest (config.plist) # options: CPU=8 MEMORY=8192 DISK_SIZE=64 make fw_prepare # download IPSWs, extract, merge, generate manifest make fw_patch # patch boot chain (regular variant) +# or: sudo make fw_patch_less # patchless variant (- AMFI, SSV, Img4, TXM bypasses) # or: make fw_patch_dev # dev variant (+ TXM entitlement/debug bypasses) # or: make fw_patch_jb # jailbreak variant (+ full security bypass) ``` diff --git a/scripts/setup_machine.sh b/scripts/setup_machine.sh index c7c50a5..82e0707 100755 --- a/scripts/setup_machine.sh +++ b/scripts/setup_machine.sh @@ -57,6 +57,7 @@ NONE_INTERACTIVE_RAW="${NONE_INTERACTIVE:-0}" NONE_INTERACTIVE=0 JB_MODE=0 DEV_MODE=0 +LESS_MODE=0 SKIP_PROJECT_SETUP=0 die() { @@ -723,6 +724,19 @@ run_make() { make "$@" } +run_make_sudo() { + local label="$1" + shift + + echo "" + echo "=== ${label} ===" + if [[ -n "${SUDO_PASSWORD:-}" ]]; then + sudo -A -- make "$@" + else + sudo -- make "$@" + fi +} + start_boot_dfu() { mkdir -p "$LOG_DIR" @@ -932,6 +946,9 @@ parse_args() { --dev) DEV_MODE=1 ;; + --less) + LESS_MODE=1 + ;; --skip-project-setup) SKIP_PROJECT_SETUP=1 ;; @@ -942,6 +959,7 @@ Usage: setup_machine.sh [--jb] [--dev] [--skip-project-setup] Options: --jb Use jailbreak firmware patching + jailbreak CFW install. --dev Use dev firmware patching + dev CFW install. + --less Use patchless firmware patching + CFW install. --skip-project-setup Skip setup_tools/build stage. Environment: @@ -968,8 +986,8 @@ main() { local cfw_install_target="cfw_install" local mode_label="base" - if [[ "$JB_MODE" -eq 1 && "$DEV_MODE" -eq 1 ]]; then - die "--jb and --dev are mutually exclusive" + if (( JB_MODE + DEV_MODE + LESS_MODE > 1 )); then + die "--jb, --dev, and --less are mutually exclusive" fi if [[ "$JB_MODE" -eq 1 ]]; then @@ -980,6 +998,10 @@ main() { fw_patch_target="fw_patch_dev" cfw_install_target="cfw_install_dev" mode_label="dev" + elif [[ "$LESS_MODE" -eq 1 ]]; then + fw_patch_target="fw_patch_less" + cfw_install_target="" + mode_label="less" fi echo "[*] setup_machine mode: ${mode_label}, project_setup=$([[ "$SKIP_PROJECT_SETUP" -eq 1 ]] && echo "skip" || echo "run"), non_interactive=${NONE_INTERACTIVE}" @@ -1003,7 +1025,11 @@ main() { run_make "Firmware prep" vm_new run_make "Firmware prep" fw_prepare - run_make "Firmware patch" "$fw_patch_target" + if [[ "$LESS_MODE" -eq 0 ]]; then + run_make "Firmware patch" "$fw_patch_target" + else + run_make_sudo "Firmware patch" "$fw_patch_target" + fi echo "" echo "=== Restore phase ===" @@ -1014,50 +1040,53 @@ main() { run_make "Restore" restore RESTORE_UDID="$DEVICE_UDID" RESTORE_ECID="0x$DEVICE_ECID" wait_for_post_restore_reboot stop_boot_dfu - echo "[*] Waiting ${POST_KILL_SETTLE_DELAY}s for cleanup before ramdisk stage..." - sleep "$POST_KILL_SETTLE_DELAY" - echo "" - echo "=== Ramdisk + CFW phase ===" - start_boot_dfu - load_device_identity - wait_for_recovery - run_make "Ramdisk" ramdisk_build RAMDISK_UDID="$DEVICE_UDID" - echo "[*] Ramdisk identity context: restore_udid=${DEVICE_UDID} ecid=0x${DEVICE_ECID}" - run_make "Ramdisk" ramdisk_send IRECOVERY_ECID="0x$DEVICE_ECID" RAMDISK_UDID="$DEVICE_UDID" - start_iproxy + if [[ "$LESS_MODE" -eq 0 ]]; then + echo "[*] Waiting ${POST_KILL_SETTLE_DELAY}s for cleanup before ramdisk stage..." + sleep "$POST_KILL_SETTLE_DELAY" + + echo "" + echo "=== Ramdisk + CFW phase ===" + start_boot_dfu + load_device_identity + wait_for_recovery + run_make "Ramdisk" ramdisk_build RAMDISK_UDID="$DEVICE_UDID" + echo "[*] Ramdisk identity context: restore_udid=${DEVICE_UDID} ecid=0x${DEVICE_ECID}" + run_make "Ramdisk" ramdisk_send IRECOVERY_ECID="0x$DEVICE_ECID" RAMDISK_UDID="$DEVICE_UDID" + start_iproxy - wait_for_ramdisk_ssh + wait_for_ramdisk_ssh - run_make "CFW install" "$cfw_install_target" SSH_PORT="$RAMDISK_SSH_PORT" - stop_boot_dfu - stop_iproxy + run_make "CFW install" "$cfw_install_target" SSH_PORT="$RAMDISK_SSH_PORT" + stop_boot_dfu + stop_iproxy - echo "" - echo "=== First boot ===" - if [[ "$NONE_INTERACTIVE" -eq 0 ]]; then - read -r "?[*] press Enter to start VM, after the VM has finished booting, press Enter again to finish last stage" - else - echo "[*] NONE_INTERACTIVE=1: auto-starting first boot" - fi + echo "" + echo "=== First boot ===" + if [[ "$NONE_INTERACTIVE" -eq 0 ]]; then + read -r "?[*] press Enter to start VM, after the VM has finished booting, press Enter again to finish last stage" + else + echo "[*] NONE_INTERACTIVE=1: auto-starting first boot" + fi - start_first_boot + start_first_boot - if [[ "$NONE_INTERACTIVE" -eq 0 ]]; then - read -r "?[*] Press Enter once the VM is fully booted" - else - wait_for_first_boot_prompt_auto - fi - send_first_boot_commands + if [[ "$NONE_INTERACTIVE" -eq 0 ]]; then + read -r "?[*] Press Enter once the VM is fully booted" + else + wait_for_first_boot_prompt_auto + fi + send_first_boot_commands - echo "[*] Commands sent. Waiting for VM shutdown..." - wait "$BOOT_PID" - BOOT_PID="" + echo "[*] Commands sent. Waiting for VM shutdown..." + wait "$BOOT_PID" + BOOT_PID="" - exec {BOOT_FIFO_FD}>&- || true - BOOT_FIFO_FD="" - rm -f "$BOOT_FIFO" || true - BOOT_FIFO="" + exec {BOOT_FIFO_FD}>&- || true + BOOT_FIFO_FD="" + rm -f "$BOOT_FIFO" || true + BOOT_FIFO="" + fi if [[ "$JB_MODE" -eq 1 ]]; then echo "" @@ -1072,7 +1101,11 @@ main() { echo "Setup completed." echo "=== Boot analysis ===" - run_boot_analysis + if [[ "$LESS_MODE" -eq 0 ]]; then + run_boot_analysis + else + run_make "Start VM" boot + fi } main "$@" diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index f70a9b7..325eb5e 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -24,6 +24,7 @@ public final class FirmwarePipeline { // MARK: - Variant public enum Variant: String, Sendable { + case less case regular case dev case jb @@ -173,7 +174,7 @@ public final class FirmwarePipeline { inRestoreDir: false, searchPatterns: ["AVPBooter*.bin"], patcherFactories: { - if variant != .regular { + if variant != .less { return [ { data, verbose in AVPBooterPatcher(data: data, verbose: verbose) @@ -191,9 +192,9 @@ public final class FirmwarePipeline { searchPatterns: ["Firmware/dfu/iBSS.vresearch101.RELEASE.im4p"], patcherFactories: { return switch variant { - case .regular: + case .less: [] - case .dev: + case .regular, .dev: [{ data, verbose in IBootPatcher(data: data, mode: .ibss, verbose: verbose) }] @@ -210,16 +211,16 @@ public final class FirmwarePipeline { }() )) - // 3. iBEC — The automatic patching is disabled for the regular variant (you can still just place a custom component there). + // 3. iBEC — The automatic patching is disabled for the patchless variant (you can still just place a custom component there). components.append(ComponentDescriptor( name: "iBEC", inRestoreDir: true, searchPatterns: ["Firmware/dfu/iBEC.vresearch101.RELEASE.im4p"], patcherFactories: { return switch variant { - case .regular: + case .less: [] - case .dev, .jb: + case .regular, .dev, .jb: [{ data, verbose in IBootPatcher(data: data, mode: .ibec, verbose: verbose) }] @@ -227,16 +228,16 @@ public final class FirmwarePipeline { }() )) - // 4. LLB — The automatic patching is disabled for the regular variant (you can still just place a custom component there). + // 4. LLB — The automatic patching is disabled for the patchless variant (you can still just place a custom component there). components.append(ComponentDescriptor( name: "LLB", inRestoreDir: true, searchPatterns: ["Firmware/all_flash/LLB.vresearch101.RELEASE.im4p"], patcherFactories: { return switch variant { - case .regular: + case .less: [] - case .dev, .jb: + case .regular, .dev, .jb: [{ data, verbose in IBootPatcher(data: data, mode: .llb, verbose: verbose) }] @@ -251,8 +252,12 @@ public final class FirmwarePipeline { searchPatterns: ["Firmware/txm.iphoneos.research.im4p"], patcherFactories: { return switch variant { - case .regular: + case .less: [] + case .regular: + [{ data, verbose in + TXMPatcher(data: data, verbose: verbose) + }] case .dev, .jb: [{ data, verbose in TXMDevPatcher(data: data, verbose: verbose) @@ -268,9 +273,9 @@ public final class FirmwarePipeline { searchPatterns: ["kernelcache.research.vphone600"], patcherFactories: { return switch variant { - case .regular: + case .less: [] - case .dev: + case .regular, .dev: [{ data, verbose in KernelPatcher(data: data, verbose: verbose) }] @@ -304,11 +309,11 @@ public final class FirmwarePipeline { searchPatterns: ["BuildManifest.plist"], patcherFactories: { return switch variant { - case .regular: + case .less: [{ data, verbose in CryptexFilesystemPatcher(buildManiest: data, restoreDir: try! self.findRestoreDirectory(), verbose: verbose) }] - case .dev, .jb: + case .regular, .dev, .jb: [] } }() @@ -321,11 +326,11 @@ public final class FirmwarePipeline { searchPatterns: ["BuildManifest.plist"], patcherFactories: { return switch variant { - case .regular: + case .less: [{ data, verbose in ManifestHashPatcher(data: data, restoreDir: try? self.findRestoreDirectory(), verbose: verbose) }] - case .dev, .jb: + case .regular, .dev, .jb: [] } }() diff --git a/sources/vphone-cli/VPhoneCLI.swift b/sources/vphone-cli/VPhoneCLI.swift index 18124ee..6c89f13 100644 --- a/sources/vphone-cli/VPhoneCLI.swift +++ b/sources/vphone-cli/VPhoneCLI.swift @@ -88,12 +88,14 @@ struct VPhoneBootCLI: ParsableCommand { struct PatchFirmwareCLI: ParsableCommand { enum VariantOption: String, CaseIterable, ExpressibleByArgument { + case less case regular case dev case jb var pipelineVariant: FirmwarePipeline.Variant { switch self { + case .less: .less case .regular: .regular case .dev: .dev case .jb: .jb From 692eb00e414fd551d565b176793aaeda2ec679a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:18:14 +0200 Subject: [PATCH 08/11] Add iBEC/LLB Patching for Serial Logs --- .../Pipeline/FirmwarePipeline.swift | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index 325eb5e..360a6b6 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -211,38 +211,24 @@ public final class FirmwarePipeline { }() )) - // 3. iBEC — The automatic patching is disabled for the patchless variant (you can still just place a custom component there). + // 3. iBEC components.append(ComponentDescriptor( name: "iBEC", inRestoreDir: true, searchPatterns: ["Firmware/dfu/iBEC.vresearch101.RELEASE.im4p"], - patcherFactories: { - return switch variant { - case .less: - [] - case .regular, .dev, .jb: - [{ data, verbose in - IBootPatcher(data: data, mode: .ibec, verbose: verbose) - }] - } - }() + patcherFactories: [{ data, verbose in + IBootPatcher(data: data, mode: .ibec, verbose: verbose) + }] )) - // 4. LLB — The automatic patching is disabled for the patchless variant (you can still just place a custom component there). + // 4. LLB components.append(ComponentDescriptor( name: "LLB", inRestoreDir: true, searchPatterns: ["Firmware/all_flash/LLB.vresearch101.RELEASE.im4p"], - patcherFactories: { - return switch variant { - case .less: - [] - case .regular, .dev, .jb: - [{ data, verbose in - IBootPatcher(data: data, mode: .llb, verbose: verbose) - }] - } - }() + patcherFactories: [{ data, verbose in + IBootPatcher(data: data, mode: .llb, verbose: verbose) + }] )) // 5. TXM — dev/jb variants use TXMDevPatcher (adds entitlements, debugger, dev-mode) From b630ce51eea563289994a5a3137f052370f6767e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:27:01 +0200 Subject: [PATCH 09/11] Add vphoned to Patchless Variant --- Makefile | 8 +- README.md | 12 +-- scripts/setup_machine.sh | 2 +- scripts/vphoned/vphoned.m | 6 +- .../Filesystem/CryptexFilesystemPatcher.swift | 96 +++++++++++++++++++ sources/vphone-cli/VPhoneAppDelegate.swift | 15 +-- sources/vphone-cli/VPhoneCLI.swift | 15 ++- sources/vphone-cli/VPhoneControl.swift | 7 +- sources/vphone-cli/VPhoneVirtualMachine.swift | 8 ++ 9 files changed, 150 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 6d49d39..850aed5 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,7 @@ help: @echo " make amfidont_allow_vphone Start amfidont for the signed vphone-cli binary" @echo " make boot_host_preflight Diagnose whether host can launch signed PV=3 binary" @echo " make boot Boot VM (reads from config.plist)" + @echo " make boot_less Boot VM in vphoned patchless compatibility" @echo " make boot_dfu Boot VM in DFU mode (reads from config.plist)" @echo "" @echo "Firmware pipeline:" @@ -86,6 +87,7 @@ help: @echo " IPHONE_SOURCE= URL or local path to iPhone IPSW" @echo " CLOUDOS_SOURCE= URL or local path to cloudOS IPSW" @echo " make fw_patch Patch boot chain with Swift pipeline (regular variant)" + @echo " make fw_patch_less Patch boot chain with Swift pipeline (less patches)" @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 "" @@ -198,7 +200,7 @@ vphoned: # VM management # ═══════════════════════════════════════════════════════════════════ -.PHONY: vm_new vm_backup vm_restore vm_switch vm_list amfidont_allow_vphone boot_host_preflight boot boot_dfu boot_binary_check +.PHONY: vm_new vm_backup vm_restore vm_switch vm_list amfidont_allow_vphone boot_host_preflight boot boot_less boot_dfu boot_binary_check vm_new: CPU="$(CPU)" MEMORY="$(MEMORY)" \ @@ -267,6 +269,10 @@ boot: bundle vphoned boot_binary_check cd $(VM_DIR) && "$(CURDIR)/$(BUNDLE_BIN)" \ --config ./config.plist +boot_less: bundle vphoned boot_binary_check + cd $(VM_DIR) && "$(CURDIR)/$(BUNDLE_BIN)" \ + --config ./config.plist --variant less + boot_dfu: build boot_binary_check cd $(VM_DIR) && "$(CURDIR)/$(BINARY)" \ --config ./config.plist \ diff --git a/README.md b/README.md index 15f5509..0ac012f 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ Boot a virtual iPhone (iOS 26) via Apple's Virtualization.framework using PCC re Four patch variants are available with increasing levels of security bypass: -| Variant | Boot Chain | CFW | Make Targets | -| --------------- | :---------: | :-------: | ---------------------------------- | -| **Patchless** | 2 patches | 2 phases | `fw_patch_less` (sudo) | -| **Regular** | 41 patches | 10 phases | `fw_patch` + `cfw_install` | -| **Development** | 52 patches | 12 phases | `fw_patch_dev` + `cfw_install_dev` | -| **Jailbreak** | 112 patches | 14 phases | `fw_patch_jb` + `cfw_install_jb` | +| Variant | Boot Chain | CFW | Make Targets | +| --------------- | :---------: | :-------: | ----------------------------------- | +| **Patchless** | 3 patches | 2 phases | `fw_patch_less` (root) + `boot_less`| +| **Regular** | 41 patches | 10 phases | `fw_patch` + `cfw_install` | +| **Development** | 52 patches | 12 phases | `fw_patch_dev` + `cfw_install_dev` | +| **Jailbreak** | 112 patches | 14 phases | `fw_patch_jb` + `cfw_install_jb` | > JB finalization (symlinks, Sileo, apt, TrollStore) runs automatically on first boot via `/cores/vphone_jb_setup.sh` LaunchDaemon. Monitor progress: `/var/log/vphone_jb_setup.log`. diff --git a/scripts/setup_machine.sh b/scripts/setup_machine.sh index 82e0707..72870df 100755 --- a/scripts/setup_machine.sh +++ b/scripts/setup_machine.sh @@ -1104,7 +1104,7 @@ main() { if [[ "$LESS_MODE" -eq 0 ]]; then run_boot_analysis else - run_make "Start VM" boot + run_make "Start VM" boot_less fi } diff --git a/scripts/vphoned/vphoned.m b/scripts/vphoned/vphoned.m index c31049c..3016609 100644 --- a/scripts/vphoned/vphoned.m +++ b/scripts/vphoned/vphoned.m @@ -413,6 +413,9 @@ int main(int argc, char *argv[]) { // Bootstrap: if running from install path and a cached update exists, exec // it const char *selfPath = self_executable_path(); + NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?"); + +#if !LESS if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 && access(CACHE_PATH, X_OK) == 0) { NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH); @@ -421,8 +424,7 @@ int main(int argc, char *argv[]) { strerror(errno)); unlink(CACHE_PATH); } - - NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?"); +#endif if (!vp_hid_load()) return 1; diff --git a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift index ce14d92..e8715ae 100644 --- a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift +++ b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift @@ -127,6 +127,10 @@ public final class CryptexFilesystemPatcher: Patcher { print("- Patch Mobile Activation") try patchMobileActivation(targetMount: targetMount, cfwInput: cfwInputPath) + + print("- Add vphoned") + try addVphoned(targetMount: targetMount, cfwInput: cfwInputPath) + try patchLaunchdCacheLoader(targetMount: targetMount, cfwInput: cfwInputPath) } print("- Finalizing merged image") @@ -143,6 +147,98 @@ public final class CryptexFilesystemPatcher: Patcher { return (newDmgPath, finalDestination) } + func patchLaunchdCacheLoader(targetMount: String, cfwInput: URL) throws { + let target = URL.init(filePath: targetMount) + let launchdCacheLoaderPath = target.appending(path: "/usr/libexec/launchd_cache_loader") + let pythonPath = vphoneCliDirectory.appending(path: ".venv/bin/python3") + let patcherPath = vphoneCliDirectory.appending(path: "scripts/patchers/cfw.py") + _ = try runProcess(pythonPath.path, [ + patcherPath.path, "patch-launchd-cache-loader", + launchdCacheLoaderPath.path + ]) + _ = try runProcess("/bin/chmod", ["0755", launchdCacheLoaderPath.path]) + + let signingCertificatePath = cfwInput.appending(path: "cfw_input/signcert.p12") + _ = try runProcess("/opt/homebrew/bin/ldid", [ + "-S", "-M", "-K\(signingCertificatePath.path)", + "-Icom.apple.launchd_cache_loader", + launchdCacheLoaderPath.path + ]) + } + + func addVphoned(targetMount: String, cfwInput: URL) throws { + let target = URL.init(filePath: targetMount) + let scriptDir = vphoneCliDirectory.appending(path: "scripts") + let vphonedSrc = scriptDir.appendingPathComponent("vphoned") + let vphonedBin = vphonedSrc.appendingPathComponent("vphoned") + + try buildVphoned(vphonedSrc: vphonedSrc, vphonedBin: vphonedBin) + + // Sign + let targetBin = target.appending(path: "/usr/bin/vphoned") + try FileManager.default.copyItem(at: vphonedBin, to: targetBin) + let signingCertificatePath = cfwInput.appending(path: "cfw_input/signcert.p12") + _ = try runProcess("/opt/homebrew/bin/ldid", [ + "-S\(vphonedSrc.appendingPathComponent("entitlements.plist").path)", + "-M", "-K\(signingCertificatePath.path)", + targetBin.path + ]) + _ = try runProcess("/bin/chmod", ["0755", targetBin.path]) + + let signedCopyPath = self.restoreDir.deletingLastPathComponent().appending(path: ".vphoned.signed") + if FileManager.default.fileExists(atPath: signedCopyPath.path) { + try FileManager.default.removeItem(at: signedCopyPath) + } + try FileManager.default.copyItem(at: targetBin, to: signedCopyPath) + + // Register Launch Daemon + let vphonedLaunchdPlist = vphonedSrc.appending(path: "vphoned.plist") + try FileManager.default.copyItem(at: vphonedLaunchdPlist, + to: target.appending(path: "System/Library/LaunchDaemons/vphoned.plist")) + let tmpDir = try createTmpDir() + let launchdPath = tmpDir.appending(path: "launchd.plist") + let launchDaemonsPath = tmpDir.appending(path: "launchDaemons") + let launchdOgPath = target.appending(path: "/System/Library/xpc/launchd.plist") + try FileManager.default.createDirectory(at: launchDaemonsPath, withIntermediateDirectories: false) + try FileManager.default.moveItem(at: launchdOgPath, to: launchdPath) + try FileManager.default.copyItem(at: vphonedLaunchdPlist, to: launchDaemonsPath.appending(path: vphonedLaunchdPlist.lastPathComponent)) + _ = try runProcess(vphoneCliDirectory.appending(path: ".venv/bin/python3").path, [ + vphoneCliDirectory.appending(path: "scripts/patchers/cfw.py").path, "inject-daemons", + launchdPath.path, launchDaemonsPath.path + ]) + try FileManager.default.moveItem(at: launchdPath, to: launchdOgPath) + _ = try runProcess("/bin/chmod", ["0644", launchdOgPath.path]) + } + + func buildVphoned(vphonedSrc: URL, vphonedBin: URL) throws { + let srcURLs = try FileManager.default.contentsOfDirectory( + at: vphonedSrc, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == "m" } + + var args = [ + "-sdk", "iphoneos", "clang", + "-arch", "arm64", + "-Os", + "-fobjc-arc", + "-I\(vphonedSrc.path)", + "-I\(vphonedSrc.appendingPathComponent("vendor/libarchive").path)", + "-DLESS=1", + "-o", vphonedBin.path + ] + args.append(contentsOf: srcURLs.map { $0.path }) + args.append(contentsOf: [ + "-larchive", + "-lsqlite3", + "-framework", "Foundation", + "-framework", "Security", + "-framework", "CoreServices" + ]) + + _ = try runProcess("/usr/bin/xcrun", args) + } + func addGpuDriver(targetMount: String, cfwInput: URL) throws { let target = URL.init(filePath: targetMount) diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index 980a300..1186fa9 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -51,12 +51,13 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { } print("=== vphone-cli ===") - print("ROM : \(options.romURL?.path ?? "None")") - print("Disk : \(options.diskURL.path)") - print("NVRAM : \(options.nvramURL.path)") - print("Config: \(options.configURL.path)") - print("CPU : \(options.cpuCount)") - print("Memory: \(options.memorySize / 1024 / 1024) MB") + print("Variant : \(options.variant)") + print("ROM : \(options.romURL?.path ?? "None")") + print("Disk : \(options.diskURL.path)") + print("NVRAM : \(options.nvramURL.path)") + print("Config : \(options.configURL.path)") + print("CPU : \(options.cpuCount)") + print("Memory : \(options.memorySize / 1024 / 1024) MB") print( "Screen: \(options.screenWidth)x\(options.screenHeight) @ \(options.screenPPI) PPI (scale \(options.screenScale)x)" ) @@ -75,7 +76,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { try await vm.start(forceDFU: cli.dfu) - let control = VPhoneControl() + let control = VPhoneControl(variant: options.variant) self.control = control if !cli.dfu { let vphonedURL = URL(fileURLWithPath: cli.vphonedBin) diff --git a/sources/vphone-cli/VPhoneCLI.swift b/sources/vphone-cli/VPhoneCLI.swift index 6c89f13..a570845 100644 --- a/sources/vphone-cli/VPhoneCLI.swift +++ b/sources/vphone-cli/VPhoneCLI.swift @@ -43,6 +43,9 @@ struct VPhoneBootCLI: ParsableCommand { @Option(help: "Path to signed vphoned binary for guest auto-update") var vphonedBin: String = ".vphoned.signed" + + @Option(help: "Firmware variant to execute.") + var variant: PatchFirmwareCLI.VariantOption = .regular @Option( help: "Automatically install the given IPA/TIPA after the guest control channel connects.", @@ -79,7 +82,8 @@ struct VPhoneBootCLI: ParsableCommand { screenHeight: manifest.screenConfig.height, screenPPI: manifest.screenConfig.pixelsPerInch, screenScale: manifest.screenConfig.scale, - kernelDebugPort: kernelDebugPort + kernelDebugPort: kernelDebugPort, + variant: variant.virtualMachineVariant ) } @@ -101,6 +105,15 @@ struct PatchFirmwareCLI: ParsableCommand { case .jb: .jb } } + + var virtualMachineVariant: VPhoneVirtualMachine.Variant { + switch self { + case .less: .less + case .regular: .regular + case .dev: .dev + case .jb: .jb + } + } } static let configuration = CommandConfiguration( diff --git a/sources/vphone-cli/VPhoneControl.swift b/sources/vphone-cli/VPhoneControl.swift index aa23fbf..c984415 100644 --- a/sources/vphone-cli/VPhoneControl.swift +++ b/sources/vphone-cli/VPhoneControl.swift @@ -41,7 +41,12 @@ class VPhoneControl { private var nextRequestId: UInt64 = 0 private var connectionAttemptToken: UInt64 = 0 private var reconnectWorkItem: DispatchWorkItem? + public var variant: VPhoneVirtualMachine.Variant = .regular + init(variant: VPhoneVirtualMachine.Variant) { + self.variant = variant + } + // MARK: - Pending Requests /// Callback for a pending request. Called on the read-loop queue. @@ -211,7 +216,7 @@ class VPhoneControl { self.isConnected = true print("[control] connected to \(name) v\(version), caps: \(caps)") - if needUpdate { + if needUpdate && self.variant != .less { self.pushUpdate(fd: fd) } else { self.startReadLoop(fd: fd, attemptToken: attemptToken) diff --git a/sources/vphone-cli/VPhoneVirtualMachine.swift b/sources/vphone-cli/VPhoneVirtualMachine.swift index fd51c17..428fae1 100644 --- a/sources/vphone-cli/VPhoneVirtualMachine.swift +++ b/sources/vphone-cli/VPhoneVirtualMachine.swift @@ -13,6 +13,13 @@ class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate { /// Synthetic battery source for runtime charge/connectivity updates. private var batterySource: AnyObject? + public enum Variant: String, Sendable { + case less + case regular + case dev + case jb + } + struct Options { var configURL: URL var romURL: URL? @@ -27,6 +34,7 @@ class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate { var screenPPI: Int = 460 var screenScale: Double = 3.0 var kernelDebugPort: Int? + var variant: Variant } private struct DeviceIdentity { From e7fbe0730d7283398b507d0d59e9e90483d50fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Pa=C3=9F?= <22845248+mpass99@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:22:36 +0200 Subject: [PATCH 10/11] Document Patchless arm64e Dependency --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 0ac012f..dc0d38b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ See [research/0_binary_patch_comparison.md](./research/0_binary_patch_comparison **Configure SIP/AMFI** — required for private Virtualization.framework entitlements and unsigned binary workflows. +The Patchless variant depends on an "untrusted" arm64e binary. You have to allow this by setting the host boot args "-arm64e_preview_abi" (and "amfi_get_out_of_my_way=1"). + + Boot into Recovery (long press power button), open Terminal, then choose one setup path: - **Option 1: Fully disable SIP + AMFI boot-arg (most permissive)** From 86ea9849fb6570a3ec8823b60c4242bb6611d5d0 Mon Sep 17 00:00:00 2001 From: retsel Date: Thu, 2 Apr 2026 21:01:22 +0200 Subject: [PATCH 11/11] Add debug mode to patchless --- Makefile | 4 +- README.md | 2 +- scripts/boot_host_preflight.sh | 10 +- scripts/setup_machine.sh | 14 +- scripts/setup_tools.sh | 2 +- .../DeviceTree/DeviceTreePatcher.swift | 105 +++++++++++-- .../Filesystem/CryptexFilesystemPatcher.swift | 145 +++++++++++++++++- .../Pipeline/FirmwarePipeline.swift | 15 +- .../FirmwarePatcher/TXM/TXMDevPatcher.swift | 33 +++- sources/vphone-cli/VPhoneCLI.swift | 6 +- 10 files changed, 303 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index 850aed5..cc69a42 100644 --- a/Makefile +++ b/Makefile @@ -88,6 +88,7 @@ help: @echo " CLOUDOS_SOURCE= URL or local path to cloudOS IPSW" @echo " make fw_patch Patch boot chain with Swift pipeline (regular variant)" @echo " make fw_patch_less Patch boot chain with Swift pipeline (less patches)" + @echo " DEBUG=1 Add debug tooling (SRD branding, developer mode, debugserver, SSH)" @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 "" @@ -127,6 +128,7 @@ setup_machine: $(if $(filter 1 true yes YES TRUE,$(JB)),--jb,) \ $(if $(filter 1 true yes YES TRUE,$(DEV)),--dev,) \ $(if $(filter 1 true yes YES TRUE,$(LESS)),--less,) \ + $(if $(filter 1 true yes YES TRUE,$(DEBUG)),--debug,) \ $(if $(filter 1 true yes YES TRUE,$(SKIP_PROJECT_SETUP)),--skip-project-setup,) setup_tools: @@ -295,7 +297,7 @@ fw_patch_less: patcher_build echo "fw_patch_less must be run via sudo" >&2; \ exit 1; \ fi; \ - "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(CURDIR)/$(VM_DIR)" --variant less' + "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(CURDIR)/$(VM_DIR)" --variant less $(if $(filter 1 true yes YES TRUE,$(DEBUG)),--debug,)' fw_patch_dev: patcher_build "$(CURDIR)/$(PATCHER_BINARY)" patch-firmware --vm-directory "$(CURDIR)/$(VM_DIR)" --variant dev diff --git a/README.md b/README.md index dc0d38b..88e8a61 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Boot into Recovery (long press power button), open Terminal, then choose one set **Install dependencies:** ```bash -brew install aria2 ideviceinstaller wget gnu-tar openssl@3 ldid-procursus sshpass keystone autoconf automake pkg-config libtool cmake ipsw +brew install aria2 ideviceinstaller wget gnu-tar openssl@3 ldid-procursus sshpass keystone autoconf automake pkg-config libtool cmake ipsw dropbear ``` `scripts/fw_prepare.sh` prefers `aria2c` for faster multi-connection downloads and falls back to `curl` or `wget` when needed. diff --git a/scripts/boot_host_preflight.sh b/scripts/boot_host_preflight.sh index ae4d0d2..65bf56a 100644 --- a/scripts/boot_host_preflight.sh +++ b/scripts/boot_host_preflight.sh @@ -71,13 +71,17 @@ RESEARCH_GUEST_STATUS="$( # Single install or already got a direct answer echo "$_out" else - # Multiple installs: try to auto-select "Macintosh HD" - _num=$(echo "$_out" | awk '/Macintosh HD/ { match($0, /[0-9]+/); if (RSTART > 0) { print substr($0, RSTART, RLENGTH); exit } }') + # Multiple installs: try to auto-select the currently booted volume. + _boot_vol=$(diskutil info / 2>/dev/null | awk -F':[[:space:]]+' '/Volume Name/ {print $2; exit}' || true) + _num="" + if [[ -n "$_boot_vol" ]]; then + _num=$(echo "$_out" | awk -v vol="$_boot_vol" 'index($0, vol) { match($0, /[0-9]+/); if (RSTART > 0) { print substr($0, RSTART, RLENGTH); exit } }') + fi if [[ -n "$_num" ]]; then _result=$(printf '%s\n' "$_num" | csrutil allow-research-guests status 2>/dev/null \ | grep -o 'Allow Research Guests status:.*' || true) if [[ -n "$_result" ]]; then - echo "(auto-selected: Macintosh HD) $_result" + echo "(auto-selected: ${_boot_vol}) $_result" exit 0 fi fi diff --git a/scripts/setup_machine.sh b/scripts/setup_machine.sh index 72870df..2c2a332 100755 --- a/scripts/setup_machine.sh +++ b/scripts/setup_machine.sh @@ -58,6 +58,7 @@ NONE_INTERACTIVE=0 JB_MODE=0 DEV_MODE=0 LESS_MODE=0 +DEBUG_MODE=0 SKIP_PROJECT_SETUP=0 die() { @@ -949,17 +950,21 @@ parse_args() { --less) LESS_MODE=1 ;; + --debug) + DEBUG_MODE=1 + ;; --skip-project-setup) SKIP_PROJECT_SETUP=1 ;; -h|--help) cat <<'EOF' -Usage: setup_machine.sh [--jb] [--dev] [--skip-project-setup] +Usage: setup_machine.sh [--jb] [--dev] [--less] [--debug] [--skip-project-setup] Options: --jb Use jailbreak firmware patching + jailbreak CFW install. --dev Use dev firmware patching + dev CFW install. --less Use patchless firmware patching + CFW install. + --debug Add debug tooling to less variant (SRD, devmode, debugserver, SSH). --skip-project-setup Skip setup_tools/build stage. Environment: @@ -1002,6 +1007,7 @@ main() { fw_patch_target="fw_patch_less" cfw_install_target="" mode_label="less" + [[ "$DEBUG_MODE" -eq 1 ]] && mode_label="less+debug" fi echo "[*] setup_machine mode: ${mode_label}, project_setup=$([[ "$SKIP_PROJECT_SETUP" -eq 1 ]] && echo "skip" || echo "run"), non_interactive=${NONE_INTERACTIVE}" @@ -1028,7 +1034,11 @@ main() { if [[ "$LESS_MODE" -eq 0 ]]; then run_make "Firmware patch" "$fw_patch_target" else - run_make_sudo "Firmware patch" "$fw_patch_target" + if [[ "$DEBUG_MODE" -eq 1 ]]; then + run_make_sudo "Firmware patch" "$fw_patch_target" "DEBUG=1" + else + run_make_sudo "Firmware patch" "$fw_patch_target" + fi fi echo "" diff --git a/scripts/setup_tools.sh b/scripts/setup_tools.sh index 9f68271..95593b8 100644 --- a/scripts/setup_tools.sh +++ b/scripts/setup_tools.sh @@ -26,7 +26,7 @@ ensure_repo_submodule() { echo "[1/5] Checking brew packages..." -BREW_PACKAGES=(aria2 gnu-tar openssl@3 ldid-procursus sshpass) +BREW_PACKAGES=(aria2 gnu-tar openssl@3 ldid-procursus sshpass dropbear) BREW_MISSING=() for pkg in "${BREW_PACKAGES[@]}"; do diff --git a/sources/FirmwarePatcher/DeviceTree/DeviceTreePatcher.swift b/sources/FirmwarePatcher/DeviceTree/DeviceTreePatcher.swift index a71437a..2a13c6a 100644 --- a/sources/FirmwarePatcher/DeviceTree/DeviceTreePatcher.swift +++ b/sources/FirmwarePatcher/DeviceTree/DeviceTreePatcher.swift @@ -5,7 +5,9 @@ // Strategy: // 1. Parse the flat device tree binary into a node/property tree. // 2. Apply a fixed set of property patches (serial-number, home-button-type, -// artwork-device-subtype, island-notch-location). +// artwork-device-subtype, island-notch-location). When debug is enabled, +// also apply SRD patches (research-enabled, debug-enabled, esdm-fuses, +// product-description, product-name). // 3. Serialize the modified tree back to flat binary. import Foundation @@ -14,6 +16,7 @@ import Foundation public final class DeviceTreePatcher: Patcher { public let component = "devicetree" public let verbose: Bool + let debug: Bool let buffer: BinaryBuffer var patches: [PatchRecord] = [] @@ -30,6 +33,10 @@ public final class DeviceTreePatcher: Patcher { let value: PropertyValue let patchID: String let description: String + /// When true, insert the property if it does not already exist in the node. + var insertIfMissing: Bool = false + /// When true, this patch is only applied when `debug` is enabled. + var debugOnly: Bool = false } /// The value to write into a device tree property. @@ -76,6 +83,59 @@ public final class DeviceTreePatcher: Patcher { patchID: "devicetree.island_notch_location", description: "Set island notch location to 144" ), + PropertyPatch( + nodePath: ["device-tree", "chosen"], + property: "research-enabled", + length: 4, + flags: 0, + value: .integer(1), + patchID: "devicetree.research_enabled", + description: "Set research-enabled = 1 in chosen", + insertIfMissing: true, + debugOnly: true + ), + PropertyPatch( + nodePath: ["device-tree", "chosen"], + property: "debug-enabled", + length: 4, + flags: 0, + value: .integer(1), + patchID: "devicetree.debug_enabled", + description: "Set debug-enabled = 1 in chosen", + insertIfMissing: true, + debugOnly: true + ), + PropertyPatch( + nodePath: ["device-tree", "chosen"], + property: "esdm-fuses", + length: 4, + flags: 0, + value: .integer(1), + patchID: "devicetree.esdm_fuses", + description: "Set esdm-fuses = 1 (ESDM bit 0) in chosen", + insertIfMissing: true, + debugOnly: true + ), + PropertyPatch( + nodePath: ["device-tree", "product"], + property: "product-description", + length: 26, + flags: 0, + value: .string("iPhone 16 Research Device"), + patchID: "devicetree.product_description", + description: "Set product-description to iPhone 16 Research Device", + debugOnly: true + ), + PropertyPatch( + nodePath: ["device-tree", "product"], + property: "product-name", + length: 26, + flags: 0, + value: .string("iPhone 16 Research Device"), + patchID: "devicetree.product_name", + description: "Set product-name to iPhone 16 Research Device", + debugOnly: true + ), ] // MARK: - Device Tree Structures @@ -106,9 +166,10 @@ public final class DeviceTreePatcher: Patcher { // MARK: - Init - public init(data: Data, verbose: Bool = true) { + public init(data: Data, verbose: Bool = true, debug: Bool = false) { buffer = BinaryBuffer(data) self.verbose = verbose + self.debug = debug } // MARK: - Patcher @@ -308,6 +369,16 @@ public final class DeviceTreePatcher: Patcher { return raw } + /// Encode a `PropertyValue` into bytes. + private static func encodeValue(_ value: PropertyValue, length: Int) throws -> Data { + switch value { + case let .string(s): + return encodeFixedString(s, length: length) + case let .integer(v): + return try encodeInteger(v, length: length) + } + } + /// Encode an integer value as little-endian bytes. private static func encodeInteger(_ value: UInt64, length: Int) throws -> Data { var data = Data(count: length) @@ -333,19 +404,31 @@ public final class DeviceTreePatcher: Patcher { /// Apply all property patches and record each change. private func applyPatches(root: DTNode) throws { for patch in Self.propertyPatches { - let node = try resolveNode(root, path: patch.nodePath) - let prop = try findProperty(node, name: patch.property) + if patch.debugOnly && !debug { continue } - let originalBytes = Data(prop.value.prefix(patch.length)) + let node = try resolveNode(root, path: patch.nodePath) - let newValue: Data = switch patch.value { - case let .string(s): - Self.encodeFixedString(s, length: patch.length) - case let .integer(v): - try Self.encodeInteger(v, length: patch.length) + let prop: DTProperty + let originalBytes: Data + + if let existing = node.properties.first(where: { $0.name == patch.property }) { + prop = existing + originalBytes = Data(prop.value.prefix(patch.length)) + } else if patch.insertIfMissing { + let newProp = DTProperty( + name: patch.property, length: 0, flags: 0, + value: Data(), valueOffset: 0 + ) + node.properties.append(newProp) + prop = newProp + originalBytes = Data() + } else { + throw PatcherError.patchSiteNotFound("DeviceTree: missing property '\(patch.property)'") } - prop.length = patch.length + let newValue = try Self.encodeValue(patch.value, length: patch.length) + + prop.length = newValue.count prop.flags = patch.flags prop.value = newValue diff --git a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift index e8715ae..1d26ab8 100644 --- a/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift +++ b/sources/FirmwarePatcher/Filesystem/CryptexFilesystemPatcher.swift @@ -29,6 +29,7 @@ public final class CryptexFilesystemPatcher: Patcher { public let component = "Filesystem" public let restoreDir: URL public let verbose: Bool + let debug: Bool let vphoneCliDirectory = URL(filePath: "./") var buildManiest: Data @@ -37,10 +38,11 @@ public final class CryptexFilesystemPatcher: Patcher { // MARK: - Init - public init(buildManiest: Data, restoreDir: URL, verbose: Bool = true) { + public init(buildManiest: Data, restoreDir: URL, verbose: Bool = true, debug: Bool = false) { self.buildManiest = buildManiest self.restoreDir = restoreDir self.verbose = verbose + self.debug = debug } deinit { @@ -128,6 +130,14 @@ public final class CryptexFilesystemPatcher: Patcher { print("- Patch Mobile Activation") try patchMobileActivation(targetMount: targetMount, cfwInput: cfwInputPath) + if debug { + print("- Patch debugserver") + try patchDebugserver(targetMount: targetMount, cfwInput: cfwInputPath) + + print("- Add dropbear (SSH)") + try addDropbear(targetMount: targetMount, cfwInput: cfwInputPath) + } + print("- Add vphoned") try addVphoned(targetMount: targetMount, cfwInput: cfwInputPath) try patchLaunchdCacheLoader(targetMount: targetMount, cfwInput: cfwInputPath) @@ -202,6 +212,16 @@ public final class CryptexFilesystemPatcher: Patcher { try FileManager.default.createDirectory(at: launchDaemonsPath, withIntermediateDirectories: false) try FileManager.default.moveItem(at: launchdOgPath, to: launchdPath) try FileManager.default.copyItem(at: vphonedLaunchdPlist, to: launchDaemonsPath.appending(path: vphonedLaunchdPlist.lastPathComponent)) + // Include SSH daemon plists so inject-daemons registers them in launchd + if debug { + let daemonPlistsDir = cfwInput.appending(path: "cfw_input/jb/LaunchDaemons") + for name in ["dropbear.plist", "bash.plist"] { + let src = daemonPlistsDir.appending(path: name) + if FileManager.default.fileExists(atPath: src.path) { + try FileManager.default.copyItem(at: src, to: launchDaemonsPath.appending(path: name)) + } + } + } _ = try runProcess(vphoneCliDirectory.appending(path: ".venv/bin/python3").path, [ vphoneCliDirectory.appending(path: "scripts/patchers/cfw.py").path, "inject-daemons", launchdPath.path, launchDaemonsPath.path @@ -272,8 +292,10 @@ public final class CryptexFilesystemPatcher: Patcher { func patchMobileActivation(targetMount: String, cfwInput: URL) throws { let target = URL.init(filePath: targetMount) let mobileActivationdPath = target.appending(path: "/usr/libexec/mobileactivationd") - _ = try runProcess("./.venv/bin/python3", [ - "./scripts/patchers/cfw.py", "patch-mobileactivationd", + let pythonPath = vphoneCliDirectory.appending(path: ".venv/bin/python3") + let patcherPath = vphoneCliDirectory.appending(path: "scripts/patchers/cfw.py") + _ = try runProcess(pythonPath.path, [ + patcherPath.path, "patch-mobileactivationd", mobileActivationdPath.path ]) _ = try runProcess("/bin/chmod", ["0755", mobileActivationdPath.path]) @@ -285,6 +307,123 @@ public final class CryptexFilesystemPatcher: Patcher { ]) } + // MARK: - Debug: Debugserver + + func patchDebugserver(targetMount: String, cfwInput: URL) throws { + let target = URL(filePath: targetMount) + let debugserverPath = target.appending(path: "/usr/libexec/debugserver") + + guard FileManager.default.fileExists(atPath: debugserverPath.path) else { + print(" debugserver not found, skipping") + return + } + + // Extract stock entitlements (: prefix = raw plist without blob header) + let tmpDir = try createTmpDir() + let stockEntPath = tmpDir.appending(path: "debugserver-stock.plist") + _ = try runProcess("/usr/bin/codesign", [ + "-d", "--entitlements", ":\(stockEntPath.path)", debugserverPath.path + ]) + + // Convert to XML for plutil editing + let researchEntPath = tmpDir.appending(path: "debugserver-research.plist") + _ = try runProcess("/usr/bin/plutil", [ + "-convert", "xml1", "-o", researchEntPath.path, stockEntPath.path + ]) + + // Merge SRD entitlements on top of stock (matching Apple's SRD tooling approach). + // plutil uses '.' as nesting operator — entitlement key dots must be escaped. + let srdKeys: [(String, String)] = [ + ("research\\.com\\.apple\\.license-to-operate", "true"), + ("task_for_pid-allow", "true"), + ("com\\.apple\\.private\\.cs\\.debugger", "true"), + ("com\\.apple\\.private\\.security\\.no-container", "true"), + ("com\\.apple\\.private\\.memorystatus", "true"), + ("com\\.apple\\.private\\.logging\\.diagnostic", "true"), + ("com\\.apple\\.security\\.network\\.client", "true"), + ("com\\.apple\\.security\\.network\\.server", "true"), + ("com\\.apple\\.backboardd\\.debugapplications", "true"), + ("com\\.apple\\.backboardd\\.launchapplications", "true"), + ("com\\.apple\\.frontboard\\.debugapplications", "true"), + ("com\\.apple\\.frontboard\\.launchapplications", "true"), + ("com\\.apple\\.springboard\\.debugapplications", "true"), + ] + for (key, val) in srdKeys { + _ = try? runProcess("/usr/bin/plutil", [ + "-insert", key, "-bool", val, "--", researchEntPath.path + ]) + } + + // Remove seatbelt sandbox profile (opt out of platform sandbox) + _ = try? runProcess("/usr/bin/plutil", [ + "-remove", "seatbelt-profiles", "--", researchEntPath.path + ]) + + // Re-sign preserving identifier/requirements/flags/runtime + _ = try runProcess("/usr/bin/codesign", [ + "--sign", "-", + "--force", + "--preserve-metadata=identifier,requirements,flags,runtime", + "--entitlements", researchEntPath.path, + debugserverPath.path + ]) + _ = try runProcess("/bin/chmod", ["0755", debugserverPath.path]) + } + + // MARK: - Debug: Dropbear SSH + + func addDropbear(targetMount: String, cfwInput: URL) throws { + let target = URL(filePath: targetMount) + let fm = FileManager.default + + // Extract iosbinpack64 (dropbear, bash, utilities) + let binpackTarPath = cfwInput.appending(path: "cfw_input/jb/iosbinpack64.tar") + _ = try runProcess("/usr/bin/tar", [ + "-xpf", binpackTarPath.path, + "-C", target.path + ]) + _ = try? runProcess("/usr/bin/find", [ + target.appending(path: "iosbinpack64").path, "-name", "._*", "-delete" + ]) + _ = try runProcess("/usr/sbin/chown", ["-R", "0:0", target.appending(path: "iosbinpack64").path]) + + // Pre-generate dropbear host keys + let dropbearkeyPath = "/opt/homebrew/bin/dropbearkey" + guard fm.fileExists(atPath: dropbearkeyPath) else { + throw ProcessError.failed(1, "dropbearkey not found — install with: brew install dropbear") + } + let dropbearDir = target.appending(path: "var/dropbear") + try fm.createDirectory(at: dropbearDir, withIntermediateDirectories: true) + _ = try runProcess(dropbearkeyPath, [ + "-t", "rsa", "-f", dropbearDir.appending(path: "dropbear_rsa_host_key").path + ]) + _ = try runProcess(dropbearkeyPath, [ + "-t", "ecdsa", "-f", dropbearDir.appending(path: "dropbear_ecdsa_host_key").path + ]) + + // Copy shell profiles + let binpackEtc = target.appending(path: "iosbinpack64/etc") + let varDir = target.appending(path: "var") + for name in ["profile", "motd"] { + let src = binpackEtc.appending(path: name) + let dst = varDir.appending(path: name) + if fm.fileExists(atPath: src.path) && !fm.fileExists(atPath: dst.path) { + try fm.copyItem(at: src, to: dst) + } + } + + // Copy SSH daemon plists to LaunchDaemons + let daemonPlistsDir = cfwInput.appending(path: "cfw_input/jb/LaunchDaemons") + let launchDaemonsDir = target.appending(path: "System/Library/LaunchDaemons") + for name in ["dropbear.plist", "bash.plist"] { + let src = daemonPlistsDir.appending(path: name) + let dst = launchDaemonsDir.appending(path: name) + if fm.fileExists(atPath: src.path) && !fm.fileExists(atPath: dst.path) { + try fm.copyItem(at: src, to: dst) + } + } + } + func addDyldSymlinks(targetMount: String) throws { let target = URL.init(filePath: targetMount) _ = try runProcess("/bin/ln", [ diff --git a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift index 360a6b6..b191445 100644 --- a/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift +++ b/sources/FirmwarePatcher/Pipeline/FirmwarePipeline.swift @@ -74,6 +74,7 @@ public final class FirmwarePipeline { let vmDirectory: URL let variant: Variant let verbose: Bool + let debug: Bool let loader: any FirmwareLoader // MARK: - Init @@ -82,11 +83,13 @@ public final class FirmwarePipeline { vmDirectory: URL, variant: Variant = .regular, verbose: Bool = true, + debug: Bool = false, loader: (any FirmwareLoader)? = nil ) { self.vmDirectory = vmDirectory self.variant = variant self.verbose = verbose + self.debug = debug self.loader = loader ?? ContainerFirmwareLoader() } @@ -231,13 +234,17 @@ public final class FirmwarePipeline { }] )) - // 5. TXM — dev/jb variants use TXMDevPatcher (adds entitlements, debugger, dev-mode) + // 5. TXM — dev/jb variants use TXMDevPatcher; less+debug uses developerMode only components.append(ComponentDescriptor( name: "TXM", inRestoreDir: true, searchPatterns: ["Firmware/txm.iphoneos.research.im4p"], patcherFactories: { return switch variant { + case .less where debug: + [{ data, verbose in + TXMDevPatcher(data: data, verbose: verbose, patchSet: .developerMode) + }] case .less: [] case .regular: @@ -278,13 +285,13 @@ public final class FirmwarePipeline { }() )) - // 7. DeviceTree — same for all variants + // 7. DeviceTree — same for all variants; debug adds SRD branding patches components.append(ComponentDescriptor( name: "DeviceTree", inRestoreDir: true, searchPatterns: ["Firmware/all_flash/DeviceTree.vphone600ap.im4p"], patcherFactories: [{ data, verbose in - DeviceTreePatcher(data: data, verbose: verbose) + DeviceTreePatcher(data: data, verbose: verbose, debug: self.debug) }] )) @@ -297,7 +304,7 @@ public final class FirmwarePipeline { return switch variant { case .less: [{ data, verbose in - CryptexFilesystemPatcher(buildManiest: data, restoreDir: try! self.findRestoreDirectory(), verbose: verbose) + CryptexFilesystemPatcher(buildManiest: data, restoreDir: try! self.findRestoreDirectory(), verbose: verbose, debug: self.debug) }] case .regular, .dev, .jb: [] diff --git a/sources/FirmwarePatcher/TXM/TXMDevPatcher.swift b/sources/FirmwarePatcher/TXM/TXMDevPatcher.swift index 4f169e6..a746455 100644 --- a/sources/FirmwarePatcher/TXM/TXMDevPatcher.swift +++ b/sources/FirmwarePatcher/TXM/TXMDevPatcher.swift @@ -13,14 +13,35 @@ import Foundation /// 4. debugger entitlement BL → mov w0, #1 /// 5. developer-mode guard → nop public final class TXMDevPatcher: TXMPatcher { + /// Controls which patches are applied. + public enum PatchSet { + /// Trustcache bypass + all dev patches (selector24, get-task-allow, + /// selector42|29 shellcode, debugger entitlement, developer mode). + case all + /// Developer-mode guard NOP only (for the `less` variant with `--debug`). + case developerMode + } + + let patchSet: PatchSet + + public init(data: Data, verbose: Bool = true, patchSet: PatchSet = .all) { + self.patchSet = patchSet + super.init(data: data, verbose: verbose) + } + override public func findAll() throws -> [PatchRecord] { patches = [] - try patchTrustcacheBypass() // base patch - patchSelector24ForcePass() - patchGetTaskAllowForceTrue() - patchSelector42_29Shellcode() - patchDebuggerEntitlementForceTrue() - patchDeveloperModeBypass() + switch patchSet { + case .all: + try patchTrustcacheBypass() // base patch + patchSelector24ForcePass() + patchGetTaskAllowForceTrue() + patchSelector42_29Shellcode() + patchDebuggerEntitlementForceTrue() + patchDeveloperModeBypass() + case .developerMode: + patchDeveloperModeBypass() + } return patches } diff --git a/sources/vphone-cli/VPhoneCLI.swift b/sources/vphone-cli/VPhoneCLI.swift index a570845..af972ac 100644 --- a/sources/vphone-cli/VPhoneCLI.swift +++ b/sources/vphone-cli/VPhoneCLI.swift @@ -140,11 +140,15 @@ struct PatchFirmwareCLI: ParsableCommand { @Flag(name: .customLong("quiet"), help: "Suppress per-component progress output.") var quiet: Bool = false + @Flag(name: .customLong("debug"), help: "Enable debug tooling (SRD branding, developer mode, debugserver, dropbear SSH).") + var debug: Bool = false + mutating func run() throws { let pipeline = FirmwarePipeline( vmDirectory: vmDirectory, variant: variant.pipelineVariant, - verbose: !quiet + verbose: !quiet, + debug: debug ) let records = try pipeline.patchAll()