A sandboxed iOS app with no added entitlements shows the microphone prompt. The user taps Don't Allow. The app records anyway, while its permission still reads denied and no orange indicator appears. Once started, the capture survives backgrounding and a locked device.
The bypass is one Audio call carrying the audio daemon's own audit token, which the kernel hands the app on request. Told I am audiomxd, audiomxd decides the client is itself and sends it real microphone samples.
Reproduced on iPadOS 27.0 beta 24A5390f and iOS 26.6 23G71. Fixed in iOS and iPadOS 26.6.1, macOS Tahoe 26.6.2 and the later releases below. Apple said another researcher reported it first. The 17 August advisories initially credited Nik Tsytsarkin of Meta Red Team X, and Apple added my name to them on 14 September.
Where the recording decision is made
An app's RemoteIO unit is a client-side proxy. The real client object lives in the audio daemon, which Apple's log strings call audiomxd. Control travels over MIG, and captured samples reach the app through shared memory.
The consent decision is one byte in that object:
AQIONode, the input node atClient client + 56, stores the client's 32-byte audit token at+0x28and acanbyte derived from it atRecord +0x14, which areclient + 96andclient + 76.Remotezero-fills the client's shared region whenIOClient:: IONode Client:: Enqueue Input canis not 1. A denied app gets silent samples, not none.Record
This byte was enough for real samples on both builds I tested. I did not audit other consent checks.
Who does audiomxd think you are?
When an app sends a Mach message, the kernel writes the sender's audit_token_t into the message trailer, where the sender cannot change it.1 By convention val[5] is the PID and val[7] the pidversion, which exec and posix_spawn bump, so the pair names one process execution.2 The daemon reads them with audit_token_to_pid and audit_token_to_pidversion. iOS apps lack those accessors and read the words directly.
Property 2009, an identity the client is allowed to write
AURemote also accepts an undocumented property, ID 2009, whose data is an audit_token_t. The SDK documents output-unit properties 2000 to 2016 and skips 2008 and 2009.3 For 2009 the daemon unmarshals exactly 32 bytes and rejects a shorter payload.
Such a property has a legitimate use. A process configuring audio for another process must name whose consent applies. That only works if the caller is authorized to speak for someone else, and that check is missing.
The missing bind
_AUIOServer_Set uses the authenticated MIG sender token only to find the caller's own client, so one app cannot reconfigure another's unit. For 2009 it then copies the 32 caller-supplied bytes over that client's stored token and recomputes can.4
| |
The lookup, Remote, only checks the caller's PID against the one stored when the client was created. Set reads the token at client + 96, and property 2009 overwrites exactly those bytes.
+24 holds the caller's real identity and decides ownership. +96 decides recording, and the caller can overwrite it.
No entitlement gates the write. It only needs scope 1, kAudio, which the proof of concept passes.
The implicit-allow branch
AQIONode gets the daemon's own audit token from the kernel and compares its PID and pidversion with the stored token. On a mismatch it asks TCC about whichever process the stored token names, or denies if Launch Services is not ready. On a match it skips TCC, logs this line from AQIONode. and sets can.
| |
The log call is at 0x1bc36efe8, and can is stored at 0x1bc36ed00. The denial itself is untouched, which is why the permission still reads denied.
The kernel will tell you the daemon's identity
The trailer works both ways. Get audiomxd to send you a message and the kernel stamps it with audiomxd's token. Routine 88000 of the AURemote MIG subsystem, Get, replies to an empty request. Ask for the audit trailer when receiving the reply.
| |
The pidversion in val[7] is what a guessed PID cannot supply, and the trailer hands it over.
Assembling the capture
The rest is a standard input unit.5 The only unusual line is the property write, which sets can before the session is activated. How late it can move is untested.
| |
Neither proof of concept variant has an entitlements file, and the only privacy key is NSMicrophone, which makes the prompt appear. In my tests the capture had to start in the foreground, and the background variant also sets UIBackground. Both were installed from Xcode and never went through App Review.
The fix
In 26.6.1 the setter stops trusting the caller's bytes.6 Unless the sender token is the all-0x _g sentinel, it now requires this entitlement.
| |
Without it the write fails with kAudio and logs this line.
| |
The AudioQueue path to the same can flag got its own check for the same entitlement. None of this is in the 23G71 AudioToolbox, and the 13 July beta 5, 23G5065a, still has the unguarded code shown above.
On Ravel, the AudioToolbox diff from 26.6 to 26.6.1 adds four strings: the entitlement, a rejection line for each path and a new build stamp.7 An entitlement search there finds one holder, /usr/.
The missing check is now the one named earlier, authorization to speak for someone else.
Disclosure
- reportedReported with a proof of conceptThe report included an Xcode project that reproduces it.
- fixedFixedCVE-2026-65339, “An app may be able to leak sensitive user information”. The iOS and iPadOS 26.6.1 and macOS Tahoe 26.6.2 advisories credited Nik Tsytsarkin of Meta Red Team X.
- closedReport closedApple said the issue was fixed before my report, in the 13 July iOS 26.6 beta, and found no security issue in what I sent.
- appealedAsked for reconsiderationI sent the build comparison above. The entitlement check, its log string and the rejection path are in 26.6.1, but not in
23G71or the 13 July beta.23G71shipped on 27 July, two weeks after that beta, and the proof of concept works on it. - reopenedStatus changed“We're addressing the issue in additional releases.”
- collisionMarked as a duplicateApple said another researcher reported the issue first.
- fixedFixed in four more releasesListed in the macOS Sequoia 15.8, tvOS 27, visionOS 27 and watchOS 27 advisories. The same day, Apple added my name to the 17 August advisories.
- heldHold requestedApple asked me to hold disclosure. More changes were planned.
- extendedHold extendedUntil two weeks after Apple's next release.
- fixedFixed for older devicesListed in the iOS and iPadOS 18.7.11 advisory.
- publishedDisclosureTwo weeks after the 26 October release.
ipc_kmsg_init_trailer_and_signinosfmk/ofipc/ ipc_kmsg. c xnu-12377.121.6, which the send path calls with the sending task.task_fill_audit_tokeninosfmk/sets thekern/ task. c val[]order. ↩︎XNU's
osfmk/andmach/ message. h mach/in the iOS 27.2 SDK call the token opaque and warn that its layout may change.message. h exec_handle_sugidgives the process a new pidversion on everyexecandposix_spawn. The iOS 27.2 SDK ships nobsm/, so apps have neither accessor. ↩︎libbsm. h The output-unit property enums in
Audioof the iOS 27.2 SDK. ↩︎Toolbox. framework/ Headers/ Audio Unit Properties. h AudioToolbox from the shared cache in
iPhone16,2_26.6_23G71_Restore.. Addresses are from this image. ↩︎ipsw Audio,Component Find Next Audio,Component Instance New Audio,Unit Set Property Audio,Unit Initialize Audio,Output Unit Start kAudio,Unit Sub Type_Remote IO AVAudio,Session AVAudio. Property 2009 has none. ↩︎Application. record Permission From
iPhone16,2_26.6.1_23G83_Restore., whereipsw _AUIOServer_SetatProperty 0x1bc348650checks the entitlement throughHasatBoolean Entitlement(const audit_token_t &, CFString Ref) 0x1bc348cc8. The beta 5 image,iPhone16,2_26.6_23G5065a_Restore., still has the unguarded setter atipsw 0x1bc35f650. ↩︎Ravel indexes iPhone18,2 builds, so its addresses differ from the iPhone16,2 ones here. ↩︎