Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebAudio
DescriptionUse after free in WebAudio
ComponentWebAudio
Bug ClassUAF
Tracker503929476
Fix commit96ce1273b9c4 (chromium/src) +146/-43
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.cc
modified
TEST_F
third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
modified

Files Changed

  • third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.cc
  • third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.h
  • third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
From 96ce1273b9c4d4a13ad00045170c8c5eaf2e6d29 Mon Sep 17 00:00:00 2001
From: Hongchan Choi <hongchan@google.com>
Date: Mon, 27 Apr 2026 09:17:40 -0700
Subject: [PATCH] [WebAudio] Fix race condition in WebAudioMediaStreamSource disposal

Introduce a base::Lock in AudioConsumer to safely bridge the audio
thread and the WebAudioMediaStreamSource on the main thread.

This prevents a potential race condition during garbage collection
destruction. Using base::AutoTryLock prevents blocking the real-time
audio thread.

Adds comprehensive concurrent destruction tests inside the Blink unit
suite.

Bug: 503929476
Test: t/b/r/m/webaudio/media_stream_audio_destination_handler_test.cc
Change-Id: I80382b24bc7b41702225f55c2151b3fa6bc45042
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7779837
Commit-Queue: Hongchan Choi <hongchan@chromium.org>
Reviewed-by: Michael Wilson <mjwilson@chromium.org>
Reviewed-by: Guido Urdaneta <guidou@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1621143}
---

diff --git a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.cc b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.cc
index 103071a8..50452a4c 100644
--- a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.cc
+++ b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.cc
@@ -32,7 +32,7 @@
 MediaStreamAudioDestinationHandler::MediaStreamAudioDestinationHandler(
     AudioNode& node,
     uint32_t number_of_channels,
-    WebAudioDestinationConsumer* webaudio_consumer)
+    scoped_refptr<WebAudioDestinationConsumer> webaudio_consumer)
     : AudioHandler(NodeType::kNodeTypeMediaStreamAudioDestination,
                    node,
                    node.context()->sampleRate()),
@@ -53,10 +53,10 @@
 scoped_refptr<MediaStreamAudioDestinationHandler>
 MediaStreamAudioDestinationHandler::Create(
     AudioNode& node, uint32_t number_of_channels,
-    WebAudioDestinationConsumer* webaudio_consumer) {
+    scoped_refptr<WebAudioDestinationConsumer> webaudio_consumer) {
   return base::AdoptRef(
       new MediaStreamAudioDestinationHandler(
-          node, number_of_channels, webaudio_consumer));
+          node, number_of_channels, std::move(webaudio_consumer)));
 }
 
 MediaStreamAudioDestinationHandler::~MediaStreamAudioDestinationHandler() {
@@ -190,7 +190,7 @@
 }
 
 void MediaStreamAudioDestinationHandler::SetConsumer(
-    WebAudioDestinationConsumer* destination_consumer,
+    scoped_refptr<WebAudioDestinationConsumer> destination_consumer,
     int number_of_channels,
     float sample_rate) {
   if (!destination_consumer) {
@@ -198,7 +198,7 @@
   }
 
   base::AutoLock locker(consumer_lock_);
-  destination_consumer_ = destination_consumer;
+  destination_consumer_ = std::move(destination_consumer);
   destination_consumer_->SetFormat(number_of_channels, sample_rate);
 }
 
diff --git a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.h b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.h
index 6d16a3a..e6a6063 100644
--- a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.h
+++ b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.h
@@ -29,7 +29,7 @@
   static scoped_refptr<MediaStreamAudioDestinationHandler> Create(
       AudioNode&,
       uint32_t number_of_channels,
-      WebAudioDestinationConsumer*);
+      scoped_refptr<WebAudioDestinationConsumer>);
   MediaStreamAudioDestinationHandler(
       const MediaStreamAudioDestinationHandler&) = delete;
   MediaStreamAudioDestinationHandler& operator=(
@@ -45,7 +45,9 @@
   friend class MediaStreamAudioDestinationHandlerTest;
 
   MediaStreamAudioDestinationHandler(
-      AudioNode&, uint32_t number_of_channels, WebAudioDestinationConsumer*);
+      AudioNode&,
+      uint32_t number_of_channels,
+      scoped_refptr<WebAudioDestinationConsumer>);
 
   // AudioHandler
   void Process(uint32_t frames_to_process) override;
