Low watch CVSS 6.5 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Bluetooth
DescriptionInsufficient policy enforcement in Bluetooth
ComponentBluetooth
Bug ClassPolicy Bypass
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.
Tracker496371586
Fix commite6b4397484c4 (chromium/src) +33/-0
CWECWE-284
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-26 (per vendor advisory)
Fix landed2026-05-05 (public git)
Disclosed2026-06-30
Patch gap56 days — fix public in source before the advisory

Background

Web Bluetooth `watchAdvertisements()`
a web API that lets a page subscribe to a Bluetooth device’s advertisement packets, plumbed through WebBluetoothServiceImpl in the browser process.
Pending clients queue
watch_advertisements_pending_clients_, the list where a watchAdvertisements() request waits when no active discovery session exists yet.
`ClearAdvertisementClients()`
the browser-side cleanup routine invoked when the page loses visibility (tab hidden), meant to stop all advertisement-related activity for privacy/policy reasons.
Visibility policy
Chrome’s rule that Bluetooth scanning/advertisement watching must not continue while a tab is backgrounded/hidden.

Root Cause Analysis

When a page calls watchAdvertisements() and no active discovery session exists, the request is parked in watch_advertisements_pending_clients_. On visibility loss the browser calls WebBluetoothServiceImpl::ClearAdvertisementClients(), which cleared scanning_clients_ and watch_advertisements_clients_ but never touched watch_advertisements_pending_clients_. Consequently a pending watch request survived the tab being hidden, so once a discovery session later started the request could resume delivering advertisements to a backgrounded page, violating the visibility policy invariant that all advertisement watching stops when the tab is not visible.

The fix moves the pending queue out and runs each pending client’s callback with WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED, guaranteeing pending requests are aborted and cleared alongside the active ones.

Key insight
ClearAdvertisementClients() enforced the visibility policy on active clients but forgot the pending-clients queue; the fix aborts and clears watch_advertisements_pending_clients_ too, closing the bypass.

Attack Path

  1. Issue a pending watch request A page calls watchAdvertisements() for a device while no active discovery session exists, so the request lands in watch_advertisements_pending_clients_ rather than being started immediately.
  2. Hide the tab The user backgrounds/hides the tab, invoking ClearAdvertisementClients(), which pre-patch cleared only active clients and left the pending request intact.
  3. Bypass the visibility restriction When a discovery session subsequently becomes available, the still-queued pending client is serviced, delivering advertisement data to the non-visible page in violation of policy.
  4. Result The hidden page continues to observe Bluetooth advertisements it should have been denied, a privacy/policy bypass; no memory-safety primitive is involved.

Impact Assessment

A malicious or backgrounded web page gains the ability to keep receiving Bluetooth advertisement data after its tab is hidden, defeating the visibility-based restriction enforced in the browser process. Preconditions are that the page already holds Web Bluetooth permission and issued a watchAdvertisements() request while no discovery session was active. The advisory rates this as insufficient policy enforcement in Bluetooth; no exploited-in-the-wild status is stated and no memory-safety escalation is implied.

Changed Functions

FunctionChangeNotes
for
content/browser/bluetooth/web_bluetooth_service_impl.cc
modified

Files Changed

  • content/browser/bluetooth/web_bluetooth_service_impl.cc
  • content/browser/bluetooth/web_bluetooth_service_impl.h
  • content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc

Audit Directions

  • Parallel state containers
    When a cleanup routine iterates one collection of clients/sessions, verify every sibling collection representing the same feature (active vs. pending vs. deferred) is cleared in the same place.
  • Visibility/lifecycle teardown completeness
    Audit Clear*/Reset* handlers tied to tab visibility or navigation to confirm they cover queued/deferred work, not just in-progress work.
  • Deferred-request queues
    Review any “pending until precondition met” queue to ensure it is invalidated whenever the guarding policy state (visibility, permission, focus) changes.
From e6b4397484c484f790d1ba2c75fd1c0b59ab6182 Mon Sep 17 00:00:00 2001
From: Rob Pitkin <robpitkin@google.com>
Date: Tue, 05 May 2026 16:12:32 -0700
Subject: [PATCH] Web Bluetooth: Abort pending watchAdvertisements on visibility loss

When a Web Bluetooth client calls watchAdvertisements(), if no active
discovery session exists, the request is placed in the
watch_advertisements_pending_clients_ queue.

