Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in GetUserMedia
DescriptionOut of bounds read in GetUserMedia
ComponentGetUserMedia
Sub-componentSelectAudioOutput
Bug ClassOOB
Tracker499112419
Fix commit8d0a63fdec27 (chromium/src) +70/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/media/media_stream_ui_proxy.cc
modified
TEST_P
content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
  • content/browser/renderer_host/media/media_stream_ui_proxy.cc
  • content/browser/renderer_host/media/media_devices_dispatcher_host.cc
From 8d0a63fdec27763359f34bdb3b87c356f5a96d9d Mon Sep 17 00:00:00 2001
From: Hongchan Choi <[email protected]>
Date: Wed, 16 Sep 2026 10:43:44 -0700
Subject: [PATCH] SelectAudioOutput: post UI-proxy result back to IO thread

MediaDevicesDispatcherHost::OnEnumeratedAudioOutputDevices() hands the
OnSelectedDeviceInfo callback to MediaStreamUIProxy, which forwards it
to WebContentsDelegate::ProcessSelectAudioOutput() on the UI thread.
The default delegate implementation invokes the callback synchronously
there, but OnSelectedDeviceInfo expects to run on the IO thread (it
DCHECKs and accesses IO-thread state).

Wrap the callback in BindPostTaskToCurrentDefault so the result is
always posted back to the IO thread before the WeakPtr is checked,
matching the other call sites in the same function.

Add a unit test that drives SelectAudioOutput through the
MediaStreamUIProxy path and asserts the result is delivered via a posted
task rather than synchronously inside the device-enumeration callback.

Bug: 499112419
Test: content_unittests --ozone-platform=headless --gtest_filter=*SelectAudioOutput*
Change-Id: I4a96bfebe7dc7e3725bdcc4104f999b498bbcb04
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8395459
Reviewed-by: Guido Urdaneta <[email protected]>
Commit-Queue: Hongchan Choi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1699047}
---
diff --git a/content/browser/renderer_host/media/media_devices_dispatcher_host.cc b/content/browser/renderer_host/media/media_devices_dispatcher_host.cc
index 2358cff7..2a6842ec 100644
--- a/content/browser/renderer_host/media/media_devices_dispatcher_host.cc
+++ b/content/browser/renderer_host/media/media_devices_dispatcher_host.cc
@@ -680,11 +680,17 @@
                 /*tests_use_fake_render_frame_hosts=*/false)
           : MediaStreamUIProxy::Create();
 
+  // Post the reply back to the IO sequence via BindPostTaskToCurrentDefault
+  // because embedders may execute the callback synchronously on the UI thread.
+  // This guarantees the WeakPtr dereference inside OnSelectedDeviceInfo is
+  // serialized with host destruction on the IO thread, preventing a
+  // cross-thread Use-After-Free.
   ui_proxy->RequestSelectAudioOutput(
       std::make_unique<SelectAudioOutputRequest>(
           render_frame_host_id_, std::move(audio_output_devices)),
-      base::BindOnce(&MediaDevicesDispatcherHost::OnSelectedDeviceInfo,
-                     weak_factory_.GetWeakPtr(), std::move(enumeration)));
+      base::BindPostTaskToCurrentDefault(
+          base::BindOnce(&MediaDevicesDispatcherHost::OnSelectedDeviceInfo,
+                         weak_factory_.GetWeakPtr(), std::move(enumeration))));
 }
 
 void MediaDevicesDispatcherHost::OnSelectedDeviceInfo(
diff --git a/content/browser/renderer_host/media/media_stream_ui_proxy.cc b/content/browser/renderer_host/media/media_stream_ui_proxy.cc
index 03200c12..52dbd9e5 100644
--- a/content/browser/renderer_host/media/media_stream_ui_proxy.cc
+++ b/content/browser/renderer_host/media/media_stream_ui_proxy.cc
@@ -182,10 +182,8 @@
       request->render_frame_host_id().child_id,
       request->render_frame_host_id().frame_routing_id);
   if (!render_delegate) {
-    content::GetIOThreadTaskRunner({})->PostTask(
-        FROM_HERE, base::BindOnce(std::move(callback),
-                                  base::unexpected(
-                                      SelectAudioOutputError::kNotSupported)));
+    std::move(callback).Run(
+        base::unexpected(SelectAudioOutputError::kNotSupported));
     return;
   }
 
