iOS 27 Control Center volume slider: a sandbox-profile mismatch
On an iOS 27.0 guest (iOS 27 userland over the cloudOS 26.4 kernel), Control
Center's volume slider is stuck full and will not move. The hardware volume keys
work. Research/Guest/virtio_sound.md §8 recorded the symptom; this note traces
it, records that the first fix attempt (a mach-lookup exception entitlement on
SpringBoard) was deployed and did not work, explains why, and lays out the
remaining options with their blast radius.
Fixed (2026-10-04). Option 1 — a launchd interpose of
sandbox_check_by_audit_token that allows SpringBoard's one lookup of the
volume service — is implemented and boot-tested on mictest-iphone: the guest
boots (launchd survives the pid-1 interpose), the interpose logs the allow, and
the Control Center slider tracks a drag (volume 0.59 → 0 → 0.62, where before it
was stuck at 0.44 and inert). See "The fix" below.
Symptom (verified)
Opening Control Center, slider confirmed full and inert by screenshot:
Dragging the slider leaves audio.state active_volume at 0.4375 (unchanged).
The kernel line is rate-limited after the first hit.
What "Protobox" is, and how a mach-lookup is enforced (verified + inferred)
"Protobox" is the compiled-profile evaluation engine in the Sandbox kext on the
iOS 17+/26 generation; its deny log prefix is Protobox: where older kernels
logged Sandbox:. A mach-lookup is the XPC/bootstrap service look-up gate:
when a client connects to a Mach service, launchd (via libxpc, in the shared
cache) asks the Sandbox kext whether the client may look up that global
name — sandbox_check_by_audit_token(client_token, "mach-lookup", SANDBOX_FILTER_GLOBAL_NAME, name). The kernel's Protobox evaluates the client's
compiled profile and returns allow/deny, logging the deny with the client's
name/pid (hence SpringBoard(36), not launchd). It is not a mac_policy_ops
MACF hook, so the project's kernel-boot-sandbox_ext ops-table retarget cannot
reach it. (Verified: the call site being in launchd/libxpc is standard iOS
internals; the in-kernel Protobox eval + its log is confirmed by the kernel-side
log line. The exact userspace symbol was not disassembled here.)
Where SpringBoard's profile comes from (verified)
The booting kernel is the cloudOS 26.4 research kernelcache
(…/FirmwareOriginals/…/kernelcache.research.vphone600,
Darwin Kernel Version 25.4.0 … xnu-12377.100.591.502.1~2/RELEASE_ARM64_VRESEARCH1),
and it contains the Sandbox kext (com.apple.security.sandbox).
iOS 27 ships no userland platform sandbox-profile collection:
/System/Library/Sandbox/Profiles on the guest holds only
com.apple.doorsd.sb. The platform profiles for system processes — SpringBoard
among them (com.apple.springboard and the SpringBoard.app/SpringBoard path
are strings in the kernel's collection) — are baked into the Sandbox kext's
builtin collection in the cloudOS 26.4 kernelcache. That is the profile the
Protobox evaluator applies to SpringBoard.
Why the lookup is denied (verified)
The server side is fine: /System/Library/LaunchDaemons/com.apple.audiomxd.plist
(iOS 27) lists com.apple.mediaexperience.avvolumeclient.xpc in MachServices.
The 26.4 profile predates the service. strings on the decompressed
kernelcache:
So no profile in the 26.4 collection names the service, and no rule is gated on
the controlcentervolumeclient.allow entitlement SpringBoard holds.
The exception-entitlement fix was deployed and FAILED (verified)
The first attempt (commit f0d2a04, reverted on this branch) merged
com.apple.mediaexperience.avvolumeclient.xpc into SpringBoard's own
com.apple.security.exception.mach-lookup.global-name entitlement array and
re-signed — the mechanism Apple provides and the project uses for Campo (row
14). The coordinator deployed it on mictest-iphone (bundle 2.4.0-local.e0d48739,
via --update-environment). Result, verified by pulling the live binaries:
- The entitlement is present, in the authoritative form. The re-signed
SpringBoard (121904 B, CDHash
aaa80bc3…, ad-hoc) has a well-formed DER entitlements blob whosecom.apple.security.exception.mach-lookup.global-namearray is[com.apple.mobileasset.autoasset, com.apple.usernotifications.subscriber-service.launching, com.apple.usernotifications.subscriber-service.non-launching, com.apple.mediaexperience.avvolumeclient.xpc]— our name is there (DER:…global-name0\x82\x0b5\x0c\x1f…avvolumeclient.xpc). - The denial is unchanged in that same boot, slider still dead.
So the premise "SpringBoard already carries the exception array, so its profile honours it" is false for SpringBoard's profile.
It is the profile, not the signature (verified)
Two hypotheses for the failure; the second is ruled out:
-
DER vs XML (ruled out).
VPhoneSigneremits both forms (signature.entitlements = (combined.xml(), combined.der)), butcodesign -d --entitlements :-prints nothing and warns "binary contains an invalid entitlements blob. The OS will ignore these entitlements" — the XML blob it writes is malformed. The DER blob is valid and carries the name. This is not the cause, because Campo is re-signed by the identical path and shows the identical signature shape (invalid XML blob, valid DER) yet its mach-lookup exception entitlements are honoured — Campo (the wallpaper renderer) renders, which it cannot do if its backboard/frontboard exception lookups were denied (row 14). Modern AMFI/sandbox read DER; the malformed XML blob is harmless here. (It is still a latentVPhoneSignerbug — see below.) -
Profile (the cause). Campo runs under the
temporary-sandboxprofile (its declaredcom.apple.private.sandbox.profile:embedded), which honours the mach-lookup exception entitlement. SpringBoard runs under its platform profile, which does not apply exception-derived mach-lookup rules (platform profiles bake their allows in; exception entitlements are a third-party-app escape hatch). The Campo precedent is therefore a different process with a different profile, and does not transfer to SpringBoard.Supporting detail: of SpringBoard's three Apple-provided exception names,
com.apple.mobileasset.autoassetis in the kernel collection (allowable by literal name), whilecom.apple.usernotifications.subscriber-service.launchingis absent from it — i.e. Apple listed names in the array that the base profile does not allow, expecting the exception to cover them on a matching kernel. On this 26.4 kernel that coverage is what is missing for SpringBoard.Inferred, not bytecode-proven: the exact rule (or its absence) inside SpringBoard's compiled profile was not read out — the kernelcache has no symbols and no profile decompiler was available for the Protobox format. The behavioural evidence (identical signing, opposite result, known platform-vs-temporary-sandbox distinction) is strong and consistent.
Conclusion: the exception-entitlement route is dead for SpringBoard. The patch was removed from this branch.
Separate bug found: VPhoneSigner writes an invalid XML entitlements blob
Every binary re-signed by VPhoneSigner (SpringBoard and Campo both observed)
has a malformed legacy XML entitlements slot — codesign reports it invalid and
says the OS ignores it. Harmless for AMFI and the sandbox (DER is authoritative),
but any consumer that still reads the XML/CFDictionary entitlements
(CSSLOT_ENTITLEMENTS) gets nothing. Worth fixing in VPhoneSign independently
of this issue. Not addressed here.
Fix options (ranked), with blast radius
The profile is fixed in the kernelcache and cannot be recompiled; the exception entitlement does not help. Every remaining route touches a boot-critical surface and needs live validation on a VM the owner controls.
-
launchd-side sandbox-check short-circuit (recommended, narrowest). The gate is a userspace call in launchd/libxpc (
sandbox_check_by_audit_token, operation"mach-lookup"). The project already injectslaunchdhook-vphone.dylibinto launchd (pid 1) and interposesposix_spawn/memorystatus_controlthere via__DATA,__interpose; dyld interposition reaches the shared cache's own callers (the MISFix row documents exactly this). Add an interpose ofsandbox_check_by_audit_tokenthat returns 0 (allow) only whenoperation == "mach-lookup"and the global-name argument== "com.apple.mediaexperience.avvolumeclient.xpc", and otherwise forwards unchanged.- Opens: any client (not just SpringBoard) may look up that one volume service. Low impact — it is a volume controller.
- Who it affects: only callers that look up that exact name (SpringBoard's Control Center volume client; possibly mediaremoted / other volume UI).
- Risk / why not shipped blind: it is a pid-1 interpose of a variadic
function. Matching the real ABI (the
audit_token_tby-value struct, the filter-type width, the single string variadic) and forwarding non-target calls correctly must be confirmed against the live signature; a mistake crash-loops launchd → unbootable guest. Needs the privatesandbox.hsignature and a boot test. This is why it is handed over rather than committed.
-
Kernel patch: allow the
mach-lookupoperation in Protobox (broad fallback). A semantic kernel patch making the Protobox mach-lookup evaluation always allow.- Opens: every sandboxed guest process may look up every registered Mach service — it removes the mach-lookup sandbox boundary for the whole guest.
- Who it affects: all processes (apps included). Consistent with this
guest's already-dismantled sandbox posture (30+ hooks neutered by
kernel-boot-sandbox_ext, container upcall bypassed, AMFI off), but it is the broad "disable the sandbox" change and must be stated as such. - Risk: needs the Protobox decision anchor located in a sym+ decompiler- free kext, and a boot test; broad.
-
Service rename to an allowed name (rejected). Make audiomxd advertise the avvolumeclient endpoint under a name SpringBoard's profile already allows and interpose SpringBoard's lookup string. Rejected: needs an allowed-but-unused name for SpringBoard (unknown without the profile), is two-sided (audiomxd's plist + its dispatch + SpringBoard's lookup), and the obvious candidate
com.apple.coremedia.volumecontroller.xpcis already in use and speaks a different protocol. -
Compiled-profile binary edit in the kernelcache (rejected). Insert an allow rule for the name into SpringBoard's compiled profile. Rejected: editing Protobox bytecode (string table + filter graph + offsets) with no (de)serializer is extremely fragile and not a semantic anchor; a rejected profile breaks boot or the whole guest sandbox.
The sandbox_check_by_audit_token ABI, confirmed (2026-10-04)
Option 1 needs the live signature before any pid-1 interpose can be written.
Read from libsystem_sandbox.dylib, extracted from the guest's shared cache
(26.6; this is a stable libsystem API and does not change into 27). The
exported _sandbox_check_by_audit_token (at 0x2a4153c50) disassembles to:
So the ABI is:
- x0 — pointer to the
audit_token_t(the source signature passes it by value; a 32-byte composite is >16 bytes, so AAPCS64 replaces it with a pointer to a caller copy). Confirmed: the body reads[x0+0x14]/[x0+0x1c]. - x1 —
const char *operation. - w2 — the
sandbox_filter_typeenum. - variadic args — on the stack, starting at the incoming sp
(
x2 = x29+0x10 = sp_on_entryis the va_list base). Apple's arm64 ABI puts all variadic arguments on the stack, so for amach-lookup/SANDBOX_FILTER_GLOBAL_NAMEcheck the one service-name string is the first stack slot.
The source signature is therefore
int sandbox_check_by_audit_token(audit_token_t, const char *operation, enum sandbox_filter_type, ...).
Why forwarding is the hazard, and the safe shape
The number of variadic args varies by operation, so a C interpose cannot
forward the tail with real(token, op, filter) — that drops every variadic
argument, and non-target calls (every other sandbox check launchd makes)
would be evaluated with a garbage name. The forward has to preserve the
incoming stack and x0–x2 untouched.
The safe shape is an assembly tail-call trampoline, not a C function: on
entry it compares operation against mach-lookup and the first stack arg
against the one service name; on a match it returns 0; otherwise it restores
sp and x0–x2 to their entry values and bs to the real function. Because the
incoming sp is not disturbed, the real function's va_list still points at the
original stack args and the tail forwards perfectly. The compares must run on
a frame of the trampoline's own (saving x0–x2/lr across the strcmp calls),
torn down exactly before the branch.
This is still pid-1 code: a fault here crash-loops launchd and the guest does not boot. It needs the trampoline written against this ABI and a boot test on a throwaway 27.0 VM before it goes anywhere near a VM that matters. It is the owner's call whether to take that surface on, and the permission system gates the write.
Validation status
- Verified: the kernel build and its Sandbox kext; the service/entitlement absent from the collection vs. the sibling and old-volume services present; iOS 27 shipping no userland profile collection; audiomxd registering the service; the live denial and dead slider; the exception-entitlement fix deployed with the name confirmed in SpringBoard's DER and the denial unchanged; Campo re-signed the identical way with its exceptions honoured.
- Inferred: that SpringBoard's platform profile does not apply exception-derived mach-lookup rules (behavioural, not bytecode-proven).
- Confirmed (2026-10-04): the enforcement point for option 1 is launchd
itself. The guest's
/sbin/launchd(iOS 27) imports_sandbox_check_by_audit_tokenand carries themach-lookupstring, so the service-lookup check is made in launchd's own binary — not in libxpc (which imports neither) and not only in the kernel. (stringson a shared-cache dylib extracted withipsw dyld extractdoes not see the sandbox operation-name table — control names likefile-read-dataare absent too — so the earlier libxpc check was inconclusive, not negative.) Thesandbox_check_by_audit_tokenABI is recorded above. - Implemented (2026-10-04), pending boot test: option 1 is written in
VPhoneGuestComponents/LaunchHook/launchdhook-vphone.cas a C interpose ofsandbox_check_by_audit_token. The call site was read first: all 21 calls in the guest's/sbin/launchdpass zero or one variadic argument (the name, at the incoming sp), so a C variadic interpose that reads one argument and forwards it reconstructs every call faithfully — the hand-written tail-call trampoline is not needed. The interpose returns allow (0) only foroperation == "mach-lookup", filterGLOBAL_NAME/LOCAL_NAME(2/3, which guarantee a valid name string), and name== avvolumeclient.xpc; every other check forwards to the real function (dyld does not interpose the defining image's own call, as the existingposix_spawn/memorystatus_controlinterposes in the same file rely on). It logs each allow to/var/mobile/Library/Caches/vphone-launchdhook-sandbox.log. Built and the__interposesection and thesandbox_check_by_audit_tokenimport were verified. Boot-tested onmictest-iphone: the guest boots, the allow is logged, and the slider tracks a drag (0.59 → 0 → 0.62). The code and its commit message are the record of how it works. - Not done: any working fix — options 1/2 need live validation on a VM the owner controls, on surfaces (pid-1 interpose / kernel sandbox) that brick boot if wrong.
Recommended next step
Implement option 1 in launchdhook-vphone.c, confirming the live
sandbox_check_by_audit_token signature and the operation/filter constants
first, then boot-test on a throwaway 27.0 VM: open Control Center, confirm no
Protobox … deny(1) mach-lookup …avvolumeclient.xpc, and that the slider tracks
and active_volume changes — and that launchd/SpringBoard stay healthy.