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 Media
DescriptionUse after free in Media
ComponentMedia
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.
Tracker492218537
Fix commitfccaeb9e0967 (chromium/src) +83/-40
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-03-13 (per vendor advisory)
Fix landed2026-03-18 (public git)
Disclosed2026-04-07
Patch gap20 days — fix public in source before the advisory

Background

`AudioRendererMixerInput`
a Blink media object that feeds one audio stream into a shared AudioRendererMixer, and whose output device can be changed at runtime via SwitchOutputDevice.
`switch_output_device_in_progress_`
a boolean flag marking that an asynchronous device switch is mid-flight, read by both the teardown path and the switch-completion callback.
`setSinkId()` / `SwitchOutputDevice`
the web-facing and internal operations that asynchronously acquire a new AudioRendererSink and, on success, reconnect the mixer input to it.
`Stop()`
the teardown entry point that removes the mixer input and releases its sink; it may be invoked at any time, including during an in-flight switch.

Root Cause Analysis

The flag switch_output_device_in_progress_ coordinated two asynchronous operations (Stop() and the device-switch completion callback) but was read and written without any lock, so the two could interleave. In the old code the switch-completion callback unconditionally cleared the flag and, when the device status was OK, reconnected the input to the freshly acquired sink — setting sink_, calling the inner teardown, then Start()/Play() — even if Stop() had already run and torn the object down. Because Stop() could execute concurrently with (or just before) the callback, the callback could reattach a new sink and restart playback on a mixer input that had already been stopped/released, a use-after-free on the sink/mixer state (inferred for the exact freed object).

The fix introduces device_change_lock_ (with GUARDED_BY) around every access to switch_output_device_in_progress_: Stop() takes the lock, clears the flag, then runs the renamed StopInternal(); the completion callback takes the lock and, if the flag was cleared by a concurrent Stop() (!switch_output_device_in_progress_), stops the new sink and returns OUTPUT_DEVICE_STATUS_ERROR_INTERNAL instead of reconnecting. This makes the check-flag-then-reconnect sequence atomic with respect to Stop().

Key insight
The race was that the device-switch completion callback could reconnect a new sink after Stop() had already torn the object down, because the shared switch_output_device_in_progress_ flag was unsynchronized; guarding it with device_change_lock_ and bailing out when Stop() won the race closes the window.

Attack Path

  1. Start a stream and begin a switch A renderer driving an audio element/graph calls setSinkId(), triggering SwitchOutputDevice, which sets switch_output_device_in_progress_ = true and asynchronously requests a new sink.
  2. Tear down during the switch Before the switch completes, the stream is stopped (e.g. element/context teardown), invoking Stop() while the device change is still pending.
  3. Lose the race Without the lock, the asynchronous switch-completion callback observes a stale/OK state and proceeds into the reconnect branch (sink_ = std::move(sink), inner stop, Start()/Play()).
  4. Use-after-free The callback operates on mixer-input/sink state already released by Stop(), yielding a use-after-free (inferred); the regression test StopDuringSwitchOutputDevice pins the fixed behavior by expecting OUTPUT_DEVICE_STATUS_ERROR_INTERNAL.

Impact Assessment

An attacker controlling a renderer (ordinary web content using audio output-device selection) can race Stop() against an in-flight output-device switch to provoke a use-after-free in the Blink media audio code, in the renderer process. The vendor advisory rates this high severity and labels it a use-after-free in Media; it does not report in-the-wild exploitation. Exploitation of the freed object into a read/write primitive or code execution is a timing race and is not demonstrated by the patch (inferred).

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.cc
modified

Files Changed

  • third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.cc
  • third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.h

Audit Directions

  • Unsynchronized async coordination flags
    Flag boolean “in progress” members shared between an asynchronous completion callback and a teardown/Stop path; without a lock the callback can act on already-released state.
  • Callbacks that revive torn-down objects
    Audit async callbacks that re-acquire or reconnect resources (sink_ = ..., Start()/Play()) to confirm they re-check for a cancellation/stop that may have occurred while the operation was in flight.
  • Re-entrant teardown
    Look for completion handlers that call the public Stop()/destructor-like entry recursively; prefer an internal variant to avoid re-locking and to make the cancellation check authoritative.
