55d9d9007f54ee61fa38accb59cd88a8d4075847 Use-after-free in MediaElementAudioSourceNode::provideInput when iframe is detached
Triage note: Audio render thread can be mid-render in provideInput when the parent's HTMLMediaElement::clearMediaPlayer() tears down the player after the source's iframe is removed; fix adds thread-safety handling (WTF_IGNORES_THREAD_SAFETY_ANALYSIS) to clearMediaPlayer, closing a web-reachable render-thread UAF/race.
Contents
The bug at a glance
The bug is a cross-thread use-after-free reachable entirely from web content: any page that creates a MediaElementAudioSourceNode over an <audio> element inside an iframe and then removes that iframe drives HTMLMediaElement::stop() into clearMediaPlayer() while the real-time audio render thread may still be inside provideInput(). Because MediaPlayer is DestructionThread::Main, ~MediaPlayer runs synchronously on the main thread and can free the RemoteAudioSourceProvider out from under the render thread’s live raw pointer, giving an attacker a race-conditioned dangling-pointer read/call with heap-grooming potential. The window is small and non-deterministic, which is why this rates High/8.1 rather than Critical.
WebAudio’s MediaElementAudioSourceNode straddles two threads: the render thread pulls audio through a raw AudioSourceProvider* while the main thread owns the MediaPlayer that provides it. Every teardown path in HTMLMediaElement that touches m_player was taught to hold the audio node’s processLock first — createMediaPlayer() and mediaPlayerWill/DidInitializeMediaEngine() all do — except clearMediaPlayer(), which quietly reset m_player with no lock. Detach the source element’s iframe and the ActiveDOMObject stop path fires clearMediaPlayer() mid-render, synchronously destroying the RemoteAudioSourceProvider the render thread is still calling into. The fix is a three-line lock acquisition that finally makes clearMediaPlayer() honor the same contract as its siblings.
Root cause
MediaElementAudioSourceNode::process() runs on the WebAudio render thread. It obtains the current AudioSourceProvider by calling audioSourceProvider() on the HTMLMediaElement, and does so while holding the node’s own processLock(). Critically, audioSourceProvider() returns a raw AudioSourceProvider* and drops the local RefPtr<MediaPlayer> it briefly held; the render thread then calls provideInput() on that bare pointer, which is owned by the MediaPlayer (as its RemoteAudioSourceProvider). Nothing keeps the MediaPlayer alive for the duration of the render-thread call except the main thread’s m_player reference.
On the main thread, HTMLMediaElement::clearMediaPlayer() executed if (RefPtr player = m_player) { player->invalidate(); m_player = nullptr; } without acquiring the audio node’s processLock. MediaPlayer is declared DestructionThread::Main, so when the local RefPtr and m_player are the last references, ~MediaPlayer runs synchronously right there on the main thread and tears down the RemoteAudioSourceProvider. If the render thread is simultaneously inside provideInput(), it is now operating on freed memory: a classic cross-thread use-after-free.
The path is web-reachable without any special privilege. createMediaElementSource(audio) wires an <audio> element living in a child iframe into a MediaElementAudioSourceNode in the parent context. Removing the iframe (frame.remove()) fires the media element’s ActiveDOMObject stop(), and both HTMLMediaElement::stop() and userCancelledLoad(ShouldDestroyMediaPlayer) call clearMediaPlayer(). Because the audio graph is still connected in the parent and rendering, the render thread is very likely inside process()/provideInput() at exactly that moment.
The fix wraps the player teardown in the audio node’s processLock. Inside the if (RefPtr player = m_player) block, under ENABLE(WEB_AUDIO), it takes a RefPtr to m_audioSourceNode and, if present, emplaces a Locker<Lock> on audioSourceNode->processLock() before calling player->invalidate() and clearing m_player. process() acquires that same lock with tryLock(), so there is no deadlock risk: if the main thread holds it, the render thread simply fails the tryLock and zeros its output for one quantum while teardown completes. The function is annotated WTF_IGNORES_THREAD_SAFETY_ANALYSIS because the conditional/optional locking pattern cannot be proven correct by Clang’s static thread-safety analysis.
Key code
clearMediaPlayer() now takes the audio node’s processLock before destroying the player
-void HTMLMediaElement::clearMediaPlayer()
+void HTMLMediaElement::clearMediaPlayer() WTF_IGNORES_THREAD_SAFETY_ANALYSIS
{
...
if (RefPtr player = m_player) {
+#if ENABLE(WEB_AUDIO)
+ RefPtr audioSourceNode = m_audioSourceNode.get();
+ std::optional<Locker<Lock>> audioSourceNodeLocker;
+ if (audioSourceNode)
+ audioSourceNodeLocker.emplace(audioSourceNode->processLock());
+#endif
player->invalidate();
m_player = nullptr;
}
Patch walkthrough
Source/WebCore/html/HTMLMediaElement.cpp— clearMediaPlayer() gains the WTF_IGNORES_THREAD_SAFETY_ANALYSIS annotation and, inside the existingif (RefPtr player = m_player)block, acquires the audio source node’s processLock before invalidating and nulling m_player. Guarded by ENABLE(WEB_AUDIO), it fetches a RefPtr to m_audioSourceNode and conditionally emplaces a std::optional<Locker<Lock>> on audioSourceNode->processLock(). This ensures the render thread cannot be mid-provideInput() while ~MediaPlayer destroys the RemoteAudioSourceProvider on the main thread, matching the locking contract already used by createMediaPlayer() and mediaPlayerWill/DidInitializeMediaEngine().LayoutTests/webaudio/mediaelementsource-clear-detached-frame.html— New regression test that creates an AudioContext, loads an iframe whose <audio> element is wired into the parent via createMediaElementSource, connects it to the destination, starts playback, waits 200ms so the render thread is actively pulling audio, then removes the iframe to trigger clearMediaPlayer() mid-render. A follow-up timeout reports PASS if the process did not crash, exercising the exact cross-thread teardown window.LayoutTests/webaudio/resources/mediaelementsource-clear-detached-frame-iframe.html— The child-frame resource: a minimal document containing a looping <audio src=“media/24bit-22khz.wav”>. Looping guarantees the element keeps feeding the render thread until the frame is detached.LayoutTests/webaudio/mediaelementsource-clear-detached-frame-expected.txt— Expected-results file establishing the pass criterion: a single PASS line confirming no crash after detaching the iframe whose audio element is connected to a MediaElementAudioSourceNode.
Background
MediaElementAudioSourceNode — A WebAudio node that taps an HTMLMediaElement’s decoded audio into an AudioContext graph. Its process() runs on the real-time render thread and pulls samples through the media element’s AudioSourceProvider, so it must coordinate with the main thread that owns the underlying MediaPlayer.
processLock() — A WTF Lock on the audio node used to serialize the render thread’s process() against main-thread mutations of the media pipeline. process() takes it with tryLock() (non-blocking) so the audio thread never stalls; main-thread mutators take it with a blocking Locker.
DestructionThread::Main — MediaPlayer declares that its destructor must run on the main thread. Because clearMediaPlayer() releases the last reference on the main thread, ~MediaPlayer — and thus destruction of the RemoteAudioSourceProvider — executes synchronously and inline, not deferred, which is what makes the free race against a live render-thread pointer.
WTF_IGNORES_THREAD_SAFETY_ANALYSIS — A WebKit attribute that suppresses Clang’s compile-time thread-safety (Capability) analysis for a function. It is needed here because the lock is acquired conditionally into a std::optional<Locker>, a pattern the static analyzer cannot verify.
Vulnerability window
- Setup — Page creates an AudioContext and an iframe whose document holds a looping <audio>. createMediaElementSource(audio) builds a MediaElementAudioSourceNode in the parent and connects it to context.destination.
- Steady state — audio.play() starts playback; the render thread repeatedly runs MediaElementAudioSourceNode::process(), holding processLock and calling provideInput() on the raw AudioSourceProvider* backed by the MediaPlayer’s RemoteAudioSourceProvider.
- Trigger — frame.remove() detaches the iframe. The media element’s ActiveDOMObject stop() (or userCancelledLoad) runs on the main thread and calls HTMLMediaElement::clearMediaPlayer().
- Race / free — clearMediaPlayer() drops the last MediaPlayer reference; ~MediaPlayer runs synchronously on the main thread and destroys the RemoteAudioSourceProvider while the render thread may still be inside provideInput().
- Use-after-free — The render thread dereferences/invokes the freed provider — crash, or an exploitable stale read/vtable call if the memory was reclaimed and groomed.
- Fix — clearMediaPlayer() acquires audioSourceNode->processLock() before invalidate()/m_player=nullptr, so the render thread either finishes its quantum first or tryLock-fails and outputs silence for one quantum; no dangling pointer, no deadlock.
Proof of concept
This is the shipped regression test, reduced. It reliably opens the race window: playback is started, the code waits 200ms so the real-time render thread is actively inside process()/provideInput(), then removes the iframe to force clearMediaPlayer() on the main thread. On a vulnerable build the render thread touches the just-freed RemoteAudioSourceProvider. It is a crash-repro, not a weaponized primitive — turning the race into a controlled UAF requires winning the timing and grooming the freed allocation, which the PoC does not attempt.
<script>
const context = new AudioContext({ sampleRate: 44100 });
const frame = document.createElement("iframe");
frame.src = "resources/mediaelementsource-clear-detached-frame-iframe.html";
frame.onload = async () => {
const audio = frame.contentDocument.querySelector("audio");
const source = context.createMediaElementSource(audio);
source.connect(context.destination);
audio.play().catch(() => {});
await new Promise(r => setTimeout(r, 200)); // let render thread pull audio
frame.remove(); // fires clearMediaPlayer() mid-render
};
document.body.appendChild(frame);
</script>
<!-- iframe body: <audio src="media/24bit-22khz.wav" loop></audio> -->
Exploitation
- Open the window — Keep a MediaElementAudioSourceNode connected and rendering from an <audio> in a child frame so the render thread is continuously in provideInput(), then detach the frame to fire clearMediaPlayer() on the main thread — maximizing overlap.
- Win the race — Repeatedly build and tear down the frame/source across many iterations and audio quanta; the free must land while the render thread holds the raw provider pointer. Timing is probabilistic and hardware-dependent.
- Groom the free — To move from crash to control, the attacker must reclaim the freed RemoteAudioSourceProvider allocation with attacker-shaped data before the render thread’s dereference/virtual call, which is difficult across the tiny cross-thread window and constrained by the object’s size class.
Detection & hunting
For defenders and SOC / detection engineers:
- AddressSanitizer / GuardMalloc crashes —
- Crash correlation with iframe detach —
- ThreadSanitizer data race —
Audit directions
- Other m_player mutators —
- Raw provider handoffs —
- DestructionThread::Main objects on render paths —