mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-10-02 08:04:32 +08:00
Close review gaps in the hardening pass
- cfw install on a Disk.img that cannot be cloned renames the root snapshot through a fresh no-follow descriptor. That descriptor must still be the single-link inode checked at the start. It no longer reopens the path minutes later. - Guest clipboard images use the download size limit again, so images over 64 MiB copy as before. - The --api-listen proxy always forwards admitted requests with Host: localhost, so any loopback or LAN listen address works with vphoned's Host check. - Cancelling a CFW install from Launchpad needs no authorization and never prompts. The helper still stops only an install the same user started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bb26d0250a
commit
46802eb728
+35
-3
@@ -121,8 +121,10 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
// Another volume, or no clone support: attach the caller's file
|
||||
// in place. Confirm the name still refers to the inode verified
|
||||
// above immediately before hdiutil opens it. A swap in the moment
|
||||
// between this check and hdiutil's own open remains possible;
|
||||
// the clone path, the normal case on APFS, has no such window.
|
||||
// between this check and hdiutil's own open remains possible,
|
||||
// and hdiutil then works on the caller's own replacement. The
|
||||
// snapshot rename below writes only the verified inode. The clone
|
||||
// path, the normal case on APFS, has no such window.
|
||||
guard try bundleDirectory.refersTo("Disk.img", file: disk) else {
|
||||
throw ValidationError("The VM disk image changed during the install. Try again.")
|
||||
}
|
||||
@@ -207,7 +209,11 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
systemMounted = false
|
||||
_ = try tool("/usr/bin/hdiutil", ["detach", baseDisk], quiet: true)
|
||||
diskAttached = false
|
||||
try VPhoneAPFSSnapshot.rename(imageAt: image)
|
||||
if cloned {
|
||||
try VPhoneAPFSSnapshot.rename(imageAt: image)
|
||||
} else {
|
||||
try renameSnapshot(in: bundleDirectory, verified: disk, label: image)
|
||||
}
|
||||
if cloned {
|
||||
// Hand the clone back with the original's owner and mode, then
|
||||
// swap it in by rename through the pinned bundle descriptor.
|
||||
@@ -257,6 +263,32 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames the root snapshot in the caller's Disk.img when it could not
|
||||
/// be cloned. The install runs for minutes after the image was checked,
|
||||
/// so the name is opened again without following links and must still be
|
||||
/// the same single-link inode before root writes to it.
|
||||
private func renameSnapshot(
|
||||
in bundle: VPhoneConfinedDirectory,
|
||||
verified disk: VPhoneConfinedFile,
|
||||
label: URL,
|
||||
) throws {
|
||||
let writable = openat(bundle.descriptor, "Disk.img", O_RDWR | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC)
|
||||
guard writable >= 0 else {
|
||||
throw ValidationError("The VM disk image changed during the install. Try again.")
|
||||
}
|
||||
defer { close(writable) }
|
||||
var metadata = stat()
|
||||
guard fstat(writable, &metadata) == 0,
|
||||
metadata.st_mode & S_IFMT == S_IFREG,
|
||||
metadata.st_dev == disk.device,
|
||||
metadata.st_ino == disk.inode,
|
||||
metadata.st_nlink == 1
|
||||
else {
|
||||
throw ValidationError("The VM disk image changed during the install. Try again.")
|
||||
}
|
||||
try VPhoneAPFSSnapshot.rename(descriptor: writable, url: label)
|
||||
}
|
||||
|
||||
/// The prepared restore tree: a real folder (not a link) owned by the
|
||||
/// caller, holding iPhone-BuildManifest.plist. `iPhone*_Restore` is what
|
||||
/// `fw prepare` writes; any other `*Restore*` folder is the older fallback.
|
||||
|
||||
@@ -59,10 +59,11 @@ public final class VPhoneAPIProxy {
|
||||
@discardableResult
|
||||
public func start() async throws -> (url: URL, token: String) {
|
||||
let token = try Self.makeToken()
|
||||
// vphoned accepts only a loopback Host. A proxy on another address
|
||||
// receives that address as Host, so it forwards admitted requests
|
||||
// vphoned accepts only a few loopback Host names, and a client names
|
||||
// whatever address the proxy listens on (127.0.0.2, a LAN address).
|
||||
// The token already admitted the request, so it is always forwarded
|
||||
// with `Host: localhost`.
|
||||
let forwardedHost = Self.isLoopback(host) ? nil : "localhost"
|
||||
let forwardedHost = "localhost"
|
||||
let provider = provider
|
||||
let channel = try await ServerBootstrap(group: group)
|
||||
.serverChannelOption(ChannelOptions.backlog, value: 128)
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@ final class VPhoneGuestControl {
|
||||
var info = try await call("clipboard.get")
|
||||
let image =
|
||||
(info["has_image"] as? Bool == true)
|
||||
? try await http(method: "GET", path: "/v1/clipboard/image").body : nil
|
||||
? try await http(method: "GET", path: "/v1/clipboard/image", limits: .download).body : nil
|
||||
info["ok"] = true
|
||||
return (info, image)
|
||||
}
|
||||
|
||||
@@ -192,6 +192,24 @@ public enum VPhoneAPFSSnapshot {
|
||||
newPrefix: String = defaultNewPrefix,
|
||||
dryRun: Bool = false,
|
||||
log: (String) -> Void = { print($0) },
|
||||
) throws -> Report {
|
||||
let fd = open(url.path, dryRun ? O_RDONLY : O_RDWR)
|
||||
guard fd >= 0 else { throw VPhoneAPFSSnapshotError.cannotOpen(url, errno: errno) }
|
||||
defer { close(fd) }
|
||||
return try rename(descriptor: fd, url: url, newPrefix: newPrefix, dryRun: dryRun, log: log)
|
||||
}
|
||||
|
||||
/// The same rename on an image the caller already opened (read-write
|
||||
/// unless `dryRun`). Root code uses this to rewrite the exact file it
|
||||
/// verified rather than whatever the path names later. `url` only labels
|
||||
/// errors.
|
||||
@discardableResult
|
||||
public static func rename(
|
||||
descriptor fd: Int32,
|
||||
url: URL,
|
||||
newPrefix: String = defaultNewPrefix,
|
||||
dryRun: Bool = false,
|
||||
log: (String) -> Void = { print($0) },
|
||||
) throws -> Report {
|
||||
let newPrefixBytes = Array(newPrefix.utf8)
|
||||
guard newPrefixBytes.count == oldPrefix.count else {
|
||||
@@ -201,10 +219,6 @@ public enum VPhoneAPFSSnapshot {
|
||||
)
|
||||
}
|
||||
|
||||
let fd = open(url.path, dryRun ? O_RDONLY : O_RDWR)
|
||||
guard fd >= 0 else { throw VPhoneAPFSSnapshotError.cannotOpen(url, errno: errno) }
|
||||
defer { close(fd) }
|
||||
|
||||
var stats = stat()
|
||||
guard fstat(fd, &stats) == 0 else {
|
||||
throw VPhoneAPFSSnapshotError.cannotOpen(url, errno: errno)
|
||||
|
||||
@@ -262,14 +262,10 @@ final class VPhoneLaunchpadHelperClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuses the authorization the install obtained and never prompts. With
|
||||
/// none yet, this app has started no install to cancel.
|
||||
/// Never prompts. The helper stops only an install this user started.
|
||||
func cancelCustomFirmware() {
|
||||
guard let authorization = authorizationSession.existingExternalForm() else {
|
||||
return
|
||||
}
|
||||
let proxy = currentConnection().remoteObjectProxy as? VPhoneLaunchpadHelperProtocol
|
||||
proxy?.cancelCustomFirmware(authorization: authorization) {}
|
||||
proxy?.cancelCustomFirmware {}
|
||||
}
|
||||
|
||||
// MARK: - XPC plumbing
|
||||
@@ -382,15 +378,6 @@ final nonisolated class VPhoneLaunchpadHelperAuthorizationSession: @unchecked Se
|
||||
}
|
||||
}
|
||||
|
||||
/// The external form of the authorization obtained earlier, without
|
||||
/// prompting. Nil when no privileged call has been made yet.
|
||||
func existingExternalForm() -> Data? {
|
||||
guard let reference = lock.withLock({ self.reference }) else {
|
||||
return nil
|
||||
}
|
||||
return try? Self.externalForm(of: reference)
|
||||
}
|
||||
|
||||
private func authorize() throws -> Data {
|
||||
let reference = try lock.withLock {
|
||||
if let reference = self.reference {
|
||||
|
||||
@@ -141,14 +141,14 @@ final class VPhoneLaunchpadHelperService: NSObject, VPhoneLaunchpadHelperProtoco
|
||||
}
|
||||
}
|
||||
|
||||
func cancelCustomFirmware(authorization: Data, reply: @escaping @Sendable () -> Void) {
|
||||
func cancelCustomFirmware(reply: @escaping @Sendable () -> Void) {
|
||||
let callerUID = callerUID
|
||||
// Not on `work`: a bundle install queued there must not delay a cancel.
|
||||
// No authorization check: the install's right may have expired by
|
||||
// now, and a prompt here could leave the install running. Only the
|
||||
// user who started the install can stop it.
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
defer { reply() }
|
||||
guard (try? VPhoneLaunchpadHelperAuthorization.require(authorization)) != nil else {
|
||||
return
|
||||
}
|
||||
Self.firmwareLock.lock()
|
||||
if Self.firmwareOwner == callerUID {
|
||||
Self.firmwareProcess?.interrupt()
|
||||
|
||||
@@ -59,8 +59,9 @@ nonisolated protocol VPhoneLaunchpadHelperProtocol {
|
||||
reply: @escaping @Sendable (Int32, String?) -> Void,
|
||||
)
|
||||
|
||||
/// Sends SIGINT to a running CFW install started by the same user.
|
||||
func cancelCustomFirmware(authorization: Data, reply: @escaping @Sendable () -> Void)
|
||||
/// Sends SIGINT to a running CFW install started by the same user. It
|
||||
/// needs no authorization: it can only stop the caller's own install.
|
||||
func cancelCustomFirmware(reply: @escaping @Sendable () -> Void)
|
||||
|
||||
/// Removes the helper's launchd job and binary, then exits.
|
||||
func uninstallHelper(authorization: Data, reply: @escaping @Sendable (String?) -> Void)
|
||||
|
||||
Reference in New Issue
Block a user