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:
Lakr233
2026-09-25 17:45:19 +07:00
co-authored by Claude Opus 5.5
parent bb26d0250a
commit 46802eb728
7 changed files with 67 additions and 32 deletions
@@ -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)
@@ -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)