diff --git a/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc b/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
index 28605e7..826e512b 100644
--- a/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
+++ b/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
@@ -21,6 +21,7 @@
 #include "base/task/single_thread_task_runner.h"
 #include "base/test/bind.h"
 #include "base/test/gmock_callback_support.h"
+#include "base/test/scoped_command_line.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
 #include "build/build_config.h"
@@ -1075,6 +1076,65 @@
   EXPECT_EQ(result->device_info.device_id, last_audio_output_device_id);
 }
 
+TEST_P(SelectAudioOutputTest, SelectAudioOutputForwardsRequestToUIProxy) {
+  // When no authorized device ID is provided, MediaDevicesDispatcherHost
+  // delegates the selection request to MediaStreamUIProxy on the UI thread.
+  //
+  // To avoid cross-thread Use-After-Free races where the host is destroyed on
+  // the IO thread while the UI thread executes the callback, the reply must be
+  // posted back to the IO task runner via BindPostTaskToCurrentDefault rather
+  // than executed synchronously within the UI proxy's call stack.
+  //
+  // In this single-threaded test harness, we verify this asynchronous contract:
+  // We post a probe task to the task runner during the salt/origin lookup. At
+  // that moment, the SelectAudioOutput result must NOT be ready yet (it should
+  // only arrive after subsequent posted tasks run).
+  render_frame_host_->SimulateUserActivation();
+  base::test::ScopedCommandLine scoped_command_line;
+  scoped_command_line.GetProcessCommandLine()->AppendSwitch(
+      switches::kUseFakeUIForMediaStream);
+
+  MediaDevicesManager* const devices_manager =
+      media_stream_manager_->media_devices_manager();
+  devices_manager->SetPermissionChecker(
+      std::make_unique<MediaDevicesPermissionChecker>(true));
+
+  base::test::TestFuture<blink::mojom::SelectAudioOutputResultPtr> future;
+  std::optional<bool> result_ready_before_reply;
+
+  // Intercept the salt/origin lookup—the earliest asynchronous checkpoint in
+  // device selection—to schedule a probe task into the sequence queue ahead
+  // of any posted reply from the UI proxy.
+  devices_manager->set_get_salt_and_origin_cb_for_testing(
+      base::BindLambdaForTesting(
+          [&](GlobalRenderFrameHostId, MediaDeviceSaltAndOriginCallback cb) {
+            // 1. Complete the expected salt and origin lookup.
+            GetMediaDeviceSaltAndOrigin(GlobalRenderFrameHostId(-1, -1),
+                                        std::move(cb));
+
+            // 2. Post a probe task to check if the SelectAudioOutput result was
+            //    delivered synchronously.
+            auto check_readiness = base::BindLambdaForTesting([&]() {
+              result_ready_before_reply = future.IsReady();
+            });
+
+            base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+                FROM_HERE, std::move(check_readiness));
+          }));
+
+  // Trigger the audio output selection request.
+  host_->SelectAudioOutput(std::string(), future.GetCallback());
+
+  // Wait for the result and verify the async contract and outcome.
+  blink::mojom::SelectAudioOutputResultPtr result = future.Take();
+  EXPECT_EQ(result->status, blink::mojom::AudioOutputStatus::kNoPermission);
+  EXPECT_TRUE(result->device_info.device_id.empty());
+
+  // Confirm that the result was delivered via posted task (not synchronously).
+  ASSERT_TRUE(result_ready_before_reply.has_value());
+  EXPECT_FALSE(*result_ready_before_reply);
+}
+
 INSTANTIATE_TEST_SUITE_P(All,
                          SelectAudioOutputTest,
                          testing::Values(std::string(), "https://test.com"));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc b/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
index 28605e7..826e512b 100644
--- a/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
+++ b/content/browser/renderer_host/media/media_devices_dispatcher_host_unittest.cc
@@ -21,6 +21,7 @@
 #include "base/task/single_thread_task_runner.h"
 #include "base/test/bind.h"
 #include "base/test/gmock_callback_support.h"
