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