CVE-2026-15119
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/renderer_host/media/audio_input_device_manager_unittest.cc |
modified |
Files Changed
content/browser/renderer_host/media/audio_input_device_manager.cccontent/browser/renderer_host/media/audio_input_device_manager.hcontent/browser/renderer_host/media/audio_input_device_manager_unittest.cc
Patch
From 1787d529449a9e5c1d4aa4505f34fe98f6307c20 Mon Sep 17 00:00:00 2001 From: Tove Petersson <[email protected]> Date: Mon, 29 Jun 2026 07:31:33 -0700 Subject: [PATCH] Abort pending AudioInputDeviceManager open when closed early AudioInputDeviceManager::Open() returns a session id immediately and queries the audio system asynchronously. If Close() is called for that session before the query completes, the device is not yet in |devices_| so Close() returns without doing anything, and the later OpenedOnIOThread() callback still registers the device, leaving a session that MediaStreamManager no longer tracks. Track sessions whose open is still in flight and drop the result of OpenedOnIOThread() when Close() has already been called for the session. Bug: 523505418 Change-Id: Ib64b19cbf2fb3077bae7107b11d0570d4551efde Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8004471 Commit-Queue: Tove Petersson <[email protected]> Reviewed-by: Guido Urdaneta <[email protected]> Cr-Commit-Position: refs/heads/main@{#1654054} --- diff --git a/content/browser/renderer_host/media/audio_input_device_manager.cc b/content/browser/renderer_host/media/audio_input_device_manager.cc index 2723decb..3d82f8e96 100644 --- a/content/browser/renderer_host/media/audio_input_device_manager.cc +++ b/content/browser/renderer_host/media/audio_input_device_manager.cc @@ -110,6 +110,7 @@ // Generate a new id for this device. auto session_id = base::UnguessableToken::Create(); SendAudioLogMessage(GetOpenLogString(session_id, device)); + pending_open_sessions_.insert(session_id); // base::Unretained(this) is safe, because AudioInputDeviceManager is // destroyed not earlier than on the IO message loop destruction. @@ -143,8 +144,12 @@ DCHECK_CURRENTLY_ON(BrowserThread::IO); SendAudioLogMessage("Close({session_id=" + session_id.ToString() + "})"); auto device = GetDevice(session_id); - if (device == devices_.end()) + if (device == devices_.end()) { + // The asynchronous device query started by Open() may not have completed + // yet. Drop the session so that OpenedOnIOThread() does not register it. + pending_open_sessions_.erase(session_id); return; + } const blink::mojom::MediaStreamType stream_type = device->type; devices_.erase(device); @@ -161,10 +166,16 @@ const std::optional<media::AudioParameters>& input_params, const std::optional<std::string>& matched_output_device_id) { DCHECK_CURRENTLY_ON(BrowserThread::IO); - DCHECK(GetDevice(session_id) == devices_.end()); DCHECK(!input_params || input_params->IsValid()); DCHECK(!matched_output_device_id || !matched_output_device_id->empty()); + if (!pending_open_sessions_.erase(session_id)) { + // The session was closed while the device query was in flight. + return; + } + + DCHECK(GetDevice(session_id) == devices_.end()); + SendAudioLogMessage("Opened({session_id=" + session_id.ToString() + "})"); blink::MediaStreamDevice media_stream_device(device.type, device.id, device.name); diff --git a/content/browser/renderer_host/media/audio_input_device_manager.h b/content/browser/renderer_host/media/audio_input_device_manager.h index a632e8b..4459034 100644 --- a/content/browser/renderer_host/media/audio_input_device_manager.h +++ b/content/browser/renderer_host/media/audio_input_device_manager.h @@ -16,6 +16,7 @@ #include <string> #include <vector> +#include "base/containers/flat_set.h" #include "base/memory/raw_ptr.h" #include "base/observer_list.h" #include "base/threading/thread.h" @@ -74,6 +75,12 @@ base::ObserverList<MediaStreamProviderListener>::Unchecked listeners_; blink::MediaStreamDevices devices_; + // Sessions for which Open() has been called and the asynchronous device + // query is still in flight. A session is removed from this set either when + // OpenedOnIOThread() runs or when Close() is called for it before that + // happens, in which case OpenedOnIOThread() will discard the result. + base::flat_set<base::UnguessableToken> pending_open_sessions_; + const raw_ptr<media::AudioSystem> audio_system_; }; diff --git a/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc b/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc index 231e9b7d..21978ff 100644 --- a/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc +++ b/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc @@ -28,6 +28,7 @@ #include "testing/gtest/include/gtest/gtest.h" #include "third_party/blink/public/common/mediastream/media_stream_request.h" +using testing::_; using testing::InSequence; namespace content { @@ -378,4 +379,45 @@ } } +// Closes a session while its asynchronous open is still pending. The pending +// open should be aborted and the session should never be registered. +TEST_F(AudioInputDeviceManagerNoDevicesTest, CloseWhileOpenIsPending) { + ASSERT_FALSE(devices_.empty()); + + base::UnguessableToken session_id = manager_->Open(devices_.front()); + manager_->Close(session_id); + + EXPECT_CALL(*audio_input_listener_, Opened(_, session_id)).Times(0); + WaitForOpenCompletion(); + + EXPECT_EQ(nullptr, manager_->GetOpenedDeviceById(session_id)); +} + +// Closes one of two pending sessions. The other session should still open +// normally. +TEST_F(AudioInputDeviceManagerNoDevicesTest, + CloseOneOfMultiplePendingSessions) { + ASSERT_GE(devices_.size(), 2u); + + base::UnguessableToken first_session_id = manager_->Open(devices_[0]); + base::UnguessableToken second_session_id = manager_->Open(devices_[1]); + manager_->Close(first_session_id); + + EXPECT_CALL(*audio_input_listener_, Opened(_, first_session_id)).Times(0); + EXPECT_CALL(*audio_input_listener_, + Opened(devices_[1].type, second_session_id)) + .Times(1); + WaitForOpenCompletion(); + + EXPECT_EQ(nullptr, manager_->GetOpenedDeviceById(first_session_id)); + EXPECT_NE(nullptr, manager_->GetOpenedDeviceById(second_session_id)); + + base::RunLoop run_loop; + EXPECT_CALL(*audio_input_listener_, + Closed(devices_[1].type, second_session_id)) + .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); })); + manager_->Close(second_session_id); + run_loop.Run(); +} + } // namespace content
Regression Test / PoC
diff --git a/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc b/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc
index 231e9b7d..21978ff 100644
--- a/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc
+++ b/content/browser/renderer_host/media/audio_input_device_manager_unittest.cc
@@ -28,6 +28,7 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/mediastream/media_stream_request.h"
+using testing::_;
using testing::InSequence;
namespace content {
@@ -378,4 +379,45 @@
}
}
+// Closes a session while its asynchronous open is still pending. The pending
+// open should be aborted and the session should never be registered.
+TEST_F(AudioInputDeviceManagerNoDevicesTest, CloseWhileOpenIsPending) {
+ ASSERT_FALSE(devices_.empty());
+
+ base::UnguessableToken session_id = manager_->Open(devices_.front());
+ manager_->Close(session_id);
+
+ EXPECT_CALL(*audio_input_listener_, Opened(_, session_id)).Times(0);
+ WaitForOpenCompletion();
+
+ EXPECT_EQ(nullptr, manager_->GetOpenedDeviceById(session_id));
+}
+
+// Closes one of two pending sessions. The other session should still open
+// normally.
+TEST_F(AudioInputDeviceManagerNoDevicesTest,
+ CloseOneOfMultiplePendingSessions) {
+ ASSERT_GE(devices_.size(), 2u);
+
+ base::UnguessableToken first_session_id = manager_->Open(devices_[0]);
+ base::UnguessableToken second_session_id = manager_->Open(devices_[1]);
+ manager_->Close(first_session_id);
+
+ EXPECT_CALL(*audio_input_listener_, Opened(_, first_session_id)).Times(0);
+ EXPECT_CALL(*audio_input_listener_,
+ Opened(devices_[1].type, second_session_id))
+ .Times(1);
+ WaitForOpenCompletion();
+
+ EXPECT_EQ(nullptr, manager_->GetOpenedDeviceById(first_session_id));
+ EXPECT_NE(nullptr, manager_->GetOpenedDeviceById(second_session_id));
+
+ base::RunLoop run_loop;
+ EXPECT_CALL(*audio_input_listener_,
+ Closed(devices_[1].type, second_session_id))
+ .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+ manager_->Close(second_session_id);
+ run_loop.Run();
+}
+
} // namespace content
Original Bug Report
Unauthorized silent audio capture via MediaStreamManager race condition
Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential race condition in AudioInputDeviceManager and MediaStreamManager allows a compromised renderer to bypass audio capture security controls. By cancelling a stream request immediately after creating it, a renderer can leak a valid session ID and create an orphaned session. This session can then be used to silently record audio without user notification or permission checks.
Affected files:
content/browser/renderer_host/media/audio_input_device_manager.cccontent/browser/renderer_host/media/audio_input_device_manager.h
Estimated timestamp from git blame: Unknown (Google3 checkout)
Summary
A potential race condition in the browser’s handling of audio stream generation allows a compromised renderer to bypass security controls and persistently record audio without any user-facing indicators (like the Omnibox red dot). By cancelling a GenerateStreams request precisely while the browser is querying the OS for audio device parameters, the renderer can induce the browser to leak a valid session_id while failing to clean up the pending session. The renderer can subsequently use this leaked session_id to establish an unauthorized audio stream.
Vulnerability Details
When a renderer requests an audio stream, the browser initiates a sequence that involves an asynchronous call to the underlying audio system. A compromised renderer can race a CancelRequest against this asynchronous operation to leave the system in a vulnerable state.
Suggested steps to trigger this potential vulnerability:
- Initiate Request: A compromised renderer sends a
GenerateStreamsIPC to the browser. - Browser Processing:
MediaStreamManager::HandleAccessRequestResponsecallsAudioInputDeviceManager::Open. This generates a newsession_idand starts an asynchronous OS query (audio_system_->GetInputDeviceInfo), passingAudioInputDeviceManager::OpenedOnIOThreadas the callback. - Early Return:
AudioInputDeviceManager::Openimmediately returns thesession_idtoMediaStreamManagerbefore the async query completes. The device state is set toMEDIA_REQUEST_STATE_OPENING. - Race Condition Trigger: The compromised renderer immediately sends a
CancelRequestIPC. - Failed Cleanup:
MediaStreamManager::CancelRequestcallsAudioInputDeviceManager::Close(session_id). However, because the async query hasn’t finished, the session is not yet in thedevices_vector.Closereturns early without cleaning up or invalidating the pending callback (audio_input_device_manager.cc:145-147). - ID Leak:
MediaStreamManagerexecutes thedevice_stopped_callbackto notify the renderer that the stream is closing. This intentionally sends the validsession_idback to the renderer via theMediaStreamDeviceObserver::OnDeviceStoppedIPC (media_stream_manager.cc:2078). - Request Deletion:
MediaStreamManagerdeletes the request from itsrequests_map, tearing down any associated UI proxies. The browser believes the request is fully cancelled. - Zombie Session: Sometime later, the async audio query completes.
AudioInputDeviceManager::OpenedOnIOThreadexecutes and unconditionally adds the session to itsdevices_vector (audio_input_device_manager.cc:178). The session is now active but untracked byMediaStreamManager. - Stream Creation: The compromised renderer uses the leaked
session_idto callRenderFrameAudioInputStreamFactory::CreateStream. - Validation Bypass:
CreateStreamattempts to validate the request viaMediaStreamManager::ValidateAudioSession. This relies onValidateSession, which looks up thesession_idin therequests_map. Because the request was deleted in Step 7, it is not found. Due to a logic flaw intended to handle benign race conditions (if (!request) { return true; }), validation incorrectly passes (media_stream_manager.cc:2378-2380). - Silent Recording:
CreateStreamfinds the orphaned device inAudioInputDeviceManagerand opens the stream. Because the request was deleted, no UI proxy exists, meaning no recording indicators are shown to the user, and revoking permissions has no effect.
Suggested Fix
There are multiple ways to address this issue:
- Track Pending Sessions in AIDM:
AudioInputDeviceManagershould keep track of pending asynchronous open requests. IfCloseis called for a pendingsession_id, it should record the cancellation and ensureOpenedOnIOThreaddoes not add the device to thedevices_list when the callback eventually fires. - Fix Session Validation: The
return truelogic inMediaStreamManager::ValidateSessionis dangerous. If a session is truly untracked, it should returnfalse. However, this requires careful handling to avoid breaking legitimate race conditions where a stream is closing. - Prevent ID Leakage: If a request is cancelled while in
MEDIA_REQUEST_STATE_OPENING, do not execute thedevice_stopped_callback, or do not include the validsession_idin the payload, as the device was never fully handed to the renderer.
The most robust fix is likely #1: ensuring AudioInputDeviceManager correctly aborts pending sessions when Close is called.
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.