Files
vphone-cli/scripts/patchers/cfw_patch_hv_vmm_dsc.py
zqxwce af90c9a903 userland: Add DSC hv_vmm_present byte-5 mangle with sign-in blacklist and slot reattest (EXP only)
Companion user-mode patches to the kernel-side OID rename. Mangles
byte 5 of every `kern.hv_vmm_present` cstring inside DSC dylibs EXCEPT
those in `DONT_PATCH_INSTALL_NAMES` (sign-in / device-likeness consumers,
~15 entries). Patched dylibs query the renamed OID and get the truthful
1 (graphics + accel passthrough); blacklisted dylibs keep the original
cstring, hit ENOENT on the renamed kernel, defensively cache 0 ("not
running on a VM") for sign-in / device-attestation surfaces.

- `scripts/patchers/cfw_patch_hv_vmm_dsc.py` — DSC orchestrator. Walks
  every `kern.hv_vmm_present\\0` cstring in any executable mapping,
  resolves the containing dylib via Mach-O-header walk-back +
  LC_ID_DYLIB, applies the byte-5 mangle to non-blacklisted dylibs, and
  drives slot-hash re-attestation in the chunk's `CS_CodeDirectory`.
- `scripts/patchers/cfw_dsc_chunks.py` — pure-Python helper for the
  chunked DSC layout: vmaddr<->chunk-fileoff mapping, install-name
  walk-back, byte-level read/write at a vmaddr.
- `scripts/patchers/cfw_dsc_codesign.py` — per-page SHA-256 slot-hash
  re-attestation for DSC chunks (16 KiB pages). Required on
  `codeSigningMonitor == 2` (TXM) hardware where per-page hash checks
  would otherwise SIGKILL the patched dylibs at first demand-page-in.
- `scripts/patchers/cfw_patch_hv_vmm.py` — standalone Mach-O variant of
  the cstring mangle (kept for completeness; the historical
  standalone-binary loop step was removed in favor of the
  blacklist-flip-via-kernel-rename design).
- `scripts/patchers/cfw_patch_hv_vmm_rootfs.py` — rootfs-path inventory
  shared with the install scripts (former JB-3.5 / 6.5/7 loop input).
- `scripts/patch_hv_vmm_userland.sh` — thin wrapper used by the install
  script (`dsc` and `standalone` operations; `watchdogd` is added by
  the next commit).
- `scripts/patchers/cfw.py` — new subcommands: `patch-hv-vmm`,
  `patch-hv-vmm-dsc`, `list-hv-vmm-rootfs-paths`.
- `scripts/cfw_install_exp.sh` — adds a pre-step that decrypts the
  SystemOS Cryptex AEA into the cache location `cfw_install.sh` already
  uses, mounts it, applies the DSC patch, and unmounts. The unmodified
  base `cfw_install.sh` then picks up the cached (patched) DMG.

Research docs in this commit describe the full EXP variant comprehensively
(`research/0_binary_patch_comparison.md` top-of-doc note, EXP-Only
Kernel Methods section, DSC userland subsection, plus forward references
to EXP-JB-3.5 / EXP-JB-6 / EXP-JB-7 subsections wired up by the following
commits; `research/firmware_manifest_and_origins.md` sections 9-11
similarly forward-describe the DT and build-version pieces).

JB and DEV variants are NOT affected: their install scripts don't invoke
any of this.
2026-05-18 16:14:55 +03:00

267 lines
12 KiB
Python

"""DSC-side orchestrator for the hv_vmm_present user-mode patch
(blacklist-flip design).
Design summary
--------------
This module works in tandem with `KernelJBPatchHvVmmRename` (Swift
kernel patcher) which renames the kernel's sysctl OID from
`hv_vmm_present` to `Xv_vmm_present`. After the kernel patch:
- `sysctlbyname("kern.hv_vmm_present", ...)` returns ENOENT.
- `sysctlbyname("kern.Xv_vmm_present", ...)` returns 1 (the OID's
real int value, since this device is in fact a VM).
This module's job is to selectively rewrite cstrings inside DSC
dylibs so each dylib queries either the original (now ENOENT-returning)
name or the new (truthful-1-returning) name. Byte 5 of the
`"kern.hv_vmm_present\\0"` cstring is flipped from `'h'` (0x68) to
`'X'` (0x58), producing `"kern.Xv_vmm_present\\0"` — the same name the
kernel OID now answers to. The `kern.` namespace prefix is preserved
(byte-0 mangle would have produced `Xern.hv_vmm_present` which can
never resolve because `Xern` isn't a registered top-level).
Blacklist semantics
-------------------
The `DONT_PATCH_INSTALL_NAMES` list names dylibs whose cstring stays
ORIGINAL — they query `kern.hv_vmm_present`, get ENOENT, and the
caller's defensive post-call check leaves the cached `is_vmm` byte
at BSS-zero (0). Those libs end up thinking "not in a VM" — which is
what we want for sign-in / device-likeness consumers.
Every OTHER dylib that contains the cstring gets the byte-5 mangle —
they query `kern.Xv_vmm_present`, get 1, cache 1, and think "in a VM"
— exactly the value they would have seen on a stock device. This
keeps the graphics path (libMobileGestalt, PhotoFoundation,
AirPlaySupport, VisionKitCore, CoreVideo) and compute/accel fast
paths (CoreML, Espresso, AppleNeuralEngine, …) intact, because they
keep getting their original answer.
"""
import os
import struct
import sys
from .cfw_patch_hv_vmm import (
NEEDLE,
MANGLED_NEEDLE,
MANGLE_OFFSET,
ORIGINAL_BYTE,
MANGLED_BYTE,
)
from .cfw_dsc_chunks import DSCChunks
from .cfw_dsc_codesign import reattest_modified_pages
# ─────────────────────────────────────────────────────────────────────
# Blacklist of DSC dylibs to LEAVE UNPATCHED.
#
# A dylib in this list keeps its cstring as `"kern.hv_vmm_present\0"`.
# With the kernel-rename patch in place, that name resolves to ENOENT,
# the dylib's defensive `cbnz w0, skip` takes the skip path, and the
# cached `is_vmm` byte stays at BSS-zero (0). The dylib thinks the
# device is not a VM.
#
# A dylib NOT in this list has its cstring rewritten to
# `"kern.Xv_vmm_present\0"` (byte 5 mangle, 'h' → 'X'). That name
# resolves to the renamed OID on the kernel side and returns 1. The
# dylib thinks the device IS a VM — same as stock.
#
# Comment a single line out (re-enabling the patch for that dylib —
# i.e., MOVING IT OUT of the blacklist) to bisect which consumer
# regressed an observable.
#
# Order is grouped by why each entry needs to lie about VM presence;
# the order itself doesn't affect patching.
# ─────────────────────────────────────────────────────────────────────
DONT_PATCH_INSTALL_NAMES = (
# ── Identity / activation / anti-abuse.
"/System/Library/PrivateFrameworks/AAAFoundation.framework/AAAFoundation",
"/System/Library/PrivateFrameworks/AuthKit.framework/AuthKit",
"/System/Library/PrivateFrameworks/IDSFoundation.framework/IDSFoundation",
"/System/Library/PrivateFrameworks/DeviceIdentity.framework/DeviceIdentity",
"/System/Library/PrivateFrameworks/DeviceCheckInternal.framework/DeviceCheckInternal",
"/System/Library/PrivateFrameworks/MobileActivation.framework/MobileActivation",
"/System/Library/PrivateFrameworks/ApplePushService.framework/ApplePushService",
# ── Store / IAP.
"/System/Library/PrivateFrameworks/AppStoreUtilities.framework/AppStoreUtilities",
# ── Consumer services.
"/System/Library/PrivateFrameworks/CorePrescription.framework/CorePrescription",
"/System/Library/PrivateFrameworks/CoreCDP.framework/CoreCDP",
"/System/Library/PrivateFrameworks/EmailFoundation.framework/EmailFoundation",
"/System/Library/PrivateFrameworks/FindMyBase.framework/FindMyBase",
"/System/Library/PrivateFrameworks/TrialServer.framework/TrialServer",
"/System/Library/PrivateFrameworks/DVTInstrumentsUtilities.framework/DVTInstrumentsUtilities",
"/System/Library/PrivateFrameworks/WatchdogServiceManagement.framework/WatchdogServiceManagement",
)
_DONT_PATCH_SET: frozenset[str] = frozenset(DONT_PATCH_INSTALL_NAMES)
def _classify(chunks, string_vma):
"""Walk back from `string_vma` to find the containing Mach-O header
and return its install name (LC_ID_DYLIB), or None if not found.
"""
header_vma = chunks.find_macho_header_before(string_vma)
if header_vma is None:
return None
return chunks.read_install_name_at(header_vma)
def patch_hv_vmm_in_dsc(chunks_dir, *, dry_run=False):
"""Apply the blacklist-flip mangle inside the DSC chunks at
`chunks_dir`.
For every occurrence of `"kern.hv_vmm_present\\0"` in any
executable DSC mapping, resolve the containing dylib's install
name; if the install name is NOT in `DONT_PATCH_INSTALL_NAMES`,
rewrite byte 5 of the cstring from 'h' to 'X'.
Returns {install_name: count_of_cstring_sites_mangled}.
"""
chunks = DSCChunks(chunks_dir)
print(f" [.] {chunks!r}")
print(f" [.] locating cstring \"kern.hv_vmm_present\\0\"...")
string_vmas = chunks.find_string_vmas(NEEDLE)
# Also pick up cstrings already mangled by a prior run — we still
# need their 16 KiB pages added to the re-attestation set so a
# re-run on a previously-patched DSC keeps slot hashes in sync.
already_mangled_vmas = chunks.find_string_vmas(MANGLED_NEEDLE)
if not string_vmas and not already_mangled_vmas:
print(f" [-] cstring not present in any executable mapping; "
f"nothing to do (either patched already or absent)")
return {}
print(f" [.] {len(string_vmas)} pristine + {len(already_mangled_vmas)} "
f"already-mangled cstring occurrence(s) found")
print(f" [.] resolving containing dylib for each occurrence "
f"(blacklist has {len(_DONT_PATCH_SET)} entries — these stay "
f"unpatched)...")
results = {}
patched_total = 0
skipped_in_blacklist = 0
skipped_unclassified = 0
refused = 0
# Track every vmaddr we actually mangled so the re-attestation pass
# at the end recomputes exactly the affected 16 KiB pages.
modified_vmas: list[int] = []
for vma in sorted(string_vmas):
try:
install_name = _classify(chunks, vma)
except Exception as e:
print(f" [-] could not classify string@0x{vma:X}: "
f"{type(e).__name__}: {e}")
install_name = None
label = install_name or "<unknown dylib>"
if install_name is None:
# Unknown dylibs are NOT in the blacklist → would normally
# be patched. Refuse here because we can't audit them.
print(f" [.] SKIP (no install name resolvable) "
f"string@0x{vma:X}")
skipped_unclassified += 1
results[label] = results.get(label, 0)
continue
if install_name in _DONT_PATCH_SET:
print(f" [.] SKIP (blacklisted — stays unpatched, "
f"will hit ENOENT): {install_name} string@0x{vma:X}")
skipped_in_blacklist += 1
results[label] = results.get(label, 0)
continue
# Sanity: the byte at vma must currently look like the pristine cstring.
try:
current = chunks.read_at_vma(vma, len(NEEDLE))
except (KeyError, IOError) as e:
print(f" [-] read failed at 0x{vma:X}: {e}")
refused += 1
continue
if current[MANGLE_OFFSET:MANGLE_OFFSET + 1] == MANGLED_BYTE:
# Already mangled by a prior run; nothing to write, but
# the re-attest pass below picks it up via already_mangled_vmas.
print(f" [.] already mangled at byte {MANGLE_OFFSET}: "
f"{label} string@0x{vma:X}")
results[label] = results.get(label, 0)
continue
if current != NEEDLE:
print(f" [-] unexpected bytes at 0x{vma:X} "
f"({current!r}); refusing ({label})")
refused += 1
continue
action = "would mangle" if dry_run else "mangled"
if not dry_run:
# Write the single byte at vma + MANGLE_OFFSET.
chunks.write_at_vma(vma + MANGLE_OFFSET, MANGLED_BYTE)
print(f" [+] {action} {label} string@0x{vma:X} "
f"byte {MANGLE_OFFSET} "
f"({ORIGINAL_BYTE.decode()} -> {MANGLED_BYTE.decode()}) "
f"now queries kern.Xv_vmm_present, will see 1 (in a VM)")
results[label] = results.get(label, 0) + 1
patched_total += 1
modified_vmas.append(vma)
# Second pass: previously-mangled cstrings. Add their pages to the
# re-attestation set so a re-run brings slot hashes into sync.
# IMPORTANT: we INTENTIONALLY do not refuse a mangled cstring whose
# install_name is in the blacklist — that's an out-of-band action
# (someone re-enabled patching for that dylib in a prior run and
# then commented it back into the blacklist). We log it loudly.
reattest_only_count = 0
blacklist_drift_count = 0
for vma in sorted(already_mangled_vmas):
try:
install_name = _classify(chunks, vma)
except Exception:
install_name = None
if install_name is None:
continue
if install_name in _DONT_PATCH_SET:
# This dylib is supposed to be unpatched but appears mangled.
# Most likely the operator mangled and then re-added it to
# the blacklist; we surface this and still re-attest the
# page so the slot hash matches the current bytes.
print(f" [!] drift: {install_name} is in the blacklist "
f"but already mangled at string@0x{vma:X} — slot will "
f"be re-attested to current bytes; consider whether "
f"you actually want this dylib re-patched")
modified_vmas.append(vma)
blacklist_drift_count += 1
continue
modified_vmas.append(vma)
reattest_only_count += 1
if reattest_only_count:
print(f" [.] also queueing {reattest_only_count} already-mangled "
f"cstring page(s) for re-attestation")
if blacklist_drift_count:
print(f" [.] {blacklist_drift_count} blacklisted dylib(s) found "
f"mangled on disk — see drift warnings above")
# Page-hash re-attestation. On hardware with TXM enforcement
# (`codeSigningMonitor == 2` — iPhone17,3 / iOS 26.1), the kernel
# defers per-page validation to TXM (vm_fault.c:2763-2780). TXM has
# the original SHA-256 slot hashes baked in via the DSC's chunk-
# level CS_CodeDirectory, so a byte-mangle alone causes
# `KERN_PROTECTION_FAILURE` / `CODESIGNING / Invalid Page` SIGKILL
# of any process that demand-pages the patched page. Recomputing
# the slot hash makes the modified (content, hash) pair consistent.
# The CDHash of the CD blob changes as a side-effect but TXM
# accepts it at DSC mount time on this build (verified empirically).
if modified_vmas and not dry_run:
print(f" [.] re-attesting {len(modified_vmas)} modified page(s)...")
reattest_modified_pages(chunks, modified_vmas, dry_run=False)
elif modified_vmas and dry_run:
print(f" [.] dry-run: would re-attest {len(modified_vmas)} "
f"modified page(s)")
reattest_modified_pages(chunks, modified_vmas, dry_run=True)
print(f" [+] DSC patch complete: {patched_total} cstring(s) mangled, "
f"{skipped_in_blacklist} in-blacklist (left unpatched), "
f"{skipped_unclassified} unclassified, "
f"{refused} refused/error")
return results