From fccaeb9e0967fdc628a6057c4140d2be7649a706 Mon Sep 17 00:00:00 2001
From: Dale Curtis <[email protected]>
Date: Tue, 17 Mar 2026 17:33:03 -0700
Subject: [PATCH] Ensure AudioRendererMixer holds lock during sink switch

This guards `switch_output_device_in_progress_` with a lock that
can be held during the final phase of a setSinkId() operation
within the AudioRendererMixerInput. It ensures that if Stop()
is called, we don't incorrectly reconnect the new sink, and if
a device changes is in flight, that we stall the Stop() call.

R=tguilbert

Fixed: 492218537
Change-Id: I9ec6efb9678762a22b1b1c8a2f8918771c264678
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7673253
Reviewed-by: Thomas Guilbert <[email protected]>
Commit-Queue: Dale Curtis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1600910}
---

diff --git a/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.cc b/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.cc
index a3160d1..ce6b35e 100644
--- a/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.cc
+++ b/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.cc
@@ -96,6 +96,17 @@
 }
 
 void AudioRendererMixerInput::Stop() {
+  {
+    // Prevents race conditions when Stop() is called during a device change.
+    base::AutoLock auto_lock(device_change_lock_);
+    if (switch_output_device_in_progress_) {
+      switch_output_device_in_progress_ = false;
+    }
+  }
+  StopInternal();
+}
+
+void AudioRendererMixerInput::StopInternal() {
   // Stop() may be called at any time, if Pause() hasn't been called we need to
   // remove our mixer input before shutdown.
   Pause();
@@ -154,10 +165,13 @@
     return;
   }
 