@@ -64,7 +66,7 @@
   // Sets the WebAudioDestinationConsumer that receives audio data from this
   // handler. The consumer is then responsible for providing this data to the
   // MediaStream infrastructure.
-  void SetConsumer(WebAudioDestinationConsumer*,
+  void SetConsumer(scoped_refptr<WebAudioDestinationConsumer>,
                    int number_of_channels,
                    float sample_rate);
 
@@ -76,8 +78,11 @@
   void SendLogMessage(const String& function_name, const String& message);
 
   base::Lock consumer_lock_;
-  // `destination_consumer_` is owned by the node's MediaStreamSource.
-  raw_ptr<WebAudioDestinationConsumer, DanglingUntriaged>
+  // `destination_consumer_` is the `AudioConsumer` proxy that acts as a
+  // thread-safe bridge between the AudioHandler on the real-time audio thread
+  // and the WebAudioMediaStreamSource on the main thread. It handles proper
+  // synchronization to prevent Use-After-Free issues during garbage collection.
+  scoped_refptr<WebAudioDestinationConsumer>
       destination_consumer_ GUARDED_BY(consumer_lock_);
   Vector<const float*> consumer_bus_wrapper_ GUARDED_BY(consumer_lock_);
 
diff --git a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
index c946912f..306ccff 100644
--- a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
+++ b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
@@ -7,6 +7,8 @@
 #include <memory>
 
 #include "base/synchronization/lock.h"
+#include "base/synchronization/waitable_event.h"
+#include "base/task/thread_pool.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "build/build_config.h"
 #include "media/base/audio_bus.h"
@@ -31,6 +33,7 @@
 #include "third_party/blink/renderer/modules/webaudio/testing/mock_web_audio_device.h"
 #include "third_party/blink/renderer/platform/loader/fetch/memory_cache.h"
 #include "third_party/blink/renderer/platform/mediastream/webaudio_destination_consumer.h"
+#include "third_party/blink/renderer/platform/mediastream/webaudio_media_stream_source.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/testing/testing_platform_support.h"
 #include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
@@ -76,14 +79,22 @@
     node_ = MediaStreamAudioDestinationNode::Create(
         *audio_context, 2, ASSERT_NO_EXCEPTION);
     bus_ = AudioBus::Create(2, 10);
+    consumer_ =
+        base::MakeRefCounted<StrictMock<MockWebAudioDestinationConsumer>>();
+  }
+
+  void TearDown() override {
+    // Clear the consumer to avoid leaking the mock object.
+    CallRemoveDestinationConsumer();
   }
 
   ~MediaStreamAudioDestinationHandlerTest() override = default;
 