+#include "base/test/scoped_command_line.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
 #include "build/build_config.h"
@@ -1075,6 +1076,65 @@
   EXPECT_EQ(result->device_info.device_id, last_audio_output_device_id);
 }
 
+TEST_P(SelectAudioOutputTest, SelectAudioOutputForwardsRequestToUIProxy) {
+  // When no authorized device ID is provided, MediaDevicesDispatcherHost
+  // delegates the selection request to MediaStreamUIProxy on the UI thread.
+  //
+  // To avoid cross-thread Use-After-Free races where the host is destroyed on
+  // the IO thread while the UI thread executes the callback, the reply must be
+  // posted back to the IO task runner via BindPostTaskToCurrentDefault rather
+  // than executed synchronously within the UI proxy's call stack.
+  //
+  // In this single-threaded test harness, we verify this asynchronous contract:
+  // We post a probe task to the task runner during the salt/origin lookup. At
+  // that moment, the SelectAudioOutput result must NOT be ready yet (it should
+  // only arrive after subsequent posted tasks run).
+  render_frame_host_->SimulateUserActivation();
+  base::test::ScopedCommandLine scoped_command_line;
+  scoped_command_line.GetProcessCommandLine()->AppendSwitch(
+      switches::kUseFakeUIForMediaStream);
+
+  MediaDevicesManager* const devices_manager =
+      media_stream_manager_->media_devices_manager();
+  devices_manager->SetPermissionChecker(
+      std::make_unique<MediaDevicesPermissionChecker>(true));
+
+  base::test::TestFuture<blink::mojom::SelectAudioOutputResultPtr> future;
+  std::optional<bool> result_ready_before_reply;
+
+  // Intercept the salt/origin lookup—the earliest asynchronous checkpoint in
+  // device selection—to schedule a probe task into the sequence queue ahead
+  // of any posted reply from the UI proxy.
+  devices_manager->set_get_salt_and_origin_cb_for_testing(
+      base::BindLambdaForTesting(
+          [&](GlobalRenderFrameHostId, MediaDeviceSaltAndOriginCallback cb) {
+            // 1. Complete the expected salt and origin lookup.
+            GetMediaDeviceSaltAndOrigin(GlobalRenderFrameHostId(-1, -1),
+                                        std::move(cb));
+
+            // 2. Post a probe task to check if the SelectAudioOutput result was
+            //    delivered synchronously.
+            auto check_readiness = base::BindLambdaForTesting([&]() {
+              result_ready_before_reply = future.IsReady();
+            });
+
+            base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
+                FROM_HERE, std::move(check_readiness));
+          }));
+
+  // Trigger the audio output selection request.
+  host_->SelectAudioOutput(std::string(), future.GetCallback());
+
+  // Wait for the result and verify the async contract and outcome.
+  blink::mojom::SelectAudioOutputResultPtr result = future.Take();
+  EXPECT_EQ(result->status, blink::mojom::AudioOutputStatus::kNoPermission);
+  EXPECT_TRUE(result->device_info.device_id.empty());
+
+  // Confirm that the result was delivered via posted task (not synchronously).
+  ASSERT_TRUE(result_ready_before_reply.has_value());
+  EXPECT_FALSE(*result_ready_before_reply);
+}
+
 INSTANTIATE_TEST_SUITE_P(All,
                          SelectAudioOutputTest,
                          testing::Values(std::string(), "https://test.com"));
Loading diff…

Original Bug Report

reported by [email protected]

UAF in MediaDevicesDispatcherHost via Cross-Thread WeakPtr TOCTOU

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 security team.

Overview: A potential Use-After-Free (UAF) exists in MediaDevicesDispatcherHost due to a Time-Of-Check to Time-Of-Use (TOCTOU) race condition with a base::WeakPtr. A callback bound on the IO thread is executed directly on the UI thread, allowing the host object to be destroyed concurrently after the WeakPtr validity check passes but before execution completes. Because base::WeakPtr’s internal pointer is not protected by MiraclePtr, this race can lead to an exploitable browser-process sandbox escape.

