Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in GetUserMedia
DescriptionIncomplete cleanup in GetUserMedia
ComponentGetUserMedia
Bug ClassLogic Error
Tracker523661149
Fix commit091454c83744 (chromium/src) +89/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/renderer_host/media/media_stream_manager_unittest.cc
modified
BindLambdaForTesting
content/browser/renderer_host/media/media_stream_manager_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/media/media_stream_manager.cc
  • content/browser/renderer_host/media/media_stream_manager.h
  • content/browser/renderer_host/media/media_stream_manager_unittest.cc
From 091454c83744c925b85fe6f9f9398c0ee9a219f2 Mon Sep 17 00:00:00 2001
From: Guido Urdaneta <[email protected]>
Date: Thu, 09 Jul 2026 08:03:54 -0700
Subject: [PATCH] Restore device state after denied desktop-capture source change.

This fixes a bug in which devices in a screen capture remain open if a
surface change is attempted and denied via UI and a compromised
renderer sends a CancelRequest. In this case the tracks are stopped
but the devices stay capturing.

Note that this denial is currently possible only with the combination
of the fake UI and a compromised renderer.

This fix is AI generated (see bug reference)

Fixed: 523661149
Change-Id: I7c0ffdde7a4ebcfdd356272179fde43ed682a941
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8064184
Reviewed-by: Tove Petersson <[email protected]>
Commit-Queue: Guido Urdaneta <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1659556}
---

diff --git a/content/browser/renderer_host/media/media_stream_manager.cc b/content/browser/renderer_host/media/media_stream_manager.cc
index b12a044..c05088d 100644
--- a/content/browser/renderer_host/media/media_stream_manager.cc
+++ b/content/browser/renderer_host/media/media_stream_manager.cc
@@ -3248,7 +3248,15 @@
     }
     case blink::MEDIA_DEVICE_UPDATE: {
       // Fail to change capture source, keep everything unchanged and
-      // bring the previous shared tab to the front.
+      // bring the previous shared tab to the front. Restore the request
+      // state for the still-active devices so that later teardown closes
+      // them as usual.
+      if (blink::IsAudioInputMediaType(request->audio_type())) {
+        request->SetState(request->audio_type(), MEDIA_REQUEST_STATE_DONE);
+      }
+      if (blink::IsVideoInputMediaType(request->video_type())) {
+        request->SetState(request->video_type(), MEDIA_REQUEST_STATE_DONE);
+      }
       DCHECK_EQ(1u, request->stream_devices_set.stream_devices.size());
       const blink::mojom::StreamDevices& devices =
           *request->stream_devices_set.stream_devices[0];
diff --git a/content/browser/renderer_host/media/media_stream_manager.h b/content/browser/renderer_host/media/media_stream_manager.h
index f08401a8..0893f38 100644
--- a/content/browser/renderer_host/media/media_stream_manager.h
+++ b/content/browser/renderer_host/media/media_stream_manager.h
@@ -513,6 +513,8 @@
   FRIEND_TEST_ALL_PREFIXES(MediaStreamManagerTest, DesktopCaptureDeviceStopped);
   FRIEND_TEST_ALL_PREFIXES(MediaStreamManagerTest, DesktopCaptureDeviceChanged);
   FRIEND_TEST_ALL_PREFIXES(MediaStreamManagerTest,
+                           DesktopCaptureDeviceChangeDeniedThenCancel);
+  FRIEND_TEST_ALL_PREFIXES(MediaStreamManagerTest,
                            MultiCaptureOnMediaStreamUIWindowId);
   FRIEND_TEST_ALL_PREFIXES(MediaStreamManagerTest,
                            MultiCaptureAllDevicesOpened);
diff --git a/content/browser/renderer_host/media/media_stream_manager_unittest.cc b/content/browser/renderer_host/media/media_stream_manager_unittest.cc
index 4a96404..7365ef3 100644
--- a/content/browser/renderer_host/media/media_stream_manager_unittest.cc
+++ b/content/browser/renderer_host/media/media_stream_manager_unittest.cc
@@ -23,6 +23,7 @@
 #include "base/task/single_thread_task_runner.h"
 #include "base/test/bind.h"
 #include "base/test/mock_callback.h"
+#include "base/test/scoped_command_line.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
 #include "base/unguessable_token.h"
@@ -1400,6 +1401,83 @@
                                           video_device.session_id());
 }
 
