Give libmisfix one route: the spawn hooks, SpringBoard included

SpringBoard did carry SystemHook; what it lacked was libmisfix beside it.
Which libraries a process gets is decided in its parent, and launchd starts
SpringBoard itself, so SystemHook's list naming it was never consulted. A
probe reading KERN_PROCARGS2 showed DYLD_INSERT_LIBRARIES held SystemHook
alone in SpringBoard and both libraries in installd and misagent.

  - vpIsMISFixTarget moves to InjectionEnvironment.h and the launchd hook
    asks it too, inserting libmisfix only when the dylib exists.
  - vpInsertHooks dropped the extra library when the environment already
    named SystemHook; fixed, with make test-injection-environment.
  - No guest binary carries a libmisfix load command. The three declarations
    that added them are gone, cfw install puts each .bak back and removes
    /mf, and inject-dylib --reclaim-source-version goes with its only caller.
  - SystemHook's logs are world-writable: a root-created 0644 file silently
    dropped every line from a mobile process, which is what made SpringBoard
    look as though it never carried the hook.

Measured on test-26.4 and test-27.0, both recreated from local IPSWs: from
the first boot SpringBoard, installd and misagent carry both libraries, and
AirBuild installed through installd opens on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-30 22:46:10 +09:00
co-authored by Claude Opus 5.5
parent 7a413718d5
commit 1c19bb41da
17 changed files with 281 additions and 373 deletions
File diff suppressed because one or more lines are too long
@@ -136,7 +136,10 @@ linked call, no options -> 0x0 (0xE8008014 without the hook)
`system-installd-cfw-adhoc_signature` — `VPhoneGuestComponents/MISFix/MISFix-vphone.c`,
built as `/usr/lib/libmisfix.dylib`, attached to `/usr/libexec/installd` by a
`LC_LOAD_WEAK_DYLIB` that `cfw install` inserts. It interposes
`LC_LOAD_WEAK_DYLIB` that `cfw install` inserts. (Superseded 2026-09-30: the
declaration is gone, and the spawn hooks insert libmisfix into installd,
misagent and SpringBoard — see "One route for libmisfix" in
`Research/0_binary_patch_comparison.md`.) It interposes
`MISValidateSignatureAndCopyInfo` and
`MISValidateSignatureAndCopyInfoWithProgress`, adds `AllowAdHocSigning` to the
caller's options, and calls through. Nothing in the dyld shared cache is
@@ -252,7 +255,8 @@ again.
## What was built
`system-misagent-cfw-device_identity` — the same `libmisfix.dylib`, attached to
`/usr/libexec/misagent`, interposing `MGCopyAnswer` and `MGCopyAnswerWithError`
`/usr/libexec/misagent` (now inserted at spawn, like installd's; the
declaration is gone), interposing `MGCopyAnswer` and `MGCopyAnswerWithError`
and answering `UniqueDeviceID` with the value in `libmisfix.plist`. Set it to a
device the team has already registered and that team's profiles install here,
with no portal round trip and nothing to redo after a rebuild. Absent or empty,
@@ -24,7 +24,7 @@ import Foundation
/// `+[MICodeSigningVerifier _validateSignatureAndCopyInfoForURL:withOptions:error:]`
/// inside installd, which calls `MISValidateSignatureAndCopyInfo` and evaluates
/// the profile's `ProvisionedDevices` itself. Two processes answer the UDID
/// question independently — which is why `cfw install` injects libmisfix into
/// question independently — which is why the spawn hooks insert libmisfix into
/// both — so refreshing one and not the other leaves them disagreeing.
///
/// Measured on test-26.4 (2026-09-30): with the override changed to a UDID in
@@ -43,12 +43,11 @@ extension GuestAPI {
static let udidConfigPaths = ["/var/db/vphone/misfix.plist", "/usr/lib/libmisfix.plist"]
static let udidConfigKey = "UniqueDeviceID"
/// The daemons `cfw install` injects libmisfix into, and so the ones holding
/// a UDID answer that a change has to invalidate. Keep in step with the
/// `injectedDylibPath: "/usr/lib/libmisfix.dylib"` call sites in
/// `VPhoneCustomFirmwareInstaller`, bar SpringBoard: it carries the hook
/// for the launch check alone, never asks for the UDID, and stopping it
/// would take the home screen down with it.
/// The daemons libmisfix is inserted into, and so the ones holding a UDID
/// answer that a change has to invalidate. Keep in step with
/// `vpIsMISFixTarget` in `VPhoneGuestComponents/Shared/InjectionEnvironment.h`,
/// bar SpringBoard: it carries the hook for the launch check alone, never
/// asks for the UDID, and stopping it would take the home screen down with it.
static let udidHookedDaemons = ["misagent", "installd"]
static func executeDeviceIdentity(_ method: String, _ params: [String: Any]) throws -> [String: Any]? {
@@ -78,8 +78,8 @@ extension GuestAPI {
}
// Same for the MIS hook: a running daemon keeps the copy it mapped at
// launch, so replacing the file changes nothing until it restarts.
// SystemHook is what inserts libmisfix into these, so a new SystemHook
// matters to them too. SpringBoard is deliberately not in this list —
// SystemHook (through xpcproxy) is what inserts libmisfix into these,
// so a new SystemHook matters to them too. SpringBoard is deliberately not in this list —
// restarting it is a respring, which is `system.respring` to ask for
// and not something an environment update should do behind the back of
// whoever is looking at the screen.
@@ -22,13 +22,6 @@
// `ncmds` / `sizeofcmds` in the header change. What *does* move is the code
// signature — `.strip` removes it and truncates the slice, which is why the
// policy is part of this API rather than a separate pass.
//
// Some binaries leave almost no padding. SpringBoard on 24A435 has 16 bytes:
// even after `.strip` frees its 16-byte LC_CODE_SIGNATURE, a 32-byte command
// fits only by leaving the re-signer no room to put the signature back. For
// those, `reclaimsSourceVersion` drops LC_SOURCE_VERSION — a version stamp
// nothing reads at load time — and moves the commands after it up. Opt-in,
// because it is the one case where an existing command is removed.
import Foundation
import VPhonePatchKit
@@ -69,10 +62,6 @@ private extension Data {
replaceSubrange(offset ..< offset + count, with: Data(repeating: 0, count: count))
}
func isZero(_ range: Range<Int>) -> Bool {
!self[range].contains(where: { $0 != 0 })
}
func holds(_ offset: Int, _ length: Int) -> Bool {
offset >= 0 && length >= 0 && offset + length <= count
}
@@ -94,8 +83,6 @@ public struct CustomFirmwareDylibInjection: Sendable {
public let isWeak: Bool
/// True when LC_CODE_SIGNATURE and its blob were removed.
public let removedCodeSignature: Bool
/// True when LC_SOURCE_VERSION was dropped to make room.
public let removedSourceVersion: Bool
/// Slots re-hashed under `.keepAndReattest`. Empty under `.strip`.
public let rehashedSlots: [CustomFirmwareSlotRehash]
}
@@ -127,9 +114,6 @@ public enum CustomFirmwareInjectDylib {
static let lcSegment64: UInt32 = 0x19
static let lcSymtab: UInt32 = 0x02
static let lcCodeSignature: UInt32 = 0x1D
static let lcSourceVersion: UInt32 = 0x2A
/// sizeof(struct linkedit_data_command): what a re-signer adds back after `.strip`.
static let codeSignatureCommandSize = 16
static let lcLoadDylib: UInt32 = 0x0C
static let lcLoadWeakDylib: UInt32 = 0x8000_0018
@@ -150,7 +134,6 @@ public enum CustomFirmwareInjectDylib {
weak: Bool = true,
policy: CodeSignaturePolicy = .strip,
allowNonEmptyPadding: Bool = false,
reclaimsSourceVersion: Bool = false,
) throws -> [CustomFirmwareDylibInjection] {
guard FileManager.default.fileExists(atPath: url.path) else {
throw PatcherError.fileNotFound(url.path)
@@ -162,7 +145,6 @@ public enum CustomFirmwareInjectDylib {
weak: weak,
policy: policy,
allowNonEmptyPadding: allowNonEmptyPadding,
reclaimsSourceVersion: reclaimsSourceVersion,
)
try data.write(to: url)
return injections
@@ -176,7 +158,6 @@ public enum CustomFirmwareInjectDylib {
weak: Bool = true,
policy: CodeSignaturePolicy = .strip,
allowNonEmptyPadding: Bool = false,
reclaimsSourceVersion: Bool = false,
) throws -> [CustomFirmwareDylibInjection] {
if data.startIndex != 0 {
data = Data(data)
@@ -188,7 +169,6 @@ public enum CustomFirmwareInjectDylib {
weak: weak,
policy: policy,
allowNonEmptyPadding: allowNonEmptyPadding,
reclaimsSourceVersion: reclaimsSourceVersion,
)
switch data.loadBEValue(UInt32.self, at: 0) {
case fatMagic:
@@ -216,7 +196,6 @@ public enum CustomFirmwareInjectDylib {
let weak: Bool
let policy: CodeSignaturePolicy
let allowNonEmptyPadding: Bool
let reclaimsSourceVersion: Bool
}
// MARK: Universal Binaries
@@ -332,27 +311,6 @@ public enum CustomFirmwareInjectDylib {
let pathBytes = Array(options.dylibPath.utf8)
let paddedPathSize = (pathBytes.count & ~(pathPadding - 1)) + pathPadding
let commandSize = dylibCommandSize + paddedPathSize
// A stripped signature comes back when the binary is re-signed, and
// its command needs 16 bytes after ours.
var removedSourceVersion = false
let needed = commandSize + (removedCodeSignature ? codeSignatureCommandSize : 0)
let freeEnd = commandsOffset + sizeofcmds
if options.reclaimsSourceVersion,
let sourceVersion = layout.sourceVersion,
!data.isZero(freeEnd ..< Swift.min(freeEnd + needed, data.count))
{
guard case .strip = options.policy else {
throw PatcherError.invalidFormat("reclaiming LC_SOURCE_VERSION needs the .strip signature policy")
}
let tail = sourceVersion.commandOffset + sourceVersion.commandSize
let moved = Data(data[tail ..< freeEnd])
data.replaceSubrange(sourceVersion.commandOffset ..< sourceVersion.commandOffset + moved.count, with: moved)
data.zeroBytes(at: freeEnd - sourceVersion.commandSize, count: sourceVersion.commandSize)
ncmds -= 1
sizeofcmds -= sourceVersion.commandSize
removedSourceVersion = true
}
let commandOffset = commandsOffset + sizeofcmds
guard data.holds(commandOffset, commandSize), commandOffset + commandSize <= headerOffset + sliceSize else {
@@ -412,7 +370,6 @@ public enum CustomFirmwareInjectDylib {
loadCommandSize: commandSize,
isWeak: options.weak,
removedCodeSignature: removedCodeSignature,
removedSourceVersion: removedSourceVersion,
rehashedSlots: rehashed,
)
}
@@ -430,7 +387,6 @@ public enum CustomFirmwareInjectDylib {
struct SliceLayout {
var codeSignature: CodeSignatureCommand?
var codeSignatureIsLast = false
var sourceVersion: (commandOffset: Int, commandSize: Int)?
/// Offset of the __LINKEDIT LC_SEGMENT_64 command, and its slice-relative extent.
var linkEditCommandOffset: Int?
var linkEditFileOffset = 0
@@ -468,8 +424,6 @@ public enum CustomFirmwareInjectDylib {
dataSize: Int(data.loadLEValue(UInt32.self, at: offset + 12)),
)
layout.codeSignatureIsLast = index == ncmds - 1
case lcSourceVersion:
layout.sourceVersion = (offset, cmdsize)
case lcSegment64:
guard data.holds(offset, 72) else {
throw PatcherError.invalidFormat("LC_SEGMENT_64 is truncated")
@@ -82,9 +82,7 @@ public enum FirmwareGuestSystemPatchSet {
Accepts a provisioning profile that wants online authorization, by short-circuiting \
the check in the shared cache. Off by default: libmisfix.dylib already declines the \
same check from userspace in installd, misagent and SpringBoard, and editing the \
cache for it stops an iOS 27 guest booting. Kept for a 26.x guest whose SpringBoard \
was installed without system-springboard-cfw-launch_authorization. Not offered on \
iOS 27, where it stops the guest booting.
cache for it stops an iOS 27 guest booting. Not offered on iOS 27.
""",
target: .dyldSharedCache,
applicability: misTrustAuthBases,
@@ -127,51 +125,6 @@ public enum FirmwareGuestSystemPatchSet {
target: .guestExecutable(path: "/sbin/launchd"),
bootEssential: true,
),
VPhonePatchDeclaration(
identifier: "system-installd-cfw-adhoc_signature",
title: "installd signature policy",
summary: """
Lets Xcode install an app the guest would otherwise refuse. installd asks \
MobileIdentityService to validate a bundle without allowing an ad-hoc \
signature and insists on a provisioning profile no VM can satisfy, so an \
install fails at 0xE8008014 or 0xE8008015 even though the guest runs \
unsigned code perfectly well. A hook in /usr/lib/libmisfix.dylib, loaded \
into installd, sets the options MIS already understands, answers \
ProvisionsAllDevices for every profile, and turns on the ad-hoc switch \
MICodeSigningVerifier carries and installd never sets. Nothing in the dyld \
shared cache is written on disk: the libmis functions are detoured in \
installd's own copy-on-write pages.
""",
target: .guestExecutable(path: "/usr/libexec/installd"),
),
VPhonePatchDeclaration(
identifier: "system-misagent-cfw-device_identity",
title: "misagent profile scope",
summary: """
Lets any provisioning profile install on this guest. misagent asks the \
profile whether it provisions all devices and otherwise compares its \
ProvisionedDevices against the UDID MobileGestalt reports; a VM's UDID is \
in nobody's list, so a paid team's profile fails at 0xE8008012. The same \
hook, loaded into misagent, answers the first question yes, so the profile \
installs for real and the app is validated against it. It can also answer \
the UDID query with a device set in /usr/lib/libmisfix.plist, which is off \
until one is set and does not change what Xcode or lockdown report.
""",
target: .guestExecutable(path: "/usr/libexec/misagent"),
),
VPhonePatchDeclaration(
identifier: "system-springboard-cfw-launch_authorization",
title: "SpringBoard launch authorization",
summary: """
Lets an installed developer-signed app launch. SpringBoard validates the app \
with MIS again before launching it, and MIS asks for online authorization a \
guest without an activation record can never get, so the launch is refused \
with 0xE8008026 and "Unable to Verify App". The same hook, loaded into \
SpringBoard, declines that check the way it does in installd. This replaces \
dyld-exp-mis_trust_auth without writing the shared cache.
""",
target: .guestExecutable(path: "/System/Library/CoreServices/SpringBoard.app/SpringBoard"),
),
VPhonePatchDeclaration(
identifier: "system-debugserver-cfw-install",
title: "debugserver",
@@ -212,7 +165,12 @@ public enum FirmwareGuestSystemPatchSet {
VPhonePatchDeclaration(
identifier: "system-launchdaemons-boot-environment",
title: "Guest environment",
summary: "Installs the launchd environment and plists the guest tools read.",
summary: """
Installs the launchd environment and plists the guest tools read, and the hooks \
launchd and SystemHook insert at spawn. Among them is libmisfix.dylib, inserted \
into installd, misagent and SpringBoard, which lets Xcode install and launch an \
app signed for someone else's team, or ad hoc, without writing the shared cache.
""",
target: .guestFile(path: "/Library/LaunchDaemons"),
bootEssential: true,
),
@@ -68,8 +68,7 @@ public enum FirmwarePatchSetCatalog {
/// libmis only calls `checkTrustAndAuthorization` when that flag is set, so
/// `0xE8008026` — "missing trust and/or authorization", which a hacktivated
/// guest with no activation record can never satisfy — is never produced.
/// `cfw install` injects that hook into `installd`, `misagent` and
/// `SpringBoard`.
/// The spawn hooks insert it into `installd`, `misagent` and `SpringBoard`.
///
/// Leaving the patch on costs more than it buys. On iOS 27 it stops the guest
/// booting: TXM rejects the re-attested page, dyld cannot map
@@ -78,12 +77,10 @@ public enum FirmwarePatchSetCatalog {
/// neither the seeding prologue the patcher matches nor the patcher's own
/// output, so `cfw install` fails outright before it writes anything.
///
/// Why it stays declared rather than being deleted: the hook only covers the
/// processes it is injected into, and until
/// `system-springboard-cfw-launch_authorization` SpringBoard was not one of
/// them, so an installed app was refused at launch. A 26.x guest whose
/// SpringBoard predates that patch can check this box instead of running
/// `cfw install` again; on 27 it should not.
/// Why it stays declared rather than being deleted: on a 26.x guest whose
/// environment predates SpringBoard getting the hook, an installed app is
/// refused at launch, and this box is an alternative to updating the
/// environment. On 27 it is not offered.
public static let misTrustAuthPatch = "dyld-exp-mis_trust_auth"
/// The former EXP patches that make the guest claim to be an iPhone17,3.
@@ -614,77 +614,4 @@ struct CustomFirmwareInjectDylibTests {
try #require(after.status == 0, "injected hello failed: \(after.output)")
#expect(after.output == "world hello\n")
}
/// SpringBoard's shape: room for the command, but not for the signature a
/// re-signer puts back after it. Dropping LC_SOURCE_VERSION has to make that
/// room, leave every byte past it alone, and leave a binary dyld still runs.
@Test func `reclaiming LC_SOURCE_VERSION makes room for the command and the signature`() throws {
let fixtures = MachOFixture.repositoryRoot
.appending(path: "VPhoneExecutable/VPhoneCommand/FirmwarePatcherTestFixtures/DylibInjection")
let directory = FileManager.default.temporaryDirectory
.appending(path: "DylibInjection-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let executable = directory.appending(path: "hello")
let dylib = directory.appending(path: "swizzle.dylib")
let clang = URL(filePath: "/usr/bin/clang")
let buildExecutable = try MachOFixture.run(
clang,
[
"-fobjc-arc", "-framework", "Foundation", "-Wl,-headerpad,0x4000",
fixtures.appending(path: "hello.m").path, "-o", executable.path,
],
)
try #require(buildExecutable.status == 0, "hello fixture: \(buildExecutable.output)")
let buildDylib = try MachOFixture.run(
clang,
[
"-dynamiclib", "-fobjc-arc", "-framework", "Foundation",
fixtures.appending(path: "swizzle.m").path, "-o", dylib.path,
],
)
try #require(buildDylib.status == 0, "swizzle fixture: \(buildDylib.output)")
// Leave exactly enough zero padding for the command once the 16-byte
// LC_CODE_SIGNATURE is stripped, and mark the first byte past it.
var data = try Data(contentsOf: executable)
let ncmds = data.loadLE(UInt32.self, at: 16)
let commandsEnd = 32 + Int(data.loadLE(UInt32.self, at: 20))
let pathBytes = dylib.path.utf8.count
let commandSize = 24 + (pathBytes & ~7) + 8
let marker = commandsEnd + commandSize - 16
data[marker] = 0xFF
try data.write(to: executable)
let injection = try #require(
try CustomFirmwareInjectDylib.inject(
dylibPath: dylib.path,
into: executable,
reclaimsSourceVersion: true,
).first,
)
#expect(injection.removedCodeSignature)
#expect(injection.removedSourceVersion)
var after = try Data(contentsOf: executable)
#expect(after[marker] == 0xFF, "the byte past the reclaimed room must not be written")
// Out: LC_CODE_SIGNATURE and LC_SOURCE_VERSION. In: the dylib.
#expect(after.loadLE(UInt32.self, at: 16) == ncmds - 1)
#expect(Int(after.loadLE(UInt32.self, at: 20)) == commandsEnd - 32 - 16 - 16 + commandSize)
#expect(MachOFixture.dylibLoadCommands(in: after).last?.path == dylib.path)
#expect(MachOFixture.dylibLoadCommands(in: after).count == MachOFixture.dylibLoadCommands(in: data).count + 1)
// codesign needs the reserved 16 bytes, and zero ones.
after[marker] = 0
try after.write(to: executable)
let sign = try MachOFixture.run(
URL(filePath: "/usr/bin/codesign"),
["--force", "--sign", "-", "--timestamp=none", executable.path],
)
try #require(sign.status == 0, "signing reclaimed hello: \(sign.output)")
let run = try MachOFixture.run(executable, [])
try #require(run.status == 0, "reclaimed hello failed: \(run.output)")
#expect(run.output == "world hello\n")
}
}
@@ -569,8 +569,8 @@ struct VPhoneCustomFirmwareInstaller {
}
// Version-agnostic: the guest is hacktivated on every base, so the
// profile check this opens fails on every base too. Off in `standard`,
// because libmisfix declines the same check from userspace in installd
// and misagent without touching the cache — see
// because libmisfix declines the same check from userspace in installd,
// misagent and SpringBoard without touching the cache — see
// FirmwarePatchSetCatalog.misTrustAuthPatch for why editing the cache
// is the worse trade on 27.
if on(FirmwarePatchSetCatalog.misTrustAuthPatch) {
@@ -655,46 +655,7 @@ struct VPhoneCustomFirmwareInstaller {
injectedDylibPath: "/vh",
)
}
if on("system-installd-cfw-adhoc_signature") {
// No bytes of installd's own change: the hook rides in on a weak
// load command and does its work through dyld interposition.
try patchMachO(
system: system,
work: work,
path: "usr/libexec/installd",
identifier: "com.apple.installd",
preserveEntitlements: true,
injectedDylibPath: "/usr/lib/libmisfix.dylib",
)
}
if on("system-misagent-cfw-device_identity") {
try patchMachO(
system: system,
work: work,
path: "usr/libexec/misagent",
identifier: "com.apple.misagent",
preserveEntitlements: true,
injectedDylibPath: "/usr/lib/libmisfix.dylib",
)
}
if on("system-springboard-cfw-launch_authorization") {
// SpringBoard asks MIS again before it launches an app, and no
// spawn hook reaches it: launchd starts it directly, in its
// conclave, and it never carries SystemHook. A load command is the
// one route that always holds.
// Its header has 16 spare bytes, so the command names the short
// alias and LC_SOURCE_VERSION makes room for the new signature.
try installLibraryAlias(system: system, alias: "mf", target: "/usr/lib/libmisfix.dylib")
try patchMachO(
system: system,
work: work,
path: "System/Library/CoreServices/SpringBoard.app/SpringBoard",
identifier: "com.apple.springboard",
preserveEntitlements: true,
injectedDylibPath: "/mf",
reclaimsSourceVersion: true,
)
}
try restoreMISFixTargets(system: system)
if on("system-debugserver-cfw-install") {
try patchDebugserver(system: system, work: work)
}
@@ -955,6 +916,29 @@ struct VPhoneCustomFirmwareInstaller {
}
}
/// Put back the Apple binaries earlier installs linked libmisfix into.
///
/// installd, misagent and SpringBoard now get the hook the way every other
/// guest process gets SystemHook: the spawn hooks insert it (see
/// `vpIsMISFixTarget` in `VPhoneGuestComponents/Shared/InjectionEnvironment.h`).
/// A guest installed before that still carries a load command in each, and
/// `patchMachO` left the original beside it as `.bak`. The `/mf` alias
/// existed only for SpringBoard's.
private func restoreMISFixTargets(system: VPhoneConfinedDirectory) throws {
let targets = [
"usr/libexec/installd",
"usr/libexec/misagent",
"System/Library/CoreServices/SpringBoard.app/SpringBoard",
]
for path in targets where try system.isRegularFile("\(path).bak") {
try system.rename("\(path).bak", to: path)
print(" [+] \(path): restored the original, libmisfix is inserted at spawn")
}
if try system.isSymlink("mf"), try system.readLink("mf") == "/usr/lib/libmisfix.dylib" {
try system.removeItem("mf")
}
}
/// libmisfix's settings file, and only when the guest has none.
///
/// Unlike the libraries above this is not the bundle's to own: it carries a
@@ -1081,7 +1065,6 @@ struct VPhoneCustomFirmwareInstaller {
identifier: String? = nil,
preserveEntitlements: Bool = false,
injectedDylibPath: String? = nil,
reclaimsSourceVersion: Bool = false,
) throws {
let backup = "\(path).bak"
if try !system.exists(backup) {
@@ -1099,10 +1082,7 @@ struct VPhoneCustomFirmwareInstaller {
try patch(verb, [staged.path])
}
if let injectedDylibPath {
try patch(
"inject-dylib",
[staged.path, injectedDylibPath] + (reclaimsSourceVersion ? ["--reclaim-source-version"] : []),
)
try patch("inject-dylib", [staged.path, injectedDylibPath])
}
try VPhoneSigner.sign(
fileAt: staged,
@@ -164,22 +164,17 @@ struct VPhoneCustomFirmwareInjectDylibCommand: ParsableCommand {
@Argument(help: "Path the guest will load the dylib from (e.g. /b)")
var dylibPath: String
@Flag(help: "Drop LC_SOURCE_VERSION when the header has no room for the command and the re-signed signature")
var reclaimSourceVersion = false
func run() throws {
let injections = try CustomFirmwareInjectDylib.inject(
dylibPath: dylibPath,
into: binary,
weak: true,
policy: .strip,
reclaimsSourceVersion: reclaimSourceVersion,
)
for injection in injections {
let stripped = injection.removedCodeSignature ? ", signature stripped" : ""
let reclaimed = injection.removedSourceVersion ? ", LC_SOURCE_VERSION dropped" : ""
print(" [+] LC_LOAD_WEAK_DYLIB \(dylibPath) -> \(binary.lastPathComponent) "
+ "(slice +0x\(String(injection.sliceOffset, radix: 16))\(stripped)\(reclaimed))")
+ "(slice +0x\(String(injection.sliceOffset, radix: 16))\(stripped))")
}
}
}
@@ -91,10 +91,14 @@ static int vpSpawnWith(VPSpawnFunction spawn, pid_t *restrict pid, const char *r
// 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);
// launchd starts some jobs itself rather than through xpcproxy — SpringBoard
// is one — so the MIS hook has to be decided here as well as in SystemHook.
const char *misFix = vpMISFixFor(path);
VPInjectionEnvironment injected = vpInsertHooks(envp, vpBootRoot, misFix);
int status = spawn(pid, path, actions, attributes, argv, injected.values ? injected.values : envp);
if (bootstrapProgram || appProgram || strcmp(path, "/usr/libexec/xpcproxy") == 0) {
if (bootstrapProgram || appProgram || misFix || strcmp(path, "/usr/libexec/xpcproxy") == 0) {
const char *event = !injected.values ? "unchanged" :
misFix ? "inserted+misfix" :
vpInjectionDisabled(envp) ? "inserted-tweaks-disabled" : "inserted";
vpLogInjection(event, path, status);
vpLogSpawn(event, path, status == 0 && pid ? *pid : -1, status);
+1 -1
View File
@@ -73,7 +73,7 @@ const char *MISFixCallerImage(const void *address);
/// Whether this process's executable is named `name`, compared on the last
/// path component.
///
/// The same dylib is linked into installd, misagent and SpringBoard, and not
/// The same dylib is inserted into installd, misagent and SpringBoard, and not
/// every hook belongs in all three: MobileInstallation's policy is installd's
/// alone, and loading that framework into SpringBoard to swizzle it would
/// change a process the hook has no business in.
@@ -188,7 +188,7 @@ static BOOL vpAllowAdhocSigning(id self, SEL selector) {
return YES;
}
/// installd only. The same dylib is linked into misagent and SpringBoard, and
/// installd only. The same dylib is inserted into misagent and SpringBoard, and
/// neither runs an install; `vpSwizzle` would dlopen MobileInstallation into
/// them just to find a class they never use.
__attribute__((constructor)) static void vpInstallPolicyHooks(void) {
+7 -1
View File
@@ -80,7 +80,7 @@ GPU_PROVENANCE := $(STAGE)/gpu/README.md
# Directory timestamps also catch files removed from the wildcard inputs.
.PHONY: all gpu package test-loader-links test-vcam-dataplane clean
.PHONY: all gpu package test-loader-links test-injection-environment test-vcam-dataplane clean
all: $(ARTIFACTS) $(PLISTS) $(GPU_PROVENANCE)
@@ -97,6 +97,12 @@ test-loader-links:
Tests/RootHideLoaderLinksTests.c Shared/RootHideLoaderLinks.c
@$(OUT)/RootHideLoaderLinksTests
test-injection-environment:
@mkdir -p $(OUT)
/usr/bin/clang -Wall -Wextra -Werror -Wno-unused-function -Wno-write-strings \
-o $(OUT)/InjectionEnvironmentTests Tests/InjectionEnvironmentTests.c
@$(OUT)/InjectionEnvironmentTests
# Host proof harness for the camera data plane the guest hooks share.
test-vcam-dataplane:
@mkdir -p $(OUT)
@@ -4,26 +4,56 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define VP_SYSTEM_HOOK "/usr/lib/SystemHook-vphone.dylib"
// The MIS hook. Inserted only into the processes that evaluate a code
// signature or a provisioning profile — see `vpIsMISFixTarget` in
// SystemHook-vphone.c — rather than carried by every spawn.
//
// It is inserted rather than linked, and that distinction is the point.
// `cfw install` used to give installd and misagent an LC_LOAD_WEAK_DYLIB, which
// makes the hook a dependency of the main executable. That is enough to
// interpose calls the main executable makes itself, which is why misagent's
// UDID override worked, and it is *not* enough for a call made between two
// shared-cache images: installd's profile check is
// `+[MICodeSigningVerifier _validateSignatureAndCopyInfoForURL:withOptions:error:]`
// in MobileInstallation calling libmis, with installd's own image not involved,
// and that one kept seeing the guest's real UDID. DYLD_INSERT_LIBRARIES loads
// the hook ahead of everything else, which is where an interpose covers the
// cache's own uses of a symbol too.
// signature or a provisioning profile — see `vpIsMISFixTarget` below —
// rather than carried by every spawn. This insertion is the only way it gets
// there: no guest binary carries a load command for it.
#define VP_MIS_FIX "/usr/lib/libmisfix.dylib"
static int vpPathHasSuffix(const char *path, const char *suffix) {
size_t length = path ? strlen(path) : 0;
size_t want = strlen(suffix);
return length >= want && strcmp(path + length - want, suffix) == 0;
}
// The processes that evaluate a code signature or a provisioning profile, and
// so the ones that have to agree about what device this is and what signatures
// are acceptable. Everything else spawns without libmisfix.
//
// installd runs `+[MICodeSigningVerifier
// _validateSignatureAndCopyInfoForURL:withOptions:error:]`, which
// is in MobileInstallation and calls libmis. This is the install.
// misagent installs the embedded profile and checks ProvisionedDevices.
// SpringBoard asks MIS again at launch; without the hook an installed app is
// refused there with 0xE8008026.
//
// Both spawn hooks ask this, because the targets do not share a parent:
// installd and misagent are started through xpcproxy, which carries
// SystemHook, and SpringBoard (`POSIXSpawnType` App) is started by launchd
// itself, which carries only the launchd hook. Asking in one of them alone is
// what left SpringBoard without libmisfix.
//
// Matched on the end of the path so a bootstrap or cryptex copy of the same
// binary is caught too.
static int vpIsMISFixTarget(const char *path) {
if (!path)
return 0;
return vpPathHasSuffix(path, "/usr/libexec/installd") ||
vpPathHasSuffix(path, "/usr/libexec/misagent") ||
vpPathHasSuffix(path, "/SpringBoard.app/SpringBoard");
}
// The library to insert alongside SystemHook for `path`, or NULL. NULL as well
// when the dylib is not installed, so a guest without it never gets a
// DYLD_INSERT_LIBRARIES entry naming a missing file.
static const char *vpMISFixFor(const char *path) {
return vpIsMISFixTarget(path) && access(VP_MIS_FIX, R_OK) == 0 ? VP_MIS_FIX : NULL;
}
typedef struct {
char **values;
char *hook;
@@ -130,7 +160,10 @@ static VPInjectionEnvironment vpInsertHooks(char *const env[], const char *root,
}
snprintf(result.root, size, "VPHONE_JB_ROOT=%s", root);
}
result.values = calloc(count + (addHook && dyld == (size_t)-1) +
// Either addition rewrites the whole DYLD_INSERT_LIBRARIES entry, so an
// environment that already names SystemHook still gets the extra library.
const int addLibraries = addHook || addExtra;
result.values = calloc(count + (addLibraries && dyld == (size_t)-1) +
(addRoot && jbRoot == (size_t)-1) + 1, sizeof(char *));
if (!result.values) {
free(result.hook);
@@ -138,20 +171,15 @@ static VPInjectionEnvironment vpInsertHooks(char *const env[], const char *root,
return (VPInjectionEnvironment){0};
}
for (size_t i = 0; i < count; i++)
result.values[i] = addHook && i == dyld ? result.hook :
result.values[i] = addLibraries && i == dyld ? result.hook :
addRoot && i == jbRoot ? result.root : env[i];
if (addHook && dyld == (size_t)-1)
if (addLibraries && dyld == (size_t)-1)
result.values[count++] = result.hook;
if (addRoot && jbRoot == (size_t)-1)
result.values[count] = result.root;
return result;
}
// The system hook alone, which is what every spawn gets.
static VPInjectionEnvironment vpInsertHook(char *const env[], const char *root) {
return vpInsertHooks(env, root, NULL);
}
static void vpFreeEnvironment(VPInjectionEnvironment *environment) {
free(environment->values);
free(environment->hook);
@@ -10,6 +10,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static int vpInXPCProxy;
@@ -45,51 +46,21 @@ static int vpIsInjectionTarget(const char *path) {
(vpInBootstrap && path[0] != '/');
}
static int vpPathHasSuffix(const char *path, const char *suffix) {
size_t length = path ? strlen(path) : 0;
size_t want = strlen(suffix);
return length >= want && strcmp(path + length - want, suffix) == 0;
}
// The processes that evaluate a code signature or a provisioning profile, and
// so the ones that have to agree about what device this is and what signatures
// are acceptable. Everything else spawns without libmisfix.
//
// installd runs `+[MICodeSigningVerifier
// _validateSignatureAndCopyInfoForURL:withOptions:error:]`, which
// is in MobileInstallation and calls libmis. This is the install.
// misagent installs the embedded profile and checks ProvisionedDevices.
// SpringBoard asks MIS again at launch, which is the half neither daemon
// covers: an installed app is refused at launch with 0xE8008026.
//
// Matched on the end of the path so a bootstrap or cryptex copy of the same
// binary is caught too.
//
// This list is a second route, not the one the hook depends on. `cfw install`
// links libmisfix into all three with a load command, and for SpringBoard
// that is the only route that works: SpringBoard never carries this dylib.
// Measured on test-27.0 (2026-09-30): launchd starts it without an xpcproxy
// (the launchd hook's spawn log has every neighbouring child pid but not
// SpringBoard's), and SpringBoard's own constructor line, which any `.app/`
// path would write to vphone-systemhook.log, never appears. Its job runs in a
// conclave (`_Conclave` in com.apple.SpringBoard.plist); whether the insert
// is dropped there or never made is not settled.
static int vpIsMISFixTarget(const char *path) {
if (!path)
return 0;
return vpPathHasSuffix(path, "/usr/libexec/installd") ||
vpPathHasSuffix(path, "/usr/libexec/misagent") ||
vpPathHasSuffix(path, "/SpringBoard.app/SpringBoard");
}
// Every process loads this hook, root or not, so its logs must be writable by
// all of them. The first writer is always a root xpcproxy, and a file it
// creates 0644 silently drops every line from a mobile process — SpringBoard
// and every app looked as though they never carried the hook. The owner makes
// it world-writable on each open; for anyone else fchmod fails harmlessly.
static int vpOpenLog(const char *name) {
char path[PATH_MAX];
int used = snprintf(path, sizeof(path), "/var/mobile/Library/Caches/%s", name);
int fd = used > 0 && (size_t)used < sizeof(path)
? open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644)
? open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0666)
: -1;
if (fd >= 0)
if (fd >= 0) {
fchmod(fd, 0666);
return fd;
}
const char *home = getenv("CFFIXED_USER_HOME");
if (!home)
home = getenv("HOME");
@@ -128,8 +99,7 @@ static void vpPrepareLoaderLink(const char *path) {
// get their loader links prepared.
static VPInjectionEnvironment vpPrepareChild(const char *path, char *const envp[], const char *kind) {
const int misFix = vpIsMISFixTarget(path);
VPInjectionEnvironment injected =
vpInsertHooks(envp, getenv("VPHONE_JB_ROOT"), misFix ? VP_MIS_FIX : NULL);
VPInjectionEnvironment injected = vpInsertHooks(envp, getenv("VPHONE_JB_ROOT"), vpMISFixFor(path));
if (vpIsInjectionTarget(path) || misFix) {
vpPrepareLoaderLink(path);
char decision[80];
@@ -0,0 +1,59 @@
#include "../Shared/InjectionEnvironment.h"
#include <assert.h>
#define EXTRA "/usr/lib/extra.dylib"
static const char *value(char *const env[], const char *name) { return vpEnvValue(env, name); }
static size_t count(char *const env[]) {
size_t total = 0;
while (env[total])
total++;
return total;
}
// A spawn with no DYLD_INSERT_LIBRARIES gets both, SystemHook first.
static void insertsBoth(void) {
char *env[] = {"HOME=/var/mobile", NULL};
VPInjectionEnvironment result = vpInsertHooks(env, NULL, EXTRA);
assert(result.values);
assert(count(result.values) == 2);
assert(strcmp(value(result.values, "DYLD_INSERT_LIBRARIES"), VP_SYSTEM_HOOK ":" EXTRA) == 0);
vpFreeEnvironment(&result);
}
// The environment already names SystemHook — launchd passes its own to a job
// it starts directly — and the extra library must still be added.
static void addsExtraBesideAnExistingHook(void) {
char *env[] = {"DYLD_INSERT_LIBRARIES=" VP_SYSTEM_HOOK, "HOME=/var/mobile", NULL};
VPInjectionEnvironment result = vpInsertHooks(env, NULL, EXTRA);
assert(result.values);
assert(count(result.values) == 2);
assert(strcmp(value(result.values, "DYLD_INSERT_LIBRARIES"), EXTRA ":" VP_SYSTEM_HOOK) == 0);
vpFreeEnvironment(&result);
}
static void leavesACompleteEnvironmentAlone(void) {
char *env[] = {"DYLD_INSERT_LIBRARIES=" VP_SYSTEM_HOOK ":" EXTRA, NULL};
VPInjectionEnvironment result = vpInsertHooks(env, NULL, EXTRA);
assert(!result.values);
vpFreeEnvironment(&result);
}
static void namesTheMISFixTargets(void) {
assert(vpIsMISFixTarget("/usr/libexec/installd"));
assert(vpIsMISFixTarget("/usr/libexec/misagent"));
assert(vpIsMISFixTarget("/System/Library/CoreServices/SpringBoard.app/SpringBoard"));
assert(!vpIsMISFixTarget("/usr/libexec/xpcproxy"));
assert(!vpIsMISFixTarget("/usr/libexec/installdx"));
assert(!vpIsMISFixTarget(NULL));
}
int main(void) {
insertsBoth();
addsExtraBesideAnExistingHook();
leavesACompleteEnvironmentAlone();
namesTheMISFixTargets();
puts("InjectionEnvironmentTests: ok");
return 0;
}