Correct what libmisfix claims, and probe whether a detour is possible

The measurement is in: an interpose does not reach a call made from one
shared-cache image to another. installd's whole install produced one line,
`MGCopyAnswer(BuildVersion) from installd` — the main executable's own call
and nothing else. No UniqueDeviceID query, although libmis resolved one; it
skipped every installed profile with 0xE8008012 and returned 0xE8008015.
No MISValidateSignatureAndCopyInfo line either, from a log that is now
unconditional. The signature was never the problem: `cdhash is trusted`.

So MISFixSignature.c's premise was wrong — installd does not decline to ask
MIS for ad-hoc acceptance, it never reaches this hook at all — and the
UniqueDeviceID key does not make an Xcode install succeed by itself. Both
files now say so, and say where the remaining fixes live: a shared-cache
patch, or a VM created with the ECID of an already-registered device, since
a modern UDID is <chip-id>-<ECID> and only the ECID is chosen.

Rewriting the callee instead of the call sites would reach every caller.
That costs a trampoline and arm64e relocation work, all of it wasted unless
the process can make a cache text page writable, so the probe asks that one
question behind its own flag and writes back the bytes already there.

Its first run got both halves wrong in a way worth keeping written down.
dyld applies interposing to dlsym, so RTLD_DEFAULT returned this dylib's own
replacement; and asking for write without execute on the page then executing
from it faulted immediately, which crash-looped installd until the flag went
off. The symbol is now resolved through a handle on libMobileGestalt, the
target is refused if it lands in this image, and the request is RWX.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-30 19:35:03 +09:00
co-authored by Claude Opus 5
parent 9abcc600db
commit c68e2c8428
5 changed files with 320 additions and 29 deletions
@@ -0,0 +1,212 @@
// MISFixCacheWriteProbe.c — can this process write a shared-cache text page?
//
// One question, asked at load, behind the `ProbeCacheWrite` flag, and it
// decides how the whole MIS problem gets fixed.
//
// `__DATA,__interpose` rewrites *call sites* in the images dyld links, so it
// never reaches a call made from one shared-cache image to another — measured
// in MISFixDeviceIdentity.c, and the reason libmisfix cannot touch installd's
// profile check. Rewriting the *callee* instead would reach every caller,
// inside the cache or out: the classic five-instruction detour at the top of
// `MGCopyAnswer` and `MISValidateSignatureAndCopyInfo`, with the displaced
// instructions moved to a trampoline.
//
// That costs a few hundred lines of arm64e relocation work, and all of it is
// wasted unless the process can make a cache text page writable first. The
// cache is mapped read-execute and shared by every process on the system, so
// the only way in is copy-on-write: ask for `VM_PROT_COPY` and get a private
// copy of that page. Whether the kernel allows it here depends on this guest's
// codesigning patches, not on anything this dylib does — so it is measured,
// not assumed.
//
// ## What the probe does, and what it deliberately does not
//
// It writes the bytes that are already there. The four bytes at the top of
// `MGCopyAnswer` are read, the page is made writable *without giving up
// execute*, those same four bytes are written back, the result is read again
// and compared, and the page is put back to read-execute. A run that succeeds
// completely leaves the process byte-for-byte as it found it; a run that fails
// anywhere leaves it as it found it too, because nothing different was ever
// written.
//
// Two mistakes from the first run are guarded against by name, because both
// are easy to make again and both crash a daemon that installs software:
// resolving the symbol through `RTLD_DEFAULT` (interposed — it returns this
// dylib's own replacement), and asking for write *instead of* execute on a
// page holding live code.
//
// The region's current and maximum protections are logged first. That is the
// cheap half of the answer: a region whose `max_protection` carries no write
// bit can never be made writable, and no amount of entitlement changes that.
//
// The probe never touches `MISValidateSignatureAndCopyInfo`, and never leaves
// a page writable. Making a real detour is a separate change, and it should
// not be able to happen by accident in a daemon that installs software.
#include "MISFixConfig.h"
// The iPhoneOS SDK refuses `mach/mach_vm.h` outright, so this uses the
// `vm_*` entry points in `mach/vm_map.h` instead. On arm64 they take the same
// 64-bit addresses and sizes; only the names differ.
#include <dlfcn.h>
#include <mach/mach.h>
#include <ptrauth.h>
#include <stdint.h>
#include <string.h>
/// The symbol the probe stands on. Exported by libMobileGestalt, in the shared
/// cache, and the one a real detour would go on first.
#define kProbeSymbol "MGCopyAnswer"
/// The image it must come out of. Named explicitly, because the obvious way to
/// resolve the symbol is wrong: dyld applies interposing to `dlsym` as well as
/// to call sites, so `dlsym(RTLD_DEFAULT, "MGCopyAnswer")` returns *this
/// dylib's* replacement. The first run of this probe did exactly that, stripped
/// execute from the page it was executing on, and took installd down with it.
#define kProbeImage "/usr/lib/libMobileGestalt.dylib"
/// How many bytes the probe rewrites. One instruction: enough to prove the
/// page is writable, small enough that a partial write cannot straddle a page.
#define kProbeLength 4u
/// The region this address is in, logged for its protections.
///
/// `max_protection` is the half that cannot be argued with: it is the ceiling
/// `mach_vm_protect` may raise the current protection to, and a cache text
/// region that does not carry `VM_PROT_WRITE` in it rules the detour out
/// before any of the rest is tried.
static void vpDescribeRegion(vm_address_t address) {
vm_address_t start = address;
vm_size_t size = 0;
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object = MACH_PORT_NULL;
kern_return_t result = vm_region_64(
mach_task_self(),
&start,
&size,
VM_REGION_BASIC_INFO_64,
(vm_region_info_t)&info,
&count,
&object
);
if (result != KERN_SUCCESS) {
MISFixNote("probe: vm_region_64 failed: %s", mach_error_string(result));
return;
}
MISFixNote(
"probe: region %p+%llx prot=%x max=%x shared=%d reserved=%d",
(void *)start,
(unsigned long long)size,
info.protection,
info.max_protection,
info.shared,
info.reserved
);
}
/// Try to make `length` bytes at `address` writable, and say how it went.
///
/// `VM_PROT_COPY` is the whole point: without it the request is "let this
/// shared mapping be written", which the kernel refuses for a region other
/// processes have mapped. With it, the request is "give me my own copy of
/// these pages, writable", which is what a detour needs and what leaves every
/// other process on the system untouched.
/// Execute is asked for alongside write, not traded against it.
///
/// Dropping it is what killed the first run: a page that loses `VM_PROT_EXECUTE`
/// faults on the next instruction fetched from it, and on a page holding live
/// code that is immediate. A detour has the same problem for a different
/// reason — another thread may be inside the function being rewritten — so
/// RWX is what it would ask for too, and this measures the thing that matters.
static kern_return_t vpMakeWritable(vm_address_t address, vm_size_t length) {
return vm_protect(
mach_task_self(),
address,
length,
FALSE,
VM_PROT_READ | VM_PROT_WRITE | VM_PROT_EXECUTE | VM_PROT_COPY
);
}
static kern_return_t vpRestore(vm_address_t address, vm_size_t length) {
return vm_protect(
mach_task_self(),
address,
length,
FALSE,
VM_PROT_READ | VM_PROT_EXECUTE
);
}
__attribute__((constructor)) static void vpProbeCacheWrite(void) {
if (!MISFixConfiguredFlag(kMISFixProbeCacheWriteKey))
return;
// RTLD_NOLOAD, because the answer is only interesting for an image already
// mapped from the cache, and a handle-scoped dlsym is not interposed.
void *image = dlopen(kProbeImage, RTLD_LAZY | RTLD_NOLOAD);
if (image == NULL) {
MISFixNote("probe: %s is not loaded here: %s", kProbeImage, dlerror());
return;
}
void *symbol = dlsym(image, kProbeSymbol);
dlclose(image);
if (symbol == NULL) {
MISFixNote("probe: %s not found in %s", kProbeSymbol, kProbeImage);
return;
}
// A function pointer out of dlsym is signed on arm64e; the address the VM
// functions want is the plain one.
const uint8_t *function = ptrauth_strip(symbol, ptrauth_key_function_pointer);
const char *owner = MISFixCallerImage(function);
MISFixNote("probe: %s at %p in %s", kProbeSymbol, function, owner);
// Last line of defence against the first run's mistake. Whatever the
// resolution did, refuse to touch a page this dylib's own code is on.
Dl_info self;
if (dladdr((const void *)(uintptr_t)&vpProbeCacheWrite, &self) != 0
&& self.dli_fbase != NULL)
{
Dl_info target;
if (dladdr(function, &target) != 0 && target.dli_fbase == self.dli_fbase) {
MISFixNote("probe: %s resolved into libmisfix itself — refusing", kProbeSymbol);
return;
}
}
// Page alignment, because protection is a per-page property and asking
// about four bytes would silently widen to the page anyway.
vm_size_t page = vm_page_size;
vm_address_t start = (vm_address_t)(uintptr_t)function & ~(vm_address_t)(page - 1);
vpDescribeRegion(start);
uint8_t before[kProbeLength];
memcpy(before, function, sizeof(before));
kern_return_t opened = vpMakeWritable(start, page);
if (opened != KERN_SUCCESS) {
MISFixNote("probe: vm_protect(rwx|copy) failed: %s — a detour is not possible here",
mach_error_string(opened));
return;
}
MISFixNote("probe: vm_protect(rwx|copy) succeeded");
vpDescribeRegion(start);
// The same bytes, written back. Nothing about this process's behaviour
// changes whether it lands or not; only whether it lands is interesting.
memcpy((void *)(uintptr_t)function, before, sizeof(before));
uint8_t after[kProbeLength];
memcpy(after, function, sizeof(after));
int identical = memcmp(before, after, sizeof(before)) == 0;
kern_return_t closed = vpRestore(start, page);
MISFixNote(
"probe: wrote %u bytes, readback %s, restore r-x %s — a detour %s possible here",
kProbeLength,
identical ? "matches" : "DIFFERS",
closed == KERN_SUCCESS ? "ok" : mach_error_string(closed),
identical && closed == KERN_SUCCESS ? "is" : "may not be"
);
}
+14 -3
View File
@@ -188,9 +188,7 @@ const char *MISFixCallerImage(const void *address) {
return slash != NULL && slash[1] != '\0' ? slash + 1 : info.dli_fname;
}
void MISFixLog(const char *format, ...) {
if (!MISFixConfiguredFlag(kMISFixLogQueriesKey))
return;
void MISFixNote(const char *format, ...) {
char message[512];
va_list arguments;
va_start(arguments, format);
@@ -202,3 +200,16 @@ void MISFixLog(const char *format, ...) {
// them whichever process is carrying the hook.
os_log(OS_LOG_DEFAULT, "libmisfix[%d]: %{public}s", getpid(), message);
}
void MISFixLog(const char *format, ...) {
if (!MISFixConfiguredFlag(kMISFixLogQueriesKey))
return;
char message[512];
va_list arguments;
va_start(arguments, format);
int written = vsnprintf(message, sizeof(message), format, arguments);
va_end(arguments);
if (written <= 0)
return;
MISFixNote("%s", message);
}
+18 -3
View File
@@ -9,9 +9,14 @@
//
// UniqueDeviceID (string) The UDID to answer MobileGestalt with. Set it
// to a device already registered with a team and
// that team's provisioning profiles install on
// this guest. Empty or absent: the guest answers
// with its own.
// that team's profiles install on this guest.
// Empty or absent: the guest answers with its
// own. Reaches misagent only — installd's own
// check is made inside the shared cache, where
// an interpose does not land, so this does not
// by itself make an Xcode install succeed. See
// "How far this reaches" in
// MISFixDeviceIdentity.c.
// LogQueries (bool) Log every MobileGestalt query this hook sees.
// Off by default: these daemons are asked a lot.
// For finding out whether a process asks through
@@ -43,6 +48,12 @@ int MISFixConfiguredFlag(CFStringRef key);
/// A no-op unless the flag is set.
void MISFixLog(const char *format, ...) __attribute__((format(printf, 1, 2)));
/// Log `format` whatever the configuration says, with the same prefix.
///
/// For a diagnostic that carries its own switch and would otherwise need two
/// flags set to say anything.
void MISFixNote(const char *format, ...) __attribute__((format(printf, 1, 2)));
/// The name of the image `address` belongs to — the last path component of the
/// Mach-O that contains it, or `"?"` when nothing claims it.
///
@@ -65,4 +76,8 @@ const char *MISFixCallerImage(const void *address);
/// The flag every diagnostic in this dylib is behind.
#define kMISFixLogQueriesKey CFSTR("LogQueries")
/// The flag for the shared-cache write probe. Off by default, and nothing
/// reads it but ``MISFixCacheWriteProbe.c``.
#define kMISFixProbeCacheWriteKey CFSTR("ProbeCacheWrite")
#endif
@@ -43,11 +43,40 @@
// profiles install here, with no portal round trip and nothing to redo after a
// rebuild.
//
// ## How far this reaches, measured
//
// misagent, and nothing else that matters. Its main executable calls
// `MGCopyAnswer` itself, so the interpose catches it and a profile naming the
// configured device installs.
//
// installd does not benefit, and no version of this dylib can make it. Its
// profile check runs MobileInstallation → libmis → libMobileGestalt, all three
// inside the dyld shared cache, and an interpose rewrites call sites in the
// images dyld links — not the cache's own. Measured on test-26.4 (2026-09-30,
// `libmisfix[726]`): one `devicectl device install app`, `LogQueries` on, and
// the only line from installd is `MGCopyAnswer(BuildVersion) from installd`.
// No `UniqueDeviceID` query, although libmis plainly resolved one — it skipped
// every installed profile with `0xE8008012` and then returned
//
// +[MICodeSigningVerifier _validateSignatureAndCopyInfoForURL:withOptions:error:]:
// 80: Failed to verify code signature of …/AirBuild.app : 0xe8008015
//
// The signature itself was fine; the same capture has `cdhash: <private> is
// trusted`. libmis's other route to a UDID is closed too:
// `amfi_interface_query_bootarg_state returned error Function not implemented`.
//
// So an Xcode or `devicectl` install still needs the guest's *own* UDID to be
// in the profile. Two things could give it that, and neither belongs in this
// file: a shared-cache patch on libmis, or creating the VM with the ECID of a
// device the team has already registered — a modern UDID is
// `<chip-id>-<ECID>`, and the ECID is chosen at `vm create`, so that one needs
// no hook and tells no lie.
//
// ## The inconsistency this creates, stated plainly
//
// The guest now gives two different answers about which device it is. What
// Xcode, `devicectl` and lockdown report is unchanged — that UDID is built by
// TXM before the kernel runs, out of the device tree's `chip-id` and
// The guest gives two different answers about which device it is. What Xcode,
// `devicectl` and lockdown report is unchanged — that UDID is built by TXM
// before the kernel runs, out of the device tree's `chip-id` and
// `unique-chip-id`, and nothing in userspace can alter it. Only the processes
// carrying this hook see the configured value.
//
@@ -69,24 +98,17 @@ extern CFTypeRef MGCopyAnswerWithError(CFStringRef property, uint32_t *error);
///
/// Off unless the config sets `LogQueries`, because these daemons are asked a
/// lot and the log is how a person watches an install. It exists because the
/// interesting failure is *silence*: on test-26.4 the override reaches misagent
/// and a profile installs, but installd then refuses the same app with
/// interesting failure is *silence*: the override reaches misagent and a
/// profile installs, but installd then refuses the same app with
/// `0xE8008015`, and the two explanations — installd asking and getting the
/// wrong answer, versus installd never asking through this symbol at all —
/// look identical from outside. `MICodeSigningVerifier` lives in
/// MobileInstallation, not in installd, and it calls `libmis`, so the query
/// that matters is made cache-to-cache; whether an interpose catches that is
/// exactly what this answers. If an install produces no line here from
/// installd, the call is not coming through `MGCopyAnswer` and the hook needs a
/// different point to stand on.
/// look identical from outside.
///
/// The caller's image is part of the line because the first run answered the
/// question only halfway: installd logged `MGCopyAnswer(BuildVersion)` and no
/// `UniqueDeviceID`, while libmis plainly resolved a UDID — it skipped every
/// profile with `0xE8008012`. Either installd's own code asked for the build
/// version and the frameworks ask past this interpose, or the interpose does
/// reach them and libmis finds the UDID somewhere other than MobileGestalt.
/// `MISFixCallerImage` tells the two apart in one line.
/// It has now told us which. Each line names the caller's image, and installd
/// produced exactly one, `MGCopyAnswer(BuildVersion) from installd`: the main
/// executable's own call and nothing else. The header's "How far this reaches"
/// has the rest. The instrument stays because the answer is a property of this
/// cache and this dyld, not a law, and one capture re-checks it.
static void vpLogQuery(CFStringRef property, int answered, const char *caller) {
char name[128];
if (property == NULL
+36 -5
View File
@@ -1,4 +1,34 @@
// MISFixSignature.c — let installd accept an ad-hoc signed app bundle.
// MISFixSignature.c — widen MIS's idea of an acceptable signature.
//
// ## Measured 2026-09-30: this never runs in installd, and cannot
//
// Read this first, because the rest of the file was written believing
// otherwise. A `__DATA,__interpose` replacement is applied to *call sites*, and
// every call site that matters here is inside the dyld shared cache:
//
// MobileInstallation.framework → libmis.dylib (cache to cache)
// libmis.dylib → libMobileGestalt (cache to cache)
//
// Neither is rewritten, whether this dylib arrives as a weak dependency of the
// main executable or ahead of everything through `DYLD_INSERT_LIBRARIES`. On
// test-26.4, with `LogQueries` on and the log for `MISValidateSignatureAndCopyInfo`
// made unconditional, a whole `devicectl device install app` produced exactly
// one line from installd:
//
// libmisfix[726]: MGCopyAnswer(BuildVersion) from installd passed through
//
// `from installd` is the point: the one call this hook catches is the one the
// main executable makes itself. `+[MICodeSigningVerifier
// _validateSignatureAndCopyInfoForURL:withOptions:error:]` ran to its line 80
// and failed, and no line here records it.
//
// So the options are never widened in installd. What that daemon actually
// refuses, and why, is in MISFixDeviceIdentity.c; fixing it means changing the
// shared cache, not this dylib. misagent is different — its main executable
// calls `MGCopyAnswer` itself — and the UDID override there does work.
//
// The hook is kept because it costs nothing and is correct where it is
// reached, and because it is the control that measured all of this.
//
// A guest restored by this project runs unsigned code happily: the kernel
// patches (`amfi_trustcache`, `jb.post_validation`, `jb.amfi_execve`) admit it,
@@ -56,10 +86,11 @@
//
// ## Mechanism
//
// See `MISFixInterpose.h`. The dylib reaches installd through a
// `LC_LOAD_WEAK_DYLIB` that `cfw install` inserts, the same way the launchd
// hook is attached — weak, deliberately, so an installd whose libmisfix has
// been removed still boots.
// See `MISFixInterpose.h`. SystemHook puts this dylib in
// `DYLD_INSERT_LIBRARIES` for the processes it recognises by path, so it is
// loaded ahead of everything — which is the strongest position an interpose
// can be in, and still not enough to reach the cache-internal call sites
// above.
#include "MISFixConfig.h"
#include "MISFixInterpose.h"