+TEST_F(MediaStreamManagerTest, DesktopCaptureDeviceChangeDeniedThenCancel) {
+  const std::string tab_id =
+      DesktopMediaID(DesktopMediaID::TYPE_WEB_CONTENTS, /*id=*/0,
+                     WebContentsMediaCaptureId(5, 5))
+          .ToString();
+  media_stream_manager_->UseFakeUIFactoryForTests(
+      base::BindLambdaForTesting([&]() {
+        auto fake_ui = std::make_unique<FakeMediaStreamUIProxy>(
+            /*tests_use_fake_render_frame_hosts=*/true);
+        fake_ui->AddAvailableDevices({blink::MediaStreamDevice(
+            blink::mojom::MediaStreamType::GUM_DESKTOP_VIDEO_CAPTURE, tab_id,
+            "Tab")});
+        return std::unique_ptr<FakeMediaStreamUIProxy>(std::move(fake_ui));
+      }));
+
+  blink::StreamControls controls(false /* request_audio */,
+                                 true /* request_video */);
+  controls.video.stream_type =
+      blink::mojom::MediaStreamType::GUM_DESKTOP_VIDEO_CAPTURE;
+  const int requester_id = 1;
+  const int page_request_id = 1;
+
+  blink::MediaStreamDevice video_device;
+  MediaStreamManager::GenerateStreamsCallback generate_stream_callback =
+      base::BindOnce(GenerateStreamsCallback, &run_loop_,
+                     /*request_audio=*/false,
+                     /*request_video=*/true, /*audio_device=*/nullptr,
+                     &video_device,
+                     /*audio_share=*/true);
+  EXPECT_CALL(*media_observer_, OnMediaRequestStateChanged(_, _, _, _, _, _))
+      .Times(testing::AtLeast(1));
+
+  media_stream_manager_->GenerateStreams(
+      kRenderFrameHostId, requester_id, page_request_id, controls,
+      MediaDeviceSaltAndOrigin::Empty(), false /* user_gesture */,
+      StreamSelectionInfo::NewSearchOnlyByDeviceId({}),
+      std::move(generate_stream_callback),
+      MediaStreamManager::DeviceStoppedCallback(),
+      MediaStreamManager::DeviceChangedCallback(),
+      MediaStreamManager::DeviceRequestStateChangeCallback(),
+      MediaStreamManager::DeviceCaptureConfigurationChangeCallback(),
+      MediaStreamManager::DeviceCaptureHandleChangeCallback(),
+      MediaStreamManager::ZoomLevelChangeCallback());
+  run_loop_.Run();
+  EXPECT_EQ(controls.video.stream_type, video_device.type);
+
+  const std::string request_label = GetLatestLabel();
+  const base::UnguessableToken session_id = video_device.session_id();
+  media::VideoCaptureFormats formats;
+  ASSERT_TRUE(
+      media_stream_manager_->video_capture_manager()->GetDeviceSupportedFormats(
+          session_id, &formats));
+
+  // Request a source change but have the picker dismiss it without making a
+  // new selection. The original capture is expected to continue unchanged.
+  base::test::ScopedCommandLine scoped_command_line;
+  scoped_command_line.GetProcessCommandLine()->AppendSwitchASCII(
+      switches::kUseFakeUIForMediaStream, "deny");
+  media_stream_manager_->ChangeMediaStreamSourceFromBrowser(
+      request_label, DesktopMediaID(),
+      /*captured_surface_control_active=*/false);
+  base::RunLoop().RunUntilIdle();
+  EXPECT_EQ(
+      1u,
+      media_stream_manager_->GetDevicesOpenedByRequest(request_label).size());
+
+  // Cancelling the request must close the underlying capture session.
+  media_stream_manager_->CancelRequest(request_label);
+  base::RunLoop().RunUntilIdle();
+  EXPECT_TRUE(
+      media_stream_manager_->GetDevicesOpenedByRequest(request_label).empty());
+  formats.clear();
+  EXPECT_FALSE(
+      media_stream_manager_->video_capture_manager()->GetDeviceSupportedFormats(
+          session_id, &formats));
+}
+
 TEST_F(MediaStreamManagerTest, MultiCaptureOnMediaStreamUIWindowId) {
   std::vector<media::VideoCaptureSessionId> session_ids;
   VideoCaptureManager::SetDesktopCaptureWindowIdCallback callback =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/media/media_stream_manager_unittest.cc b/content/browser/renderer_host/media/media_stream_manager_unittest.cc
index 4a96404..7365ef3 100644
--- a/content/browser/renderer_host/media/media_stream_manager_unittest.cc
+++ b/content/browser/renderer_host/media/media_stream_manager_unittest.cc
@@ -23,6 +23,7 @@
 #include "base/task/single_thread_task_runner.h"
 #include "base/test/bind.h"
 #include "base/test/mock_callback.h"
+#include "base/test/scoped_command_line.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
 #include "base/unguessable_token.h"
@@ -1400,6 +1401,83 @@
                                           video_device.session_id());
 }
 
+TEST_F(MediaStreamManagerTest, DesktopCaptureDeviceChangeDeniedThenCancel) {
+  const std::string tab_id =
+      DesktopMediaID(DesktopMediaID::TYPE_WEB_CONTENTS, /*id=*/0,
+                     WebContentsMediaCaptureId(5, 5))
+          .ToString();
+  media_stream_manager_->UseFakeUIFactoryForTests(
+      base::BindLambdaForTesting([&]() {
+        auto fake_ui = std::make_unique<FakeMediaStreamUIProxy>(
+            /*tests_use_fake_render_frame_hosts=*/true);
+        fake_ui->AddAvailableDevices({blink::MediaStreamDevice(
+            blink::mojom::MediaStreamType::GUM_DESKTOP_VIDEO_CAPTURE, tab_id,
+            "Tab")});
+        return std::unique_ptr<FakeMediaStreamUIProxy>(std::move(fake_ui));
+      }));
+
+  blink::StreamControls controls(false /* request_audio */,
+                                 true /* request_video */);
+  controls.video.stream_type =
+      blink::mojom::MediaStreamType::GUM_DESKTOP_VIDEO_CAPTURE;
+  const int requester_id = 1;
+  const int page_request_id = 1;
+
+  blink::MediaStreamDevice video_device;
+  MediaStreamManager::GenerateStreamsCallback generate_stream_callback =
+      base::BindOnce(GenerateStreamsCallback, &run_loop_,
+                     /*request_audio=*/false,
+                     /*request_video=*/true, /*audio_device=*/nullptr,
+                     &video_device,
+                     /*audio_share=*/true);
+  EXPECT_CALL(*media_observer_, OnMediaRequestStateChanged(_, _, _, _, _, _))
+      .Times(testing::AtLeast(1));
+
+  media_stream_manager_->GenerateStreams(
+      kRenderFrameHostId, requester_id, page_request_id, controls,
+      MediaDeviceSaltAndOrigin::Empty(), false /* user_gesture */,
+      StreamSelectionInfo::NewSearchOnlyByDeviceId({}),
+      std::move(generate_stream_callback),
+      MediaStreamManager::DeviceStoppedCallback(),
+      MediaStreamManager::DeviceChangedCallback(),
+      MediaStreamManager::DeviceRequestStateChangeCallback(),
+      MediaStreamManager::DeviceCaptureConfigurationChangeCallback(),
+      MediaStreamManager::DeviceCaptureHandleChangeCallback(),
+      MediaStreamManager::ZoomLevelChangeCallback());
+  run_loop_.Run();
+  EXPECT_EQ(controls.video.stream_type, video_device.type);
+
+  const std::string request_label = GetLatestLabel();
+  const base::UnguessableToken session_id = video_device.session_id();
+  media::VideoCaptureFormats formats;
+  ASSERT_TRUE(
+      media_stream_manager_->video_capture_manager()->GetDeviceSupportedFormats(
+          session_id, &formats));
+
+  // Request a source change but have the picker dismiss it without making a
+  // new selection. The original capture is expected to continue unchanged.
+  base::test::ScopedCommandLine scoped_command_line;
+  scoped_command_line.GetProcessCommandLine()->AppendSwitchASCII(
+      switches::kUseFakeUIForMediaStream, "deny");
+  media_stream_manager_->ChangeMediaStreamSourceFromBrowser(
+      request_label, DesktopMediaID(),
+      /*captured_surface_control_active=*/false);
+  base::RunLoop().RunUntilIdle();
+  EXPECT_EQ(
+      1u,
+      media_stream_manager_->GetDevicesOpenedByRequest(request_label).size());
+
+  // Cancelling the request must close the underlying capture session.
+  media_stream_manager_->CancelRequest(request_label);
+  base::RunLoop().RunUntilIdle();
+  EXPECT_TRUE(
+      media_stream_manager_->GetDevicesOpenedByRequest(request_label).empty());
+  formats.clear();
+  EXPECT_FALSE(
+      media_stream_manager_->video_capture_manager()->GetDeviceSupportedFormats(
+          session_id, &formats));
+}
+
 TEST_F(MediaStreamManagerTest, MultiCaptureOnMediaStreamUIWindowId) {
   std::vector<media::VideoCaptureSessionId> session_ids;
   VideoCaptureManager::SetDesktopCaptureWindowIdCallback callback =
Loading diff…

Original Bug Report

reported by [email protected]

Potential Screen Capture Privacy Leak due to MediaStreamManager State Error

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 state machine inconsistency in MediaStreamManager allows media capture to continue silently after being stopped if a prior source change request failed. The failure leaves the request stuck in a pending state, causing subsequent stop requests to bypass hardware closure while still removing all UI privacy indicators.

Affected files:

  • content/browser/renderer_host/media/media_stream_manager.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A state machine bug exists in MediaStreamManager that can cause media capture devices (such as screen sharing) to remain active in the background after the user believes they have stopped the capture.

If a user is sharing their screen and initiates a source change (e.g., clicking “Share this tab instead”), but then cancels the media picker prompt, the internal state of the capture session becomes corrupted. Subsequently, if the user or the web application attempts to stop the stream, the browser successfully removes all UI privacy indicators (like the sharing bubble and tab icon) but completely bypasses the logic to stop the underlying hardware capture. A compromised renderer process could theoretically continue receiving captured frames indefinitely without the user’s knowledge.

Technical Details

When an active capture session undergoes a source change, MediaStreamManager::ChangeMediaStreamSourceFromBrowser is invoked. This updates the underlying DeviceRequest type to blink::MEDIA_DEVICE_UPDATE and sets the request state to MEDIA_REQUEST_STATE_PENDING_APPROVAL while waiting for the user to select a new tab in the media picker.

If the user cancels the picker, MediaStreamManager::FinalizeRequestFailed is called. For MEDIA_DEVICE_UPDATE requests, this function contains a logic error:

void MediaStreamManager::FinalizeRequestFailed(
    DeviceRequests::const_iterator request_it,
    MediaStreamRequestResult result) {
  // ...
  switch (request->request_type()) {
    // ...
    case blink::MEDIA_DEVICE_UPDATE: {
      // Fail to change capture source, keep everything unchanged and
      // bring the previous shared tab to the front.
      // ...
      return; // Early return!
    }
  }
  DeleteRequest(request_it);
}

The early return allows the original stream to survive the cancellation, but it fails to revert the DeviceRequest state back to MEDIA_REQUEST_STATE_DONE. The state remains stuck at MEDIA_REQUEST_STATE_PENDING_APPROVAL.

Later, when the stream is stopped (e.g., the user clicks “Stop sharing”, or the tab is closed), MediaStreamManager::CancelRequest is called to tear down the capture. It iterates over the active devices and checks their state:

// content/browser/renderer_host/media/media_stream_manager.cc
void MediaStreamManager::CancelRequest(...) {
  // ...
  for (...) {
      const MediaRequestState state = request->state(device.type);
      if (state != MEDIA_REQUEST_STATE_OPENING &&
          state != MEDIA_REQUEST_STATE_DONE) {
        continue; // Skips CloseDevice!
      }
      CloseDevice(device.type, device.session_id());
  }
  // ...
  DeleteRequest(request_it);
}

Because the state is stuck at MEDIA_REQUEST_STATE_PENDING_APPROVAL, the condition evaluates to true, and continue; is executed. The critical call to CloseDevice is skipped, meaning VideoCaptureManager::Close is never invoked, and the hardware continues capturing.

Finally, CancelRequest calls DeleteRequest(request_it), which destroys the DeviceRequest and its MediaStreamUIProxy. This removes all user-facing privacy indicators. However, the Mojo IPC (VideoCaptureHost) remains open, allowing a compromised renderer to silently exfiltrate the continuous stream of captured frames.

Potential Exploit Steps

Note: These are suggested steps based on static analysis.

  1. An attacker gains code execution in a renderer process.
  2. The attacker triggers a legitimate screen capture request (getDisplayMedia()), which the user approves.
  3. The attacker prompts the user to change the shared tab (e.g., by presenting a confusing UI that leads the user to click the browser’s “Share this tab instead” button but then cancel the resulting prompt).
  4. The user cancels the source change prompt. The state is now corrupted.
  5. The attacker’s script calls track.stop(), or the user clicks “Stop sharing”.
  6. The browser destroys the UI indicators, making the user think capture has stopped.
  7. The compromised renderer ignores standard IPC stop messages, keeps the VideoCaptureHost Mojo pipe open, and continues to silently receive screen frames.

Suggested Fix

In MediaStreamManager::FinalizeRequestFailed, within the blink::MEDIA_DEVICE_UPDATE switch case, explicitly reset the request state to MEDIA_REQUEST_STATE_DONE for the active devices before executing the early return.

    case blink::MEDIA_DEVICE_UPDATE: {
      // Fail to change capture source, keep everything unchanged and
      // bring the previous shared tab to the front.
      request->SetState(request->audio_type(), MEDIA_REQUEST_STATE_DONE);
      request->SetState(request->video_type(), MEDIA_REQUEST_STATE_DONE);
      // ...
      return;
    }

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.

View on issue tracker