Files
vphone-cli/VPhoneGuestComponents/MISFix/MISFixDetour.c
T
LakrandClaude Opus 5 ca6c09932b 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>
2026-09-30 21:17:52 +09:00

276 lines
11 KiB
C

// MISFixDetour.c — the four-word absolute jump, and the trampoline behind it.
//
// See MISFixDetour.h for why a detour rather than an interpose. This file is
// the mechanics, and every instruction word it emits was checked against the
// assembler rather than remembered:
//
// ldr x16, #8 58000050
// br x16 d61f0200
// movz x5, #0x1234 d2824685 (base d2800000 | imm<<5 | Rd)
// movk x5, #0xabcd, lsl 16 f2b579a5 (base f2a00000)
// movk x5, #1, lsl 32 f2c00025 (base f2c00000)
// movk x5, #0, lsl 48 f2e00005 (base f2e00000)
// adrp x9, . 90000009 (mask 9f000000 -> 90000000)
// adr x9, . 10000009 (mask 9f000000 -> 10000000)
//
// x16 is IP0, scratch at a function boundary by the procedure call standard,
// which is what makes the literal-load jump safe as the first thing a function
// does. The literal form is used rather than `adrp`+`add` because it has no
// range limit: the trampoline arena and the shared cache need not be within
// ±4GB of each other.
#include "MISFixDetour.h"
#include "MISFixConfig.h"
#include <libkern/OSCacheControl.h>
#include <mach/mach.h>
#include <ptrauth.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
/// Instructions displaced from the top of the target. Four words is what the
/// absolute jump costs.
#define kDetourWords 4u
#define kDetourBytes (kDetourWords * 4u)
/// Room for one trampoline: four displaced instructions, each of which may
/// expand to four words when it has to be rewritten, plus the four-word jump
/// back.
#define kTrampolineBytes 128u
// MARK: - Encoding
static uint32_t vpMovz(unsigned rd, uint16_t value) {
return 0xD2800000u | ((uint32_t)value << 5) | (rd & 0x1Fu);
}
static uint32_t vpMovk(unsigned rd, uint16_t value, unsigned shift) {
static const uint32_t bases[] = { 0xF2800000u, 0xF2A00000u, 0xF2C00000u, 0xF2E00000u };
return bases[shift & 3u] | ((uint32_t)value << 5) | (rd & 0x1Fu);
}
/// `value` into `rd`, always four words so a caller can size buffers without
/// asking. The redundant `movk`s cost two cycles once, at load.
static void vpEmitAbsolute(uint32_t *out, unsigned rd, uint64_t value) {
out[0] = vpMovz(rd, (uint16_t)(value & 0xFFFFu));
out[1] = vpMovk(rd, (uint16_t)((value >> 16) & 0xFFFFu), 1);
out[2] = vpMovk(rd, (uint16_t)((value >> 32) & 0xFFFFu), 2);
out[3] = vpMovk(rd, (uint16_t)((value >> 48) & 0xFFFFu), 3);
}
/// `ldr x16, #8 ; br x16 ; .quad target` — four words, no range limit.
static void vpEmitJump(uint32_t *out, uint64_t target) {
out[0] = 0x58000050u;
out[1] = 0xD61F0200u;
memcpy(&out[2], &target, sizeof(target));
}
/// Sign-extend the low `bits` of `value`.
static int64_t vpSignExtend(uint64_t value, unsigned bits) {
uint64_t mask = 1ull << (bits - 1);
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
/// address. Returns the number of words written, or 0 when the instruction is
/// PC-relative in a way this does not handle — in which case the whole detour
/// is abandoned, because half a relocation is a corrupted function.
static unsigned vpRelocate(uint32_t insn, uint64_t pc, uint32_t *out) {
// ADR / ADRP. Both put a PC-relative address in a register, and both
// become "materialise that same absolute address".
if ((insn & 0x1F000000u) == 0x10000000u) {
unsigned rd = insn & 0x1Fu;
uint64_t immhi = (insn >> 5) & 0x7FFFFu;
uint64_t immlo = (insn >> 29) & 0x3u;
int64_t offset = vpSignExtend((immhi << 2) | immlo, 21);
int isPage = (insn & 0x80000000u) != 0;
uint64_t address = isPage ? ((pc & ~0xFFFull) + (uint64_t)(offset << 12))
: (pc + (uint64_t)offset);
vpEmitAbsolute(out, rd, address);
return 4;
}
// Unconditional B. Becomes an absolute jump to where it went.
if ((insn & 0xFC000000u) == 0x14000000u) {
int64_t offset = vpSignExtend(insn & 0x03FFFFFFu, 26) * 4;
vpEmitJump(out, pc + (uint64_t)offset);
return 4;
}
// Everything else that reads PC is refused rather than guessed at: BL
// (0x94000000), the conditional and compare branches (B.cond 0x54000000,
// CBZ/CBNZ 0x34000000, TBZ/TBNZ 0x36000000), and the literal loads
// (0x18000000). A prologue normally contains none of them; when one turns
// up, the log says so and nothing is written.
if ((insn & 0xFC000000u) == 0x94000000u // BL
|| (insn & 0xFF000010u) == 0x54000000u // B.cond
|| (insn & 0x7E000000u) == 0x34000000u // CBZ/CBNZ/TBZ/TBNZ
|| (insn & 0x3B000000u) == 0x18000000u) // LDR/LDRSW literal, PRFM literal
{
return 0;
}
out[0] = insn;
return 1;
}
// MARK: - Trampoline storage
/// A slab inside this dylib's own `__TEXT` was the intended fallback for a
/// process that may not map executable memory, and it is deliberately not
/// 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 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;
vm_address_t start = (vm_address_t)(uintptr_t)address & ~(vm_address_t)(page - 1);
vm_address_t end = ((vm_address_t)(uintptr_t)address + length + page - 1)
& ~(vm_address_t)(page - 1);
return vm_protect(mach_task_self(), start, end - start, FALSE, protection);
}
/// Writable memory for one trampoline, or NULL. The caller fills it and then
/// makes it executable; it is never both at once, for the reason in
/// ``vpProtect``'s callers below.
static void *vpAllocateTrampoline(void) {
void *page = mmap(NULL, kTrampolineBytes, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANON, -1, 0);
return page == MAP_FAILED ? NULL : page;
}
// MARK: - Installing
const char *MISFixDetourDescribe(MISFixDetourResult result) {
switch (result) {
case MISFixDetourOK: return "installed";
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";
}
return "unknown";
}
MISFixDetourResult MISFixDetour(
const char *label,
void *function,
void *replacement,
void **original
) {
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
// first instruction the detour did not overwrite.
uint32_t displaced[kDetourWords];
memcpy(displaced, target, sizeof(displaced));
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,
body + words
);
if (written == 0)
return MISFixDetourUnrelocatable;
words += written;
}
vpEmitJump(body + words, (uint64_t)(uintptr_t)target + kDetourBytes);
words += 4;
// Write, then make executable — never both, and this is measured rather
// than stylistic. Apple silicon enforces write-xor-execute in hardware
// below the VM permissions, so a page mapped RWX comes back `prot=7` and
// then faults on the store:
//
// EXC_BAD_ACCESS (SIGBUS), UNKNOWN_0x32
//
// 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;
memcpy(trampoline, body, words * 4u);
if (vpProtect(trampoline, words * 4u, VM_PROT_READ | VM_PROT_EXECUTE) != KERN_SUCCESS) {
munmap(trampoline, kTrampolineBytes);
return MISFixDetourNoTrampoline;
}
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.
if (vpProtect(target, kDetourBytes, VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY)
!= KERN_SUCCESS)
{
munmap(trampoline, kTrampolineBytes);
return MISFixDetourPageReadOnly;
}
uint32_t detour[kDetourWords];
vpEmitJump(detour, (uint64_t)(uintptr_t)ptrauth_strip(replacement,
ptrauth_key_function_pointer));
memcpy((void *)(uintptr_t)target, detour, sizeof(detour));
sys_icache_invalidate((void *)(uintptr_t)target, kDetourBytes);
int landed = memcmp(target, detour, sizeof(detour)) == 0;
vpProtect(target, kDetourBytes, VM_PROT_READ | VM_PROT_EXECUTE);
if (!landed) {
if (original != NULL)
*original = NULL;
return MISFixDetourWriteFailed;
}
// 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;
}