eaacbbfb95cc794ea1948f9015d2fabb5a0a861c Use-after-free of AudioSampleDataSource::m_ringBuffer — no lock between setOutputFormat and pullAvailableSamplesAsChunks
Triage note: The non-RC_REMOVE_SEC_ACCEL_3 fix in AudioSampleDataSource adds m_ringBufferLock (WTF_GUARDED_BY_LOCK) around m_ringBuffer/m_scratchBuffer/m_inputDescription/m_outputDescription, null-checks the ring buffer under lock in push/pull paths, swaps and frees the old buffers under lock in setOutputFormat with a generation counter, and uses DropLockForScope with a generation re-check so a buffer replaced during a consumeFilledBuffer callback is detected. This closes a data race leading to use-after-free on media-stream audio.
The bug at a glance
AudioSampleDataSource’s ring buffer and its input/output stream descriptions were read by the realtime audio thread (pushSamples/pullSamples and the pullAvailable* chunk paths) while the main thread replaced them in setOutputFormat, with no lock. The realtime thread could dereference m_ringBuffer while setOutputFormat std::exchange’d it away and freed the old one — a data race culminating in a use-after-free on media-stream/speech-recognition audio in WebCore. The diff proves the shared-state-without-lock defect and ships a full locking + generation-count remediation, so high severity for a cross-thread UAF is justified; exploitability of realtime-audio UAF into RCE is not demonstrated and is (inferred).
A lock-free hot path meeting a mutating setter: the realtime audio thread assumed m_ringBuffer and the stream descriptions were stable for the duration of a pull, but setOutputFormat on another thread could swap and free them mid-use, including re-entrantly during a consumeFilledBuffer callback.
Root cause
The invariant is that m_ringBuffer, m_scratchBuffer, m_inputDescription and m_outputDescription must not be observed or freed concurrently with the realtime thread using them. Pre-patch there was no lock: these were plain members touched by both the realtime audio thread and the main thread.
Pre-patch sequence: 1. the realtime audio thread enters pullSamples/pullAvailableSampleChunk and reads m_ringBuffer->channelCount() / m_outputDescription->bytesPerFrame() and begins fetching from m_ringBuffer; 2. concurrently the main thread calls setOutputFormat, which sets m_outputDescription and (in the fixed code path, previously unguarded) would replace m_ringBuffer/m_scratchBuffer; 3. with no lock, setOutputFormat frees the old InProcessCARingBuffer while the realtime thread still holds a raw pointer into it; 4. the realtime thread dereferences the freed ring buffer (UAF), or reads a half-updated description, producing memory corruption; additionally pullAvailableSamplesAsChunks invokes a consumeFilledBuffer callback mid-loop, during which the buffer could be replaced, so even a coarse lock held across the callback would either deadlock or miss the replacement.
The fix adds mutable Lock m_ringBufferLock and annotates m_ringBuffer, m_scratchBuffer, m_inputDescription, m_outputDescription (and the internal push/pull/setupConverter helpers) with WTF_GUARDED_BY_LOCK / WTF_REQUIRES_LOCK. Every push/pull entry point takes the lock and null-checks m_ringBuffer (and the relevant description) before use, returning early if absent. setOutputFormat takes the lock, std::exchange’s the old m_ringBuffer/m_scratchBuffer into locals, bumps m_ringBufferGenerationCount, and frees the old buffers only after dropping the lock (DisableMallocRestrictionsForCurrentThreadScope). For the re-entrant callback, pullAvailableSamplesAsChunks wraps consumeFilledBuffer in consumeFilledBufferWithoutHoldingLock(), which records the generation, drops the lock via DropLockForScope during the callback, then re-checks m_ringBuffer && m_ringBufferGenerationCount == generationCount and bails out if the buffer was replaced underneath it. inputDescription()/outputDescription() now return std::optional by value under the lock instead of raw pointers into the member, and callers (SpeechRecognitionCaptureSourceImpl, AudioMediaStreamTrackRendererCocoa, IncomingAudioMediaStreamTrackRendererUnit) are updated to the by-value API.
Key code
Lock-guarded swap with generation re-check across the unlocked callback
mutable Lock m_ringBufferLock;
std::unique_ptr<InProcessCARingBuffer> m_ringBuffer WTF_GUARDED_BY_LOCK(m_ringBufferLock);
uint64_t m_ringBufferGenerationCount WTF_GUARDED_BY_LOCK(m_ringBufferLock) { 0 };
// setOutputFormat (under lock): swap out, bump generation, free after unlock
previousRingBuffer = std::exchange(m_ringBuffer, { });
++m_ringBufferGenerationCount;
// pullAvailableSamplesAsChunks: detect replacement during the callback
auto consumeFilledBufferWithoutHoldingLock = [&] {
auto generationCount = m_ringBufferGenerationCount;
{ DropLockForScope unlocker { locker }; consumeFilledBuffer(); }
return m_ringBuffer && m_ringBufferGenerationCount == generationCount;
};
Fix approach
The patch introduces m_ringBufferLock guarding all shared audio state and converts the lock-free hot paths into lock-then-null-check paths that bail cleanly when the ring buffer is absent. setOutputFormat now atomically (under lock) swaps the ring/scratch buffers out and bumps a generation counter, deferring the actual free until after the lock is dropped so no realtime consumer can hold a pointer into a buffer being freed. The hard case — the consumeFilledBuffer callback that must run without the lock — is handled by snapshotting the generation count, dropping the lock only around the callback, and re-validating that the buffer still exists and the generation is unchanged afterward, aborting the loop if a replacement happened. The description accessors become value-returning under the lock, eliminating raw pointers into mutable members. After the fix, concurrent setOutputFormat and realtime pulls are serialized, and any replacement during a callback is detected rather than used.
Patch walkthrough
Source/WebCore/platform/audio/cocoa/AudioSampleDataSource.h— Adds mutable Lock m_ringBufferLock, WTF_GUARDED_BY_LOCK on m_ringBuffer/m_scratchBuffer/m_inputDescription/m_outputDescription plus a new m_ringBufferGenerationCount, WTF_REQUIRES_LOCK on the internal helpers, and changes inputDescription()/outputDescription() to return std::optional by value.Source/WebCore/platform/audio/cocoa/AudioSampleDataSource.mm— Takes the lock in setInputFormat/setOutputFormat/pushSamples/pullSamples/pullAvailableSampleChunk/pullAvailableSamplesAsChunks with null-checks; setOutputFormat swaps buffers under lock, bumps the generation, and frees after unlock; adds consumeFilledBufferWithoutHoldingLock with the DropLockForScope + generation re-check; implements the value-returning description accessors under lock.Source/WebCore/Modules/speech/SpeechRecognitionCaptureSourceImpl.cpp— Adapts to the by-value inputDescription() API, snapshotting currentInputDescription before comparing.Source/WebCore/platform/mediastream/cocoa/AudioMediaStreamTrackRendererCocoa.cpp— Adapts the pushSamples recreate-check to the by-value inputDescription() API.Source/WebCore/platform/mediastream/cocoa/IncomingAudioMediaStreamTrackRendererUnit.cpp— Adapts addSource to snapshot the by-value outputDescription() before use.
Background
AudioSampleDataSource — A ThreadSafeRefCounted WebCore object buffering media-stream/speech audio between a producer and the realtime audio render thread via an in-process CARingBuffer.
Realtime audio thread vs main thread — Audio is pulled on a high-priority realtime thread that must not allocate or block; configuration changes like setOutputFormat come from the main thread, creating a producer/consumer race on shared buffers.
InProcessCARingBuffer — The ring buffer holding PCM frames; replacing it (on format change) frees the old allocation, which is unsafe while the realtime thread still fetches from it.
WTF_GUARDED_BY_LOCK / WTF_REQUIRES_LOCK / DropLockForScope — WTF Clang thread-safety annotations and a scoped lock-release helper; DropLockForScope temporarily releases a held Locker for the duration of a callback that must not run under the lock.
Generation counter pattern — A monotonically increasing count bumped whenever the protected resource is replaced; code that drops the lock re-reads it afterward to detect that the resource changed while unlocked.
Vulnerability window
- Setup — A media-stream or speech-recognition audio pipeline creates an AudioSampleDataSource with an input/output format and ring buffer.
- Trigger — The main thread calls setOutputFormat (e.g. on a format/route change) while the realtime thread is mid-pull.
- Pre-patch race — setOutputFormat replaces/frees m_ringBuffer with no lock while the realtime thread dereferences the old buffer/description.
- Fault — The realtime thread uses a freed ring buffer or torn description — UAF/corruption, including during a consumeFilledBuffer callback.
- Post-patch — All accesses serialize on m_ringBufferLock; the swap frees only after unlock, and the generation re-check aborts if a replacement occurred during the callback.
Proof of concept
No regression test ships in the diff. Conceptual trigger (inferred): drive a getUserMedia/WebRTC or SpeechRecognition audio path so the realtime thread is actively pulling from an AudioSampleDataSource, then force an output-format change (e.g. changing sink/route or sample rate) from the main thread so setOutputFormat replaces the ring buffer concurrently; the race is timing-dependent and must be won while a pull is in flight. The web-reachable surface is media-stream/speech-recognition audio rendering (inferred); the diff proves the unsynchronized shared state and ships the locking fix.
Exploitation
- Reach — Web content with microphone/media-stream or SpeechRecognition access drives audio through AudioSampleDataSource in WebCore.
- Trigger — Induce concurrent setOutputFormat (format/route change) while the realtime thread pulls, racing the ring-buffer swap/free.
- Primitive — Use-after-free / data race on the InProcessCARingBuffer (and torn reads of stream descriptions) from the realtime audio thread.
- Escalation (inferred) — Turning a realtime-thread UAF into controlled corruption requires winning the narrow race repeatedly and reclaiming the freed ring-buffer allocation with attacker-influenced data; feasibility and reliability are not shown in the diff and the realtime/no-alloc constraints make grooming hard.
Detection & hunting
For defenders and detection engineers:
- Unsynchronized ring-buffer access —
- Use-after-free on format change —
- Callback-time replacement —
Remaining risk in the patch
- Realtime lock contention — Holding m_ringBufferLock on the realtime thread risks priority inversion/glitches if the main thread holds it during setOutputFormat allocation; the fix mitigates by freeing after unlock but still allocates/initializes buffers under the lock in setOutputFormat.
- Generation re-check scope — The DropLockForScope + generation re-check covers consumeFilledBuffer in pullAvailableSamplesAsChunks; any other path that releases the lock mid-operation or caches a raw m_ringBuffer pointer beyond a single locked region would need the same discipline.
- ASSERT-only input checks — pushSamples still uses ASSERT_UNUSED(*m_inputDescription == sampleDescription) after the lock/null-check; in release builds a mismatch is not enforced, so correctness of the description contract relies on callers.
Audit directions
- Other realtime-audio shared state —
- Raw-pointer description accessors —
- Lock-drop callbacks —