-  void CallSetDestinationConsumer(WebAudioDestinationConsumer* consumer,
-                                  int num_channels,
-                                  float sample_rate) {
-    Handler().SetConsumer(consumer, num_channels, sample_rate);
+  void CallSetDestinationConsumer(
+      scoped_refptr<WebAudioDestinationConsumer> consumer,
+      int num_channels,
+      float sample_rate) {
+    Handler().SetConsumer(std::move(consumer), num_channels, sample_rate);
   }
 
   bool CallRemoveDestinationConsumer() {
@@ -100,7 +111,7 @@
 
  protected:
   Persistent<MediaStreamAudioDestinationNode> node_;
-  StrictMock<MockWebAudioDestinationConsumer> consumer_;
+  scoped_refptr<StrictMock<MockWebAudioDestinationConsumer>> consumer_;
   scoped_refptr<AudioBus> bus_;
   ScopedTestingPlatformSupport<AudioContextTestPlatform> platform_;
   test::TaskEnvironment task_environment_;
@@ -113,29 +124,29 @@
 
 TEST_F(MediaStreamAudioDestinationHandlerTest, SetDestinationConsumer) {
   // Expect SetFormat() to be called with these arguments.
-  EXPECT_CALL(consumer_, SetFormat(2, 44100));
-  CallSetDestinationConsumer(&consumer_, 2, 44100);
+  EXPECT_CALL(*consumer_, SetFormat(2, 44100));
+  CallSetDestinationConsumer(consumer_, 2, 44100);
 
-  EXPECT_CALL(consumer_, ConsumeAudio(_, 10));
+  EXPECT_CALL(*consumer_, ConsumeAudio(_, 10));
   CallConsumeAudio(bus_.get(), 10);
 }
 
 TEST_F(MediaStreamAudioDestinationHandlerTest, RemoveDestinationConsumer) {
-  EXPECT_CALL(consumer_, SetFormat(2, 44100));
-  CallSetDestinationConsumer(&consumer_, 2, 44100);
+  EXPECT_CALL(*consumer_, SetFormat(2, 44100));
+  CallSetDestinationConsumer(consumer_, 2, 44100);
 
   // The removal should be successful.
   EXPECT_TRUE(CallRemoveDestinationConsumer());
 
   // The consumer should not be called.
-  EXPECT_CALL(consumer_, ConsumeAudio(_, 10)).Times(0);
+  EXPECT_CALL(*consumer_, ConsumeAudio(_, 10)).Times(0);
   CallConsumeAudio(bus_.get(), 10);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
index c946912f..306ccff 100644
--- a/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
+++ b/third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler_test.cc
@@ -7,6 +7,8 @@
 #include <memory>
 
 #include "base/synchronization/lock.h"
+#include "base/synchronization/waitable_event.h"
+#include "base/task/thread_pool.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "build/build_config.h"
 #include "media/base/audio_bus.h"
@@ -31,6 +33,7 @@
 #include "third_party/blink/renderer/modules/webaudio/testing/mock_web_audio_device.h"
 #include "third_party/blink/renderer/platform/loader/fetch/memory_cache.h"
 #include "third_party/blink/renderer/platform/mediastream/webaudio_destination_consumer.h"
+#include "third_party/blink/renderer/platform/mediastream/webaudio_media_stream_source.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/testing/testing_platform_support.h"
 #include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
@@ -76,14 +79,22 @@
     node_ = MediaStreamAudioDestinationNode::Create(
         *audio_context, 2, ASSERT_NO_EXCEPTION);
     bus_ = AudioBus::Create(2, 10);
+    consumer_ =
+        base::MakeRefCounted<StrictMock<MockWebAudioDestinationConsumer>>();
+  }
+
+  void TearDown() override {
+    // Clear the consumer to avoid leaking the mock object.
+    CallRemoveDestinationConsumer();
   }
 
   ~MediaStreamAudioDestinationHandlerTest() override = default;
 
-  void CallSetDestinationConsumer(WebAudioDestinationConsumer* consumer,
-                                  int num_channels,
-                                  float sample_rate) {
-    Handler().SetConsumer(consumer, num_channels, sample_rate);
+  void CallSetDestinationConsumer(
+      scoped_refptr<WebAudioDestinationConsumer> consumer,
+      int num_channels,
+      float sample_rate) {
+    Handler().SetConsumer(std::move(consumer), num_channels, sample_rate);
   }
 
   bool CallRemoveDestinationConsumer() {
@@ -100,7 +111,7 @@
 
  protected:
   Persistent<MediaStreamAudioDestinationNode> node_;
-  StrictMock<MockWebAudioDestinationConsumer> consumer_;
+  scoped_refptr<StrictMock<MockWebAudioDestinationConsumer>> consumer_;
   scoped_refptr<AudioBus> bus_;
   ScopedTestingPlatformSupport<AudioContextTestPlatform> platform_;
   test::TaskEnvironment task_environment_;
@@ -113,29 +124,29 @@
 
 TEST_F(MediaStreamAudioDestinationHandlerTest, SetDestinationConsumer) {
   // Expect SetFormat() to be called with these arguments.
-  EXPECT_CALL(consumer_, SetFormat(2, 44100));
-  CallSetDestinationConsumer(&consumer_, 2, 44100);
+  EXPECT_CALL(*consumer_, SetFormat(2, 44100));
+  CallSetDestinationConsumer(consumer_, 2, 44100);
 
-  EXPECT_CALL(consumer_, ConsumeAudio(_, 10));
+  EXPECT_CALL(*consumer_, ConsumeAudio(_, 10));
   CallConsumeAudio(bus_.get(), 10);
 }
 
 TEST_F(MediaStreamAudioDestinationHandlerTest, RemoveDestinationConsumer) {
-  EXPECT_CALL(consumer_, SetFormat(2, 44100));
-  CallSetDestinationConsumer(&consumer_, 2, 44100);
+  EXPECT_CALL(*consumer_, SetFormat(2, 44100));
+  CallSetDestinationConsumer(consumer_, 2, 44100);
 
   // The removal should be successful.
   EXPECT_TRUE(CallRemoveDestinationConsumer());
 
   // The consumer should not be called.
-  EXPECT_CALL(consumer_, ConsumeAudio(_, 10)).Times(0);
+  EXPECT_CALL(*consumer_, ConsumeAudio(_, 10)).Times(0);
   CallConsumeAudio(bus_.get(), 10);
 }
 
 TEST_F(MediaStreamAudioDestinationHandlerTest,
        ConsumeInvalidDestinationConsumer) {
   // The consumer should get no calls.
-  EXPECT_CALL(consumer_, ConsumeAudio(_, 10)).Times(0);
+  EXPECT_CALL(*consumer_, ConsumeAudio(_, 10)).Times(0);
   CallConsumeAudio(bus_.get(), 10);
 }
 
@@ -150,4 +161,48 @@
   EXPECT_FALSE(CallRemoveDestinationConsumer());
 }
 
+TEST_F(MediaStreamAudioDestinationHandlerTest, AudioConsumerStressTest) {
+  std::atomic<bool> stop_requested{false};
+  base::WaitableEvent done_event;
+
+  Vector<float> data_l(128, 0.0f);
+  Vector<float> data_r(128, 0.0f);
+  Vector<const float*> audio_data = {data_l.data(), data_r.data()};
+
+  auto source = std::make_unique<WebAudioMediaStreamSource>(
+      task_environment_.GetMainThreadTaskRunner());
+  scoped_refptr<WebAudioDestinationConsumer> consumer = source->Consumer();
+  consumer->SetFormat(2, 44100);
+
+  // Start a background thread to continuously consume audio data.
+  base::ThreadPool::PostTask(
+      FROM_HERE, {base::MayBlock()},
+      base::BindOnce(
+          [](scoped_refptr<WebAudioDestinationConsumer> consumer,
+             Vector<const float*> audio_data, std::atomic<bool>* stop,
+             base::WaitableEvent* done) {
+            while (!stop->load(std::memory_order_relaxed)) {
+              consumer->ConsumeAudio(audio_data, 10);
+            }
+            done->Signal();
+          },
+          consumer, audio_data, base::Unretained(&stop_requested),
+          base::Unretained(&done_event)));
+
+  // Stress test by recreating the media source on the main thread while the
+  // background thread continuously uses the *original* consumer. This verifies
+  // that the background thread can safely use the consumer even after the
+  // source that created it has been destroyed (testing Detach() safety).
+  for (int i = 0; i < 500; ++i) {
+    source.reset();
+    source = std::make_unique<WebAudioMediaStreamSource>(
+        task_environment_.GetMainThreadTaskRunner());
+    consumer = source->Consumer();
+    consumer->SetFormat(2, 44100);
+  }
+
+  stop_requested.store(true, std::memory_order_relaxed);
+  done_event.Wait();
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential cross-thread heap use-after-free in AudioPushFifo via GC race

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential cross-thread race condition between the main thread garbage collector and the audio rendering thread can cause a heap use-after-free write in AudioPushFifo. A pre-finalizer may destroy the underlying audio buffer while the background audio thread is actively writing to it.

Affected files:

  • media/base/audio_push_fifo.cc
  • third_party/blink/renderer/platform/mediastream/media_stream_source.cc
  • third_party/blink/renderer/platform/mediastream/webaudio_media_stream_source.cc
  • third_party/blink/renderer/platform/mediastream/media_stream_audio_source.cc
  • third_party/blink/renderer/modules/webaudio/media_stream_audio_destination_handler.cc

Estimated timestamp from git blame: 2026-01-23

Summary

A potential heap use-after-free (UAF) vulnerability exists in the WebAudio to MediaStream pipeline. A lack of proper synchronization during garbage collection (GC) allows the MediaStreamSource pre-finalizer to destroy a WebAudioMediaStreamSource and its associated AudioPushFifo buffer while the audio rendering thread is concurrently writing audio frames into that buffer. This results in an out-of-bounds/Use-After-Free write of attacker-controlled audio float data into freed heap memory.

Technical Details

  1. When a MediaStreamAudioDestinationNode is created, it allocates a WebAudioMediaStreamSource and then wraps it in a garbage-collected MediaStreamSource.
  2. Due to Blink’s Oilpan LIFO (reverse-registration) execution order for pre-finalizers, MediaStreamSource::Dispose will execute before MediaStreamAudioDestinationNode::Dispose during a GC sweep.
  3. The audio thread processes data via MediaStreamAudioDestinationHandler::Process, which acquires consumer_lock_ and forwards audio to WebAudioMediaStreamSource::ConsumeAudio.
  4. Inside ConsumeAudio, the audio data is pushed into an AudioPushFifo. The audio thread extracts stack-local base::span references pointing directly to the AudioBus’s underlying memory (base::AlignedHeapArray<float>) and begins a copy loop (CopyPartialFramesTo).
  5. If the AudioContextAsyncStateTransitions feature is enabled, the AudioContext may still be in the kSuspended state immediately after creation, causing MediaStreamAudioDestinationNode::HasPendingActivity() to return false. This makes the node and its MediaStreamSource unexpectedly eligible for garbage collection, even though the background render thread is actively processing the graph.
  6. The GC executes MediaStreamSource::Dispose() on the main thread, which directly deletes the WebAudioMediaStreamSource (and consequently frees the AudioBus’s float array) without acquiring consumer_lock_.
  7. The audio thread, completely unaware of the destruction, continues its copy loop, writing attacker-controlled float values into the now-freed AlignedHeapArray memory.

MiraclePtr (BackupRefPtr) does not protect against this vulnerability because the audio thread accesses the buffer memory via raw base::span slices that were retrieved before the object’s destruction, bypassing the BRP quarantine mechanisms for the AudioBus object itself.

Suggested Exploit Steps

(Note: These are potential steps to trigger the vulnerability, as our automated tooling cannot run code to verify a proof-of-concept.)

  1. Create an AudioContext with AudioContextAsyncStateTransitions enabled.
  2. Create an AudioBufferSourceNode populated with a crafted AudioBuffer containing malicious pointers and forged vtables encoded as float values.
  3. Create a MediaStreamAudioDestinationNode and connect the source node to it.
  4. Call start() on the source node to initiate audio processing.
  5. Immediately remove all Javascript references to the destination node and trigger garbage collection before the AudioContext fully transitions to the kRunning state.
  6. The GC will free the AudioBus memory while the audio thread is concurrently copying the crafted float payload into it. By allocating a victim object into the freed block during the race window, the UAF write will corrupt the victim object, potentially leading to arbitrary Remote Code Execution (RCE) in the renderer.

Suggested Fix

There are multiple potential ways to address this issue:

  • Prevent Premature GC: Modify MediaStreamAudioDestinationNode::HasPendingActivity() to return true whenever an async state transition is pending. This prevents the nodes from being collected while the audio thread might be starting up.
  • Ensure Safe Destruction Order: The audio thread relies on MediaStreamAudioDestinationNode::Dispose() to acquire consumer_lock_ and nullify destination_consumer_. Ensure this cleanup runs before the underlying MediaStreamSource destroys the WebAudioMediaStreamSource.
  • Use Smart Pointers/Thread-Safe Destruction: Manage the lifecycle of WebAudioMediaStreamSource across threads using scoped_refptr instead of relying entirely on the GC pre-finalizer order.

Evaluated with Chrome root at commit: 2b349e31cb87959d6a548625986c65e0a2d2e380


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker