mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-10-02 08:04:32 +08:00
#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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user