Fix RootHide /dev, sshd and sudo loader links (#519, #520)

#519: vphoned wrote jbroot/dev as the link text /rootfs/dev. The kernel
resolves link text, not vroot paths, so it dangled: every shell failed on
/dev/null and sshd never started. It is now /dev, an existing
/rootfs/dev link is replaced, and rootfs -> / is created.

#520: Irisin unpacks packages without RootHide dpkg's hook, so nothing
linked .jbroot in deeper package directories, and sudo could not load
libsudo_util from usr/libexec/sudo.
- vphoned walks the bootstrap for directories holding Mach-O files and
  links each one, at install, at startup, and one second after the root's
  Library/dpkg changes. That pass also runs the base steps that waited for
  pwd_mkdb or ssh-keygen, so sshd has host keys once openssh is installed.
- The spawn hooks follow the executable's LC_RPATH and LC_LOAD_DYLIB
  entries inside the root and link each dependency's directory, within a
  fixed bound. SystemHook does the same for TweakLoader before its dlopen.
- launchd starts xpcproxy and bootstrap daemons through posix_spawnp, which
  the launchd hook now interposes. SystemHook is chain-loaded into every
  child whatever its environment; DISABLE_TWEAKS and safe mode only keep
  ElleKit out.

The installer moves to Daemon/Bootstrap, with RootHide and rootless code
in their own folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-29 20:26:03 +09:00
co-authored by Claude Opus 5.5
parent 30082845ae
commit 0f9a949bd6
16 changed files with 1742 additions and 1171 deletions
+19 -5
View File
@@ -44,16 +44,27 @@ package file, so reinstalling packages does not bring them back.
| `root/var`, `root/etc` | directory `0755`, `0:0`, when missing |
| `root/var/root` | directory `0700`, `0:0` |
| `root/var/tmp` | link `../tmp` |
| `root/dev` | link `/rootfs/dev`, the convention of RootHide's own `private/preboot → /rootfs/private/preboot` |
| `root/dev` | link `/dev`; an existing link whose text is exactly `/rootfs/dev` is replaced |
| `root/rootfs` | link `/`, the vroot bridge to the real root |
| `root/etc/passwd`, `root/etc/group` | copies of `/private/etc/…`, `0644`, `0:0` |
| `root/etc/master.passwd` | copy of `/private/etc/master.passwd`, created `0600`, `0:0` |
| `root/etc/pwd.db`, `root/etc/spwd.db` | `root/usr/sbin/pwd_mkdb -p /etc/master.passwd`, then `0644` and `0600`; checked with `root/usr/bin/id root` when present |
| `root/etc/ssh/ssh_host_*_key` | `root/usr/bin/ssh-keygen -A`, after the databases |
Link text is read by the kernel, not by vroot. vroot shows the real root as
`/rootfs`, but a link stored as `/rootfs/dev` resolves to the physical
`/rootfs/dev`, which does not exist. vphoned wrote exactly that before #519,
and nothing created `root/rootfs` either. Every shell then printed
`/dev/null: Directory nonexistent` and sshd never started. On 26.6.2 in
iGhostVT, a fresh tab still printed the error with `dev → /rootfs/dev` plus
`rootfs → /`, and stopped printing it with `dev → /dev`. Irisin writes
package links the same way: its `linkText` turns `/rootfs/x` into `/x`.
Rules:
- A missing item is created; an existing item is left alone whatever its type,
owner or mode. A `root/tmp` made by hand keeps its owner.
owner or mode. A `root/tmp` made by hand keeps its owner. The one
exception is the dangling `dev → /rootfs/dev` earlier vphoned wrote.
- The databases are rebuilt only when either is missing or older than
`master.passwd`, so a password changed with `passwd` survives a restart.
- Host keys are generated only when `root/etc/ssh` exists (openssh is
@@ -62,8 +73,10 @@ Rules:
libroothide through the `.jbroot` link `ensureRootHideLinks` seeds beside
it, so its arguments are vroot paths.
- On a first install the bootstrap has no `pwd_mkdb` or `ssh-keygen` yet.
Those steps are reported under `deferred` in the install result and run on
the next vphoned start after Irisin's Bootstrap Install.
Those steps are reported under `deferred` in the install result. They run
one second after the next package operation rewrites the root's
`Library/dpkg`, or on the next vphoned start, so sshd has host keys as soon
as openssh is installed.
- Any other failure names the path. At install it rolls the install back like
the other RootHide steps; at startup it is logged.
@@ -90,7 +103,8 @@ Irisin's Bootstrap Install and one restart, without manual changes:
```sh
# under vroot, in iGhostVT or over ssh
ls -ld /tmp /var/tmp /dev /var/root # 1777 dir, link, link to /rootfs/dev, 0700 dir
ls -ld /tmp /var/tmp /var/root # 1777 dir, link, 0700 dir
ls /dev /rootfs/private # both list the real root's entries
: > /dev/null && echo dev-ok
id root && id mobile # both resolve
ls -l /etc/pwd.db /etc/spwd.db /etc/ssh/ssh_host_*_key
+46 -8
View File
@@ -26,9 +26,37 @@ framework or extension directory.
The PAM modules need their own link: without it sshd reports
`PAM: initialisation failed`. It does not cover
`Applications/*.app` installed later.
- The launchd hook sees physical bootstrap executable paths and app paths
before `posix_spawn`. It passes `VPHONE_JB_ROOT` and SystemHook injection to
`xpcproxy` and direct app spawns.
- Irisin unpacks packages without RootHide dpkg's hook, so nothing linked
deeper package directories. sudo failed (#520) because
`usr/libexec/sudo/libsudo_util.0.dylib` could not load
`@loader_path/.jbroot/usr/lib/libvrootapi.dylib`; `apt/methods`,
`engines-3` and `ossl-modules` have the same gap. After the fixed list,
`ensureRootHideMachOLinks` walks `bin`, `sbin`, `usr` (minus `usr/share`
and `usr/include`) and `Library` without following symlinks, reads each
regular file's magic until a directory shows a thin 64-bit or fat Mach-O,
and creates `../`×depth + `.jbroot` there. A directory with any `.jbroot`
entry is skipped, and a failed link is not fatal. It runs at install, on
`vphoned` startup, and one second after the root's `Library/dpkg` changes:
`watchRootHidePackages` keeps a vnode watch on that directory, which
Irisin, apt and dpkg all rewrite when a package operation finishes.
- The spawn hooks cannot replace that watch. `sudo` is usually run from a
mobile shell (an ssh session, or `ighostvtd-io`'s zsh), and mobile cannot
create a link in the root-owned `usr/libexec/sudo`. On 26.6.2, with the
link removed and the hooks in place, sudo still failed from a mobile shell;
touching `Library/dpkg` recreated the link and `sudo id` returned root.
- launchd (26.6.2) starts jobs through `posix_spawnp`: xpcproxy, and each
bootstrap LaunchDaemon such as sshd or ighostvtd. Its only `posix_spawn`
call re-executes `/sbin/launchd`; app spawns reach the interposed
`posix_spawn` through another image. The launchd hook interposes both. Before
it did, no xpcproxy or bootstrap daemon loaded SystemHook, so an ssh
session had none either.
- SystemHook is chain-loaded into every child, by launchd and by SystemHook's
`posix_spawn`, `posix_spawnp` and `execve`, whatever the child's
environment says; only launchd re-executing itself is left alone. A process
that rebuilds its child's environment, as sshd does for a session, would
otherwise drop it. `DISABLE_TWEAKS`, `_SafeMode` and `_MSSafeMode` are
honored in the child's constructor, which then skips ElleKit's
TweakLoader. Only bootstrap, app, xpcproxy and camera processes are logged.
- SystemHook's constructor loads `TweakLoader.dylib` with `dlopen`, but a
required `LC_LOAD_DYLIB` is resolved by dyld before that constructor. An
in-process repair there cannot recover this launch failure. SystemHook in
@@ -48,17 +76,27 @@ spellings, and creates a relative `.jbroot` link in the executable's own
directory. It resolves and validates an existing link, refuses a non-link or
a link to another root, and never writes into an unrelated app container.
The spawn result is preserved if link creation fails; the hooks log the
failure. A single directory operation is bounded work in PID 1. Embedded
frameworks with their own `@loader_path/.jbroot` dependency will still need
their own link before dyld loads them; that needs a separate package-side
pass or a carefully bounded Mach-O directory walk.
failure. It then reads the executable's arm64 load commands: each
`@loader_path/.jbroot/…` `LC_RPATH` directory gets a link, and each
`LC_LOAD_DYLIB`-family dependency that resolves inside the root, directly or
through those rpaths, gets a link in its directory and is walked in turn.
sudo is the case this covers: `usr/bin/sudo` has the rpath
`@loader_path/.jbroot/usr/libexec/sudo` and loads
`@rpath/libsudo_util.0.dylib`. The walk is bounded for PID 1: 32 images,
16 rpaths and 512 KiB of load commands per image, with no symlink followed
out of the root. SystemHook runs the same walk on `TweakLoader.dylib` before
its `dlopen`, which covers `usr/lib/ellekit`. A link needs a writable
directory, so a walk in a mobile process only helps where mobile may write;
the `vphoned` pass above covers the rest.
The fixed bootstrap link seeding in `vphoned` remains necessary. A test
LaunchDaemon loaded after boot spawned without passing through either observed
interposer: a copied RootHide `true` executable under an unlinked app directory
exited with `OS_REASON_DYLD` (status 6), then exited successfully (status 0)
after its `.jbroot` link was created. Removing vphoned's `usr/libexec` seed
could therefore regress the first Irisin daemon launch. An installer-side link
could therefore regress the first Irisin daemon launch. That daemon missed
both interposers because launchd started it through `posix_spawnp`, which the
launchd hook did not interpose then. An installer-side link
pass is useful for such jobs and for normal uninstall cleanup, but cannot
cover third-party installers by itself. A dyld path remap could
remove the filesystem links entirely, but would require an early, versioned
+7 -4
View File
@@ -127,11 +127,14 @@ the `.jbroot` loader links in the bootstrap root and standard executable and
library directories. On startup it repairs missing links for an existing
completed installation without replacing links that point elsewhere. These
links let `@loader_path/.jbroot/usr/lib/...` dependencies resolve when a
package manager later installs tools such as `dash`.
package manager later installs tools such as `dash`. It then links every
bootstrap directory that holds a Mach-O file, and repeats that walk one
second after the root's `Library/dpkg` changes, so a package installed later
by Irisin, apt or dpkg is linked without a reboot.
The launchd and SystemHook spawn bridges also create a missing `.jbroot`
beside a bootstrap executable just before it starts, covering applications
installed after the initial bootstrap. The fixed links remain necessary for
jobs whose launch path does not pass through either observed spawn bridge.
beside a bootstrap executable and its in-root dependencies just before it
starts, when the spawning process may write there. See
`Research/roothide_loader_links.md`.
`POST /v1/bootstrap/firmware` (RPC `bootstrap.firmware`)
repairs the record for a bootstrap already identified by the completion marker
without running another install. The reply includes the tag,
@@ -0,0 +1,88 @@
import Darwin
import Foundation
// MARK: - Firmware record
extension GuestIrisinInstaller {
static func repairFirmwareRecord() throws -> [String: Any] {
installLock.lock()
defer { installLock.unlock() }
guard let marker = try completedBootstrap(),
try marker.root == bootstrapRoot(layout: marker.layout, detected: nil),
isDirectory(marker.root)
else { throw GuestAPIError.operationFailed("No valid completed vphoned bootstrap was found") }
let firmware = try ensureFirmwareRecord(root: marker.root)
return ["layout": marker.layout, "jbroot": marker.root,
"firmware_version": firmware.version, "dpkg_database_updated": firmware.updated]
}
/// This vphone bootstrap has no firmware maintainer script. Write the
/// virtual package into the same dpkg status file Irisin and its helper read.
static func ensureFirmwareRecord(root: String) throws -> (version: String, updated: Bool) {
let os = ProcessInfo.processInfo.operatingSystemVersion
let version = "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"
let database = URL(fileURLWithPath: root, isDirectory: true)
.appendingPathComponent("Library/dpkg", isDirectory: true)
try FileManager.default.createDirectory(at: database, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o755])
let statusURL = database.appendingPathComponent("status")
var info = stat()
let statusResult = lstat(statusURL.path, &info)
if statusResult != 0, errno != ENOENT {
throw GuestAPIError.operationFailed("Could not inspect dpkg status")
}
if statusResult == 0, info.st_mode & mode_t(S_IFMT) != mode_t(S_IFREG) {
throw GuestAPIError.operationFailed("dpkg status is not a regular file")
}
let existing = statusResult == 0
? try String(contentsOf: statusURL, encoding: .utf8)
: ""
var paragraphs = existing.replacingOccurrences(of: "\r\n", with: "\n")
.components(separatedBy: "\n\n")
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
let matches = paragraphs.indices.filter { index in
paragraphs[index].split(separator: "\n").contains("Package: firmware")
}
guard matches.count <= 1 else {
throw GuestAPIError.operationFailed("dpkg status contains duplicate firmware records")
}
if let index = matches.first {
let lines = paragraphs[index].split(separator: "\n").map(String.init)
let oldVersion = lines.first(where: { $0.hasPrefix("Version: ") })
.map { String($0.dropFirst("Version: ".count)) } ?? ""
guard lines.contains("Status: install ok installed") else {
throw GuestAPIError.operationFailed("Existing firmware record is not installed")
}
if !lines.contains("Maintainer: vphoned") {
return (oldVersion, false)
}
if oldVersion == version {
return (version, false)
}
var updated = lines.map {
$0.hasPrefix("Version: ") ? "Version: \(version)" : $0
}
if oldVersion.isEmpty {
updated.append("Version: \(version)")
}
paragraphs[index] = updated.joined(separator: "\n")
} else {
paragraphs.append("""
Package: firmware
Essential: yes
Status: install ok installed
Priority: required
Section: System
Installed-Size: 0
Maintainer: vphoned
Architecture: all
Version: \(version)
Description: virtual package for this vphone iOS firmware
""")
}
try (paragraphs.joined(separator: "\n\n") + "\n\n")
.write(to: statusURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: statusURL.path)
return (version, true)
}
}
@@ -0,0 +1,118 @@
import Darwin
import Foundation
// MARK: - Payload
extension GuestIrisinInstaller {
static func validatePayload(
app: URL, daemon: URL, helper: URL, plist: URL,
version: String, architecture: String, layout: String,
) throws {
let info = NSDictionary(contentsOf: app.appendingPathComponent("Info.plist"))
guard info?["CFBundleIdentifier"] as? String == "wiki.qaq.irisin",
info?["CFBundleShortVersionString"] as? String == version,
info?["IrisinCurrentArchitecture"] as? String == architecture,
FileManager.default.isExecutableFile(atPath: app.appendingPathComponent("irisin").path),
FileManager.default.isExecutableFile(atPath: daemon.path),
FileManager.default.isExecutableFile(atPath: helper.path),
let properties = NSDictionary(contentsOf: plist) as? [String: Any],
properties["Label"] as? String == serviceLabel,
let arguments = properties["ProgramArguments"] as? [String],
arguments == [layout == "roothide" ? "/usr/libexec/irisind" : rootlessRoot + "/usr/libexec/irisind"]
else { throw GuestAPIError.operationFailed("Irisin release payload is incomplete or has the wrong layout") }
}
/// mobile owns /var/mobile/Documents, so either path component may be a
/// symlink it planted to have root hand another directory to mobile. Both
/// are opened without following a link, and ownership is set through the
/// descriptor rather than the path.
static func prepareAppData() throws {
let documents = "/var/mobile/Documents"
let name = "wiki.qaq.irisin"
if mkdir(documents, 0o755) != 0, errno != EEXIST {
throw GuestAPIError.operationFailed("Could not create \(documents): \(String(cString: strerror(errno)))")
}
let parent = open(documents, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
guard parent >= 0 else {
throw GuestAPIError.operationFailed("\(documents) is not a real directory")
}
defer { close(parent) }
if mkdirat(parent, name, 0o755) != 0, errno != EEXIST {
throw GuestAPIError.operationFailed("Could not create Irisin app data: \(String(cString: strerror(errno)))")
}
var info = stat()
guard fstatat(parent, name, &info, AT_SYMLINK_NOFOLLOW) == 0,
info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR)
else { throw GuestAPIError.operationFailed("Irisin app data path is not a real directory") }
let directory = openat(parent, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
guard directory >= 0 else {
throw GuestAPIError.operationFailed("Irisin app data path is not a real directory")
}
defer { close(directory) }
guard fchown(directory, 501, 501) == 0, fchmod(directory, 0o755) == 0 else {
throw GuestAPIError.operationFailed("Could not assign Irisin app data to mobile")
}
}
static func payloadComponents(from payload: URL, to root: URL, app: URL) throws -> [(URL, URL)] {
let files = FileManager.default
var components: [(URL, URL)] = []
func collect(_ source: URL, _ destination: URL) throws {
var info = stat()
guard lstat(source.path, &info) == 0 else {
throw GuestAPIError.operationFailed("Could not inspect Irisin payload: \(source.path)")
}
if info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), source != app {
let children = try files.contentsOfDirectory(at: source, includingPropertiesForKeys: nil)
.sorted { $0.lastPathComponent < $1.lastPathComponent }
if children.isEmpty {
try files.createDirectory(at: destination, withIntermediateDirectories: true)
}
for child in children {
try collect(child, destination.appendingPathComponent(child.lastPathComponent))
}
} else {
components.append((source, destination))
}
}
for source in try files.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil)
.sorted(by: { $0.lastPathComponent < $1.lastPathComponent })
where source.lastPathComponent != "DEBIAN"
{
try collect(source, root.appendingPathComponent(source.lastPathComponent))
}
return components
}
static func replace(_ source: URL, at target: URL) throws -> (target: URL, backup: URL?) {
let files = FileManager.default
let parent = target.deletingLastPathComponent()
try files.createDirectory(at: parent, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o755])
let suffix = UUID().uuidString
let candidate = parent.appendingPathComponent(".\(target.lastPathComponent).vphoned-\(suffix)")
let backup = parent.appendingPathComponent(".\(target.lastPathComponent).backup-\(suffix)")
try files.copyItem(at: source, to: candidate)
var old: URL?
do {
if itemExists(target) {
var info = stat()
guard lstat(target.path, &info) == 0, info.st_mode & mode_t(S_IFMT) != mode_t(S_IFLNK) else {
throw GuestAPIError.operationFailed("Irisin destination is a symlink: \(target.path)")
}
try files.moveItem(at: target, to: backup)
old = backup
}
try files.moveItem(at: candidate, to: target)
return (target, old)
} catch {
try? files.removeItem(at: candidate)
if let old {
try? files.moveItem(at: old, to: target)
}
throw error
}
}
}
@@ -0,0 +1,120 @@
import Darwin
import Foundation
// MARK: - Release and local package
extension GuestIrisinInstaller {
static let releaseURL = URL(string: "https://api.github.com/repos/Lakr233/Irisin/releases/latest")!
struct Asset {
let tag: String
let version: String
let name: String
let url: URL
let digest: String
}
static func copyLocalPackage(_ path: String, to destination: URL) throws {
let prefix = "/var/root/Library/Caches/vphoned-irisin-"
guard path.hasPrefix(prefix), path.hasSuffix(".deb"),
let id = UUID(uuidString: String(path.dropFirst(prefix.count).dropLast(4))),
path == prefix + id.uuidString + ".deb"
else { throw GuestAPIError.invalidRequest("Invalid staged Irisin package path") }
let descriptor = open(path, O_RDONLY | O_NOFOLLOW)
guard descriptor >= 0 else {
throw GuestAPIError.operationFailed("Could not open staged Irisin package")
}
defer { close(descriptor) }
var info = stat()
guard fstat(descriptor, &info) == 0,
info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
info.st_size > 0, info.st_size <= 64 * 1024 * 1024
else { throw GuestAPIError.invalidRequest("Irisin package must be a regular file under 64 MiB") }
let data = try FileHandle(fileDescriptor: descriptor, closeOnDealloc: false).readToEnd() ?? Data()
guard data.count == info.st_size else {
throw GuestAPIError.operationFailed("Could not read the complete Irisin package")
}
try data.write(to: destination, options: .atomic)
try? FileManager.default.removeItem(atPath: path)
}
static func releaseAsset(architecture: String) throws -> Asset {
let data = try fetch(releaseURL)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let tag = json["tag_name"] as? String,
tag.range(of: "^v[0-9]+(\\.[0-9]+){2,3}$", options: .regularExpression) != nil,
let assets = json["assets"] as? [[String: Any]]
else { throw GuestAPIError.operationFailed("GitHub did not return a valid Irisin release") }
let version = String(tag.dropFirst())
let name = "wiki.qaq.irisin_\(version)_\(architecture).deb"
guard let asset = assets.first(where: { $0["name"] as? String == name }),
let address = asset["browser_download_url"] as? String,
let url = URL(string: address), url.scheme == "https", url.host == "github.com",
url.path == "/Lakr233/Irisin/releases/download/\(tag)/\(name)",
let rawDigest = asset["digest"] as? String,
rawDigest.range(of: "^sha256:[0-9a-f]{64}$", options: .regularExpression) != nil
else { throw GuestAPIError.operationFailed("The latest Irisin release has no verified \(architecture) package") }
return Asset(tag: tag, version: version, name: name, url: url,
digest: String(rawDigest.dropFirst("sha256:".count)))
}
}
// MARK: - HTTPS
extension GuestIrisinInstaller {
private final class HTTPResult: @unchecked Sendable {
let semaphore = DispatchSemaphore(value: 0)
var value: Result<(Data, URLResponse), Error>?
func finish(data: Data?, response: URLResponse?, error: Error?) {
if let error {
value = .failure(error)
} else if let data, let response {
value = .success((data, response))
} else {
value = .failure(GuestAPIError.operationFailed("Empty Irisin download response"))
}
semaphore.signal()
}
}
static func fetch(_ url: URL, reportDownload: Bool = false) throws -> Data {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 180
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
var request = URLRequest(url: url)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("vphoned-Irisin-installer", forHTTPHeaderField: "User-Agent")
let result = HTTPResult()
let task = session.dataTask(with: request) { data, response, error in
result.finish(data: data, response: response, error: error)
}
task.resume()
let deadline = Date().addingTimeInterval(190)
while result.semaphore.wait(timeout: .now() + 0.2) != .success {
if reportDownload {
downloadProgress(received: task.countOfBytesReceived,
total: task.countOfBytesExpectedToReceive)
}
if Date() >= deadline {
task.cancel()
throw GuestAPIError.operationFailed("Irisin download timed out")
}
}
if reportDownload {
downloadProgress(received: task.countOfBytesReceived,
total: task.countOfBytesExpectedToReceive)
}
let (data, response) = try result.value!.get()
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw GuestAPIError.operationFailed("Irisin download returned HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)")
}
guard data.count <= 64 * 1024 * 1024 else {
throw GuestAPIError.operationFailed("Irisin download exceeds 64 MiB")
}
return data
}
}
@@ -0,0 +1,433 @@
import CryptoKit
import Darwin
import Foundation
import IcliKit
import IcliSystem
/// Installs the published Irisin payload without dpkg or maintainer scripts.
///
/// This file holds the layout-independent flow. RootHide's root, loader links
/// and bootstrap base live in `RootHide/`, rootless's `/var/jb` in `Rootless/`,
/// and the firmware record, release download and payload copy beside this file.
enum GuestIrisinInstaller {
static let serviceLabel = "wiki.qaq.irisind"
static let installLock = NSLock()
private static let progressLock = NSLock()
private nonisolated(unsafe) static var progress: [String: Any] = ["phase": "idle"]
private static let completionMarker = URL(fileURLWithPath: "/private/var/db/vphoned/bootstrap.json")
private static let legacyCompletionMarker = Bundle.main.executableURL!
.deletingLastPathComponent()
.appendingPathComponent(".vphoned-boostrap-completed")
static func install(jailbreak: [String: Any], layout: String, packagePath: String? = nil) throws -> [String: Any] {
installLock.lock()
defer { installLock.unlock() }
guard try completedBootstrap() == nil else {
throw GuestAPIError.operationFailed("Irisin bootstrap already completed")
}
setProgress(["phase": "preparing", "layout": layout])
do {
let result = try performInstall(jailbreak: jailbreak, layout: layout, packagePath: packagePath)
setProgress(["phase": "completed", "layout": layout,
"version": result["version"] ?? "", "jbroot": result["jbroot"] ?? ""])
return result
} catch {
setProgress(["phase": "failed", "layout": layout, "error": String(describing: error)])
throw error
}
}
static func status() -> [String: Any] {
progressLock.lock()
defer { progressLock.unlock() }
return progress
}
static func installedBootstrap() throws -> [String: Any] {
let roots = try bootstrapRoots()
var result: [String: Any] = ["installed": !roots.isEmpty, "roots": roots.map(\.root)]
if let installation = try completedBootstrap() {
result["layout"] = installation.layout
result["jbroot"] = installation.root
}
return result
}
static func uninstall(expectedRoots: [String], reboot: Bool = true) throws -> [String: Any] {
installLock.lock()
defer { installLock.unlock() }
let roots = try bootstrapRoots()
guard !roots.isEmpty else {
throw GuestAPIError.operationFailed("No bootstrap environment was found")
}
guard expectedRoots == roots.map(\.root) else {
throw GuestAPIError.invalidRequest("Bootstrap paths changed; inspect them again before uninstalling")
}
stopWatchingRootHidePackages()
for installation in roots {
try removeBootstrap(root: installation.root, layout: installation.layout)
}
// A legacy marker may live beside vphoned on the read-only system
// volume. Shadow it with a writable tombstone after removal.
try writeMarker(["installed": false])
if reboot {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
do {
_ = try requestReboot(userspace: false, force: true)
} catch {
NSLog("vphoned: bootstrap removed but reboot failed: %@", String(describing: error))
}
}
}
return ["roots": expectedRoots, "deleted": true, "reboot_scheduled": reboot]
}
static func refreshBootstrapOnStartup() {
do {
guard let installation = try completedBootstrap() else { return }
if installation.layout == "roothide" {
let base = try repairRootHide(root: installation.root)
if base["created"] as? [String] != [] || base["deferred"] as? [String] != [] {
NSLog("vphoned: RootHide bootstrap base: %@", String(describing: base))
}
watchRootHidePackages(root: installation.root)
}
} catch {
NSLog("vphoned: could not repair RootHide bootstrap: %@", String(describing: error))
}
do {
_ = try repairFirmwareRecord()
} catch {
NSLog("vphoned: could not refresh bootstrap firmware record: %@", String(describing: error))
}
}
// MARK: - Roots and completion marker
static func bootstrapRoot(layout: String, detected: String?) throws -> String {
layout == "rootless" ? rootlessRoot : try roothideBootstrapRoot(detected: detected)
}
private static func bootstrapRoots() throws -> [(layout: String, root: String)] {
var roots: [(layout: String, root: String)] = []
if let installation = try completedBootstrap() {
roots.append(installation)
}
if itemExists(URL(fileURLWithPath: rootlessRoot)), !roots.contains(where: { $0.root == rootlessRoot }) {
roots.append(("rootless", rootlessRoot))
}
for root in try roothideRoots() where !roots.contains(where: { $0.root == root }) {
roots.append(("roothide", root))
}
return roots.sorted { $0.root < $1.root }
}
private static func removeBootstrap(root: String, layout: String) throws {
let files = FileManager.default
let rootURL = URL(fileURLWithPath: root, isDirectory: true)
let removal = try removalRoot(root, layout: layout)
if try directoryExistsWithoutSymlink(removal.physicalPath) {
for relative in ["Library/LaunchDaemons", "basebin/LaunchDaemons"] {
let directory = rootURL.appendingPathComponent(relative, isDirectory: true).path
guard try physicalChildDirectoryExists(root: root, relative: relative) else { continue }
let plists = try files.contentsOfDirectory(atPath: directory)
.filter { $0.hasSuffix(".plist") }
.sorted()
.map { directory + "/" + $0 }
for plist in plists {
var info = stat()
guard lstat(plist, &info) == 0, info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else {
throw GuestAPIError.operationFailed("Bootstrap service is not a regular plist: \(plist)")
}
}
if !plists.isEmpty {
_ = try loadServices(plists, load: false, override: false)
}
}
let apps = rootURL.appendingPathComponent("Applications", isDirectory: true).path
if try physicalChildDirectoryExists(root: root, relative: "Applications") {
_ = try unregisterAppsInDirectory(apps, force: true)
}
try files.removeItem(atPath: removal.physicalPath)
}
if removal.isSymlink {
try files.removeItem(at: rootURL)
}
}
/// A RootHide root must be a physical directory; only rootless `/var/jb`
/// may be a link, and then its target is removed with it.
private static func removalRoot(_ root: String, layout: String) throws -> (physicalPath: String, isSymlink: Bool) {
var info = stat()
guard lstat(root, &info) == 0 else {
if errno == ENOENT {
return (root, false)
}
throw GuestAPIError.operationFailed("Could not inspect bootstrap root: \(root)")
}
if info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) {
return (root, false)
}
guard layout == "rootless", info.st_mode & mode_t(S_IFMT) == mode_t(S_IFLNK) else {
throw GuestAPIError.operationFailed("Bootstrap root is not a directory: \(root)")
}
return (try rootlessLinkTarget(root), true)
}
static func completedBootstrap() throws -> (layout: String, root: String)? {
guard let markerURL = markerForRead() else { return nil }
let data = try Data(contentsOf: markerURL)
guard let marker = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw GuestAPIError.operationFailed("Completed bootstrap marker is invalid")
}
if marker["installed"] as? Bool == false {
return nil
}
guard let layout = marker["layout"] as? String,
let root = marker["jbroot"] as? String,
(layout == "rootless" && root == rootlessRoot) || (layout == "roothide" && isRootHideRoot(root))
else { throw GuestAPIError.operationFailed("Completed bootstrap marker has an invalid root") }
return (layout, root)
}
private static func markerForRead() -> URL? {
if itemExists(completionMarker) {
return completionMarker
}
if itemExists(legacyCompletionMarker) {
return legacyCompletionMarker
}
return nil
}
private static func writeMarker(_ marker: [String: Any]) throws {
try FileManager.default.createDirectory(
at: completionMarker.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o755],
)
try JSONSerialization.data(withJSONObject: marker).write(to: completionMarker, options: .atomic)
}
// MARK: - Progress
private static func setProgress(_ value: [String: Any]) {
progressLock.lock()
progress = value
progressLock.unlock()
}
static func downloadProgress(received: Int64, total: Int64) {
progressLock.lock()
progress["downloaded_bytes"] = received
if total > 0 {
progress["total_bytes"] = total
}
progressLock.unlock()
}
// MARK: - Installation
private static func performInstall(jailbreak: [String: Any], layout: String, packagePath: String?) throws -> [String: Any] {
let detectedLayout = jailbreak["layout"] as? String
guard layout == "rootless" || layout == "roothide" else {
throw GuestAPIError.invalidRequest("layout must be rootless or roothide")
}
if let detectedLayout, layout != detectedLayout {
throw GuestAPIError.invalidRequest("Requested layout does not match the guest bootstrap")
}
let root = try bootstrapRoot(layout: layout, detected: jailbreak["jbroot"] as? String)
try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o755])
try FileManager.default.createDirectory(atPath: root + "/usr/lib", withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o755])
guard isDirectory(root) else {
throw GuestAPIError.operationFailed("Jailbreak root is not a directory: \(root)")
}
let architecture = layout == "roothide" ? "iphoneos-arm64e" : "iphoneos-arm64"
let work = FileManager.default.temporaryDirectory
.appendingPathComponent("vphoned-irisin-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: work) }
let package = work.appendingPathComponent("Irisin.deb")
let tag: String
let expectedVersion: String?
if let packagePath {
try copyLocalPackage(packagePath, to: package)
tag = "local"
expectedVersion = nil
} else {
let release = try releaseAsset(architecture: architecture)
setProgress(["phase": "downloading", "layout": layout, "tag": release.tag,
"downloaded_bytes": 0])
let packageData = try fetch(release.url, reportDownload: true)
let digest = SHA256.hash(data: packageData).map { String(format: "%02x", $0) }.joined()
guard digest == release.digest else {
throw GuestAPIError.operationFailed("Irisin release asset SHA-256 mismatch")
}
try packageData.write(to: package, options: .atomic)
tag = release.tag
expectedVersion = release.version
}
setProgress(["phase": "extracting", "layout": layout, "tag": tag])
let metadata = try readDeb(package.path)
let control = metadata["control"] as? [String: String] ?? [:]
let version = control["Version"] ?? ""
guard control["Package"] == "wiki.qaq.irisin",
!version.isEmpty, version.count <= 128,
expectedVersion == nil || version == expectedVersion,
control["Architecture"] == architecture
else { throw GuestAPIError.operationFailed("Irisin package metadata does not match the selected layout") }
let extracted = work.appendingPathComponent("extracted", isDirectory: true)
_ = try extractDeb(package.path, to: extracted.path)
let payload = layout == "rootless"
? extracted.appendingPathComponent(String(rootlessRoot.dropFirst()), isDirectory: true)
: extracted
let app = payload.appendingPathComponent("Applications/irisin.app", isDirectory: true)
let daemon = payload.appendingPathComponent("usr/libexec/irisind")
let helper = payload.appendingPathComponent("usr/libexec/irisin-install")
let plist = payload.appendingPathComponent("Library/LaunchDaemons/\(serviceLabel).plist")
try validatePayload(app: app, daemon: daemon, helper: helper, plist: plist,
version: version, architecture: architecture, layout: layout)
if layout == "roothide" {
try prepareRootHidePlist(plist, executable: root + "/usr/libexec/irisind")
}
try prepareAppData()
let rootURL = URL(fileURLWithPath: root, isDirectory: true)
let installedApp = rootURL.appendingPathComponent("Applications/irisin.app", isDirectory: true)
let installedPlist = rootURL.appendingPathComponent("Library/LaunchDaemons/\(serviceLabel).plist")
let hadApp = itemExists(installedApp)
let components = try payloadComponents(from: payload, to: rootURL, app: app)
let hadService = itemExists(installedPlist)
if hadService {
_ = try loadServices([installedPlist.path], load: false, override: false)
}
var replaced: [(target: URL, backup: URL?)] = []
do {
setProgress(["phase": "installing", "layout": layout, "tag": tag])
for (source, target) in components {
try replaced.append(replace(source, at: target))
}
var base: [String: Any]?
if layout == "roothide" {
base = try repairRootHide(root: root)
}
let registration = try registerApp(installedApp.path)
let loaded = try loadServices([installedPlist.path], load: true, override: false)
var started: [String: Any]?
var startWarning: String?
do {
started = try startService(serviceLabel)
} catch {
// Some VM launchd builds can load the job but cannot start it
// until the service-configure hook is available. The app and
// bootstrap payload are still usable in that state.
startWarning = String(describing: error)
}
let status = try serviceStatus(serviceLabel)
guard status["loaded"] as? Bool == true else {
throw GuestAPIError.operationFailed("Irisin daemon is not loaded")
}
setProgress(["phase": "firmware", "layout": layout, "tag": tag])
let firmware = try ensureFirmwareRecord(root: root)
let marker = ["tag": tag, "layout": layout, "jbroot": root]
try writeMarker(marker)
if layout == "roothide" {
watchRootHidePackages(root: root)
}
for entry in replaced {
if let backup = entry.backup {
try? FileManager.default.removeItem(at: backup)
}
}
var result: [String: Any] = [
"tag": tag,
"version": version,
"architecture": architecture,
"layout": layout,
"jbroot": root,
"app_path": installedApp.path,
"registration": registration,
"service_load": loaded,
"service_status": status,
"firmware_version": firmware.version,
"maintainer_scripts_executed": false,
"dpkg_database_updated": firmware.updated,
]
if let base {
result["roothide_base"] = base
}
if let started {
result["service_start"] = started
}
if let startWarning {
result["service_start_warning"] = startWarning
}
return result
} catch {
if itemExists(installedPlist) {
_ = try? loadServices([installedPlist.path], load: false, override: false)
}
if !hadApp, itemExists(installedApp) {
_ = try? unregisterApp(installedApp.path, force: true)
}
for entry in replaced.reversed() {
try? FileManager.default.removeItem(at: entry.target)
if let backup = entry.backup {
try? FileManager.default.moveItem(at: backup, to: entry.target)
}
}
if itemExists(installedApp) {
_ = try? registerApp(installedApp.path)
}
if hadService {
_ = try? loadServices([installedPlist.path], load: true, override: false)
}
throw error
}
}
// MARK: - Filesystem checks
static func directoryExistsWithoutSymlink(_ path: String) throws -> Bool {
var info = stat()
if lstat(path, &info) != 0 {
if errno == ENOENT {
return false
}
throw GuestAPIError.operationFailed("Could not inspect bootstrap directory: \(path)")
}
guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else {
throw GuestAPIError.operationFailed("Bootstrap directory is not a physical directory: \(path)")
}
return true
}
private static func physicalChildDirectoryExists(root: String, relative: String) throws -> Bool {
var path = root
for component in relative.split(separator: "/") {
path += "/" + component
guard try directoryExistsWithoutSymlink(path) else { return false }
}
return true
}
static func isDirectory(_ path: String) -> Bool {
var directory: ObjCBool = false
return FileManager.default.fileExists(atPath: path, isDirectory: &directory) && directory.boolValue
}
static func itemExists(_ url: URL) -> Bool {
var info = stat()
return lstat(url.path, &info) == 0
}
}
@@ -0,0 +1,238 @@
import Darwin
import Foundation
// MARK: - RootHide root
extension GuestIrisinInstaller {
static let roothideParent = "/private/var/containers/Bundle/Application"
static func roothideName(_ name: String) -> Bool {
guard name.range(of: "^\\.jbroot-[0-9a-fA-F]{16}$", options: .regularExpression) != nil,
let value = UInt64(name.dropFirst(8), radix: 16)
else { return false }
let check = (1 ... 7).reduce(UInt8(0)) {
$0 ^ UInt8(truncatingIfNeeded: value >> ($1 * 8))
}
return check == UInt8(truncatingIfNeeded: value)
}
static func isRootHideRoot(_ root: String) -> Bool {
root.hasPrefix(roothideParent + "/") && roothideName(String(root.dropFirst(roothideParent.count + 1)))
}
/// Every valid `.jbroot-<16 hex>` directory entry, sorted.
static func roothideRoots() throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: roothideParent)
.sorted()
.filter(roothideName)
.map { roothideParent + "/" + $0 }
}
static func roothideBootstrapRoot(detected: String?) throws -> String {
if let detected {
return detected
}
let roots = try roothideRoots().filter(isDirectory)
guard roots.count <= 1 else {
throw GuestAPIError.operationFailed("Multiple RootHide bootstrap roots exist")
}
if let root = roots.first {
return root
}
// User-selected stem, zero-padded to 16 hex digits with RootHide's
// XOR checksum in the final byte (0C instead of the proposed 10).
let name = ".jbroot-000114514191980C"
guard roothideName(name) else {
throw GuestAPIError.operationFailed("Configured RootHide bootstrap name is invalid")
}
return roothideParent + "/" + name
}
static func prepareRootHidePlist(_ url: URL, executable: String) throws {
let data = try Data(contentsOf: url)
guard var plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else {
throw GuestAPIError.operationFailed("Irisin launchd plist is invalid")
}
plist["ProgramArguments"] = [executable]
plist["__Patched"] = true
let updated = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try updated.write(to: url, options: .atomic)
}
/// Runs at install and on every vphoned start: the loader links, then the
/// bootstrap base. Returns the base report.
static func repairRootHide(root: String) throws -> [String: Any] {
try ensureRootHideLinks(root: root)
return try ensureRootHideBase(root: root)
}
}
// MARK: - RootHide loader links
extension GuestIrisinInstaller {
/// RootHide's @loader_path references resolve through a .jbroot link in
/// each directory containing bootstrap Mach-O files. Seed the standard
/// directories before a package manager installs its first shell.
static func ensureRootHideLinks(root: String) throws {
guard try directoryExistsWithoutSymlink(root) else {
throw GuestAPIError.operationFailed("RootHide bootstrap root is missing: \(root)")
}
let files = FileManager.default
func link(_ path: String, target: String) throws {
var info = stat()
if lstat(path, &info) == 0 {
guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFLNK),
try files.destinationOfSymbolicLink(atPath: path) == target
else {
throw GuestAPIError.operationFailed("RootHide loader link has an unexpected target: \(path)")
}
return
}
guard errno == ENOENT else {
throw GuestAPIError.operationFailed("Could not inspect RootHide loader link: \(path)")
}
try files.createSymbolicLink(atPath: path, withDestinationPath: target)
}
try link(root + "/.jbroot", target: ".")
for relative in ["bin", "sbin", "usr/bin", "usr/sbin", "usr/lib", "usr/libexec", "usr/lib/pam"] {
var directory = root
for component in relative.split(separator: "/") {
directory += "/" + component
if try !directoryExistsWithoutSymlink(directory) {
try files.createDirectory(atPath: directory, withIntermediateDirectories: false)
}
}
let depth = relative.split(separator: "/").count
let target = String(repeating: "../", count: depth) + ".jbroot"
try link(directory + "/.jbroot", target: target)
}
let seeded = ensureRootHideMachOLinks(root: root)
if !seeded.isEmpty {
NSLog("vphoned: RootHide loader links: %@", seeded.joined(separator: ", "))
}
}
/// RootHide's dpkg puts a .jbroot link beside every Mach-O it installs.
/// Irisin unpacks packages without that hook, so a dylib in a directory
/// outside the fixed list, such as usr/libexec/sudo, cannot load its
/// @loader_path/.jbroot dependencies. Walk the package directories without
/// following symlinks and link each directory holding a Mach-O file. An
/// existing .jbroot entry is left alone. The spawn hooks repair a
/// program's dependencies before it starts; this pass covers the rest.
static func ensureRootHideMachOLinks(root: String) -> [String] {
let files = FileManager.default
let skipped: Set<String> = ["usr/share", "usr/include"]
var linked: Set<String> = []
var created: [String] = []
for top in ["bin", "sbin", "usr", "Library"] {
guard (try? directoryExistsWithoutSymlink(root + "/" + top)) == true,
let walk = files.enumerator(atPath: root + "/" + top)
else { continue }
while let entry = walk.nextObject() as? String {
let relative = top + "/" + entry
switch walk.fileAttributes?[.type] as? FileAttributeType {
case .typeDirectory?:
if skipped.contains(relative) { walk.skipDescendants() }
continue
case .typeRegular?:
break
default:
continue
}
let directory = (relative as NSString).deletingLastPathComponent
let link = root + "/" + directory + "/.jbroot"
guard !linked.contains(directory) else { continue }
var info = stat()
if lstat(link, &info) == 0 {
linked.insert(directory)
continue
}
guard errno == ENOENT, isMachO(root + "/" + relative) else { continue }
linked.insert(directory)
let depth = directory.split(separator: "/").count
let target = String(repeating: "../", count: depth) + ".jbroot"
if (try? files.createSymbolicLink(atPath: link, withDestinationPath: target)) != nil {
created.append("/" + directory)
}
}
}
return created
}
// MARK: - Package changes
private static let packageQueue = DispatchQueue(label: "vphoned.roothide.packages")
private nonisolated(unsafe) static var packageWatch: DispatchSourceFileSystemObject?
private nonisolated(unsafe) static var packageRelinkPending = false
/// Irisin, apt and dpkg all rewrite Library/dpkg/status when they finish,
/// so a changed directory relinks the new package's Mach-O directories
/// right away. The spawn hooks cannot do it for a mobile shell: running
/// sudo there cannot create a link in the root-owned usr/libexec/sudo.
/// The base steps that waited for pwd_mkdb or ssh-keygen run then too,
/// so sshd has host keys as soon as openssh is installed.
static func watchRootHidePackages(root: String) {
packageQueue.async {
guard packageWatch == nil else { return }
let directory = root + "/Library/dpkg"
let descriptor = open(directory, O_EVTONLY | O_CLOEXEC)
guard descriptor >= 0 else {
NSLog("vphoned: cannot watch %@: %s", directory, strerror(errno))
return
}
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor, eventMask: [.write, .delete, .rename], queue: packageQueue,
)
source.setEventHandler {
guard let watch = packageWatch else { return }
if !watch.data.isDisjoint(with: [.delete, .rename]) {
// The bootstrap was removed; a new install starts a new watch.
watch.cancel()
packageWatch = nil
return
}
guard !packageRelinkPending else { return }
packageRelinkPending = true
// Let the package manager finish writing before walking.
packageQueue.asyncAfter(deadline: .now() + 1) {
packageRelinkPending = false
let seeded = ensureRootHideMachOLinks(root: root)
if !seeded.isEmpty {
NSLog("vphoned: RootHide loader links: %@", seeded.joined(separator: ", "))
}
do {
let base = try ensureRootHideBase(root: root)
if base["created"] as? [String] != [] {
NSLog("vphoned: RootHide bootstrap base: %@", String(describing: base))
}
} catch {
NSLog("vphoned: could not repair RootHide bootstrap: %@", String(describing: error))
}
}
}
source.setCancelHandler { close(descriptor) }
packageWatch = source
source.resume()
}
}
static func stopWatchingRootHidePackages() {
packageQueue.sync {
packageWatch?.cancel()
packageWatch = nil
}
}
static func isMachO(_ path: String) -> Bool {
let descriptor = open(path, O_RDONLY | O_NOFOLLOW)
guard descriptor >= 0 else { return false }
defer { close(descriptor) }
var magic: UInt32 = 0
guard read(descriptor, &magic, 4) == 4 else { return false }
// Thin 64-bit and fat, in either byte order.
return [0xFEED_FACF, 0xCFFA_EDFE, 0xCAFE_BABE, 0xBEBA_FECA].contains(magic)
}
}
@@ -0,0 +1,261 @@
import Darwin
import Foundation
// MARK: - RootHide bootstrap base
extension GuestIrisinInstaller {
/// A jailbreak's bootstrap installer creates what no package owns: vroot
/// `/tmp` and `/dev`, the account files and the databases user lookup
/// reads, root's home and the SSH host keys. Irisin only unpacks packages,
/// so vphoned is that installer. Paths are physical; `root/x` is `/x`
/// under vroot. A missing item is created and an existing one is left
/// alone, so a changed password, key or mode survives. A step that needs a
/// bootstrap tool waits for a later refresh until Irisin's Bootstrap
/// Install has unpacked the tool.
static func ensureRootHideBase(root: String) throws -> [String: Any] {
guard try directoryExistsWithoutSymlink(root) else {
throw GuestAPIError.operationFailed("RootHide bootstrap root is missing: \(root)")
}
var created: [String] = []
var deferred: [String] = []
// Everything under vroot resolves /tmp and /var/tmp here. Without it
// iGhostVT never starts: libghostty only logs the failed config write.
for (path, mode) in [("tmp", 0o1777), ("var", 0o755), ("var/root", 0o700), ("etc", 0o755)] {
if try ensureBaseDirectory(root + "/" + path, mode: mode_t(mode)) {
created.append("/" + path)
}
}
// The kernel resolves link text, not vroot paths: vroot shows /rootfs/x,
// but the text must be /x. Earlier vphoned wrote dev -> /rootfs/dev,
// which dangles, so every shell failed on /dev/null and sshd never
// started. rootfs is the vroot bridge to the real root.
let dev = root + "/dev"
if (try? FileManager.default.destinationOfSymbolicLink(atPath: dev)) == "/rootfs/dev" {
try FileManager.default.removeItem(atPath: dev)
}
for (path, target) in [("var/tmp", "../tmp"), ("dev", "/dev"), ("rootfs", "/")] {
if try ensureBaseLink(root + "/" + path, target: target) {
created.append("/" + path)
}
}
for (name, mode) in [("passwd", 0o644), ("group", 0o644), ("master.passwd", 0o600)] {
if try seedAccountFile(name, root: root, mode: mode_t(mode)) {
created.append("/etc/" + name)
}
}
switch try ensureAccountDatabases(root: root) {
case true?:
created += ["/etc/pwd.db", "/etc/spwd.db"]
case false?:
break
case nil:
deferred.append("/etc/pwd.db and /etc/spwd.db wait for /usr/sbin/pwd_mkdb")
return ["created": created, "deferred": deferred]
}
// ssh-keygen needs getpwuid(0), so host keys follow the databases.
switch try ensureHostKeys(root: root) {
case true?:
created.append("/etc/ssh host keys")
case false?:
break
case nil:
deferred.append("/etc/ssh host keys wait for /usr/bin/ssh-keygen")
}
return ["created": created, "deferred": deferred]
}
/// Creates a root-owned directory with exactly `mode`, since mkdir applies
/// the umask. An existing directory keeps its owner and mode.
private static func ensureBaseDirectory(_ path: String, mode: mode_t) throws -> Bool {
if mkdir(path, mode) == 0 {
guard chown(path, 0, 0) == 0, chmod(path, mode) == 0 else {
throw GuestAPIError.operationFailed(
"Could not set the owner and mode of \(path): \(String(cString: strerror(errno)))",
)
}
return true
}
let reason = String(cString: strerror(errno))
guard errno == EEXIST else {
throw GuestAPIError.operationFailed("Could not create \(path): \(reason)")
}
guard isDirectory(path) else {
throw GuestAPIError.operationFailed("RootHide bootstrap path is not a directory: \(path)")
}
return false
}
/// Unlike a loader link, an existing entry here is not checked: whatever
/// the user or a package put at the path stays.
private static func ensureBaseLink(_ path: String, target: String) throws -> Bool {
var info = stat()
if lstat(path, &info) == 0 {
return false
}
guard errno == ENOENT else {
throw GuestAPIError.operationFailed("Could not inspect \(path): \(String(cString: strerror(errno)))")
}
try FileManager.default.createSymbolicLink(atPath: path, withDestinationPath: target)
return true
}
/// The bootstrap's account files start as copies of the system's. The
/// copy is created with its final mode, so master.passwd, the shadow file,
/// is never readable by others.
private static func seedAccountFile(_ name: String, root: String, mode: mode_t) throws -> Bool {
let destination = root + "/etc/" + name
var info = stat()
if lstat(destination, &info) == 0 {
return false
}
guard errno == ENOENT else {
throw GuestAPIError.operationFailed("Could not inspect \(destination): \(String(cString: strerror(errno)))")
}
let source = "/private/etc/" + name
let input = open(source, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)
guard input >= 0 else {
throw GuestAPIError.operationFailed("Could not open \(source): \(String(cString: strerror(errno)))")
}
defer { close(input) }
let data = try FileHandle(fileDescriptor: input, closeOnDealloc: false).readToEnd() ?? Data()
let temporary = destination + ".vphoned-" + UUID().uuidString
let output = open(temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode)
guard output >= 0 else {
throw GuestAPIError.operationFailed("Could not create \(temporary): \(String(cString: strerror(errno)))")
}
let written = data.withUnsafeBytes { write(output, $0.baseAddress, $0.count) }
let prepared = written == data.count && fchown(output, 0, 0) == 0 && fchmod(output, mode) == 0
let failure = errno
close(output)
guard prepared, rename(temporary, destination) == 0 else {
let reason = String(cString: strerror(prepared ? errno : failure))
unlink(temporary)
throw GuestAPIError.operationFailed("Could not write \(destination): \(reason)")
}
return true
}
/// Procursus tools look users up through libiosexec, which reads the
/// Berkeley databases rather than the text files. Without them every
/// getpwnam and getpwuid under vroot fails with EINVAL while group lookups
/// work. They are rebuilt only when missing or older than master.passwd,
/// so a password changed with passwd survives a refresh. Returns nil while
/// the bootstrap has no pwd_mkdb.
private static func ensureAccountDatabases(root: String) throws -> Bool? {
let etc = root + "/etc/"
guard let master = try modificationTime(etc + "master.passwd") else {
throw GuestAPIError.operationFailed("RootHide account file is missing: \(etc)master.passwd")
}
if let database = try modificationTime(etc + "pwd.db"), database >= master,
let shadow = try modificationTime(etc + "spwd.db"), shadow >= master
{
return false
}
let tool = root + "/usr/sbin/pwd_mkdb"
guard FileManager.default.isExecutableFile(atPath: tool) else {
return nil
}
// pwd_mkdb also regenerates /etc/passwd from master.passwd.
try runBootstrapTool(tool, ["-p", "/etc/master.passwd"])
for (name, mode) in [("pwd.db", 0o644), ("spwd.db", 0o600)] {
let path = etc + name
guard chown(path, 0, 0) == 0, chmod(path, mode_t(mode)) == 0 else {
throw GuestAPIError.operationFailed(
"Could not set the owner and mode of \(path): \(String(cString: strerror(errno)))",
)
}
}
let id = root + "/usr/bin/id"
if FileManager.default.isExecutableFile(atPath: id) {
try runBootstrapTool(id, ["root"])
}
return true
}
/// sshd resets every connection before its banner when it has no host
/// key. `ssh-keygen -A` creates each missing default key type and leaves
/// existing keys alone. Nothing happens until openssh has created
/// /etc/ssh; returns nil while ssh-keygen is missing.
private static func ensureHostKeys(root: String) throws -> Bool? {
let directory = root + "/etc/ssh"
guard isDirectory(directory) else {
return false
}
let keys = ["ed25519", "ecdsa", "rsa"].map { "\(directory)/ssh_host_\($0)_key" }
if keys.allSatisfy({ itemExists(URL(fileURLWithPath: $0)) }) {
return false
}
let tool = root + "/usr/bin/ssh-keygen"
guard FileManager.default.isExecutableFile(atPath: tool) else {
return nil
}
try runBootstrapTool(tool, ["-A"])
return true
}
private static func modificationTime(_ path: String) throws -> Double? {
var info = stat()
guard lstat(path, &info) == 0 else {
if errno == ENOENT {
return nil
}
throw GuestAPIError.operationFailed("Could not inspect \(path): \(String(cString: strerror(errno)))")
}
return Double(info.st_mtimespec.tv_sec) + Double(info.st_mtimespec.tv_nsec) / 1_000_000_000
}
/// Runs a bootstrap tool by its physical path. It loads libroothide
/// through the `.jbroot` link beside it, so its arguments are vroot
/// paths: `/etc` in the child is `root/etc`.
private static func runBootstrapTool(_ path: String, _ arguments: [String]) throws {
var argv = ([path] + arguments).map { strdup($0) } + [nil]
defer { argv.forEach { free($0) } }
var outputPipe: [Int32] = [0, 0]
guard pipe(&outputPipe) == 0 else {
throw GuestAPIError.operationFailed("pipe: \(String(cString: strerror(errno)))")
}
var actions: posix_spawn_file_actions_t?
posix_spawn_file_actions_init(&actions)
posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0)
posix_spawn_file_actions_adddup2(&actions, outputPipe[1], STDOUT_FILENO)
posix_spawn_file_actions_adddup2(&actions, outputPipe[1], STDERR_FILENO)
posix_spawn_file_actions_addclose(&actions, outputPipe[0])
posix_spawn_file_actions_addclose(&actions, outputPipe[1])
var pid: pid_t = 0
let spawned = posix_spawn(&pid, path, &actions, nil, &argv, environ)
posix_spawn_file_actions_destroy(&actions)
close(outputPipe[1])
guard spawned == 0 else {
close(outputPipe[0])
throw GuestAPIError.operationFailed("Could not run \(path): \(String(cString: strerror(spawned)))")
}
// Drain to EOF so a chatty tool never blocks on a full pipe.
var output = Data()
var buffer = [UInt8](repeating: 0, count: 1024)
while true {
let count = read(outputPipe[0], &buffer, buffer.count)
if count < 0, errno == EINTR {
continue
}
if count <= 0 {
break
}
if output.count < 4096 {
output.append(contentsOf: buffer.prefix(min(count, 4096 - output.count)))
}
}
close(outputPipe[0])
var status: Int32 = 0
var waited = waitpid(pid, &status, 0)
while waited < 0, errno == EINTR {
waited = waitpid(pid, &status, 0)
}
guard waited == pid, status == 0 else {
let details = String(decoding: output, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
throw GuestAPIError.operationFailed("\(path) \(arguments.joined(separator: " ")) exited with status \(status): \(details)")
}
}
}
@@ -0,0 +1,27 @@
import Darwin
import Foundation
// MARK: - Rootless root
extension GuestIrisinInstaller {
static let rootlessRoot = "/var/jb"
/// `/var/jb` is normally a link into `/private/preboot`. Only a standard
/// path strictly below `/private/preboot` is accepted as its target, so
/// removal never follows the link anywhere else.
static func rootlessLinkTarget(_ root: String) throws -> String {
var target = [CChar](repeating: 0, count: Int(PATH_MAX))
let count = readlink(root, &target, target.count - 1)
guard count > 0 else { throw GuestAPIError.operationFailed("Could not read bootstrap link: \(root)") }
guard let physicalPath = String(
bytes: target.prefix(count).map { UInt8(bitPattern: $0) }, encoding: .utf8,
) else { throw GuestAPIError.operationFailed("Bootstrap link has an invalid path: \(root)") }
guard physicalPath.hasPrefix("/private/preboot/"),
physicalPath != "/private/preboot/",
physicalPath == (physicalPath as NSString).standardizingPath
else {
throw GuestAPIError.operationFailed("Rootless bootstrap link has an unexpected target: \(physicalPath)")
}
return physicalPath
}
}
File diff suppressed because it is too large Load Diff
@@ -59,9 +59,12 @@ static int vpIsBootstrapProgram(const char *path) {
return length && strncmp(path, root, length) == 0 && path[length] == '/';
}
static int vpSpawn(pid_t *restrict pid, const char *restrict path, const posix_spawn_file_actions_t *restrict actions,
const posix_spawnattr_t *restrict attributes, char *const argv[restrict],
char *const envp[restrict]) {
typedef int (*VPSpawnFunction)(pid_t *restrict, const char *restrict, const posix_spawn_file_actions_t *restrict,
const posix_spawnattr_t *restrict, char *const[restrict], char *const[restrict]);
static int vpSpawnWith(VPSpawnFunction spawn, pid_t *restrict pid, const char *restrict path,
const posix_spawn_file_actions_t *restrict actions, const posix_spawnattr_t *restrict attributes,
char *const argv[restrict], char *const envp[restrict]) {
if (!vpBootRoot[0] && path && (vpIsAppProgram(path) || strstr(path, "/.jbroot-")))
vpFindJBRoot(vpBootRoot);
int bootstrapProgram = vpIsBootstrapProgram(path);
@@ -71,22 +74,38 @@ static int vpSpawn(pid_t *restrict pid, const char *restrict path, const posix_s
if (linkStatus || (path && strstr(path, "/.jbroot-")))
vpLogInjection("loader-link", path, linkStatus);
}
if (!path || (strcmp(path, "/usr/libexec/xpcproxy") != 0 && !bootstrapProgram && !appProgram) ||
vpInjectionDisabled(envp)) {
int status = posix_spawn(pid, path, actions, attributes, argv, envp);
if (appProgram)
vpLogSpawn("app-disabled", path, status == 0 && pid ? *pid : -1, status);
return status;
}
// Every process chain-loads SystemHook, including one started with tweaks
// disabled: SystemHook itself honors DISABLE_TWEAKS and safe mode by not
// loading ElleKit. Only launchd re-executing itself is left alone.
if (!path || strcmp(path, "/sbin/launchd") == 0)
return spawn(pid, path, actions, attributes, argv, envp);
VPInjectionEnvironment injected = vpInsertHook(envp, vpBootRoot);
int status = posix_spawn(pid, path, actions, attributes, argv, injected.values ? injected.values : envp);
vpLogInjection(injected.values ? "inserted" : "unchanged", path, status);
vpLogSpawn(injected.values ? "inserted" : "unchanged", path, status == 0 && pid ? *pid : -1,
status);
int status = spawn(pid, path, actions, attributes, argv, injected.values ? injected.values : envp);
if (bootstrapProgram || appProgram || strcmp(path, "/usr/libexec/xpcproxy") == 0) {
const char *event = !injected.values ? "unchanged" :
vpInjectionDisabled(envp) ? "inserted-tweaks-disabled" : "inserted";
vpLogInjection(event, path, status);
vpLogSpawn(event, path, status == 0 && pid ? *pid : -1, status);
}
vpFreeEnvironment(&injected);
return status;
}
static int vpSpawn(pid_t *restrict pid, const char *restrict path, const posix_spawn_file_actions_t *restrict actions,
const posix_spawnattr_t *restrict attributes, char *const argv[restrict],
char *const envp[restrict]) {
return vpSpawnWith(posix_spawn, pid, path, actions, attributes, argv, envp);
}
// launchd starts jobs through posix_spawnp: xpcproxy, and each bootstrap
// LaunchDaemon such as sshd or ighostvtd when it elides the proxy. Without
// this none of them gets SystemHook.
static int vpSpawnP(pid_t *restrict pid, const char *restrict path, const posix_spawn_file_actions_t *restrict actions,
const posix_spawnattr_t *restrict attributes, char *const argv[restrict],
char *const envp[restrict]) {
return vpSpawnWith(posix_spawnp, pid, path, actions, attributes, argv, envp);
}
// Neither bootstrap exists on the first boot. RootHide is chosen once and
// keeps one randomized root across later boots; ambiguity disables discovery.
static int vpFindJBRoot(char root[PATH_MAX]) {
@@ -279,5 +298,6 @@ __attribute__((used, section("__DATA,__interpose"))) static const struct {
} vpInterpose[] = {
{(const void *)vpGetValue, (const void *)xpc_dictionary_get_value},
{(const void *)vpSpawn, (const void *)posix_spawn},
{(const void *)vpSpawnP, (const void *)posix_spawnp},
{(const void *)vpMemoryStatus, (const void *)memorystatus_control},
};
@@ -1,13 +1,36 @@
#include "RootHideLoaderLinks.h"
#include <errno.h>
#include <fcntl.h>
#include <libkern/OSByteOrder.h>
#include <limits.h>
#include <mach-o/fat.h>
#include <mach-o/loader.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
// The walk runs in PID 1 before every bootstrap spawn, so it reads at most
// this many images, remembers this many rpaths and reads this much of each
// image's load commands.
#define VP_MAX_IMAGES 32
#define VP_MAX_RPATHS 16
#define VP_MAX_COMMANDS (512 * 1024)
static const char vpLoaderPrefix[] = "@loader_path/.jbroot/";
static const char vpRPathPrefix[] = "@rpath/";
typedef struct {
char root[PATH_MAX];
char images[VP_MAX_IMAGES][PATH_MAX];
size_t imageCount;
char rpaths[VP_MAX_RPATHS][PATH_MAX];
size_t rpathCount;
int status;
} VPLinkWalk;
static int vpWithin(const char *path, const char *directory) {
size_t length = strlen(directory);
return strncmp(path, directory, length) == 0 &&
@@ -33,6 +56,207 @@ static int vpLinkTarget(const char *directory, const char *root, char target[PAT
return 0;
}
// `directory` and `root` are canonical, and `directory` is inside `root`.
static int vpEnsureDirectoryLink(const char *directory, const char *root) {
char target[PATH_MAX];
int status = vpLinkTarget(directory, root, target);
if (status)
return status;
int descriptor = open(directory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (descriptor < 0)
return errno;
struct stat info;
if (fstatat(descriptor, ".jbroot", &info, AT_SYMLINK_NOFOLLOW) == 0) {
if (!S_ISLNK(info.st_mode)) {
close(descriptor);
return EEXIST;
}
char linkPath[PATH_MAX];
char resolved[PATH_MAX];
int used = snprintf(linkPath, sizeof(linkPath), "%s/.jbroot", directory);
status = used <= 0 || (size_t)used >= sizeof(linkPath) ? ENAMETOOLONG :
!realpath(linkPath, resolved) ? errno :
strcmp(resolved, root) == 0 ? 0 : EEXIST;
} else if (errno == ENOENT) {
status = symlinkat(target, descriptor, ".jbroot") == 0 ? 0 : errno;
} else {
status = errno;
}
close(descriptor);
return status;
}
static void vpNote(VPLinkWalk *walk, int status) {
if (status && !walk->status)
walk->status = status;
}
// Reads the arm64 slice's load commands. Returns NULL for anything else.
static uint8_t *vpReadCommands(const char *path, uint32_t *count, uint32_t *size) {
int descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (descriptor < 0)
return NULL;
uint8_t *commands = NULL;
off_t offset = 0;
uint32_t magic = 0;
if (pread(descriptor, &magic, sizeof(magic), 0) != sizeof(magic))
goto done;
if (magic == FAT_CIGAM) {
struct fat_header fat;
if (pread(descriptor, &fat, sizeof(fat), 0) != sizeof(fat))
goto done;
uint32_t slices = OSSwapBigToHostInt32(fat.nfat_arch);
int found = 0;
for (uint32_t index = 0; index < slices && index < 16 && !found; index++) {
struct fat_arch arch;
off_t at = (off_t)sizeof(fat) + (off_t)index * (off_t)sizeof(arch);
if (pread(descriptor, &arch, sizeof(arch), at) != sizeof(arch))
goto done;
if ((cpu_type_t)OSSwapBigToHostInt32((uint32_t)arch.cputype) == CPU_TYPE_ARM64) {
offset = OSSwapBigToHostInt32(arch.offset);
found = 1;
}
}
if (!found)
goto done;
}
struct mach_header_64 header;
if (pread(descriptor, &header, sizeof(header), offset) != sizeof(header) ||
header.magic != MH_MAGIC_64 || header.cputype != CPU_TYPE_ARM64 ||
header.sizeofcmds == 0 || header.sizeofcmds > VP_MAX_COMMANDS)
goto done;
commands = malloc(header.sizeofcmds);
if (!commands)
goto done;
if (pread(descriptor, commands, header.sizeofcmds, offset + (off_t)sizeof(header)) !=
(ssize_t)header.sizeofcmds) {
free(commands);
commands = NULL;
goto done;
}
*count = header.ncmds;
*size = header.sizeofcmds;
done:
close(descriptor);
return commands;
}
// The string a load command stores at `offset`, if it ends inside the command.
static const char *vpCommandString(const struct load_command *command, uint32_t offset) {
if (offset < sizeof(*command) || offset >= command->cmdsize)
return NULL;
const char *string = (const char *)command + offset;
size_t room = command->cmdsize - offset;
return strnlen(string, room) < room ? string : NULL;
}
// Maps @loader_path/.jbroot/x to <root>/x. Everything else is outside the
// bootstrap or already covered by the loading image's own directory.
static int vpBootstrapPath(const VPLinkWalk *walk, const char *name, char path[PATH_MAX]) {
if (strncmp(name, vpLoaderPrefix, sizeof(vpLoaderPrefix) - 1) != 0)
return 0;
const char *relative = name + sizeof(vpLoaderPrefix) - 2;
int used = snprintf(path, PATH_MAX, "%s%s", walk->root, relative);
return used > 0 && used < PATH_MAX;
}
static void vpVisitImage(VPLinkWalk *walk, const char *image);
// Links the directory holding `candidate` and walks it, when it is a file
// inside the bootstrap. Returns whether it exists.
static int vpVisitDependency(VPLinkWalk *walk, const char *candidate) {
char canonical[PATH_MAX];
struct stat info;
if (!realpath(candidate, canonical) || stat(canonical, &info) != 0 || !S_ISREG(info.st_mode))
return 0;
if (!vpWithin(canonical, walk->root))
return 1;
vpVisitImage(walk, canonical);
return 1;
}
static void vpAddRPath(VPLinkWalk *walk, const char *name) {
char path[PATH_MAX];
char canonical[PATH_MAX];
if (!vpBootstrapPath(walk, name, path) || !realpath(path, canonical) ||
!vpWithin(canonical, walk->root))
return;
for (size_t index = 0; index < walk->rpathCount; index++) {
if (strcmp(walk->rpaths[index], canonical) == 0)
return;
}
// A plugin a library dlopens from its rpath directory needs the link too.
vpNote(walk, vpEnsureDirectoryLink(canonical, walk->root));
if (walk->rpathCount < VP_MAX_RPATHS)
strcpy(walk->rpaths[walk->rpathCount++], canonical);
}
// `image` is canonical and inside the root. Its directory gets a link, then
// each dependency dyld will look for inside the bootstrap is visited.
static void vpVisitImage(VPLinkWalk *walk, const char *image) {
for (size_t index = 0; index < walk->imageCount; index++) {
if (strcmp(walk->images[index], image) == 0)
return;
}
if (walk->imageCount >= VP_MAX_IMAGES)
return;
strcpy(walk->images[walk->imageCount++], image);
char directory[PATH_MAX];
strcpy(directory, image);
char *leaf = strrchr(directory, '/');
if (!leaf || leaf == directory)
return;
*leaf = '\0';
vpNote(walk, vpEnsureDirectoryLink(directory, walk->root));
uint32_t count = 0;
uint32_t size = 0;
uint8_t *commands = vpReadCommands(image, &count, &size);
if (!commands)
return;
// dyld searches the rpaths of every image on the loading chain, so they
// are all collected before any @rpath dependency is looked up.
for (int pass = 0; pass < 2; pass++) {
uint32_t offset = 0;
for (uint32_t index = 0; index < count && offset + sizeof(struct load_command) <= size; index++) {
const struct load_command *command = (const struct load_command *)(commands + offset);
if (command->cmdsize < sizeof(*command) || command->cmdsize > size - offset)
break;
offset += command->cmdsize;
if (pass == 0) {
if (command->cmd == LC_RPATH && command->cmdsize >= sizeof(struct rpath_command)) {
const char *name = vpCommandString(command, ((const struct rpath_command *)command)->path.offset);
if (name)
vpAddRPath(walk, name);
}
continue;
}
if (command->cmd != LC_LOAD_DYLIB && command->cmd != LC_LOAD_WEAK_DYLIB &&
command->cmd != LC_REEXPORT_DYLIB && command->cmd != LC_LAZY_LOAD_DYLIB &&
command->cmd != LC_LOAD_UPWARD_DYLIB)
continue;
if (command->cmdsize < sizeof(struct dylib_command))
continue;
const char *name = vpCommandString(command, ((const struct dylib_command *)command)->dylib.name.offset);
if (!name)
continue;
char candidate[PATH_MAX];
if (vpBootstrapPath(walk, name, candidate)) {
vpVisitDependency(walk, candidate);
} else if (strncmp(name, vpRPathPrefix, sizeof(vpRPathPrefix) - 1) == 0) {
const char *leafName = name + sizeof(vpRPathPrefix) - 1;
for (size_t rpath = 0; rpath < walk->rpathCount; rpath++) {
int used = snprintf(candidate, sizeof(candidate), "%s/%s", walk->rpaths[rpath], leafName);
if (used > 0 && (size_t)used < sizeof(candidate) && vpVisitDependency(walk, candidate))
break;
}
}
}
}
free(commands);
}
int vpEnsureRootHideLoaderLink(const char *executable, const char *root) {
if (!executable || !root || !strstr(root, "/.jbroot-"))
return 0;
@@ -42,41 +266,22 @@ int vpEnsureRootHideLoaderLink(const char *executable, const char *root) {
if (!vpWithin(executable, namedRoot))
return 0;
char canonicalRoot[PATH_MAX];
VPLinkWalk *walk = calloc(1, sizeof(*walk));
if (!walk)
return ENOMEM;
char canonicalExecutable[PATH_MAX];
if (!realpath(root, canonicalRoot) || !realpath(executable, canonicalExecutable))
return errno;
char *leaf = strrchr(canonicalExecutable, '/');
if (!leaf || leaf == canonicalExecutable)
return EINVAL;
*leaf = '\0';
if (!vpWithin(canonicalExecutable, canonicalRoot))
return 0;
char target[PATH_MAX];
int status = vpLinkTarget(canonicalExecutable, canonicalRoot, target);
if (status)
return status;
int directory = open(canonicalExecutable, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (directory < 0)
return errno;
struct stat info;
if (fstatat(directory, ".jbroot", &info, AT_SYMLINK_NOFOLLOW) == 0) {
if (!S_ISLNK(info.st_mode)) {
close(directory);
return EEXIST;
}
char linkPath[PATH_MAX];
char resolved[PATH_MAX];
int used = snprintf(linkPath, sizeof(linkPath), "%s/.jbroot", canonicalExecutable);
status = used <= 0 || (size_t)used >= sizeof(linkPath) ? ENAMETOOLONG :
!realpath(linkPath, resolved) ? errno :
strcmp(resolved, canonicalRoot) == 0 ? 0 : EEXIST;
} else if (errno == ENOENT) {
status = symlinkat(target, directory, ".jbroot") == 0 ? 0 : errno;
} else {
int status = 0;
if (!realpath(root, walk->root) || !realpath(executable, canonicalExecutable)) {
status = errno;
} else if (vpWithin(canonicalExecutable, walk->root)) {
char *leaf = strrchr(canonicalExecutable, '/');
if (!leaf || leaf == canonicalExecutable) {
status = EINVAL;
} else {
vpVisitImage(walk, canonicalExecutable);
status = walk->status;
}
}
close(directory);
free(walk);
return status;
}
@@ -1,8 +1,11 @@
#ifndef VPHONE_ROOT_HIDE_LOADER_LINKS_H
#define VPHONE_ROOT_HIDE_LOADER_LINKS_H
// Ensure @loader_path/.jbroot can resolve before dyld starts the child.
// Returns zero on success or when no change is needed, otherwise an errno.
// Ensure @loader_path/.jbroot can resolve before dyld starts the child: in the
// executable's directory, in each @loader_path/.jbroot rpath directory, and
// in the directory of each dependency dyld will find inside the bootstrap,
// walked through its own load commands within a fixed bound.
// Returns zero on success or when no change is needed, otherwise the first errno.
int vpEnsureRootHideLoaderLink(const char *executable, const char *root);
#endif
@@ -84,18 +84,27 @@ static void vpPrepareLoaderLink(const char *path) {
}
}
// Every child chain-loads SystemHook, whatever its environment says: a shell
// or sshd that rebuilds its child's environment would otherwise drop it.
// DISABLE_TWEAKS and safe mode are honored in the child's constructor, which
// then skips ElleKit. Only bootstrap, app and camera targets are logged and
// get their loader links prepared.
static VPInjectionEnvironment vpPrepareChild(const char *path, char *const envp[], const char *kind) {
VPInjectionEnvironment injected = vpInsertHook(envp, getenv("VPHONE_JB_ROOT"));
if (vpIsInjectionTarget(path)) {
vpPrepareLoaderLink(path);
char decision[64];
snprintf(decision, sizeof(decision), "%s%s%s", kind, !injected.values ? "unchanged" : "inserted",
vpInjectionDisabled(envp) ? "-tweaks-disabled" : "");
vpLogSpawn(path, decision);
}
return injected;
}
static int vpSpawnP(pid_t *restrict pid, const char *restrict path, const posix_spawn_file_actions_t *restrict actions,
const posix_spawnattr_t *restrict attributes, char *const argv[restrict],
char *const envp[restrict]) {
if (!vpIsInjectionTarget(path))
return posix_spawnp(pid, path, actions, attributes, argv, envp);
vpPrepareLoaderLink(path);
if (vpInjectionDisabled(envp)) {
vpLogSpawn(path, "disabled");
return posix_spawnp(pid, path, actions, attributes, argv, envp);
}
VPInjectionEnvironment injected = vpInsertHook(envp, getenv("VPHONE_JB_ROOT"));
vpLogSpawn(path, injected.values ? "inserted" : "unchanged");
VPInjectionEnvironment injected = vpPrepareChild(path, envp, "");
int status = posix_spawnp(pid, path, actions, attributes, argv, injected.values ? injected.values : envp);
vpFreeEnvironment(&injected);
return status;
@@ -104,30 +113,14 @@ static int vpSpawnP(pid_t *restrict pid, const char *restrict path, const posix_
static int vpSpawn(pid_t *restrict pid, const char *restrict path, const posix_spawn_file_actions_t *restrict actions,
const posix_spawnattr_t *restrict attributes, char *const argv[restrict],
char *const envp[restrict]) {
if (!vpIsInjectionTarget(path))
return posix_spawn(pid, path, actions, attributes, argv, envp);
vpPrepareLoaderLink(path);
if (vpInjectionDisabled(envp)) {
vpLogSpawn(path, "disabled");
return posix_spawn(pid, path, actions, attributes, argv, envp);
}
VPInjectionEnvironment injected = vpInsertHook(envp, getenv("VPHONE_JB_ROOT"));
vpLogSpawn(path, injected.values ? "inserted" : "unchanged");
VPInjectionEnvironment injected = vpPrepareChild(path, envp, "");
int status = posix_spawn(pid, path, actions, attributes, argv, injected.values ? injected.values : envp);
vpFreeEnvironment(&injected);
return status;
}
static int vpExecve(const char *path, char *const argv[], char *const envp[]) {
if (!vpIsInjectionTarget(path))
return execve(path, argv, envp);
vpPrepareLoaderLink(path);
if (vpInjectionDisabled(envp)) {
vpLogSpawn(path, "exec-disabled");
return execve(path, argv, envp);
}
VPInjectionEnvironment injected = vpInsertHook(envp, getenv("VPHONE_JB_ROOT"));
vpLogSpawn(path, injected.values ? "exec-inserted" : "exec-unchanged");
VPInjectionEnvironment injected = vpPrepareChild(path, envp, "exec-");
int status = execve(path, argv, injected.values ? injected.values : envp);
int savedErrno = errno;
vpFreeEnvironment(&injected);
@@ -173,7 +166,10 @@ __attribute__((constructor)) static void vpLogProcess(void) {
vpInXPCProxy = strcmp(path, "/usr/libexec/xpcproxy") == 0;
vpInBootstrap = vpIsBootstrapPath(path, getenv("VPHONE_JB_ROOT"));
int fd = vpOpenLog("vphone-systemhook.log");
// Every process loads this hook; only the ones it acts on are logged.
int fd = vpInXPCProxy || vpInBootstrap || vpIsAppPath(path) || vpIsCameraDaemon(path)
? vpOpenLog("vphone-systemhook.log")
: -1;
if (fd >= 0) {
char **arguments = *_NSGetArgv();
dprintf(fd, "pid=%d path=%s label=%s root=%s\n", getpid(), path,
@@ -204,6 +200,9 @@ __attribute__((constructor)) static void vpLogProcess(void) {
int used = snprintf(loader, sizeof(loader), "%s/usr/lib/TweakLoader.dylib", root);
if (used <= 0 || (size_t)used >= sizeof(loader))
return;
// dlopen is not a spawn: link TweakLoader's dependency directories, such
// as usr/lib/ellekit, before dyld looks for them.
vpEnsureRootHideLoaderLink(loader, root);
vpLoadLibrary("tweakloader", loader);
}
@@ -2,6 +2,8 @@
#include <assert.h>
#include <errno.h>
#include <limits.h>
#include <mach-o/loader.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -15,6 +17,83 @@ static void file(const char *path) {
assert(fclose(stream) == 0);
}
// A thin arm64 image with one optional LC_RPATH and one LC_LOAD_DYLIB.
static void image(const char *path, const char *rpath, const char *dependency) {
uint8_t commands[1024] = {0};
uint32_t size = 0, count = 0;
if (rpath) {
struct rpath_command *command = (struct rpath_command *)(commands + size);
uint32_t length = (uint32_t)((sizeof(*command) + strlen(rpath) + 8) & ~7ul);
command->cmd = LC_RPATH;
command->cmdsize = length;
command->path.offset = sizeof(*command);
strcpy((char *)command + sizeof(*command), rpath);
size += length;
count++;
}
struct dylib_command *command = (struct dylib_command *)(commands + size);
uint32_t length = (uint32_t)((sizeof(*command) + strlen(dependency) + 8) & ~7ul);
command->cmd = LC_LOAD_DYLIB;
command->cmdsize = length;
command->dylib.name.offset = sizeof(*command);
strcpy((char *)command + sizeof(*command), dependency);
size += length;
count++;
struct mach_header_64 header = {
.magic = MH_MAGIC_64, .cputype = CPU_TYPE_ARM64, .filetype = MH_EXECUTE,
.ncmds = count, .sizeofcmds = size,
};
FILE *stream = fopen(path, "w");
assert(stream);
assert(fwrite(&header, sizeof(header), 1, stream) == 1);
assert(fwrite(commands, size, 1, stream) == 1);
assert(fclose(stream) == 0);
}
static void expectLink(const char *directory, const char *target) {
char path[PATH_MAX], text[PATH_MAX];
assert((size_t)snprintf(path, sizeof(path), "%s/.jbroot", directory) < sizeof(path));
ssize_t length = readlink(path, text, sizeof(text) - 1);
assert(length > 0);
text[length] = '\0';
assert(strcmp(text, target) == 0);
assert(unlink(path) == 0);
}
// sudo's shape: usr/bin/sudo has rpath @loader_path/.jbroot/usr/libexec/sudo
// and loads @rpath/libsudo_util.0.dylib, which loads a library elsewhere.
static void dependencies(const char *root) {
const char *directories[] = {"usr", "usr/bin", "usr/lib", "usr/lib/deep", "usr/libexec", "usr/libexec/tool"};
char path[PATH_MAX];
for (size_t index = 0; index < sizeof(directories) / sizeof(*directories); index++) {
assert((size_t)snprintf(path, sizeof(path), "%s/%s", root, directories[index]) < sizeof(path));
directory(path);
}
char executable[PATH_MAX], library[PATH_MAX], deep[PATH_MAX];
snprintf(executable, sizeof(executable), "%s/usr/bin/tool", root);
snprintf(library, sizeof(library), "%s/usr/libexec/tool/libtool.0.dylib", root);
snprintf(deep, sizeof(deep), "%s/usr/lib/deep/libdeep.dylib", root);
image(executable, "@loader_path/.jbroot/usr/libexec/tool", "@rpath/libtool.0.dylib");
image(library, NULL, "@loader_path/.jbroot/usr/lib/deep/libdeep.dylib");
file(deep);
assert(vpEnsureRootHideLoaderLink(executable, root) == 0);
snprintf(path, sizeof(path), "%s/usr/bin", root);
expectLink(path, "../..");
snprintf(path, sizeof(path), "%s/usr/libexec/tool", root);
expectLink(path, "../../..");
snprintf(path, sizeof(path), "%s/usr/lib/deep", root);
expectLink(path, "../../..");
assert(unlink(executable) == 0);
assert(unlink(library) == 0);
assert(unlink(deep) == 0);
for (size_t index = sizeof(directories) / sizeof(*directories); index > 0; index--) {
assert((size_t)snprintf(path, sizeof(path), "%s/%s", root, directories[index - 1]) < sizeof(path));
assert(rmdir(path) == 0);
}
}
int main(void) {
char scratch[] = "/tmp/vphone-loader-links.XXXXXX";
assert(mkdtemp(scratch));
@@ -52,6 +131,8 @@ int main(void) {
assert(vpEnsureRootHideLoaderLink(unrelated, root) == 0);
assert(unlink(unrelated) == 0);
dependencies(root);
assert(unlink(executable) == 0);
assert(rmdir(bundle) == 0);
assert(rmdir(apps) == 0);