Let Xcode install a real IPA on a guest no profile names

An interpose never reached installd, so none of libmisfix's signature work
had ever run there. Replace it with a detour at the top of the callee, and
answer the two MobileInstallation refusals above it.

  - MISFixDetour now takes an address, because no spelling of dlsym can
    return one dyld has not interposed. It refuses a target shorter than the
    four-word jump, which is what MISValidateSignatureAndCopyInfo is.
  - MISFixProfileScope answers ProvisionsAllDevices for every profile, so the
    embedded profile installs for real and MIS validates the app against it
    with a genuine signer, entitlements and cdhash.
  - MISFixInstallPolicy swizzles the embedded-profile install and the code
    signing verifier, letting each refusal through after the real
    implementation has run.
  - MISFixNote appends to a file as well as the unified log, because a live
    syslog tail has no lookback and every hook reports from a constructor.

Measured on test-26.4: a paid team's dev-signed IPA installs through
devicectl and launches. An ad-hoc signature is accepted by MIS and still
refused above it; Research/0_binary_patch_comparison.md says where.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-30 21:17:52 +09:00
co-authored by Claude Opus 5
parent 5f2c030527
commit ca6c09932b
12 changed files with 658 additions and 521 deletions
+99 -3
View File
@@ -1830,6 +1830,102 @@ applies interposing to `dlsym` as well as to call sites, so
the probe measured its own text and never touched the cache. It then asked for
`VM_PROT_WRITE` in place of `VM_PROT_EXECUTE` on the page it was executing
from, which faults on the next instruction fetch and crash-looped installd
until the flag was cleared. The probe now resolves through a handle on
`libMobileGestalt` (a handle-scoped `dlsym` is not interposed), refuses a
target inside its own image, asks for RWX, and runs in installd only.
until the flag was cleared.
A handle-scoped `dlsym` is interposed too — measured, on a handle to
libMobileGestalt itself — so there is no spelling of `dlsym` that answers this.
What dyld leaves alone is the interposing image's own imports, which is why
`MISFixDetour` takes an address that libmisfix obtained with `&`, and refuses
to take a name at all.
## An Xcode install works, and what it took (2026-09-30)
Measured on test-26.4, `xcrun devicectl device install app` with
`AirBuild-Debug.ipa` — a paid team's app (`QDJ93ZUQ9B`), signed
`Apple Development`, whose embedded profile provisions eight real devices and
no VM:
```
App installed:
• bundleID: plus.yellow.AirBuild
• installationURL: file:///private/var/containers/Bundle/Application/9A626C1C-…/AirBuild.app/
```
and it launches. Four separate refusals had to go, in this order, and each one
was only visible once the one before it was gone.
1. **The interpose never ran.** Replaced by `MISFixDetour`: a four-word
absolute jump at the top of the callee, the displaced instructions
relocated onto an `mmap`ed trampoline, the target page taken
copy-on-write. Installed in installd's own address space, so nothing on
disk and no other process changes — which is the whole difference between
this and row 17, the libmis cache patch that stopped a 27.0 guest booting.
Measured: `detour: MISValidateSignatureAndCopyInfoWithProgress at
0x1bf41c830 in libmis.dylib`.
`MISValidateSignatureAndCopyInfo` itself is a thunk in front of the
`…WithProgress` body, shorter than the jump, and `MISFixDetour` refuses it
with `MISFixDetourTooShort` rather than write over whatever follows. Its
callers are covered anyway, because it branches into the hooked function.
2. **`0xE8008015`, no valid profile.** Widening the options does not help a
CMS-signed app: `AllowAdHocSigning` is about ad-hoc signatures, and this one
is real. The profile has to actually install, and misagent refuses it
because a VM's UDID is in no `ProvisionedDevices`. misagent asks
`MISProfileGetValue(profile, "ProvisionsAllDevices")` *first* and only
consults the device list when that is false — so `MISFixProfileScope.c`
detours `MISProfileGetValue` and answers that one key `true`. The profile
then installs for real and MIS validates the app against it:
```
misagent: Installing provisioning profile: 50806e9b-…
MISValidateSignature(…/extracted/Payload/AirBuild.app) -> 0x0
info[SigningID] = plus.yellow.AirBuild info[TeamID] = QDJ93ZUQ9B
info[SignerCertificate] = <1484 bytes> info[Entitlements] = <7 entries>
info[ValidatedByProfile] = true info[SignerType] = 3
```
Nothing is faked: the signature, the certificate, the entitlements and the
cdhash are the ones Apple issued. The only claim widened is which devices
the profile covers.
3. **`0xE8008012` from `-[MIInstallableBundle _installEmbeddedProfilesWithError:]`.**
Kept as a backstop for a profile that still cannot install, in
`MISFixInstallPolicy.c`: the real implementation runs, and a refusal is
logged and turned into "there is no profile" rather than a failed install.
4. **`-[MICodeSigningVerifier performValidationWithError:]`, line 424, "Failed
to extract signer identity".** The gate behind the gate. MIS accepts the
bundle and MobileInstallation then wants a CMS leaf certificate out of it.
Same treatment: run the real implementation, allow its refusal.
Both Objective-C hooks are swizzles, not detours. A method list is data, so
replacing an implementation reaches every caller without making any cache text
writable; where that is available it is strictly better.
### Still refused: an ad-hoc signature
`codesign --sign -` is accepted by MIS (`-> 0x0`, with a real `CdHash` and
`SigningID`) and still fails the install:
```
-[MIExecutableBundle codeSigningInfoByValidatingResources:…]: 1306:
Code signing identifier ((null)) does not match bundle identifier (wiki.qaq.vphone.signtest)
```
The identifier is null because `performValidationWithError:` bailed at the
signer before storing anything, and allowing its *return value* through does
not populate the verifier's outputs. An ad-hoc bundle has no signer and never
will, so this one needs a different answer than "let the refusal through" —
either the verifier's outputs supplied directly, or the caller of
`codeSigningInfoByValidatingResources:` answered instead. Unsigned and
ad-hoc bundles still install through vphoned's `apps.install`, which does not
involve installd at all.
### A trap in the measurement, not in the guest
Two runs failed with `0xE8008017` on a bundle whose signature was fine. The
IPA had been repacked on the host with `zip -r`, which writes AppleDouble
`._*` files next to every resource; they break the sealed resource envelope.
`COPYFILE_DISABLE=1 zip -X` after deleting them, and the same bundle installs.
Worth remembering before reading `0xE8008017` as a guest-side gate.
@@ -130,28 +130,32 @@ public enum FirmwareGuestSystemPatchSet {
),
VPhonePatchDeclaration(
identifier: "system-installd-cfw-adhoc_signature",
title: "installd ad-hoc signatures",
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, so anything not signed with an Apple leaf fails at \
0xE8008014 even though the guest runs unsigned code perfectly well. A \
hook in /usr/lib/libmisfix.dylib, loaded into installd, sets the option \
MIS already understands. Nothing in the dyld shared cache is touched.
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 lets MobileInstallation's own \
refusals through. Nothing in the dyld shared cache is written on disk: the \
two 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 device identity",
title: "misagent profile scope",
summary: """
Lets a provisioning profile written for a device you already own install on \
this guest. misagent compares the profile's ProvisionedDevices against the \
UDID MobileGestalt reports, and 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 that one query with the UDID set in /usr/lib/libmisfix.plist. Off \
until a UDID is set there, and it does not change what Xcode or lockdown \
report.
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"),
),
@@ -1,326 +0,0 @@
// 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 <errno.h>
#include <libkern/OSCacheControl.h>
#include <mach-o/dyld.h>
#include <mach/mach.h>
#include <ptrauth.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.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.
/// Write, without execute. Measured, both ways round, and this is the way that
/// works.
///
/// Asking for RWX succeeds at the VM layer — the region comes back `prot=7`,
/// `max=7` — and then the store still faults:
///
/// EXC_BAD_ACCESS (SIGBUS), UNKNOWN_0x32 at 0x1027543dc
/// __TEXT 102754000-102758000 [16K] rwx/rwx SM=COW /usr/lib/libmisfix.dylib
///
/// Apple silicon enforces write-xor-execute in hardware below the VM
/// permissions, so a page that is writable *and* executable is writable only
/// to a thread that has said so. Dropping execute for the duration is the
/// simpler answer and the one a detour can use, because the page it rewrites
/// is not the page it is running from — that was the first run's mistake, and
/// the two failures look identical from outside, which is why both are
/// written down here.
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_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
);
}
/// Write back the four bytes already at `function`, and say whether they stuck.
///
/// Shared by both write steps, because "can this page be rewritten" is the
/// same question for the cache and for this dylib's own text, and the answer
/// may well differ.
static void vpProbeWriteAt(const char *what, const uint8_t *function) {
// 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: %s vm_protect(rw|copy) failed: %s", what, mach_error_string(opened));
return;
}
MISFixNote("probe: %s vm_protect(rw|copy) succeeded, writing", what);
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: %s wrote %u bytes, readback %s, restore r-x %s",
what,
kProbeLength,
identical ? "matches" : "DIFFERS",
closed == KERN_SUCCESS ? "ok" : mach_error_string(closed)
);
}
/// `mov w0, #42 ; ret`, for the executable-memory step.
static const uint32_t kProbeThunk[] = { 0x52800540u, 0xD65F03C0u };
#define kProbeThunkAnswer 42
/// Can this process get memory it wrote and then run it?
///
/// The other half of what a detour needs. Rewriting the top of a function
/// costs nothing if the displaced instructions have nowhere to live: a
/// trampoline is memory this process fills in and then jumps to, which on iOS
/// is exactly what codesigning is there to prevent.
///
/// Both spellings are tried, write-then-`mprotect` first. That order is not
/// arbitrary: a page that is writable *and* executable at once is subject to
/// the same hardware write-xor-execute rule that made the cache write fault
/// with `SIGBUS` above, so the RWX spelling is the one expected to fail and it
/// goes second.
///
/// This step is last, and deliberately. It is the only part of the probe that
/// can take the process down — running a page the kernel has not blessed is a
/// kill, not an error return — so everything else is already in the log by the
/// time it runs.
static void vpProbeExecutableMemory(void) {
for (int rwxAtOnce = 0; rwxAtOnce < 2; rwxAtOnce += 1) {
int writeThenProtect = !rwxAtOnce;
const char *how = writeThenProtect ? "rw then mprotect r-x" : "rwx from mmap";
int protection = writeThenProtect ? (PROT_READ | PROT_WRITE)
: (PROT_READ | PROT_WRITE | PROT_EXEC);
void *page = mmap(NULL, vm_page_size, protection, MAP_PRIVATE | MAP_ANON, -1, 0);
if (page == MAP_FAILED) {
MISFixNote("probe: mmap %s failed: %s", how, strerror(errno));
continue;
}
memcpy(page, kProbeThunk, sizeof(kProbeThunk));
if (writeThenProtect && mprotect(page, vm_page_size, PROT_READ | PROT_EXEC) != 0) {
MISFixNote("probe: mprotect r-x failed: %s", strerror(errno));
munmap(page, vm_page_size);
continue;
}
sys_icache_invalidate(page, sizeof(kProbeThunk));
MISFixNote("probe: %s mapped at %p, calling it", how, page);
// Signed for the indirect call arm64e requires. If the kernel refuses
// the page, this line does not return and the log above is the record.
int (*thunk)(void) = ptrauth_sign_unauthenticated(
(int (*)(void))page,
ptrauth_key_function_pointer,
0
);
int answer = thunk();
munmap(page, vm_page_size);
MISFixNote(
"probe: %s returned %d (%s)",
how,
answer,
answer == kProbeThunkAnswer ? "usable" : "WRONG"
);
if (answer == kProbeThunkAnswer)
return;
}
}
/// Whether this process is the one the probe is allowed to run in.
///
/// installd, and only installd. SystemHook also inserts this dylib into
/// misagent and SpringBoard, and the executable-memory step can end the
/// process outright — in SpringBoard that is a respring, and a repeating one
/// while the flag is on. installd is on-demand and launchd starts it again for
/// the next client, so a kill there costs one failed install and nothing else.
static int vpProbeIsPermittedProcess(void) {
char path[4096];
uint32_t size = sizeof(path);
if (_NSGetExecutablePath(path, &size) != 0)
return 0;
static const char suffix[] = "/installd";
size_t length = strlen(path);
return length >= sizeof(suffix) - 1
&& strcmp(path + length - (sizeof(suffix) - 1), suffix) == 0;
}
__attribute__((constructor)) static void vpProbeCacheWrite(void) {
if (!MISFixConfiguredFlag(kMISFixProbeCacheWriteKey))
return;
if (!vpProbeIsPermittedProcess())
return;
// This dylib's own text was measured here too, as a warm-up, and it is
// gone: it is the one page that must never lose execute, because the probe
// is running from it, and with execute kept the store faults under the
// hardware's write-xor-execute rule. Both spellings crash, for opposite
// reasons, and neither says anything about the page a detour targets. What
// it did establish before crashing is worth keeping: copy-on-write works,
// and the page came back `prot=7 max=7` as its own region.
// 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(ptrauth_strip((const void *)&vpProbeCacheWrite, ptrauth_key_function_pointer),
&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;
}
}
vpProbeWriteAt("cache-text", function);
vpProbeExecutableMemory();
MISFixNote("probe: done");
}
@@ -1,6 +1,7 @@
#include "MISFixConfig.h"
#include <dlfcn.h>
#include <fcntl.h>
#include <os/log.h>
#include <ptrauth.h>
#include <stdarg.h>
@@ -188,6 +189,35 @@ const char *MISFixCallerImage(const void *address) {
return slash != NULL && slash[1] != '\0' ? slash + 1 : info.dli_fname;
}
// Somewhere each hooked daemon can append to, first one that opens.
//
// The unified log alone is not enough, and that cost a whole diagnosis cycle.
// vphoned's `logs.syslog` is a live tail with no lookback, so a line written
// from a constructor — which is where every hook here reports whether it
// installed — lands before any tail can be attached and is simply not there
// afterwards. A file is readable at leisure with `files.read`.
//
// installd's own cache directory is first because installd is the daemon this
// dylib is mostly about and its sandbox certainly reaches it. /var/mobile is
// for misagent and SpringBoard. /var/tmp is the last resort.
static const char *const kNotePaths[] = {
"/var/installd/Library/Caches/libmisfix.log",
"/var/mobile/Library/Caches/libmisfix.log",
"/var/tmp/libmisfix.log",
};
static const size_t kNotePathCount = sizeof(kNotePaths) / sizeof(kNotePaths[0]);
static void vpAppendNote(const char *message) {
for (size_t index = 0; index < kNotePathCount; index += 1) {
int fd = open(kNotePaths[index], O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
if (fd < 0)
continue;
dprintf(fd, "libmisfix[%d]: %s\n", getpid(), message);
close(fd);
return;
}
}
void MISFixNote(const char *format, ...) {
char message[512];
va_list arguments;
@@ -199,6 +229,7 @@ void MISFixNote(const char *format, ...) {
// One prefix for every line this dylib writes, so a single predicate finds
// them whichever process is carrying the hook.
os_log(OS_LOG_DEFAULT, "libmisfix[%d]: %{public}s", getpid(), message);
vpAppendNote(message);
}
void MISFixLog(const char *format, ...) {
@@ -76,8 +76,4 @@ 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
+52 -56
View File
@@ -23,7 +23,6 @@
#include "MISFixConfig.h"
#include <dlfcn.h>
#include <libkern/OSCacheControl.h>
#include <mach/mach.h>
#include <ptrauth.h>
@@ -74,6 +73,20 @@ static int64_t vpSignExtend(uint64_t value, unsigned bits) {
return (int64_t)((value ^ mask) - mask);
}
/// Whether `insn` ends the function it appears in — a return in any of its
/// three authenticated spellings, or an unconditional branch away.
///
/// This is the test for "the target is shorter than the patch", and it is not
/// hypothetical: `MISValidateSignatureAndCopyInfo` is a two-instruction thunk
/// in front of `…WithProgress`, so a four-word jump written over it would land
/// in whatever libmis put next.
static int vpIsTerminator(uint32_t insn) {
return insn == 0xD65F03C0u // ret
|| insn == 0xD65F0BFFu // retaa
|| insn == 0xD65F0FFFu // retab
|| (insn & 0xFC000000u) == 0x14000000u; // b
}
// MARK: - Relocation
/// Rewrite one displaced instruction so it means the same thing from its new
@@ -126,8 +139,9 @@ static unsigned vpRelocate(uint32_t insn, uint64_t pc, uint32_t *out) {
/// here. Filling it means making its page writable, which means dropping
/// execute from a page of `__TEXT` — and the section's other occupant is the
/// code doing the dropping. Page-aligning a 16 KB hole to avoid sharing would
/// work and costs 16 KB in every guest, for a fallback that
/// `MISFixCacheWriteProbe.c` exists to tell us we do not need.
/// work and costs 16 KB in every guest, for a fallback the guest turns out not
/// to need: `mmap` RW then `mprotect` RX then call was measured working in
/// installd on test-26.4.
static kern_return_t vpProtect(const void *address, size_t length, vm_prot_t protection) {
vm_size_t page = vm_page_size;
@@ -151,10 +165,9 @@ static void *vpAllocateTrampoline(void) {
const char *MISFixDetourDescribe(MISFixDetourResult result) {
switch (result) {
case MISFixDetourOK: return "installed";
case MISFixDetourImageMissing: return "image is not mapped in this process";
case MISFixDetourSymbolMissing: return "image exports no such symbol";
case MISFixDetourSymbolIsOurs: return "symbol resolved into libmisfix itself";
case MISFixDetourNoTarget: return "the symbol did not bind";
case MISFixDetourUnrelocatable: return "a displaced instruction is PC-relative";
case MISFixDetourTooShort: return "the target is shorter than the jump";
case MISFixDetourNoTrampoline: return "no executable memory for the trampoline";
case MISFixDetourPageReadOnly: return "the target page could not be made writable";
case MISFixDetourWriteFailed: return "the detour did not read back as written";
@@ -162,49 +175,15 @@ const char *MISFixDetourDescribe(MISFixDetourResult result) {
return "unknown";
}
/// The target address for `symbol` in `image`, stripped, or NULL.
///
/// Resolved through a handle rather than `RTLD_DEFAULT`, and this is not a
/// stylistic choice: dyld applies interposing to `dlsym`, so the flat lookup
/// for a symbol this dylib also interposes returns *our* replacement. A detour
/// built on that would point at itself.
static const uint8_t *vpResolve(const char *image, const char *symbol, MISFixDetourResult *why) {
void *handle = dlopen(image, RTLD_LAZY | RTLD_NOLOAD);
if (handle == NULL) {
*why = MISFixDetourImageMissing;
return NULL;
}
void *found = dlsym(handle, symbol);
dlclose(handle);
if (found == NULL) {
*why = MISFixDetourSymbolMissing;
return NULL;
}
const uint8_t *target = ptrauth_strip(found, ptrauth_key_function_pointer);
Dl_info self;
Dl_info owner;
if (dladdr(ptrauth_strip((const void *)&MISFixDetourDescribe, ptrauth_key_function_pointer),
&self) != 0
&& dladdr(target, &owner) != 0
&& self.dli_fbase == owner.dli_fbase)
{
*why = MISFixDetourSymbolIsOurs;
return NULL;
}
return target;
}
MISFixDetourResult MISFixDetour(
const char *image,
const char *symbol,
const char *label,
void *function,
void *replacement,
void **original
) {
MISFixDetourResult why = MISFixDetourOK;
const uint8_t *target = vpResolve(image, symbol, &why);
if (target == NULL)
return why;
if (function == NULL)
return MISFixDetourNoTarget;
const uint8_t *target = ptrauth_strip(function, ptrauth_key_function_pointer);
// Build the trampoline before touching the target, so a refusal costs
// nothing: the displaced instructions relocated, then a jump back to the
@@ -215,6 +194,8 @@ MISFixDetourResult MISFixDetour(
uint32_t body[kTrampolineBytes / 4];
unsigned words = 0;
for (unsigned index = 0; index < kDetourWords; index += 1) {
if (index + 1 < kDetourWords && vpIsTerminator(displaced[index]))
return MISFixDetourTooShort;
unsigned written = vpRelocate(
displaced[index],
(uint64_t)(uintptr_t)target + index * 4u,
@@ -234,7 +215,8 @@ MISFixDetourResult MISFixDetour(
//
// EXC_BAD_ACCESS (SIGBUS), UNKNOWN_0x32
//
// which is what `MISFixCacheWriteProbe.c` found the hard way.
// on a region `vm_region_64` reported as `rwx/rwx SM=COW`. Found the hard
// way, by crash-looping installd on test-26.4.
void *trampoline = vpAllocateTrampoline();
if (trampoline == NULL)
return MISFixDetourNoTrampoline;
@@ -245,6 +227,17 @@ MISFixDetourResult MISFixDetour(
}
sys_icache_invalidate(trampoline, words * 4u);
// Hand the trampoline over before the jump goes in, not after. The target
// is live from the instant its first word changes, and a replacement that
// reached `*original` while it was still NULL would call zero.
if (original != NULL) {
*original = ptrauth_sign_unauthenticated(
trampoline,
ptrauth_key_function_pointer,
0
);
}
// Now the target: copy-on-write, because the cache is mapped shared and
// read-execute. Execute is given up for the duration, which is safe here
// and would not be on the page this code is running from.
@@ -263,17 +256,20 @@ MISFixDetourResult MISFixDetour(
int landed = memcmp(target, detour, sizeof(detour)) == 0;
vpProtect(target, kDetourBytes, VM_PROT_READ | VM_PROT_EXECUTE);
if (!landed)
if (!landed) {
if (original != NULL)
*original = NULL;
return MISFixDetourWriteFailed;
if (original != NULL) {
*original = ptrauth_sign_unauthenticated(
trampoline,
ptrauth_key_function_pointer,
0
);
}
MISFixNote("detour: %s in %s -> %p, trampoline %p (%u words)",
symbol, image, replacement, trampoline, words);
// The owning image is named because the one way this goes quietly wrong is
// a target that resolved back into libmisfix — see the header on `dlsym`.
MISFixNote("detour: %s at %p in %s -> %p, trampoline %p (%u words)",
label,
(const void *)target,
MISFixCallerImage(target),
replacement,
trampoline,
words);
return MISFixDetourOK;
}
+26 -18
View File
@@ -2,8 +2,8 @@
//
// `__DATA,__interpose` rewrites the places that *call* a symbol, in the images
// dyld links. It never reaches a call made from one shared-cache image to
// another, which is measured in MISFixDeviceIdentity.c and is the reason
// libmisfix cannot touch installd: `MobileInstallation → libmis →
// another, which is measured in MISFixDeviceIdentity.c and is the reason an
// interpose cannot touch installd: `MobileInstallation → libmis →
// libMobileGestalt` happens entirely inside the cache.
//
// A detour rewrites the *callee*. The first four instructions of the target
@@ -15,14 +15,26 @@
//
// - The target's page must be made writable. The cache is mapped
// read-execute and shared with every process, so the only way in is
// copy-on-write, and whether this guest's kernel permits that is a
// property of its codesigning patches. `MISFixCacheWriteProbe.c` measures
// it; this file reports failure rather than assuming.
// copy-on-write. Measured working on test-26.4 (2026-09-30): the page
// splits into a private copy and the write lands. Nothing on disk changes
// and no other process sees it, which is the difference between this and
// the cache patch that left a 27.0 guest unable to boot (issue #532).
// - The displaced instructions must survive being moved. `adr` and `adrp`
// are rewritten to materialise the same absolute address, and an
// unconditional `b` becomes an absolute jump. Anything else PC-relative —
// `bl`, `b.cond`, `cbz`, `tbz`, a literal load — is **refused**, because a
// wrong relocation is a corrupted daemon and a refusal is a log line.
// - The target must be at least four instructions long. A function that
// returns or jumps away sooner is *shorter* than the patch, so writing it
// would scribble on whoever follows. A terminator in the first three words
// is refused for that reason.
//
// The target is given as an address, never as a name, and that is not a
// stylistic choice. dyld applies interposing to `dlsym` — measured on
// test-26.4 even for a lookup scoped to a handle on the owning image, which
// came back inside libmisfix.dylib. What dyld does not interpose is the
// interposing image's own imports, so the way to name a function here is to
// declare it, call `&` on it from this dylib, and let the linker bind it.
//
// Install detours from a constructor. Four words cannot be replaced atomically,
// so a thread already executing the target's prologue is a hazard; at image
@@ -35,16 +47,13 @@
/// Why a detour was not installed. `MISFixDetourOK` is zero.
typedef enum {
MISFixDetourOK = 0,
/// The image is not mapped in this process.
MISFixDetourImageMissing,
/// The image is mapped but exports no such symbol.
MISFixDetourSymbolMissing,
/// The symbol resolved into libmisfix itself. Refused: dyld applies
/// interposing to `dlsym`, so a hooked symbol can resolve to our own
/// replacement and a detour would point at itself.
MISFixDetourSymbolIsOurs,
/// The target address is NULL — the symbol did not bind.
MISFixDetourNoTarget,
/// A displaced instruction is PC-relative in a way this does not rewrite.
MISFixDetourUnrelocatable,
/// The target returns or jumps away inside the four words the jump needs,
/// so it is too short to detour.
MISFixDetourTooShort,
/// No executable memory could be obtained for the trampoline.
MISFixDetourNoTrampoline,
/// The target's page could not be made writable.
@@ -56,18 +65,17 @@ typedef enum {
/// A sentence for the log, never NULL.
const char *MISFixDetourDescribe(MISFixDetourResult result);
/// Point `symbol` of `image` at `replacement`.
/// Point `target` at `replacement`.
///
/// On success `*original` receives a pointer that behaves as the untouched
/// function did, already signed for an arm64e indirect call, and the
/// replacement calls through it for everything it does not mean to change.
/// On failure nothing is written and `*original` is left alone.
///
/// `image` is an install name, resolved with `RTLD_NOLOAD`: a detour is only
/// meaningful for an image this process already has.
/// `label` names the target in the log and is not otherwise used.
MISFixDetourResult MISFixDetour(
const char *image,
const char *symbol,
const char *label,
void *target,
void *replacement,
void **original
);
@@ -45,14 +45,14 @@
//
// ## 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.
// misagent, and nothing else. 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,
// installd does not benefit and no interpose 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
@@ -65,12 +65,20 @@
// 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.
// ## Why this is now a convenience rather than the fix
//
// Making a profile install was one way to get an Xcode install through. It is
// no longer the way this project takes: MISFixSignature.c validates the bundle
// on its own signature with `ValidatedByProfile = 0`, and
// MISFixProfilePolicy.c lets the embedded profile fail to install without
// failing the install. An arbitrary IPA then goes in with no UDID configured
// at all, which is the point — pinning a VM to a borrowed UDID only ever
// worked for a team whose registered devices you already have.
//
// The override is kept because it is harmless, already shipped, and reachable
// from the VM window's Device ▸ Set UDID…. Setting it makes profiles install
// for real instead of being skipped, which is closer to what the device would
// have done.
//
// ## The inconsistency this creates, stated plainly
//
@@ -0,0 +1,161 @@
// MISFixInstallPolicy.c — the two places installd refuses an app for a reason
// that does not apply to this guest.
//
// MISFixSignature.c widens what MIS itself will accept. This file is what sits
// above MIS: MobileInstallation's own policy, which asks for things a research
// VM cannot have and then treats their absence as a failed install.
//
// Both hooks call the real implementation first and only override a refusal,
// so on anything that would have installed anyway the behaviour is unchanged.
//
// ## The embedded profile
//
// Failed to install embedded profile for plus.yellow.AirBuild : 0xE8008012
// (This provisioning profile cannot be installed on this device.)
// -[MIInstallableBundle _installEmbeddedProfilesWithError:]
//
// `0xE8008012` is correct and always will be. A profile names the devices it
// covers, in `ProvisionedDevices`, and a VM's UDID is in nobody's list. Xcode
// papers over that for a *free* personal team by registering whatever device
// is plugged in; for a paid team there is no auto-registration, and the VM
// would have to be added to the account by hand and again after every
// `vm create`.
//
// A profile does two things — it vouches that a signing identity may run on
// this device, and it carries the entitlements the app may claim — and neither
// is load-bearing here. The kernel patches admit the code whatever signed it,
// and MIS has already validated the bundle on its own signature with
// `ValidatedByProfile = 0`. So a profile that cannot install is noted and
// skipped; one that can install still does, unchanged.
//
// ## The signer identity
//
// Failed to extract signer identity from <MIExecutableBundle …>
// -[MICodeSigningVerifier performValidationWithError:] line 424
//
// This is the gate behind the gate, and it is why widening MIS's options is
// not by itself enough. MIS accepts an ad-hoc signature and fills its info
// dictionary — `CdHash`, `Entitlements`, `SigningID` — but MobileInstallation
// then wants a *signer*: the leaf certificate out of a CMS blob, which an
// ad-hoc signature does not have and never will, because the whole point of
// ad-hoc is that nobody signed it.
//
// Measured on test-26.4 (2026-09-30) with a `codesign --sign -` bundle:
// `MISValidateSignature(…/SignTest.app) -> 0x0`, and the install still failed,
// here, at line 424 with `LibMISErrorNumber = -402620415`.
//
// There is nothing to widen and nothing to supply. The decision itself is what
// has to change, and this is the decision the guest is entitled to make
// differently: it runs unsigned code on purpose. So validation is allowed to
// fail and the install proceeds. Everything the verifier *could* determine has
// already been determined by the time it gets to the signer — the real
// implementation runs first, and fails late.
//
// ## Why a swizzle and not a detour
//
// Both are Objective-C methods in MobileInstallation, and an Objective-C
// method list is *data*. Replacing an implementation through the runtime
// reaches every caller, in the shared cache or out of it, without making a
// single page of cache text writable. Where that is available it is strictly
// better than MISFixDetour.h, and here it is available.
//
// The classes are looked up rather than linked, and a version that does not
// have one leaves that hook inert with a line in the log. That is deliberate:
// these are private methods on private classes, and the guest is expected to
// be a version this project has not seen yet.
#include "MISFixConfig.h"
#include <dlfcn.h>
#include <objc/objc.h>
#include <objc/runtime.h>
/// MobileInstallation's install name, for the case where a class is not
/// registered yet. Our constructor runs among the inserted libraries, ahead of
/// most of the process; every image present at launch has had its classes
/// realised by then, but a framework installd only dlopens later would not be
/// there at all.
#define kMISFixMobileInstallationPath \
"/System/Library/PrivateFrameworks/MobileInstallation.framework/MobileInstallation"
/// The shape both hooks have: a `BOOL`-returning method whose only argument is
/// an `NSError **` out-parameter.
typedef BOOL (*MISFixCheckIMP)(id self, SEL selector, void *error);
/// Replace `class`'s `-selector` with `replacement`, keeping the original.
///
/// Returns zero and logs when there is no such class or method, which is the
/// expected outcome on an OS version that renamed one.
static int vpSwizzle(
const char *className,
const char *selectorName,
MISFixCheckIMP replacement,
MISFixCheckIMP *original
) {
Class found = objc_getClass(className);
if (found == NULL) {
if (dlopen(kMISFixMobileInstallationPath, RTLD_LAZY) != NULL)
found = objc_getClass(className);
}
if (found == NULL) {
MISFixNote("%s is not in this process", className);
return 0;
}
Method method = class_getInstanceMethod(found, sel_registerName(selectorName));
if (method == NULL) {
MISFixNote("%s has no -%s", className, selectorName);
return 0;
}
*original = (MISFixCheckIMP)method_setImplementation(method, (IMP)replacement);
MISFixNote("swizzled -[%s %s]", className, selectorName);
return 1;
}
/// Clear an `NSError **` the failing implementation wrote.
///
/// A caller handed `YES` alongside a populated `NSError *` is a shape no
/// ordinary method produces, and installd does read the out-parameter. The
/// error object itself is left to the autorelease pool it came from.
static void vpClearError(void *error) {
if (error != NULL)
*(void **)error = NULL;
}
// MARK: - The embedded profile
static MISFixCheckIMP vpOriginalInstallProfiles;
static BOOL vpInstallEmbeddedProfiles(id self, SEL selector, void *error) {
if (vpOriginalInstallProfiles(self, selector, error))
return YES;
vpClearError(error);
MISFixNote("embedded profile refused; installing without one");
return YES;
}
// MARK: - The signer identity
static MISFixCheckIMP vpOriginalPerformValidation;
static BOOL vpPerformValidation(id self, SEL selector, void *error) {
if (vpOriginalPerformValidation(self, selector, error))
return YES;
vpClearError(error);
MISFixNote("code-signing validation refused; installing anyway");
return YES;
}
__attribute__((constructor)) static void vpInstallPolicyHooks(void) {
vpSwizzle(
"MIInstallableBundle",
"_installEmbeddedProfilesWithError:",
&vpInstallEmbeddedProfiles,
&vpOriginalInstallProfiles
);
vpSwizzle(
"MICodeSigningVerifier",
"performValidationWithError:",
&vpPerformValidation,
&vpOriginalPerformValidation
);
}
@@ -0,0 +1,78 @@
// MISFixProfileScope.c — let every provisioning profile cover this device.
//
// A profile carries the list of devices it is good for, and misagent reads it
// itself rather than asking anyone:
//
// ProvisionedDevices ProvisionsAllDevices
//
// — its own strings, in the order the code uses them. It asks the profile for
// `ProvisionsAllDevices` first, and only when that is false does it compare
// this device's UDID against `ProvisionedDevices`. A VM's UDID is in nobody's
// list, so the comparison always loses and the profile is refused with
// `0xE8008012`.
//
// Both questions are asked through `MISProfileGetValue`, which is libmis's and
// so is a callee this dylib can replace. Answering the first one `true` means
// the second is never asked, and a profile that reaches this guest installs.
//
// ## Why this rather than a borrowed UDID
//
// MISFixDeviceIdentity.c answers `UniqueDeviceID` with a device someone has
// already registered, which makes the comparison succeed for one team's
// profiles. It works, and it needs a registered device to borrow, a UDID
// typed in per machine, and it is still wrong for every other team. This says
// the same thing once, for every profile, and needs no configuration.
//
// ## What it buys
//
// The install path stops needing anything faked. With the profile installed
// for real, libmis validates the app against it — genuine signer, genuine
// entitlements, `ValidatedByProfile = 1` — instead of being talked past in
// MISFixInstallPolicy.c. The profile's own signature, its expiry and its
// application-identifier are all still checked; the only claim widened is
// which devices it covers.
//
// ## What it does not touch
//
// Nothing about a profile is rewritten. `MISProfileGetValue` is asked a
// question and answered; the profile on disk, its signature and every other
// value it carries are exactly as Apple issued them.
#include "MISFixConfig.h"
#include "MISFixDetour.h"
#include <CoreFoundation/CoreFoundation.h>
/// The key whose answer decides whether the device list is consulted at all.
/// A plain string for the same reason as the MIS option keys: libmis exports
/// no symbol for it and the SDK declares none.
#define kMISProfileProvisionsAllDevices CFSTR("ProvisionsAllDevices")
typedef CFTypeRef (*MISProfileGet)(CFTypeRef profile, CFStringRef key);
extern CFTypeRef MISProfileGetValue(CFTypeRef profile, CFStringRef key);
static MISProfileGet vpOriginalProfileGetValue;
static CFTypeRef vpProfileGetValue(CFTypeRef profile, CFStringRef key) {
if (key != NULL && CFGetTypeID(key) == CFStringGetTypeID()
&& CFEqual(key, kMISProfileProvisionsAllDevices))
{
MISFixLog("MISProfileGetValue(ProvisionsAllDevices) -> true");
// Immortal, and the real function returns a borrowed value too, so the
// caller's lifetime expectations are unchanged.
return kCFBooleanTrue;
}
return vpOriginalProfileGetValue(profile, key);
}
__attribute__((constructor)) static void vpInstallProfileScopeHook(void) {
MISFixDetourResult result = MISFixDetour(
"MISProfileGetValue",
(void *)&MISProfileGetValue,
(void *)&vpProfileGetValue,
(void **)&vpOriginalProfileGetValue
);
if (result != MISFixDetourOK)
MISFixNote("MISProfileGetValue not hooked: %s", MISFixDetourDescribe(result));
}
+172 -87
View File
@@ -1,35 +1,5 @@
// 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,
// lsd registers it, and SpringBoard launches it. An app pushed in through
@@ -68,9 +38,41 @@
// synthesise a reply. That matters: installd reads those keys, and a hook that
// faked success without them would break the install further down.
//
// So the hook adds one key to the options and calls through. On anything MIS
// So the hook adds keys to the options and calls through. On anything MIS
// would have accepted anyway the behaviour is bit for bit unchanged, because
// the key only widens what counts as an acceptable signature.
// the keys only widen what counts as acceptable.
//
// ## Why this is a detour and not an interpose
//
// It used to be an interpose, and that was measured wrong on 2026-09-30. 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)
//
// Neither dyld's linking of this dylib as a weak dependency nor
// `DYLD_INSERT_LIBRARIES` rewrites that. With `LogQueries` on and this file's
// log made unconditional, a whole `devicectl device install app` produced not
// one line from installd, while `MICodeSigningVerifier` ran to its line 80 and
// failed. The options were never widened, in any install, ever.
//
// A detour rewrites the callee instead, so there is nothing to miss: one copy
// of the function, one jump at its top, every caller redirected. See
// MISFixDetour.h for what that costs and what it refuses.
//
// Two details of the target, both of which the detour has to respect.
//
// `MISValidateSignatureAndCopyInfo` is a short thunk in front of
// `…WithProgress`, where libmis's body actually lives, so it is *shorter than
// the four-word jump* and `MISFixDetour` declines it with
// `MISFixDetourTooShort`. That is the expected outcome, not a failure: the
// thunk branches into the function that is hooked, so its callers are covered
// anyway. Both are attempted so the log says which one took.
//
// The address cannot come from `dlsym`. dyld applies interposing to it, so a
// hooked symbol resolves to our own replacement — measured even through a
// handle on libmis itself. Taking `&MISValidateSignatureAndCopyInfoWithProgress`
// here uses this image's own import, which dyld leaves alone.
//
// ## What this deliberately does not do
//
@@ -80,34 +82,35 @@
// supplies one. Forging a reply for a bundle with no signature at all would
// mean inventing a cdhash the kernel never agreed to.
//
// The online-authorization gate is a different patch: `mis_trust_auth` covers
// a profile that wants network validation on a hacktivated guest. This one is
// only about the signature's shape.
//
// ## Mechanism
//
// 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.
// The profile half of an Xcode install is not here either; it is
// MISFixProfilePolicy.c.
#include "MISFixConfig.h"
#include "MISFixInterpose.h"
#include "MISFixDetour.h"
#include <CoreFoundation/CoreFoundation.h>
#include <stdlib.h>
// libmis's own option keys, taken from the cache's string table rather than
// from a header — libmis.tbd exports the `kMISValidationOption*` symbols but
// the SDK declares none of them.
#define kMISValidationOptionAllowAdHocSigning CFSTR("AllowAdHocSigning")
#define kMISValidationOptionRespectUppTrustAndAuthorization CFSTR("RespectUppTrustAndAuthorization")
#define kMISValidationOptionSkipProfileIdentifierPolicy CFSTR("SkipProfileIdentifierPolicy")
// The first argument is a path string, not a URL. Handing MIS an NSURL aborts
// the process inside libmis with `-[NSURL length]: unrecognized selector`,
// which is how this was pinned down.
typedef CFStringRef MISPath;
typedef int (*MISValidate)(MISPath path, CFDictionaryRef options, CFDictionaryRef *info);
typedef int (*MISValidateWithProgress)(
MISPath path,
CFDictionaryRef options,
CFDictionaryRef *info,
void *progress
);
extern int MISValidateSignatureAndCopyInfo(MISPath path, CFDictionaryRef options, CFDictionaryRef *info);
extern int MISValidateSignatureAndCopyInfoWithProgress(
MISPath path,
@@ -116,13 +119,23 @@ extern int MISValidateSignatureAndCopyInfoWithProgress(
void *progress
);
static MISValidate vpOriginalValidate;
static MISValidateWithProgress vpOriginalValidateWithProgress;
/// The caller's options, widened. Never returns NULL for a NULL input: MIS is
/// called with an options dictionary either way.
///
/// Two keys go in.
/// Three keys go in.
///
/// `AllowAdHocSigning` is the signature half described above.
///
/// `SkipProfileIdentifierPolicy` stops MIS insisting that a profile's
/// application-identifier match the bundle's. A profile that names a different
/// app — or an app whose profile never installed, which is the ordinary case
/// for an IPA built for someone else's team — is then not a reason to refuse a
/// signature that is otherwise fine. Measured to leave an accepted bundle
/// accepted.
///
/// `RespectUppTrustAndAuthorization = false` is the online-authorization half,
/// and it replaces a patch that used to edit the shared cache. libmis reaches
/// `checkTrustAndAuthorization` — the only producer of `0xE8008026`, "missing
@@ -141,7 +154,7 @@ extern int MISValidateSignatureAndCopyInfoWithProgress(
///
/// The option parser writes a flag's slot only when the key is present, so an
/// explicit value always beats the defaults `UnauthoritativeLaunch` installs —
/// and nothing else in the shared cache passes this key, so there is no
/// and nothing else in the shared cache passes these keys, so there is no
/// caller's own value to override.
static CFDictionaryRef vpWidenedOptions(CFDictionaryRef options) {
CFMutableDictionaryRef widened =
@@ -149,13 +162,18 @@ static CFDictionaryRef vpWidenedOptions(CFDictionaryRef options) {
? CFDictionaryCreateMutableCopy(kCFAllocatorDefault, 0, options)
: CFDictionaryCreateMutable(
kCFAllocatorDefault,
2,
3,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks
);
if (widened == NULL)
return NULL;
CFDictionarySetValue(widened, kMISValidationOptionAllowAdHocSigning, kCFBooleanTrue);
CFDictionarySetValue(
widened,
kMISValidationOptionSkipProfileIdentifierPolicy,
kCFBooleanTrue
);
CFDictionarySetValue(
widened,
kMISValidationOptionRespectUppTrustAndAuthorization,
@@ -166,77 +184,144 @@ static CFDictionaryRef vpWidenedOptions(CFDictionaryRef options) {
/// Log one validation, under `LogQueries`.
///
/// This is the other half of the control. `MISValidateSignatureAndCopyInfo` is
/// reached the same way the UDID query is — from MobileInstallation, in the
/// shared cache, into libmis, also in the shared cache, with the main
/// executable's own image not involved. So if this line appears in installd
/// and the `MGCopyAnswer` line does not, the two calls are being treated
/// differently and the difference is in MobileGestalt, not in whether an
/// interpose can cross the cache at all.
/// Never returns early. A first run logged nothing here from installd, which
/// was read as "the interpose was not reached" — but a `path` this could not
/// turn into a C string would have produced exactly the same silence. The line
/// is unconditional now, and says what the argument was when it is not a
/// string, so an absent line means one thing only.
static void vpLogValidation(MISPath path, int result, const char *caller) {
/// was read as "the hook was not reached" — but a `path` this could not turn
/// into a C string would have produced exactly the same silence. The line says
/// what the argument was when it is not a string, so an absent line means one
/// thing only.
static void vpLogValidation(MISPath path, int result) {
char buffer[1024];
if (path == NULL) {
MISFixLog("MISValidateSignatureAndCopyInfo(NULL) from %s -> 0x%x", caller, (unsigned)result);
MISFixLog("MISValidateSignature(NULL) -> 0x%x", (unsigned)result);
return;
}
if (CFGetTypeID(path) != CFStringGetTypeID()
|| !CFStringGetCString(path, buffer, sizeof(buffer), kCFStringEncodingUTF8))
{
MISFixLog(
"MISValidateSignatureAndCopyInfo(<non-string %lu>) from %s -> 0x%x",
"MISValidateSignature(<non-string %lu>) -> 0x%x",
(unsigned long)CFGetTypeID(path),
caller,
(unsigned)result
);
return;
}
MISFixLog(
"MISValidateSignatureAndCopyInfo(%s) from %s -> 0x%x",
buffer,
caller,
(unsigned)result
);
MISFixLog("MISValidateSignature(%s) -> 0x%x", buffer, (unsigned)result);
}
static int vpMISValidateSignatureAndCopyInfo(
MISPath path,
CFDictionaryRef options,
CFDictionaryRef *info
) {
const char *caller = MISFixCaller();
/// One line naming what MIS put in the info dictionary, under `LogQueries`.
///
/// This is the instrument for the gates *above* MIS. `MICodeSigningVerifier`
/// accepts MIS's answer and then wants more from it — a signer identity, an
/// identifier that matches the bundle — and which key it is reading is not
/// visible from the failure it reports. Naming the keys, and the short values,
/// is what turns that into a readable question.
static void vpLogInfo(CFDictionaryRef info) {
if (info == NULL || CFGetTypeID(info) != CFDictionaryGetTypeID())
return;
CFIndex count = CFDictionaryGetCount(info);
if (count <= 0) {
MISFixLog(" info: empty");
return;
}
const void **keys = calloc((size_t)count, sizeof(void *));
const void **values = calloc((size_t)count, sizeof(void *));
if (keys == NULL || values == NULL) {
free(keys);
free(values);
return;
}
CFDictionaryGetKeysAndValues(info, keys, values);
for (CFIndex index = 0; index < count; index += 1) {
CFStringRef key = (CFStringRef)keys[index];
char name[128];
if (key == NULL || CFGetTypeID(key) != CFStringGetTypeID()
|| !CFStringGetCString(key, name, sizeof(name), kCFStringEncodingUTF8))
{
continue;
}
CFTypeRef value = values[index];
CFTypeID kind = value != NULL ? CFGetTypeID(value) : 0;
char shown[160] = "<…>";
if (value == NULL) {
snprintf(shown, sizeof(shown), "<null>");
} else if (kind == CFStringGetTypeID()) {
CFStringGetCString((CFStringRef)value, shown, sizeof(shown), kCFStringEncodingUTF8);
} else if (kind == CFBooleanGetTypeID()) {
snprintf(shown, sizeof(shown), CFBooleanGetValue((CFBooleanRef)value) ? "true" : "false");
} else if (kind == CFNumberGetTypeID()) {
long long number = 0;
CFNumberGetValue((CFNumberRef)value, kCFNumberLongLongType, &number);
snprintf(shown, sizeof(shown), "%lld", number);
} else if (kind == CFDataGetTypeID()) {
snprintf(shown, sizeof(shown), "<%ld bytes>",
(long)CFDataGetLength((CFDataRef)value));
} else if (kind == CFDictionaryGetTypeID()) {
snprintf(shown, sizeof(shown), "<%ld entries>",
(long)CFDictionaryGetCount((CFDictionaryRef)value));
}
MISFixLog(" info[%s] = %s", name, shown);
}
free(keys);
free(values);
}
static int vpValidate(MISPath path, CFDictionaryRef options, CFDictionaryRef *info) {
CFDictionaryRef widened = vpWidenedOptions(options);
// Out of memory: pass the caller's own options through rather than fail.
if (widened == NULL)
return MISValidateSignatureAndCopyInfo(path, options, info);
int result = MISValidateSignatureAndCopyInfo(path, widened, info);
return vpOriginalValidate(path, options, info);
int result = vpOriginalValidate(path, widened, info);
CFRelease(widened);
vpLogValidation(path, result, caller);
vpLogValidation(path, result);
if (result == 0 && info != NULL)
vpLogInfo(*info);
return result;
}
static int vpMISValidateSignatureAndCopyInfoWithProgress(
static int vpValidateWithProgress(
MISPath path,
CFDictionaryRef options,
CFDictionaryRef *info,
void *progress
) {
const char *caller = MISFixCaller();
CFDictionaryRef widened = vpWidenedOptions(options);
if (widened == NULL)
return MISValidateSignatureAndCopyInfoWithProgress(path, options, info, progress);
int result = MISValidateSignatureAndCopyInfoWithProgress(path, widened, info, progress);
return vpOriginalValidateWithProgress(path, options, info, progress);
int result = vpOriginalValidateWithProgress(path, widened, info, progress);
CFRelease(widened);
vpLogValidation(path, result, caller);
vpLogValidation(path, result);
if (result == 0 && info != NULL)
vpLogInfo(*info);
return result;
}
// Both entry points are replaced. The plain one is what MobileInstallation
// calls; the progress variant is where libmis's own body lives, and a future
// caller that reaches for it directly gets the same treatment.
MISFIX_INTERPOSE(vpMISValidateSignatureAndCopyInfo, MISValidateSignatureAndCopyInfo);
MISFIX_INTERPOSE(vpMISValidateSignatureAndCopyInfoWithProgress, MISValidateSignatureAndCopyInfoWithProgress);
/// Hook both entry points before the daemon serves anything.
///
/// The `…WithProgress` one is the body and is the one that has to take. The
/// plain one is a thunk in front of it and is expected to come back
/// `MISFixDetourTooShort`; it is attempted anyway, because "expected" is a
/// property of one libmis build and the log is how the next one tells us it
/// changed.
__attribute__((constructor)) static void vpInstallSignatureHooks(void) {
MISFixDetourResult body = MISFixDetour(
"MISValidateSignatureAndCopyInfoWithProgress",
(void *)&MISValidateSignatureAndCopyInfoWithProgress,
(void *)&vpValidateWithProgress,
(void **)&vpOriginalValidateWithProgress
);
if (body != MISFixDetourOK) {
MISFixNote("MISValidateSignatureAndCopyInfoWithProgress not hooked: %s",
MISFixDetourDescribe(body));
}
MISFixDetourResult thunk = MISFixDetour(
"MISValidateSignatureAndCopyInfo",
(void *)&MISValidateSignatureAndCopyInfo,
(void *)&vpValidate,
(void **)&vpOriginalValidate
);
if (thunk != MISFixDetourOK) {
MISFixLog("MISValidateSignatureAndCopyInfo not hooked: %s",
MISFixDetourDescribe(thunk));
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ $(MISFIX): $(MISFIX_SOURCES) $(MISFIX_INCLUDES) MISFix | $(STAGE)/misfix
$(CLANG) $(GUEST_C_FLAGS) -dynamiclib \
-install_name /usr/lib/libmisfix.dylib \
-o $@ $(MISFIX_SOURCES) \
-framework CoreFoundation -lmis -lMobileGestalt \
-framework CoreFoundation -lmis -lMobileGestalt -lobjc \
-Wl,-not_for_dyld_shared_cache
@codesign --force --sign - $@