-  if (switch_output_device_in_progress_) {
-    DCHECK(!godia_in_progress_);
-    pending_device_info_cb_ = std::move(info_cb);
-    return;
+  {
+    base::AutoLock auto_lock(device_change_lock_);
+    if (switch_output_device_in_progress_) {
+      DCHECK(!godia_in_progress_);
+      pending_device_info_cb_ = std::move(info_cb);
+      return;
+    }
   }
 
   godia_in_progress_ = true;
@@ -192,7 +206,10 @@
     media::OutputDeviceStatusCB callback) {
   // If a GODIA() call is in progress, defer until it's complete.
   if (godia_in_progress_) {
-    DCHECK(!switch_output_device_in_progress_);
+    {
+      base::AutoLock auto_lock(device_change_lock_);
+      DCHECK(!switch_output_device_in_progress_);
+    }
 
     // Abort any previous device switch which may be pending.
     if (pending_switch_cb_) {
@@ -214,7 +231,10 @@
     return;
   }
 
-  switch_output_device_in_progress_ = true;
+  {
+    base::AutoLock auto_lock(device_change_lock_);
+    switch_output_device_in_progress_ = true;
+  }
 
   // Request a new sink using the new device id. This process may fail, so to
   // avoid interrupting working audio, don't set any class variables until we
@@ -307,45 +327,48 @@
     media::OutputDeviceStatusCB switch_cb,
     scoped_refptr<media::AudioRendererSink> sink,
     media::OutputDeviceInfo device_info) {
-  DCHECK(switch_output_device_in_progress_);
-  switch_output_device_in_progress_ = false;
+  auto return_status = device_info.device_status();
 
-  if (device_info.device_status() != media::OUTPUT_DEVICE_STATUS_OK) {
-    sink->Stop();
-    std::move(switch_cb).Run(device_info.device_status());
+  {
+    base::AutoLock auto_lock(device_change_lock_);
 
-    // Start any pending device info request.
-    if (pending_device_info_cb_) {
-      GetOutputDeviceInfoAsync(std::move(pending_device_info_cb_));
+    if (device_info.device_status() != media::OUTPUT_DEVICE_STATUS_OK) {
+      // Case: Device change failed.
+      sink->Stop();
+    } else if (!switch_output_device_in_progress_) {
+      // Case: Stop() called during device change.
+      sink->Stop();
+      return_status = media::OUTPUT_DEVICE_STATUS_ERROR_INTERNAL;
+    } else {
+      // Case: Device change succeeded, connect to new sink.
+      const bool has_mixer = !!mixer_;
+      const bool is_playing = playing_;
+
+      // This may occur if Start() hasn't yet been called.
+      if (sink_) {
+        sink_->Stop();
+      }
+
+      sink_ = std::move(sink);
+      device_info_ = device_info;
+      device_id_ = device_info.device_id();
+
+      auto callback = callback_;
+      StopInternal();
+      callback_ = callback;
+
+      if (has_mixer) {
+        Start();
+        if (is_playing) {
+          Play();
+        }
+      }
     }
 
-    return;
+    switch_output_device_in_progress_ = false;
   }
 
-  const bool has_mixer = !!mixer_;
-  const bool is_playing = playing_;
-
-  // This may occur if Start() hasn't yet been called.
-  if (sink_) {
-    sink_->Stop();
-  }
-
-  sink_ = std::move(sink);
-  device_info_ = device_info;
-  device_id_ = device_info.device_id();
-
-  auto callback = callback_;
-  Stop();
-  callback_ = callback;
-
-  if (has_mixer) {
-    Start();
-    if (is_playing) {
-      Play();
-    }
-  }
-
-  std::move(switch_cb).Run(device_info.device_status());
+  std::move(switch_cb).Run(return_status);
 
   // Start any pending device info request.
   if (pending_device_info_cb_) {
diff --git a/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.h b/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.h
index e13df61..f511187b 100644
--- a/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.h
+++ b/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input.h
@@ -80,6 +80,7 @@
   void OnRenderError();
 
  private:
+  void StopInternal();
   ~AudioRendererMixerInput() override;
 
   friend class AudioRendererMixerInputTest;
@@ -118,6 +119,9 @@
                            scoped_refptr<media::AudioRendererSink> sink,
                            media::OutputDeviceInfo device_info);
 
+  // Prevents race conditions when Stop() is called during a device change.
+  base::Lock device_change_lock_;
+
   // AudioParameters received during Initialize().
   media::AudioParameters params_;
 
@@ -143,7 +147,8 @@
   // exclusive when executing; these flags indicate whether one or the other is
   // in progress. Each method will use the other method's to defer its action.
   bool godia_in_progress_ = false;
-  bool switch_output_device_in_progress_ = false;
+  bool switch_output_device_in_progress_ GUARDED_BY(device_change_lock_) =
+      false;
 
   // Set by GetOutputDeviceInfoAsync() if a SwitchOutputDevice() call is in
   // progress. GetOutputDeviceInfoAsync() will be invoked again with this value
diff --git a/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input_test.cc b/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input_test.cc
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input_test.cc b/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input_test.cc
index 5c8c9be0..6231309b 100644
--- a/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input_test.cc
+++ b/third_party/blink/renderer/modules/media/audio/audio_renderer_mixer_input_test.cc
@@ -215,6 +215,21 @@
   mixer_input_->Stop();
 }
 
+TEST_F(AudioRendererMixerInputTest, StopDuringSwitchOutputDevice) {
+  mixer_input_->Initialize(audio_parameters_, fake_callback_.get());
+  mixer_input_->Start();
+  const std::string kDeviceId("mock-device-id");
+  EXPECT_CALL(*this,
+              SwitchCallbackCalled(media::OUTPUT_DEVICE_STATUS_ERROR_INTERNAL));
+  base::RunLoop run_loop;
+  mixer_input_->SwitchOutputDevice(
+      kDeviceId,
+      blink::BindOnce(&AudioRendererMixerInputTest::SwitchCallback,
+                      Unretained(this), blink::Unretained(&run_loop)));
+  mixer_input_->Stop();
+  run_loop.Run();
+}
+
 // Test SwitchOutputDevice() to the same device as the current (default) device
 TEST_F(AudioRendererMixerInputTest, SwitchOutputDeviceToSameDevice) {
   mixer_input_->Initialize(audio_parameters_, fake_callback_.get());
Loading diff…

Original Bug Report

reported by [email protected]

Use-after-free in AudioRendererMixerInput via setSinkId/createMediaElementSource race

Use-after-free in AudioRendererMixerInput via setSinkId/createMediaElementSource race

Summary

A race condition between HTMLMediaElement.setSinkId() and AudioContext.createMediaElementSource() causes a use-after-free of an AudioRendererMixerInput object. The asynchronous device-switch callback OnDeviceSwitchReady captures stale state snapshots of mixer_ and playing_ without holding sink_lock_, then proceeds to re-register the input with a new mixer based on those snapshots. If SetClient() runs concurrently on the main thread and drops the provider’s reference to the input, the callback becomes the sole owner; when it completes, the input is destroyed while still registered in the mixer’s error_callbacks_ set and the AudioConverter’s transform_inputs_ list. Subsequent audio rendering on the AudioOutputDevice thread dereferences the dangling pointers, producing a heap-use-after-free. This affects all desktop platforms (Linux, macOS, Windows) and requires at least two audio output devices to be present.

Bisect

Introducing Commit: 41607b54686f80bc672c294161ca0da1cf49f89f

This commit converted the <audio> pipeline to use asynchronous device info requests. The prior implementation used a static helper for OnDeviceSwitchReady that could safely outlive the pipeline, but the conversion made it a non-static member function bound via RetainedRef(this) without introducing the synchronization necessary to protect against concurrent SetClient() calls.

Root Cause

AudioRendererMixerInput is explicitly documented as not thread-safe. Its header states that callers should rely on WebAudioSourceProviderImpl::sink_lock_ to serialize access between the main thread (WebAudio APIs) and the media thread (HTMLMediaElement APIs). The OnDeviceSwitchReady callback violates this contract: it runs on the media thread’s task runner without acquiring sink_lock_, yet reads and writes mixer_, playing_, callback_, and sink_.

When SwitchOutputDevice() initiates an asynchronous device query, it binds the completion callback with RetainedRef(this):

// audio_renderer_mixer_input.cc:229-232
new_sink->GetOutputDeviceInfoAsync(
    blink::BindOnce(&AudioRendererMixerInput::OnDeviceSwitchReady,
                    blink::RetainedRef(this), std::move(callback), new_sink));

When the async query completes, OnDeviceSwitchReady snapshots the current state and then tears down and rebuilds the mixer connection:

// audio_renderer_mixer_input.cc:327-353
const bool has_mixer = !!mixer_;
const bool is_playing = playing_;

// ... Stop old sink, update device info ...

auto callback = callback_;
Stop();
callback_ = callback;

if (has_mixer) {
  Start();           // Registers in new mixer's error_callbacks_
  if (is_playing) {
    Play();          // Registers in AudioConverter's transform_inputs_
  }
}

The race window opens between the state snapshot and the Start()/Play() calls. During this window, AudioContext.createMediaElementSource() on the main thread synchronously calls WebAudioSourceProviderImpl::SetClient(). With the kDelayStopForMediaElementSourceNode feature disabled (the default), SetClient() acquires sink_lock_ and drops the provider’s reference to the AudioRendererMixerInput:

// web_audio_source_provider_impl.cc:147-155
if (!base::FeatureList::IsEnabled(kDelayStopForMediaElementSourceNode)) {
  if (sink_) {
    sink_->Stop();
    sink_ = nullptr;   // Drops scoped_refptr<AudioRendererMixerInput>
  }
}

After SetClient() returns, the RetainedRef inside the pending OnDeviceSwitchReady callback is the only remaining reference to the AudioRendererMixerInput. When the callback resumes on the media thread, it uses stale has_mixer = true and is_playing = true to call Start() and Play(), which register the input with a freshly obtained mixer via AddErrorCallback(this) and AddMixerInput(params_, this). The mixer stores these as raw pointers:

// audio_renderer_mixer.h:84-85
base::flat_set<raw_ptr<AudioRendererMixerInput, CtnExperimental>>
    error_callbacks_ GUARDED_BY(lock_);
// audio_converter.h:129-130
typedef std::list<raw_ptr<InputCallback, CtnExperimental>> InputCallbackSet;
InputCallbackSet transform_inputs_;

When OnDeviceSwitchReady returns and its BindState is destroyed, the RetainedRef releases the last reference, freeing the 440-byte AudioRendererMixerInput object. The destructor only contains DCHECK(!started_) and DCHECK(!mixer_), which are compiled out in Release and ASAN builds, so it does not unregister from the mixer. The AudioOutputDevice thread then enters AudioRendererMixer::Render(), which calls aggregate_converter_.ConvertWithInfo(), iterating transform_inputs_ and calling ProvideInput() on the dangling pointer.

The raw_ptr<T, CtnExperimental> annotations provide BackupRefPtr protection in production Release builds with PartitionAlloc, but ASAN replaces the allocator, disabling this mitigation entirely. In Release builds without ASAN, the CtnExperimental tag enables BRP quarantine that would catch the dangling access, but the underlying race condition and object lifecycle bug remain.

Reproduce

Tested at commit 457566e1c0b41. Apply patch.diff (adds a 500ms sleep in OnDeviceSwitchReady to widen the race window), then build and run:

git apply issue_setsinkid_mixer_uaf/patch.diff
autoninja -C out/asan-release chrome

A virtual audio sink is required if no secondary hardware output is available:

pactl load-module module-null-sink sink_name=virtual_out sink_properties=device.description=VirtualOutput

Launch:

ASAN_OPTIONS=detect_odr_violation=0 xvfb-run -a out/asan-release/chrome \
  --no-sandbox --disable-gpu \
  --autoplay-policy=no-user-gesture-required \
  --use-fake-device-for-media-stream \
  --use-fake-ui-for-media-stream \
  --user-data-dir=/tmp/poc-$(date +%s) \
  issue_setsinkid_mixer_uaf/poc.html

The renderer process crashes with heap-use-after-free within seconds. Full ASAN log:

==3681971==ERROR: AddressSanitizer: heap-use-after-free on address 0x7c56d3894650 at pc 0x7f1749df1dae bp 0x7b11cfffad50 sp 0x7b11cfffad48
READ of size 8 at 0x7c56d3894650 thread T19 (AudioOutputDevi)
    #0 0x7f1749df1dad in media::AudioConverter::SourceCallback(int, media::AudioBus*) media/base/audio_converter.cc:224:33
    #1 0x7f1749df0c43 in media::AudioConverter::ProvideInput(int, media::AudioBus*) media/base/audio_converter.cc:266:5
    #2 0x7f1749df341d in base::internal::Invoker<...> base/functional/bind_internal.h:740:12
    #3 0x7f1749e1dbd0 in base::RepeatingCallback<void (int, media::AudioBus*)>::Run(...) base/functional/callback.h:346:12
    #4 0x7f1749eb30ea in media::MultiChannelResampler::ProvideInput(int, int, float*) media/base/multi_channel_resampler.cc:101:14
    ...
    #9 0x7f1749df2ee8 in media::AudioConverter::ConvertWithInfo(...) media/base/audio_converter.cc:160:19
    #10 0x7f1749e92eb7 in media::LoopbackAudioConverter::ProvideInput(...) media/base/loopback_audio_converter.cc:21:20
    #11 0x7f1749df14a2 in media::AudioConverter::SourceCallback(int, media::AudioBus*) media/base/audio_converter.cc:224:33
    #12 0x7f1749df2f31 in media::AudioConverter::ConvertWithInfo(...) media/base/audio_converter.cc:157:5
    #13 0x7f16e9658328 in blink::AudioRendererMixer::Render(...) audio_renderer_mixer.cc:155:24
    #14 0x7f1749d4cde2 in media::AudioOutputDeviceThreadCallback::Process(unsigned int) audio_output_device_thread_callback.cc:107:21
    #15 0x7f1749d0a045 in media::AudioDeviceThread::ThreadMain() audio_device_thread.cc:106:16

0x7c56d3894650 is located 16 bytes inside of 440-byte region [0x7c56d3894640,0x7c56d38947f8)
freed by thread T14 (Media) here:
    #0 0x55ab875a2cc2 in operator delete(void*, unsigned long)
    #1 0x7f16e9661a35 in base::internal::BindState<...>::Destroy(...) base/memory/ref_counted.h:438:5

previously allocated by thread T0 (chrome) here:
    #0 0x55ab875a20bd in operator new(unsigned long)
    #1 0x7f16e9662184 in blink::AudioRendererMixerManager::CreateInput(...) base/memory/scoped_refptr.h:151:12
    #2 0x7f16e964e44e in blink::AudioDeviceFactory::NewMixableSink(...) audio_device_factory.cc:151:26

SUMMARY: AddressSanitizer: heap-use-after-free media/base/audio_converter.cc:224:33 in media::AudioConverter::SourceCallback(int, media::AudioBus*)

MiraclePtr Status: MANUAL ANALYSIS REQUIRED
A pointer to the same region was extracted from a raw_ptr<T> object prior to this crash.

The complete untruncated ASAN log is in asan.log.

References

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker