kern.hv_vmm_present user-mode call sites — iPhone17,3 26.1 (23B85)
Source material: ipsws/iPhone17,3_26.1_23B85_Restore (raw IPSW; encrypted DMGs)
and ipsws/iPhone17,3_26.1_23B85_Restore_extracted (rebuilt rootfs with split DSC).
This document enumerates every place in user space that reads
kern.hv_vmm_present via the sysctl family on this firmware, what each
caller appears to do with the result, and what is reasonable to expect from
patching that specific reader instead of the kernel.
The design — blacklist-flip + kernel rename (opt-in since 2026-09-28)
Not applied by default any more. Both halves are blocked by the standard
preset and reachable only through --preset extended or two per-VM checkmarks,
because on a freshly restored 26.4 guest they mean a permanent black screen. The
reason is bluetoothd, and it is written out under B.3 below. What follows
describes the patch as it works when it is selected.
The patch pipeline takes a kernel-rename + user-mode-blacklist approach. Summary:
-
The Swift kernel patch
patch_hv_vmm_rename(JB-26, Group B) renames the kernel'shv_vmm_presentsysctl OID name in place toXv_vmm_present(single byte change at offset 0 of the 14-byte cstring\0hv_vmm_present\0). After this kernel patch:sysctlbyname("kern.hv_vmm_present", ...)returns ENOENT.sysctlbyname("kern.Xv_vmm_present", ...)returns 1, the OID's real int value.
-
The user-mode mangle pattern moved from byte 0 (
'k' → 'X', producingXern.hv_vmm_present— unroutable becauseXernisn't a top-level namespace) to byte 5 ('h' → 'X', producingkern.Xv_vmm_present— keepskern.intact, routes to the renamed OID). -
scripts/patchers/cfw_patch_hv_vmm_dsc.pynow applies the mangle as a BLACKLIST. The listDONT_PATCH_INSTALL_NAMESnames dylibs whose cstring is LEFT untouched — they query the original (now ENOENT-returning) name and cache 0 ("not in a VM"). Every other DSC dylib with the cstring gets the byte-5 mangle, queries the new name, and caches 1 ("in a VM", same as stock) — keeping graphics + compute/accel fast paths intact. -
The standalone-binary patch step over SSH (originally
cfw_install_jb.shJB-3.5 andcfw_install_dev.sh6.5/7) was removed. With the kernel rename in place, those 6 rootfs binaries fall into the "unpatched → ENOENT → cache 0" bucket automatically.This is the step that bricks a 26.4 guest. "Cache 0" is not harmless for every rootfs binary: on 23E246
bluetoothdreads the sysctl (it did not on 23B85), and a cached 0 sends it down a device-type table that has no entry for a virtual iPhone, so it crash-loops. The mangle covered only the 37 cstrings inside the shared cache; standalone executables were never touched. See the correction under B.3 for the full chain from there to a black screen.
The earlier whitelist-only design (no kernel patch, mangle a chosen subset to ENOENT) is preserved for reference in the section "Pre-blacklist whitelist enumeration" below, since the per-caller classification work is still the source-of-truth for what each binary's cstring usage actually looks like.
What the sysctl is
kern.hv_vmm_present is defined in XNU at
bsd/kern/hvg_sysctl.c:176:
static SYSCTL_PROC(_kern, OID_AUTO, hv_vmm_present,CTLTYPE_INT | CTLFLAG_ANYBODY | CTLFLAG_KERN | CTLFLAG_LOCKED, …)
Backing handler sysctl_vmm_present (line 136 in the same file) returns
an int, computed on arm64 from IODTGetDefault("vmm-present", …) —
i.e. a device-tree key that Apple's Virtualization.framework /
PCC bring-up plants in the guest. On real hardware the key is absent
and the read returns 0; on PCC/research VMs it returns 1. That is
exactly the path you are flipping today by patching the kernel.
Because this is a single named OID with CTLFLAG_ANYBODY, every user-mode
reader funnels through the canonical sysctlbyname() shape:
This shape is the matcher I used to flag “real readers” below.
Method
- Located the literal
"kern.hv_vmm_present\0"inside every Mach-O of the rebuilt rootfs (the split DSC dylibs and every executable). 56 binaries matched on bytes alone. - For each match, parsed the LC_SEGMENT_64 layout and located the
actual section. Anything that wasn't in
__TEXT,__cstring(or another section flaggedS_CSTRING_LITERALS, flag0x2) was investigated separately. - For each cstring hit, looked for ADRP+ADD or ADRP+LDR pairs in any
executable section that compute the string's vm address, then walked
forward up to ~24 instructions tracking simple register propagation
(
mov xN, xM). - Classified each xref:
CANONICAL— string ends up inx0,x3=x4=0, BL within the window. Matchessysctlbyname(name, oldp, oldlenp, NULL, 0).PROBABLE— string inx0and a BL in window, butx3/x4zeroing not seen.REFERENCED— xref exists but it is loaded into a non-x0 register or used as an address anchor for adjacent data, not handed to a syscall.STORED— string written into a struct (i.e. a registry, not a direct caller).DEAD— string lives in__cstringbut no executable instruction points at its page.
The full per-binary results (snippets, addresses, classification) are
saved at outputs/hv_vmm_present_xref.json.
Inventory by class
A. CANONICAL sysctlbyname("kern.hv_vmm_present", …) callers — 35
Each line is <binary> string@<vmaddr> xref@<vmaddr>. The xref
address is the add instruction that finalizes the pointer value
in x0; immediately followed by the canonical 5-arg sysctlbyname
preamble and a bl.
All 35 readers share the same five-argument shape. Most also share a
post-call idiom: store the boolean result into a static byte (cache),
and a sibling getter() reads the cached byte. libMobileGestalt is the
clearest exemplar:
CoreML, CoreRE, Espresso, AppleNeuralEngine, MobileActivation,
caulk, RenderBox, etc. all do the same: one call, cached, sibling
accessor. That makes the patch surface for each one very small —
overwriting cset w8, ne with mov w8, #0 (or NOPing the call and
zeroing the cached byte's accessor) is all that's needed per binary.
B. False positives ruled out by xref analysis
15 of the 56 string-bearing binaries are not actual readers:
B.1 Embedded entitlement plist (the binary just declares it can read the OID)
These four embed a com.apple.security.exception.sysctl.read-only (or
sysctl-read) entitlement that names kern.hv_vmm_present. The
entitlement only authorises the sandbox; the actual sysctlbyname call
happens in libraries they link (almost certainly libMobileGestalt or
CoreFoundation). The binaries themselves contain no instruction
xref to the string.
B.2 Compiled sandbox profile (the string lives inside an embedded SBPL blob)
The string is in __TEXT,__const, surrounded by high-entropy bytes that
match a compiled sandbox profile. No code reaches the string. As with
B.1, these only declare the OID is permitted — not that they read it.
B.3 String present in __cstring but unreferenced (linker DCE leftover)
The string sits in the cstring section but no ADRP+ADD/LDR in any executable section computes its vmaddr, and no 8-byte little-endian encoding of the address (raw, image-relative, or 36-bit chained-fixup shape) appears in any other section either.
Correction (2026-09-28): B.3 is per build, and
bluetoothdleft it. This inventory is 26.1 (23B85). On 26.4 (23E246)usr/sbin/bluetoothd(UUID58F4BA7F-2E62-3E04-8ABB-03D06FD7A591) reads the sysctl for real: thesysctlbyname("kern.hv_vmm_present", …)is at0x1004ac7b4, inside adispatch_onceat0x1004ac798whose flag is0x100b51bc0, so the answer is cached for the life of the process. Its Bluetooth chip-selection singleton (0x10042fa40) uses that flag to choose the virtual transport — the 6000 path.With the OID renamed the lookup returns ENOENT, 0 is cached, and the singleton falls through to the
MGIsDeviceOneOfTypetable instead. Nothing in the table matches a virtual iPhone, the transport singleton at0x100b50bd0stays NULL, andbluetoothdfaults onldr w23, [x0, #0x31c]at+0x402864(EXC_BAD_ACCESS at0x31c). It crash-loops until launchd throttlescom.apple.bluetoothd.That is why the guest never draws:
locationd'sCLSeparationAlertsServiceSilomakes a synchronous call tocom.apple.server.bluetooth.general.xpc, which is now throttled, so it blocks; thecom.apple.locationd.migratordatamigrator plugin then hangs for 65+ minutes (stackshots under the guest's CrashReporter asstacks+com.apple.datamigrator-*.ips); SpringBoard waits for migration to finish. A black screen with no panic and no log line naming the cause.Consequence for the shipping design: both halves of the concealment — the kernel OID rename and the shared-cache mangle — are off in the
standardpreset as of this date and are opt-in only (--preset extended, or the two checkmarks together). A freshly restored 26.4 VM does not survive them. The pair iskernelcache_exp.hv_vmm+hv_vmm_dsc; seeFirmwarePatchSetCatalog.hypervisorConcealmentPatches.What this does not say: nothing here re-verifies B.3 for the other eight binaries on 23E246, and nothing here says the 23B85 result was wrong for 23B85. Treat every "unreferenced" line as a claim about the build it was measured on.
B.4 Adjacent‑string anchor (REFERENCED but not a sysctl call)
The ADRP+ADD here computes x8 = "kern.hv_vmm_present", then immediately
x20 = x8 - 0x20, i.e. it is using our string as a fixed offset to reach
a different sysctl name 32 bytes earlier in the cstring section. The
sysctlbyname call passes x20, not our string. No need to patch.
What each CANONICAL caller is reasonable to expect to do
I am being literal here: I report what the framework path implies and what the disassembly shows. Beyond what's listed I do not have direct evidence of behaviour and would need runtime tracing to be sure.
Why the kernel-side patch breaks display while a user-side patch does not
The kernel-side patch (returning 0 from sysctl_vmm_present /
IODTGetDefault("vmm-present")) flips the answer for every consumer
in the boot pipeline, including kernel-internal users of vmm-present
(IOKit, IOSurface, display drivers, AGX). The display path consumes the
flag for its own paravirt routing — that's the symptom you observed.
A user-mode patch only changes what landed in the 35 dylib/exe call sites above. None of them are on the display bring-up path; they all run post-launchd, after the framebuffer/IOSurface plumbing is already up. So the display works as VM, while the higher-level "this is a VM" hints are suppressed wherever you choose.
Recommended patch surface
Given the vphone-cli design intent ("VM-aware where it has to be, device-like where it can be"), the smallest set that buys the most is:
libMobileGestalt.dylib— single biggest fan-in. Many other processes ask MG instead of callingsysctlbynamedirectly, so this one patch propagates.CorePrescription,WatchdogServiceManagement,EmailFoundation,PhotoFoundation,FindMyBase,AirPlaySupport,TrialServer,CoreCDP,VisionKitCore— the consumer apps/services. Patching them gives the device-like surface for sync, mail, photos, find-my, etc.AAAFoundation,AuthKit,IDSFoundation,DeviceIdentity,DeviceCheckInternal,MobileActivation,MobileActivationMigrator,ApplePushService— identity / push / activation. Patching here normalises the device's posture against Apple's anti-abuse signals.StoreKit/storekitd,AppStoreDaemon/appstored,AppStoreUtilities,StoreKitUISceneService— App Store + IAP. Patch as a group.- DO NOT patch (most likely):
CoreML,CoreRE,Espresso,AppleNeuralEngine,RenderBox,WebGPU,caulk,CoreVideo,IOSurfaceAccelerator,HostInferenceProviderService. These are the compute/accel libraries — pushing them onto the silicon path on a VM is the most likely way to cause new failures (no real ANE / no real AGX / no real H.265 hardware encoder available to take the call). - Ignore the false positives in section B entirely — they don't read the sysctl.
The exact byte-level patch shape that works for every CANONICAL site is
identical to what you already have in the kernel: turn the 1-bit boolean
that gets cached after the call into a constant 0. The ARM64 idiom
cset wN, ne (after cmp wM, #0) → mov wN, #0 (3 byte-pattern
substitutions: 0x9f1f00b1 zero immediate move, etc.) is a one-instruction
patch per consumer.
If you'd rather skip the call entirely, replace the bl <sysctlbyname>
with mov w0, #0 (success) and zero the result on the stack — also
one instruction, but now w0=0 is read as the int result so the
post-call boolean naturally becomes 0.
Files
outputs/hv_vmm_present_xref.json— full per-binary xref dump (string addresses, xref addresses, classification, surrounding disassembly).Research/hv_vmm_present_xref.json— same dump committed in-tree.
Patcher implementation (Dev + JB only)
Implemented as part of the existing cfw_install_dev.sh /
cfw_install_jb.sh flows; the regular cfw_install.sh is left
untouched (per design — the regular variant stays "as honest as
possible", and the device-likeness layer is opt-in via the dev or
jb variants).
Patch shape, every site — 1-byte cstring mangle:
The kernel returns ENOENT for the mangled name; the canonical
post-call error check (cbnz w0, skip / cmp w0,#0 ; b.ne skip)
takes the skip-cache path, so the cached "is_vmm" byte stays at its
initial 0 (stack pre-zeroed by stur wzr or BSS-zero for a global
oldp). Idempotent: a re-run can't find the unmangled cstring and
no-ops.
We don't touch executable code — only one byte of string data in
__TEXT,__cstring. The kernel call still happens (so sysctl
instrumentation still sees activity), it just fails the
name-to-MIB translation.
What gets patched
Source files
scripts/patchers/cfw_patch_hv_vmm.py— standalone cstring patcher (patch_hv_vmm(filepath)): finds occurrences of"kern.hv_vmm_present\0"in__cstring(and the objc method/class name pools, for safety) and rewrites byte 0 from'k'to'X'.scripts/patchers/cfw_dsc_chunks.py— chunked-DSC byte-level helper: vmaddr ↔ chunk-fileoff mapping, executable-mapping cstring scan, byte read/write at a vmaddr, and Mach-O header walk-back to resolve a vmaddr to the containing dylib's install name.scripts/patchers/cfw_patch_hv_vmm_dsc.py— DSC-native orchestrator. No externalipswdependency. For every"kern.hv_vmm_present\0"occurrence found in any executable mapping, walks back to the containing dylib's Mach-O header, readsLC_ID_DYLIB, and — if the install name is in the explicitPATCH_INSTALL_NAMESwhitelist — rewrites the first byte of the cstring throughDSCChunks.write_at_vma. After all whitelisted byte-mangles, callscfw_dsc_codesign.reattest_modified_pagesto recompute the SHA-256 slot hash in the containing DSC chunk'sCS_CodeDirectoryfor every affected 16 KiB page (see below — this is required on hardware withcodeSigningMonitor == 2). The whitelist is one entry per line in the source file so an operator can comment out individual dylibs during bisection. (An earlier draft usedbl _sysctlbyname→movn w0,#0instruction patching and anipsw dyld extractpath; both were scrapped — the former because it modifies executable bytes which has the same per-page-hash risk as the cstring approach but a larger surface area, and the latter becauseipswreturns a Mach-O whose load-command file offsets reference the original DSC chunk's file layout, making it unparseable as a standalone Mach-O.)scripts/patchers/cfw_dsc_codesign.py— page-hash re-attestation. Without this pass, every byte-mangle invalidates the affected 16 KiB page's SHA-256 slot hash in the chunk-levelCS_CodeDirectory. On iPhone17,3 / iOS 26.1 (codeSigningMonitor == 2), TXM enforces per-page integrity at demand-page-in (XNU defers exec-page validation to TXM forcsm_associatedmappings —research/reference/xnu/osfmk/vm/vm_fault.c:2763-2780). Concrete repro before re-attestation:lsd-2026-05-11-035240.ipsfaults inside MobileActivation's __TEXT at offset0x13DC0, in the same 16 KiB page as the mangled cstring (offset0x106C6). The re-attestation rewrites only the 32-byte slot whose page contains a modified byte; the CDHash side-effect (CD blob hash changes when slots change) is accepted by TXM at DSC mount time (empirically verified — device boots with re-attested DSC).scripts/patchers/cfw.py— adds thepatch-hv-vmm-dsc <chunks_dir>subcommand. (Apatch-hv-vmm <binary>subcommand existed when this was written and no longer does: it andcfw_patch_hv_vmm_rootfs.pywere removed in the blacklist-flip redesign, which made the standalone rootfs mangle unnecessary. See item 8 inResearch/0_binary_patch_comparison.md.)scripts/patch_hv_vmm_userland.sh— thin wrapper used by the install scripts.scripts/cfw_install_dev.sh— DSC patch is applied while the SystemOS Cryptex DMG is still mounted on the host (inside step[1/7]); standalone binaries are patched over SSH in new step[6.5/7].scripts/cfw_install_jb.sh— pre-step before invoking the regularcfw_install.sh: decrypts the SysOS Cryptex into the same cache filecfw_install.shwould, mounts it, applies the DSC patch, unmounts. The unmodifiedcfw_install.shthen sees the cached (already-patched) DMG. Standalone binaries are patched in new step[EXP-JB-3.5]after the base install completes (EXP variant only).
Dylibs that must keep seeing VM=1 (graphics + accel passthrough)
Empirically validated: lying to these dylibs about VM presence breaks
the boot graphics path or the compute/accel fast paths. They MUST
read 1 (or the original truthful value) at runtime to function. Under
the new blacklist-flip design they are therefore NOT in
DONT_PATCH_INSTALL_NAMES — which means the byte-5 mangle IS applied
to their cstrings, they query the renamed kernel sysctl
kern.Xv_vmm_present, and the kernel returns 1 to them just like a
stock device would.
Under the new design these libs are not enumerated in code — they
implicitly fall into "not blacklisted → patched → sees VM=1" by
virtue of NOT being in DONT_PATCH_INSTALL_NAMES. To explicitly
exclude a different lib from the patch (force it to lie about VM
presence), add its install name to the blacklist. The build / install
flow is idempotent in re-attestation (cfw_patch_hv_vmm_dsc.py will
detect already-mangled cstrings on a re-run and re-sync slot hashes
for their pages), so toggling an entry in or out of the blacklist
doesn't require a fresh IPSW unpack — but doing so after a previous
install requires rm -f vm/.cfw_temp/CryptexSystemOS.dmg to force a
fresh DSC mount.
What is verified vs. inferred
Verified by direct disassembly + Mach-O byte inspection:
- The 35 CANONICAL sites use
sysctlbyname("kern.hv_vmm_present", &int, &len, NULL, 0). - The 4 entitlement-plist-only matches are pure plist substring hits.
- The 7 sandbox-profile-only matches are inside a compiled SBPL blob
(string lives in
__TEXT,__const, no executable xref, identical byte signature across the seven binaries). - The 9 DEAD-string matches really are unreferenced (no ADRP/LDR computes the address; no 8-byte little-endian or 36-bit chained-fixup shape contains it).
SoundAnalysisuses our string only as an offset anchor.
Inferred from framework / binary path and the canonical caching shape (not from runtime tracing): the mapping in the table above of "what each caller uses the result for". The disassembly proves that they read the OID and how; it does not prove what they branch on. Every "likely role" / "patching effect" claim above should be confirmed by running the patched dylib and observing behaviour.