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()