Affected files:

  • content/browser/renderer_host/media/media_devices_dispatcher_host.cc
  • content/browser/renderer_host/media/media_stream_ui_proxy.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/public/browser/web_contents_delegate.cc

Estimated timestamp from git blame: 2024-11-08

Vulnerability Details

A cross-thread Time-Of-Check to Time-Of-Use (TOCTOU) Use-After-Free vulnerability exists in the Browser process, specifically within MediaDevicesDispatcherHost.

The issue originates in MediaDevicesDispatcherHost::OnEnumeratedAudioOutputDevices (running on the IO thread). This method binds a base::WeakPtr to the OnSelectedDeviceInfo callback and passes it to the UI proxy:

// content/browser/renderer_host/media/media_devices_dispatcher_host.cc
ui_proxy->RequestSelectAudioOutput(
    ...,
    base::BindOnce(&MediaDevicesDispatcherHost::OnSelectedDeviceInfo,
                   weak_factory_.GetWeakPtr(), std::move(enumeration)));

This callback is routed to the UI thread via MediaStreamUIProxy. If the WebContentsImpl lacks a delegate (or uses a default implementation), it executes the callback synchronously on the UI thread:

// content/browser/web_contents/web_contents_impl.cc:6029
std::move(callback).Run(base::unexpected(content::SelectAudioOutputError::kNotSupported));

When Run() is invoked on the UI thread, the internal base::BindOnce execution helper evaluates the validity of the WeakPtr. In Release builds, this performs a lock-free check against an AtomicFlag. If the check passes, the raw pointer is unwrapped, and the UI thread jumps into MediaDevicesDispatcherHost::OnSelectedDeviceInfo.

However, because the MediaDevicesDispatcherHost instance is owned by a mojo::UniqueReceiverSet on the IO thread, an attacker can concurrently close the Mojo pipe. This causes the IO thread to destroy the MediaDevicesDispatcherHost object after the UI thread’s WeakPtr check passes, but before the UI thread finishes executing the callback.

When the UI thread proceeds to execute the fallback logic inside OnSelectedDeviceInfo, it accesses this->select_audio_output_callback_. Since this now points to freed memory, a Use-After-Free occurs.

Impact

The base::WeakPtr implementation stores its internal pointer using RAW_PTR_EXCLUSION. Therefore, the freed MediaDevicesDispatcherHost object is not protected or quarantined by MiraclePtr (BackupRefPtr). An attacker who reallocates this memory chunk can craft a fake base::OnceCallback state to hijack the polymorphic_invoke function pointer, leading to arbitrary Remote Code Execution (RCE) and a full Sandbox Escape.

Note: Reachability currently requires the blink::features::kSelectAudioOutput feature flag to be enabled, which is disabled by default in production.

Potential Steps to Reproduce

(Note: These are suggested steps based on static analysis; our tooling agent does not yet have the ability to run code or execute a PoC.)

  1. Prerequisite: Ensure the browser is running with --enable-features=SelectAudioOutput.
  2. From a compromised renderer, obtain or simulate transient user activation.
  3. Trigger the blink::mojom::MediaDevicesDispatcherHost::SelectAudioOutput IPC request.
  4. Ensure the request is handled by a WebContents without a delegate (or a guest view that immediately rejects it) to force synchronous UI-thread callback execution.
  5. Immediately close the Mojo pipe (e.g., by dropping the remote) to trigger destruction of the MediaDevicesDispatcherHost on the IO thread.
  6. If timed correctly, the UI thread passes the WeakPtr check and attempts to run select_audio_output_callback_.Run() on the freed object.

Suggested Fix

To ensure the WeakPtr is checked and the callback is executed safely on the sequence it belongs to, wrap the callback using base::BindPostTask (or base::BindPostTaskToCurrentDefault()) when it is created on the IO thread:

ui_proxy->RequestSelectAudioOutput(
    ...,
    base::BindPostTaskToCurrentDefault(
        base::BindOnce(&MediaDevicesDispatcherHost::OnSelectedDeviceInfo,
                       weak_factory_.GetWeakPtr(), std::move(enumeration))));

This guarantees the callback will be posted back to the IO thread before the WeakPtr is unwrapped, preventing the cross-thread TOCTOU race.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


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