Let SpringBoard carry libmisfix so a signed app launches on 27.0

A paid team's IPA installed on test-27.0 and was refused at launch with
0xE8008026: SpringBoard asks MIS itself, and it never carried the hook. The
spawn route SystemHook-vphone.c listed it under was never reached.

  - system-springboard-cfw-launch_authorization links libmisfix into
    SpringBoard with a weak load command, as installd and misagent are.
  - SpringBoard's header has 16 spare bytes, so the command names a /mf
    root alias and inject-dylib --reclaim-source-version drops
    LC_SOURCE_VERSION to leave the re-signer room for its signature.
  - MISFixInstallPolicy now runs in installd only.

Measured on test-27.0 after a cold boot: SpringBoard loads libmisfix, MIS
returns 0x0 for AirBuild, and it launches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-30 22:18:21 +09:00
co-authored by Claude Opus 5.5
parent 3d4e5528bc
commit 7a413718d5
13 changed files with 325 additions and 22 deletions
File diff suppressed because one or more lines are too long
@@ -46,7 +46,9 @@ extension GuestAPI {
/// 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`.
/// `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.
static let udidHookedDaemons = ["misagent", "installd"]
static func executeDeviceIdentity(_ method: String, _ params: [String: Any]) throws -> [String: Any]? {
@@ -22,6 +22,13 @@
// `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
@@ -62,6 +69,10 @@ 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
}
@@ -83,6 +94,8 @@ 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]
}
@@ -114,6 +127,9 @@ 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
@@ -134,6 +150,7 @@ 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)
@@ -145,6 +162,7 @@ public enum CustomFirmwareInjectDylib {
weak: weak,
policy: policy,
allowNonEmptyPadding: allowNonEmptyPadding,
reclaimsSourceVersion: reclaimsSourceVersion,
)
try data.write(to: url)
return injections
@@ -158,6 +176,7 @@ public enum CustomFirmwareInjectDylib {
weak: Bool = true,
policy: CodeSignaturePolicy = .strip,
allowNonEmptyPadding: Bool = false,
reclaimsSourceVersion: Bool = false,
) throws -> [CustomFirmwareDylibInjection] {
if data.startIndex != 0 {
data = Data(data)
@@ -169,6 +188,7 @@ public enum CustomFirmwareInjectDylib {
weak: weak,
policy: policy,
allowNonEmptyPadding: allowNonEmptyPadding,
reclaimsSourceVersion: reclaimsSourceVersion,
)
switch data.loadBEValue(UInt32.self, at: 0) {
case fatMagic:
@@ -196,6 +216,7 @@ public enum CustomFirmwareInjectDylib {
let weak: Bool
let policy: CodeSignaturePolicy
let allowNonEmptyPadding: Bool
let reclaimsSourceVersion: Bool
}
// MARK: Universal Binaries
@@ -311,6 +332,27 @@ 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 {
@@ -370,6 +412,7 @@ public enum CustomFirmwareInjectDylib {
loadCommandSize: commandSize,
isWeak: options.weak,
removedCodeSignature: removedCodeSignature,
removedSourceVersion: removedSourceVersion,
rehashedSlots: rehashed,
)
}
@@ -387,6 +430,7 @@ 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
@@ -424,6 +468,8 @@ 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")
@@ -81,11 +81,10 @@ public enum FirmwareGuestSystemPatchSet {
summary: """
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 and misagent, and editing the cache for it \
stops an iOS 27 guest booting. Turn it on only on a 26.x base, and only to launch \
an app signed with a free personal-team certificate — that launch goes through \
SpringBoard, which the hook does not cover. Not offered on iOS 27, where it stops \
the guest booting.
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.
""",
target: .dyldSharedCache,
applicability: misTrustAuthBases,
@@ -160,6 +159,19 @@ public enum FirmwareGuestSystemPatchSet {
""",
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",
@@ -68,7 +68,8 @@ 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` and `misagent`.
/// `cfw install` injects that hook 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
@@ -77,11 +78,12 @@ 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.
///
/// What it still buys, and why it stays declared rather than being deleted:
/// the hook only covers the processes it is injected into, so an app signed
/// with a *free personal-team* certificate can be installed but is still
/// refused at launch, where SpringBoard asks MIS itself. A VM that wants that
/// on a 26.x base can check this box; on 27 it should not.
/// 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.
public static let misTrustAuthPatch = "dyld-exp-mis_trust_auth"
/// The former EXP patches that make the guest claim to be an iPhone17,3.
@@ -614,4 +614,77 @@ 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")
}
}
@@ -677,6 +677,24 @@ struct VPhoneCustomFirmwareInstaller {
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,
)
}
if on("system-debugserver-cfw-install") {
try patchDebugserver(system: system, work: work)
}
@@ -921,16 +939,20 @@ struct VPhoneCustomFirmwareInstaller {
}
// launchd has little free header space for another load command.
// /vh fits the same 32-byte command as the old /b without reusing it.
let alias = "vh"
let target = "/usr/lib/launchdhook-vphone.dylib"
try installLibraryAlias(system: system, alias: "vh", target: "/usr/lib/launchdhook-vphone.dylib")
try installMISFixDefaults(system: system)
}
/// A symlink at the volume root, so a load command in a binary with little
/// header space can name a library in seven bytes or fewer.
private func installLibraryAlias(system: VPhoneConfinedDirectory, alias: String, target: String) throws {
if try system.exists(alias) {
guard try system.readLink(alias) == target else {
throw ValidationError("Another file already uses /vh on the VM system volume. Remove it, then install CFW again.")
throw ValidationError("Another file already uses /\(alias) on the VM system volume. Remove it, then install CFW again.")
}
} else {
try system.createSymlink(target: target, at: alias)
}
try installMISFixDefaults(system: system)
}
/// libmisfix's settings file, and only when the guest has none.
@@ -1059,6 +1081,7 @@ struct VPhoneCustomFirmwareInstaller {
identifier: String? = nil,
preserveEntitlements: Bool = false,
injectedDylibPath: String? = nil,
reclaimsSourceVersion: Bool = false,
) throws {
let backup = "\(path).bak"
if try !system.exists(backup) {
@@ -1076,7 +1099,10 @@ struct VPhoneCustomFirmwareInstaller {
try patch(verb, [staged.path])
}
if let injectedDylibPath {
try patch("inject-dylib", [staged.path, injectedDylibPath])
try patch(
"inject-dylib",
[staged.path, injectedDylibPath] + (reclaimsSourceVersion ? ["--reclaim-source-version"] : []),
)
}
try VPhoneSigner.sign(
fileAt: staged,
@@ -164,17 +164,22 @@ 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))")
+ "(slice +0x\(String(injection.sliceOffset, radix: 16))\(stripped)\(reclaimed))")
}
}
}
@@ -16,8 +16,8 @@
EXP variant; the camera does not need them, and with the concealment they left
guests without location (issue #438). The fourth is the MIS
online-authorization short-circuit: libmisfix.dylib declines the same check from
userspace in installd and misagent, so editing the shared cache for it buys
nothing there, costs an iOS 27 guest its boot (issue #532), and on 24A435 cannot
userspace in installd, misagent and SpringBoard, so editing the shared cache for
it buys nothing there, costs an iOS 27 guest its boot (issue #532), and on 24A435 cannot
even be applied. See FirmwarePatchSetCatalog.misTrustAuthPatch.
Each patch's own version gate still decides whether it lands on this pairing of
@@ -2,6 +2,7 @@
#include <dlfcn.h>
#include <fcntl.h>
#include <mach-o/dyld.h>
#include <os/log.h>
#include <ptrauth.h>
#include <stdarg.h>
@@ -173,6 +174,15 @@ int MISFixConfiguredFlag(CFStringRef key) {
return CFBooleanGetValue((CFBooleanRef)value) ? 1 : 0;
}
int MISFixProcessIs(const char *name) {
char path[4096];
uint32_t size = sizeof(path);
if (name == NULL || _NSGetExecutablePath(path, &size) != 0)
return 0;
const char *slash = strrchr(path, '/');
return strcmp(slash != NULL ? slash + 1 : path, name) == 0;
}
const char *MISFixCallerImage(const void *address) {
if (address == NULL)
return "?";
@@ -70,6 +70,15 @@ void MISFixNote(const char *format, ...) __attribute__((format(printf, 1, 2)));
/// calls too.
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
/// 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.
int MISFixProcessIs(const char *name);
/// The image of whoever called the function this appears in.
#define MISFixCaller() MISFixCallerImage(__builtin_return_address(0))
@@ -188,7 +188,12 @@ static BOOL vpAllowAdhocSigning(id self, SEL selector) {
return YES;
}
/// installd only. The same dylib is linked 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) {
if (!MISFixProcessIs("installd"))
return;
vpOriginalInstallProfiles = (MISFixCheckIMP)vpSwizzle(
"MIInstallableBundle",
"_installEmbeddedProfilesWithError:",
@@ -60,11 +60,20 @@ static int vpPathHasSuffix(const char *path, const char *suffix) {
// 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 app signed with a free personal-team certificate
// could be installed and then refused at launch with 0xE8008026.
// 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;