If the page loses visibility, ClearAdvertisementClients() is called.
However, this function only cleared active clients and neglected
watch_advertisements_pending_clients_.

This CL ensures that all pending clients are aborted and cleared from
watch_advertisements_pending_clients_ during ClearAdvertisementClients(),
preventing visibility bypass.

Added a test to verify that pending requests are aborted when tab is hidden.

Bug: 496371586
Change-Id: I98945181d9eb132a4cec1869edfdb20ca269a2c9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7814024
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Rob Pitkin <robpitkin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1625796}
---

diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.cc b/content/browser/bluetooth/web_bluetooth_service_impl.cc
index 534f529..9a8a4ff1 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.cc
@@ -2391,6 +2391,13 @@
 void WebBluetoothServiceImpl::ClearAdvertisementClients() {
   scanning_clients_.clear();
   watch_advertisements_clients_.clear();
+
+  auto pending_clients = std::move(watch_advertisements_pending_clients_);
+  for (auto& pending_client : pending_clients) {
+    pending_client->RunCallback(
+        blink::mojom::WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED);
+  }
+
   allowed_scan_filters_.clear();
   accept_all_advertisements_ = false;
 }
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.h b/content/browser/bluetooth/web_bluetooth_service_impl.h
index 2b37b966..3798fb0 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.h
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.h
@@ -165,6 +165,8 @@
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
                            TwoWatchAdvertisementsReqFail);
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
+                           WatchAdvertisementsReqAbortedWhenTabHidden);
+  FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
                            SecWatchAdvertisementsReqAfterFirstSuccess);
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
                            WatchAdvertisementsFastPathChecksPermission);
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
index 5edbae8..309c147 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
@@ -1161,6 +1161,30 @@
 }
 
 TEST_F(WebBluetoothServiceImplTest,
+       WatchAdvertisementsReqAbortedWhenTabHidden) {
+  TestFuture<WebBluetoothResult> future;
+
+  const auto battery_device_id = AddTestDevice(battery_device_bundle());
+
+  mojo::PendingAssociatedRemote<blink::mojom::WebBluetoothAdvertisementClient>
+      client_remote;
+
+  battery_device_bundle().advertisement_client().BindReceiver(
+      client_remote.InitWithNewEndpointAndPassReceiver());
+
+  // Install SUCCESS result for StartScanWithFilter
+  adapter_->SetStartScanWithFilterResult(
+      device::UMABluetoothDiscoverySessionOutcome::SUCCESS);
+
+  service_ptr_->WatchAdvertisementsForDevice(
+      battery_device_id, std::move(client_remote), future.GetCallback());
+
+  contents()->SetVisibilityAndNotifyObservers(Visibility::HIDDEN);
+
+  EXPECT_EQ(future.Get(), WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED);
+}
+
+TEST_F(WebBluetoothServiceImplTest,
        SecWatchAdvertisementsReqAfterFirstSuccess) {
   // Install SUCCESS result for StartScanWithFilter
   adapter_->SetStartScanWithFilterResult(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
index 5edbae8..309c147 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
@@ -1161,6 +1161,30 @@
 }
 
 TEST_F(WebBluetoothServiceImplTest,
+       WatchAdvertisementsReqAbortedWhenTabHidden) {
+  TestFuture<WebBluetoothResult> future;
+
+  const auto battery_device_id = AddTestDevice(battery_device_bundle());
+
+  mojo::PendingAssociatedRemote<blink::mojom::WebBluetoothAdvertisementClient>
+      client_remote;
+
+  battery_device_bundle().advertisement_client().BindReceiver(
+      client_remote.InitWithNewEndpointAndPassReceiver());
+
+  // Install SUCCESS result for StartScanWithFilter
+  adapter_->SetStartScanWithFilterResult(
+      device::UMABluetoothDiscoverySessionOutcome::SUCCESS);
+
+  service_ptr_->WatchAdvertisementsForDevice(
+      battery_device_id, std::move(client_remote), future.GetCallback());
+
+  contents()->SetVisibilityAndNotifyObservers(Visibility::HIDDEN);
+
+  EXPECT_EQ(future.Get(), WebBluetoothResult::WATCH_ADVERTISEMENTS_ABORTED);
+}
+
+TEST_F(WebBluetoothServiceImplTest,
        SecWatchAdvertisementsReqAfterFirstSuccess) {
   // Install SUCCESS result for StartScanWithFilter
   adapter_->SetStartScanWithFilterResult(
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Web Bluetooth watchAdvertisements visibility bypass via uncleared pending-client queue

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The Web Bluetooth implementation in Chrome fails to properly clear pending client requests and discovery session state when a tab is hidden. This allows a backgrounded tab to potentially continue receiving Bluetooth advertisement data from authorized devices, bypassing intended privacy protections. Two distinct bypass vectors exist due to incomplete state clearing in ClearAdvertisementClients().

Affected files:

  • content/browser/bluetooth/web_bluetooth_service_impl.cc
  • content/browser/bluetooth/web_bluetooth_service_impl.h
  • content/browser/bluetooth/advertisement_client.cc

Estimated timestamp from git blame: 2026-03-11

Description

A potential privacy bypass vulnerability exists in the Web Bluetooth API implementation in Chrome. When a tab is backgrounded or loses focus, WebBluetoothServiceImpl::ClearAdvertisementClients() is called to stop the delivery of Bluetooth advertisements to the renderer. However, this function is incomplete, leading to two potential bypass vectors that could allow hidden tabs to receive proximity and tracking data (RSSI, manufacturer data, etc.) from previously authorized Bluetooth devices.

1. Pending Client Promotion Race

When device.watchAdvertisements() is called and no discovery session is currently active, the client is added to a pending queue (watch_advertisements_pending_clients_) and an asynchronous request to start a discovery session is sent to the platform.

If the tab is hidden before the platform responds, ClearAdvertisementClients() is triggered but fails to clear this pending queue (content/browser/bluetooth/web_bluetooth_service_impl.cc:2382). When OnStartDiscoverySessionForWatchAdvertisements() eventually receives the session handle, it iterates through the surviving pending queue and moves the clients into the active watch_advertisements_clients_ list (content/browser/bluetooth/web_bluetooth_service_impl.cc:1788). The checks performed during this promotion only verify device permission and Mojo pipe liveness, not the current visibility state of the tab, allowing a hidden tab to become an active listener.

2. Synchronous Session Reuse Bypass

ClearAdvertisementClients() also fails to reset the watch_advertisements_discovery_session_ handle or call MaybeStopDiscovery(). Consequently, the discovery session remains active in the browser process even after a tab is backgrounded.

If a hidden tab re-sends the watchAdvertisements IPC, WatchAdvertisementsForDeviceImpl() follows a synchronous fast-path (lines 1733-1741 in web_bluetooth_service_impl.cc) because the session already exists. This path directly inserts the client into the active list without any visibility checks, synchronously re-subscribing the hidden tab to the active session.

Once a client is in the active list via either vector, DeviceAdvertisementReceived() delivers advertisement events to the hidden tab via WatchAdvertisementsClient::SendEvent (content/browser/bluetooth/advertisement_client.cc:62). This enables persistent tracking of the user’s nearby Bluetooth devices without their knowledge or consent, even when the tab is not visible.

(Note: These are potential steps and analysis, as this agent cannot run code to verify.)

Suggested Steps to Reproduce

Vector 1 (Race):

  1. Grant an origin permission to a Bluetooth device via navigator.bluetooth.requestDevice().
  2. From the page, call device.watchAdvertisements().
  3. Immediately switch to another tab or minimize the browser window before the OS Bluetooth session starts.
  4. Observe that the hidden page potentially continues to receive advertisingevent data (RSSI, manufacturer data) despite the visibility change.

Vector 2 (Synchronous Reuse):

  1. Grant an origin permission to a Bluetooth device.
  2. From the page, call device.watchAdvertisements() and wait for the session to successfully start.
  3. Switch to another tab or minimize the browser window.
  4. While hidden, have the page call device.watchAdvertisements() again.
  5. Observe that the hidden page potentially receives advertisingevent data.

Suggested Fix

Update WebBluetoothServiceImpl::ClearAdvertisementClients() to comprehensively clear all advertisement-related state:

  1. Clear the watch_advertisements_pending_clients_ queue.
  2. Reset watch_advertisements_discovery_session_ and ble_scan_discovery_session_.
  3. Alternatively, ensure MaybeStopDiscovery() is called when visibility changes so that the underlying platform sessions are properly halted when no active visible clients remain.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker