CVE-2026-14035
Overview
Background
- Web Bluetooth `watchAdvertisements()`
- a web API that lets a page subscribe to a Bluetooth device’s advertisement packets, plumbed through
WebBluetoothServiceImplin the browser process. - Pending clients queue
watch_advertisements_pending_clients_, the list where awatchAdvertisements()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.
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
- Issue a pending watch request
A page calls
watchAdvertisements()for a device while no active discovery session exists, so the request lands inwatch_advertisements_pending_clients_rather than being started immediately. - Hide the tab
The user backgrounds/hides the tab, invoking
ClearAdvertisementClients(), which pre-patch cleared only active clients and left the pending request intact. - 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.
- 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
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
| Function | Change | Notes |
|---|---|---|
forcontent/browser/bluetooth/web_bluetooth_service_impl.cc |
modified |
Files Changed
content/browser/bluetooth/web_bluetooth_service_impl.cccontent/browser/bluetooth/web_bluetooth_service_impl.hcontent/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
Audit Directions
- Parallel state containersWhen 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 completenessAudit
Clear*/Reset*handlers tied to tab visibility or navigation to confirm they cover queued/deferred work, not just in-progress work. - Deferred-request queuesReview any “pending until precondition met” queue to ensure it is invalidated whenever the guarding policy state (visibility, permission, focus) changes.
Patch
From e6b4397484c484f790d1ba2c75fd1c0b59ab6182 Mon Sep 17 00:00:00 2001 From: Rob Pitkin <[email protected]> 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 <[email protected]> Commit-Queue: Rob Pitkin <[email protected]> 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(
Regression Test / PoC
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(
Original Bug Report
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.cccontent/browser/bluetooth/web_bluetooth_service_impl.hcontent/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):
- Grant an origin permission to a Bluetooth device via
navigator.bluetooth.requestDevice(). - From the page, call
device.watchAdvertisements(). - Immediately switch to another tab or minimize the browser window before the OS Bluetooth session starts.
- Observe that the hidden page potentially continues to receive
advertisingeventdata (RSSI, manufacturer data) despite the visibility change.
Vector 2 (Synchronous Reuse):
- Grant an origin permission to a Bluetooth device.
- From the page, call
device.watchAdvertisements()and wait for the session to successfully start. - Switch to another tab or minimize the browser window.
- While hidden, have the page call
device.watchAdvertisements()again. - Observe that the hidden page potentially receives
advertisingeventdata.
Suggested Fix
Update WebBluetoothServiceImpl::ClearAdvertisementClients() to comprehensively clear all advertisement-related state:
- Clear the
watch_advertisements_pending_clients_queue. - Reset
watch_advertisements_discovery_session_andble_scan_discovery_session_. - 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.