Medium watch CVSS 5.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in GetUserMedia
DescriptionIncomplete cleanup in GetUserMedia
ComponentGetUserMedia
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker523661149
Fix commit091454c83744 (chromium/src) +89/-1
CWECWE-459
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-13 (per vendor advisory)
Fix landed2026-07-09 (public git)
Disclosed2026-08-25
Patch gap47 days — fix public in source before the advisory

Background

`MediaStreamManager`
the browser-process component that tracks getUserMedia / desktop-capture requests, the devices they opened, and the per-device request state used to drive teardown.
Request state
a per-media-type state on the request (e.g. MEDIA_REQUEST_STATE_DONE) that later teardown (CancelRequest) consults to decide whether a still-open device must be closed.
`MEDIA_DEVICE_UPDATE`
the request event handled when a desktop-capture source change is attempted; on denial the code keeps the existing capture unchanged and re-fronts the previously shared tab.
Source change denial
a ChangeMediaStreamSourceFromBrowser request the picker UI rejects without selecting a new surface, leaving the original devices still capturing.

Root Cause Analysis

When a desktop-capture source change was attempted and then denied, the MEDIA_DEVICE_UPDATE branch kept the original capture running and brought the prior shared tab to the front, but it did not restore the request state of the still-active audio/video devices. Because the denial path left those devices in a request state that later teardown does not treat as active-and-closable, a subsequent CancelRequest (which a compromised renderer can send) stopped the tracks but failed to close the underlying capture devices, so screen/tab capture continued after the request was cancelled. This violates the cleanup invariant that cancelling a request closes every device it opened.

The fix, inside the MEDIA_DEVICE_UPDATE handler in media_stream_manager.cc, calls request->SetState(..., MEDIA_REQUEST_STATE_DONE) for the audio type (when blink::IsAudioInputMediaType) and the video type (when blink::IsVideoInputMediaType), so the still-active devices are marked DONE and later teardown closes them as usual.

Key insight
The mistake was leaving the denied-source-change path with devices still capturing but their request state not restored to MEDIA_REQUEST_STATE_DONE, so cancellation stopped tracks without closing devices; the fix re-marks the active audio/video types DONE so teardown reliably closes them.

Attack Path

  1. Start a desktop capture A page obtains a screen/tab capture via getUserMedia, opening a desktop-capture device tracked by MediaStreamManager.
  2. Attempt and deny a source change A source change is requested (ChangeMediaStreamSourceFromBrowser) and denied via the UI without a new selection, hitting the MEDIA_DEVICE_UPDATE branch that keeps the capture unchanged but leaves the device request state unrestored.
  3. Send a cancel from a compromised renderer A compromised renderer issues CancelRequest; pre-patch, teardown stops the tracks but, seeing the un-restored state, does not close the still-open capture device.
  4. Capture persists after cancel The desktop-capture device keeps capturing despite the request having been cancelled, as the regression test DesktopCaptureDeviceChangeDeniedThenCancel confirms by checking that formats remain queryable pre-fix.

Impact Assessment

The bug leaves a desktop/tab-capture device open and capturing after the associated request is cancelled, so screen contents continue to be captured without a corresponding active request — an incomplete-cleanup / resource-leak with privacy implications in the browser process. The commit states this denial path is currently reachable only with the combination of the fake UI and a compromised renderer, which bounds real-world reachability. The advisory classifies this as a LogicError (“Incomplete cleanup in GetUserMedia”) and does not report in-the-wild exploitation; any broader impact is (inferred).

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_unittest.cc
  • content/browser/renderer_host/media/media_stream_manager.h

Audit Directions

  • State restoration on denial/error paths
    Audit request handlers where an operation is denied or fails but resources stay live; confirm the per-resource state is restored so downstream cleanup still closes them.
  • Cancel/teardown driven by stale state
    Review teardown paths (CancelRequest and similar) that decide what to close based on request/device state to ensure no earlier branch can leave that state inconsistent with what is actually open.
  • Renderer-triggered lifecycle transitions
    Examine sequences a compromised renderer can drive (source change then cancel, stop then restart) for orderings that stop tracks without releasing the underlying capture devices.
From 091454c83744c925b85fe6f9f9398c0ee9a219f2 Mon Sep 17 00:00:00 2001
From: Guido Urdaneta <guidou@chromium.org>
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 <tovep@chromium.org>
Commit-Queue: Guido Urdaneta <guidou@chromium.org>
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 rj...@google.com

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