Medium watch CVSS 8.8 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Bluetooth
DescriptionInsufficient policy enforcement in Bluetooth
ComponentBluetooth
Sub-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.
Tracker503912196
Fix commita0b55d797bbc (chromium/src) +143/-11
CWECWE-602
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-18 (per vendor advisory)
Fix landed2026-05-15 (public git)
Disclosed2026-06-30
Patch gap46 days — fix public in source before the advisory

Background

Web Bluetooth GATT blocklist
BluetoothBlocklist, a security list that marks specific GATT UUIDs as excluded from reads and/or writes so that websites cannot access dangerous attributes; enforced in the browser process.
GATT descriptor vs. characteristic
a descriptor is a child attribute of a characteristic providing metadata; the blocklist can exclude a characteristic while a descriptor under it is not itself listed.
`IsExcludedFromReads` / `IsExcludedFromWrites`
BluetoothBlocklist predicates that return whether a given UUID is barred from read or write access.
`RemoteDescriptorReadValue` / `RemoteDescriptorWriteValue`
the WebBluetoothServiceImpl Mojo handlers that service a renderer’s request to read or write a descriptor value.

Root Cause Analysis

The descriptor read and write handlers in web_bluetooth_service_impl.cc checked only the descriptor’s own UUID against the blocklist (IsExcludedFromReads/IsExcludedFromWrites on query_result.descriptor->GetUUID()), never the UUID of the descriptor’s parent characteristic. The security invariant — that a characteristic which is blocklisted must not be accessible through any of its attributes — was violated, because a website could reach a protected characteristic’s data through one of its (non-blocklisted) descriptors.

The fix ORs in an additional check on query_result.characteristic->GetUUID() in both handlers, so a read or write is rejected with BLOCKLISTED_READ/BLOCKLISTED_WRITE if either the descriptor or its parent characteristic is blocklisted. The regression tests confirm that after blocklisting the parent characteristic’s UUID, RemoteDescriptorReadValue and RemoteDescriptorWriteValue return the blocklisted results.

Key insight
The blocklist was enforced only on the descriptor’s own UUID and not on its parent characteristic, so descriptors served as an unguarded side door to protected characteristics; adding the parent-characteristic check to both handlers closes it.

Attack Path

  1. Connect to a device with a blocklisted characteristic A malicious page uses Web Bluetooth to connect and gains a GATT service containing a characteristic whose UUID is on the blocklist (excluded from reads and/or writes).
  2. Enumerate the descriptor The page calls RemoteCharacteristicGetDescriptors to obtain a descriptor instance id under that blocklisted characteristic (the descriptor itself not being blocklisted).
  3. Bypass via descriptor read/write The page calls RemoteDescriptorReadValue or RemoteDescriptorWriteValue on that descriptor; pre-patch, only the descriptor UUID was checked, so the operation proceeded despite the parent characteristic being protected.
  4. Access protected data The read returns descriptor data of, or the write mutates, a characteristic the blocklist was meant to shield, defeating the policy control.

Impact Assessment

A website with Web Bluetooth access to a device can bypass the GATT blocklist and perform read/write operations on descriptors of characteristics that policy intended to be inaccessible, all within the browser-process Web Bluetooth service on behalf of the renderer. The advisory classifies this as insufficient policy enforcement in Bluetooth with no exploited-in-the-wild status stated; impact is confined to circumventing the blocklist rather than memory corruption. The precondition is an established Web Bluetooth connection to a device exposing a blocklisted characteristic with an enumerable descriptor.

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
  • third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js

Audit Directions

  • Parent/child attribute enforcement
    Review every access-control check on hierarchical objects (services → characteristics → descriptors) to confirm that restrictions on a parent are inherited by children rather than checked only at the leaf.
  • Blocklist coverage per entry point
    Audit all Web Bluetooth Mojo handlers (RemoteCharacteristic*, RemoteDescriptor*, service-level reads/writes) to ensure each independently enforces the blocklist for the full ancestry of the target UUID.
  • Indirect access paths
    Look for cases where a protected resource can be reached through a related, unlisted object (aliasing, metadata attributes, notify sessions) that skips the primary policy gate.
From a0b55d797bbc3d7dfd31113f4957df873d50d20b Mon Sep 17 00:00:00 2001
From: Rob Pitkin <[email protected]>
Date: Fri, 15 May 2026 16:40:45 -0700
Subject: [PATCH] bluetooth: Fix missing blocklist check for characteristic in descriptors

Web Bluetooth fails to check the GATT blocklist for a descriptor's
parent characteristic during read and write operations. This allows
websites to bypass security restrictions and interact with descriptors
of protected characteristics.

This CL adds checks in `RemoteDescriptorReadValue` and
`RemoteDescriptorWriteValue` to ensure that the parent characteristic is
also checked against the GATT blocklist before allowing read or write
operations on its descriptors.

Unit tests are added to verify that reads and writes are blocked when
the parent characteristic is blocklisted.

Bug: 503912196
Change-Id: If62e10d5ac66ed414aec1a3a4af346133a584374
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7837659
Commit-Queue: Rob Pitkin <[email protected]>
Reviewed-by: Matt Reynolds <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1631633}
---

diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.cc b/content/browser/bluetooth/web_bluetooth_service_impl.cc
index a075173..deea337 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.cc
@@ -1383,7 +1383,9 @@
   }
 
   if (BluetoothBlocklist::Get().IsExcludedFromReads(
-          query_result.descriptor->GetUUID())) {
+          query_result.descriptor->GetUUID()) ||
+      BluetoothBlocklist::Get().IsExcludedFromReads(
+          query_result.characteristic->GetUUID())) {
     std::move(callback).Run(blink::mojom::WebBluetoothResult::BLOCKLISTED_READ,
                             /*value=*/{});
     return;
@@ -1423,7 +1425,9 @@
   }
 
   if (BluetoothBlocklist::Get().IsExcludedFromWrites(
-          query_result.descriptor->GetUUID())) {
+          query_result.descriptor->GetUUID()) ||
+      BluetoothBlocklist::Get().IsExcludedFromWrites(
+          query_result.characteristic->GetUUID())) {
     std::move(callback).Run(
         blink::mojom::WebBluetoothResult::BLOCKLISTED_WRITE);
     return;
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl.h b/content/browser/bluetooth/web_bluetooth_service_impl.h
index c414992..73196dd4 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl.h
+++ b/content/browser/bluetooth/web_bluetooth_service_impl.h
@@ -178,6 +178,12 @@
                            EmulatedAdapterRemovalRestoresOriginalAdapter);
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
                            ServiceDestroyedDuringAdapterAcquisition);
+  FRIEND_TEST_ALL_PREFIXES(
+      WebBluetoothServiceImplTest,
+      RemoteDescriptorReadValue_ParentCharacteristicBlocklisted);
+  FRIEND_TEST_ALL_PREFIXES(
+      WebBluetoothServiceImplTest,
+      RemoteDescriptorWriteValue_ParentCharacteristicBlocklisted);
 
 #if PAIR_BLUETOOTH_ON_DEMAND()
   FRIEND_TEST_ALL_PREFIXES(WebBluetoothServiceImplTest,
diff --git a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
index 42523def..4880d79 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
@@ -32,6 +32,7 @@
 #include "device/bluetooth/test/mock_bluetooth_adapter.h"
 #include "device/bluetooth/test/mock_bluetooth_device.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_characteristic.h"
+#include "device/bluetooth/test/mock_bluetooth_gatt_descriptor.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_notify_session.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_service.h"
 #include "mojo/public/cpp/bindings/associated_receiver.h"
@@ -96,6 +97,8 @@
 const device::BluetoothUUID kBatteryServiceUUID(kBatteryServiceUUIDString);
 const device::BluetoothUUID kBatteryLevelCharacteristicUUID(
     "00002a19-0000-1000-8000-00805f9b34fb");
+const device::BluetoothUUID kCharacteristicUserDescriptionDescriptorUUID(
+    "00002901-0000-1000-8000-00805f9b34fb");
 const BluetoothDeviceBundleData battery_device_bundle_data = {
     kBatteryServiceId, kBatteryLevelCharacteristicId, kBatteryServiceUUID,
     kBatteryLevelCharacteristicUUID, kTestCharacteristicProperties};
@@ -559,6 +562,7 @@
     heart_rate_device_bundle_.reset();
     service_ptr_ = nullptr;
     SetBrowserClientForTesting(old_browser_client_);
+    BluetoothBlocklist::Get().ResetToDefaultValuesForTest();
     RenderViewHostImplTestHarness::TearDown();
   }
 
@@ -1442,4 +1446,95 @@
   EXPECT_CALL(*adapter_, StopScan).Times(1);
 }
 
+TEST_F(WebBluetoothServiceImplTest,
+       RemoteDescriptorReadValue_ParentCharacteristicBlocklisted) {
+  RegisterTestCharacteristic();
+
+  FakeBluetoothCharacteristic& test_characteristic =
+      battery_device_bundle().characteristic();
+
+  auto mock_descriptor =
+      std::make_unique<testing::NiceMock<device::MockBluetoothGattDescriptor>>(
+          &test_characteristic, "test_descriptor_id",
+          kCharacteristicUserDescriptionDescriptorUUID,
+          device::BluetoothRemoteGattCharacteristic::PERMISSION_NONE);
+
+  test_characteristic.AddMockDescriptor(std::move(mock_descriptor));
+
+  base::test::TestFuture<
+      WebBluetoothResult,
+      std::optional<
+          std::vector<blink::mojom::WebBluetoothRemoteGATTDescriptorPtr>>>
+      get_descriptors_future;
+  service_ptr_->RemoteCharacteristicGetDescriptors(
+      test_characteristic.GetIdentifier(),
+      WebBluetoothGATTQueryQuantity::SINGLE, std::nullopt,
+      get_descriptors_future.GetCallback());
+
+  EXPECT_EQ(get_descriptors_future.Get<0>(), WebBluetoothResult::SUCCESS);
+
+  const auto& descriptors = get_descriptors_future.Get<1>();
+  ASSERT_TRUE(descriptors.has_value());
+  ASSERT_FALSE(descriptors->empty());
+  std::string descriptor_instance_id = descriptors->at(0)->instance_id;
+
+  BluetoothBlocklist::Get().Add(test_characteristic.GetUUID(),
+                                BluetoothBlocklist::Value::EXCLUDE_READS);
+
+  base::test::TestFuture<WebBluetoothResult, std::vector<uint8_t>> read_future;
+  service_ptr_->RemoteDescriptorReadValue(
+      descriptor_instance_id,
+      base::BindLambdaForTesting(
+          [&read_future](WebBluetoothResult result,
+                         base::span<const uint8_t> value) {
+            read_future.SetValue(
+                result, std::vector<uint8_t>(value.begin(), value.end()));
+          }));
+
+  EXPECT_EQ(read_future.Get<0>(), WebBluetoothResult::BLOCKLISTED_READ);
+}
+
+TEST_F(WebBluetoothServiceImplTest,
+       RemoteDescriptorWriteValue_ParentCharacteristicBlocklisted) {
+  RegisterTestCharacteristic();
+
+  FakeBluetoothCharacteristic& test_characteristic =
+      battery_device_bundle().characteristic();
+
+  auto mock_descriptor =
+      std::make_unique<testing::NiceMock<device::MockBluetoothGattDescriptor>>(
+          &test_characteristic, "test_descriptor_id",
+          kCharacteristicUserDescriptionDescriptorUUID,
+          device::BluetoothRemoteGattCharacteristic::PERMISSION_NONE);
+
+  test_characteristic.AddMockDescriptor(std::move(mock_descriptor));
+
+  base::test::TestFuture<
+      WebBluetoothResult,
+      std::optional<
+          std::vector<blink::mojom::WebBluetoothRemoteGATTDescriptorPtr>>>
+      get_descriptors_future;
+  service_ptr_->RemoteCharacteristicGetDescriptors(
+      test_characteristic.GetIdentifier(),
+      WebBluetoothGATTQueryQuantity::SINGLE, std::nullopt,
+      get_descriptors_future.GetCallback());
+
+  EXPECT_EQ(get_descriptors_future.Get<0>(), WebBluetoothResult::SUCCESS);
+
+  const auto& descriptors = get_descriptors_future.Get<1>();
+  ASSERT_TRUE(descriptors.has_value());
+  ASSERT_FALSE(descriptors->empty());
+  std::string descriptor_instance_id = descriptors->at(0)->instance_id;
+
+  BluetoothBlocklist::Get().Add(test_characteristic.GetUUID(),
+                                BluetoothBlocklist::Value::EXCLUDE_WRITES);
+
+  std::vector<uint8_t> value = {1, 2, 3};
+  base::test::TestFuture<WebBluetoothResult> write_future;
+  service_ptr_->RemoteDescriptorWriteValue(descriptor_instance_id, value,
+                                           write_future.GetCallback());
+
+  EXPECT_EQ(write_future.Get(), WebBluetoothResult::BLOCKLISTED_WRITE);
+}
+
 }  // namespace content
diff --git a/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js b/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js
index c503bf7..37e126df 100644
--- a/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js
+++ b/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js
@@ -29,6 +29,7 @@
     'bad1c9a2-9a5b-4015-8b60-1579bbbf2135';
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 42523def..4880d79 100644
--- a/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
+++ b/content/browser/bluetooth/web_bluetooth_service_impl_unittest.cc
@@ -32,6 +32,7 @@
 #include "device/bluetooth/test/mock_bluetooth_adapter.h"
 #include "device/bluetooth/test/mock_bluetooth_device.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_characteristic.h"
+#include "device/bluetooth/test/mock_bluetooth_gatt_descriptor.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_notify_session.h"
 #include "device/bluetooth/test/mock_bluetooth_gatt_service.h"
 #include "mojo/public/cpp/bindings/associated_receiver.h"
@@ -96,6 +97,8 @@
 const device::BluetoothUUID kBatteryServiceUUID(kBatteryServiceUUIDString);
 const device::BluetoothUUID kBatteryLevelCharacteristicUUID(
     "00002a19-0000-1000-8000-00805f9b34fb");
+const device::BluetoothUUID kCharacteristicUserDescriptionDescriptorUUID(
+    "00002901-0000-1000-8000-00805f9b34fb");
 const BluetoothDeviceBundleData battery_device_bundle_data = {
     kBatteryServiceId, kBatteryLevelCharacteristicId, kBatteryServiceUUID,
     kBatteryLevelCharacteristicUUID, kTestCharacteristicProperties};
@@ -559,6 +562,7 @@
     heart_rate_device_bundle_.reset();
     service_ptr_ = nullptr;
     SetBrowserClientForTesting(old_browser_client_);
+    BluetoothBlocklist::Get().ResetToDefaultValuesForTest();
     RenderViewHostImplTestHarness::TearDown();
   }
 
@@ -1442,4 +1446,95 @@
   EXPECT_CALL(*adapter_, StopScan).Times(1);
 }
 
+TEST_F(WebBluetoothServiceImplTest,
+       RemoteDescriptorReadValue_ParentCharacteristicBlocklisted) {
+  RegisterTestCharacteristic();
+
+  FakeBluetoothCharacteristic& test_characteristic =
+      battery_device_bundle().characteristic();
+
+  auto mock_descriptor =
+      std::make_unique<testing::NiceMock<device::MockBluetoothGattDescriptor>>(
+          &test_characteristic, "test_descriptor_id",
+          kCharacteristicUserDescriptionDescriptorUUID,
+          device::BluetoothRemoteGattCharacteristic::PERMISSION_NONE);
+
+  test_characteristic.AddMockDescriptor(std::move(mock_descriptor));
+
+  base::test::TestFuture<
+      WebBluetoothResult,
+      std::optional<
+          std::vector<blink::mojom::WebBluetoothRemoteGATTDescriptorPtr>>>
+      get_descriptors_future;
+  service_ptr_->RemoteCharacteristicGetDescriptors(
+      test_characteristic.GetIdentifier(),
+      WebBluetoothGATTQueryQuantity::SINGLE, std::nullopt,
+      get_descriptors_future.GetCallback());
+
+  EXPECT_EQ(get_descriptors_future.Get<0>(), WebBluetoothResult::SUCCESS);
+
+  const auto& descriptors = get_descriptors_future.Get<1>();
+  ASSERT_TRUE(descriptors.has_value());
+  ASSERT_FALSE(descriptors->empty());
+  std::string descriptor_instance_id = descriptors->at(0)->instance_id;
+
+  BluetoothBlocklist::Get().Add(test_characteristic.GetUUID(),
+                                BluetoothBlocklist::Value::EXCLUDE_READS);
+
+  base::test::TestFuture<WebBluetoothResult, std::vector<uint8_t>> read_future;
+  service_ptr_->RemoteDescriptorReadValue(
+      descriptor_instance_id,
+      base::BindLambdaForTesting(
+          [&read_future](WebBluetoothResult result,
+                         base::span<const uint8_t> value) {
+            read_future.SetValue(
+                result, std::vector<uint8_t>(value.begin(), value.end()));
+          }));
+
+  EXPECT_EQ(read_future.Get<0>(), WebBluetoothResult::BLOCKLISTED_READ);
+}
+
+TEST_F(WebBluetoothServiceImplTest,
+       RemoteDescriptorWriteValue_ParentCharacteristicBlocklisted) {
+  RegisterTestCharacteristic();
+
+  FakeBluetoothCharacteristic& test_characteristic =
+      battery_device_bundle().characteristic();
+
+  auto mock_descriptor =
+      std::make_unique<testing::NiceMock<device::MockBluetoothGattDescriptor>>(
+          &test_characteristic, "test_descriptor_id",
+          kCharacteristicUserDescriptionDescriptorUUID,
+          device::BluetoothRemoteGattCharacteristic::PERMISSION_NONE);
+
+  test_characteristic.AddMockDescriptor(std::move(mock_descriptor));
+
+  base::test::TestFuture<
+      WebBluetoothResult,
+      std::optional<
+          std::vector<blink::mojom::WebBluetoothRemoteGATTDescriptorPtr>>>
+      get_descriptors_future;
+  service_ptr_->RemoteCharacteristicGetDescriptors(
+      test_characteristic.GetIdentifier(),
+      WebBluetoothGATTQueryQuantity::SINGLE, std::nullopt,
+      get_descriptors_future.GetCallback());
+
+  EXPECT_EQ(get_descriptors_future.Get<0>(), WebBluetoothResult::SUCCESS);
+
+  const auto& descriptors = get_descriptors_future.Get<1>();
+  ASSERT_TRUE(descriptors.has_value());
+  ASSERT_FALSE(descriptors->empty());
+  std::string descriptor_instance_id = descriptors->at(0)->instance_id;
+
+  BluetoothBlocklist::Get().Add(test_characteristic.GetUUID(),
+                                BluetoothBlocklist::Value::EXCLUDE_WRITES);
+
+  std::vector<uint8_t> value = {1, 2, 3};
+  base::test::TestFuture<WebBluetoothResult> write_future;
+  service_ptr_->RemoteDescriptorWriteValue(descriptor_instance_id, value,
+                                           write_future.GetCallback());
+
+  EXPECT_EQ(write_future.Get(), WebBluetoothResult::BLOCKLISTED_WRITE);
+}
+
 }  // namespace content
diff --git a/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js b/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js
index c503bf7..37e126df 100644
--- a/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js
+++ b/third_party/blink/web_tests/external/wpt/bluetooth/resources/bluetooth-fake-devices.js
@@ -29,6 +29,7 @@
     'bad1c9a2-9a5b-4015-8b60-1579bbbf2135';
 var request_disconnection_characteristic_uuid =
     '01d7d88a-7451-419f-aeb8-d65e7b9277af';
+var heart_rate_measurement_uuid = '00002a37-0000-1000-8000-00805f9b34fb';
 
 /* Descriptor UUIDs */
 var blocklist_test_descriptor_uuid = 'bad2ddcf-60db-45cd-bef9-fd72b153cf7c';
@@ -610,12 +611,25 @@
  *         objects.
  */
 async function getBlocklistExcludeReadsDescriptor() {
-  let result = await getBlocklistExcludeWritesCharacteristic();
-  let descriptor = await result.characteristic.getDescriptor(
-      blocklist_exclude_reads_descriptor_uuid);
+  let result = await getBlocklistTestService();
+
+  let fake_heart_rate_characteristic =
+      await result.fake_service.addFakeCharacteristic({
+        uuid: heart_rate_measurement_uuid,
+        properties: ['read', 'write'],
+      });
+
+  let fake_descriptor = await fake_heart_rate_characteristic.addFakeDescriptor(
+      {uuid: blocklist_exclude_reads_descriptor_uuid});
+
+  let characteristic = await result.service.getCharacteristic(heart_rate_measurement_uuid);
+  let descriptor = await characteristic.getDescriptor(blocklist_exclude_reads_descriptor_uuid);
+
   return Object.assign(result, {
+    characteristic,
+    fake_characteristic: fake_heart_rate_characteristic,
     descriptor,
-    fake_descriptor: result.fake_blocklist_exclude_reads_descriptor
+    fake_descriptor
   });
 }
 
@@ -641,12 +655,25 @@
  *         objects.
  */
 async function getBlocklistExcludeWritesDescriptor() {
-  let result = await getBlocklistExcludeWritesCharacteristic();
-  let descriptor = await result.characteristic.getDescriptor(
-      'gatt.client_characteristic_configuration');
+  let result = await getBlocklistTestService();
+
+  let fake_heart_rate_characteristic =
+      await result.fake_service.addFakeCharacteristic({
+        uuid: heart_rate_measurement_uuid,
+        properties: ['read', 'write'],
+      });
+
+  let fake_descriptor = await fake_heart_rate_characteristic.addFakeDescriptor(
+      {uuid: 'gatt.client_characteristic_configuration'});
+
+  let characteristic = await result.service.getCharacteristic(heart_rate_measurement_uuid);
+  let descriptor = await characteristic.getDescriptor('gatt.client_characteristic_configuration');
+
   return Object.assign(result, {
-    descriptor: descriptor,
-    fake_descriptor: result.fake_blocklist_exclude_writes_descriptor,
+    characteristic,
+    fake_characteristic: fake_heart_rate_characteristic,
+    descriptor,
+    fake_descriptor
   });
 }
Loading diff…

Original Bug Report

reported by [email protected]

Missing GATT blocklist check for parent characteristic in Web Bluetooth descriptors

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Web Bluetooth implementation fails to check the GATT blocklist for a descriptor’s parent characteristic during read and write operations. This allows websites to bypass security restrictions and interact with descriptors of protected characteristics, violating the Web Bluetooth specification.

Affected files:

  • content/browser/bluetooth/web_bluetooth_service_impl.cc

Estimated timestamp from git blame: 2024-10-23

Summary

The GATT blocklist in Web Bluetooth is designed to restrict access to sensitive services, characteristics, and descriptors. However, the implementation of GATT descriptor read and write operations in WebBluetoothServiceImpl fails to verify the blocklist status of the descriptor’s parent characteristic. This omission allows a malicious site to read or write descriptors associated with a partially blocked characteristic, bypassing the security and privacy protections intended by the GATT blocklist.

Technical Details

In content/browser/bluetooth/web_bluetooth_service_impl.cc, the methods RemoteDescriptorReadValue and RemoteDescriptorWriteValue handle GATT descriptor interactions.

According to the Web Bluetooth specification:

  • For readValue() on a descriptor (§ 13.5): “If characteristic is in the Blocked Characteristic for Read UUIDs list, then return a promise rejected with a ‘SecurityError’ DOMException.”
  • For writeValue() on a descriptor (§ 13.5): “If characteristic is in the Blocked Characteristic for Write UUIDs list, then return a promise rejected with a ‘SecurityError’ DOMException.”

The current implementation only checks whether the descriptor’s own UUID is in the blocklist, ignoring the status of the characteristic that owns it.

Vulnerable Code Snippets

In WebBluetoothServiceImpl::RemoteDescriptorReadValue:

1378:   if (BluetoothBlocklist::Get().IsExcludedFromReads(
1379:           query_result.descriptor->GetUUID())) {
1380:     std::move(callback).Run(blink::mojom::WebBluetoothResult::BLOCKLISTED_READ,
1381:                             /*value=*/{});
1382:     return;
1383:   }

In WebBluetoothServiceImpl::RemoteDescriptorWriteValue:

1418:   if (BluetoothBlocklist::Get().IsExcludedFromWrites(
1419:           query_result.descriptor->GetUUID())) {
1420:     std::move(callback).Run(
1421:         blink::mojom::WebBluetoothResult::BLOCKLISTED_WRITE);
1422:     return;
1423:   }

Because characteristics entirely excluded from the blocklist (marked with Value::EXCLUDE) are filtered during discovery (WebBluetoothServiceImpl::RemoteServiceGetCharacteristics), they are unaffected. However, partially restricted characteristics (e.g., those marked with Value::EXCLUDE_READS or Value::EXCLUDE_WRITES) remain discoverable by the renderer, allowing an attacker to request their descriptors.

Potential Attack Steps

These are potential steps an attacker would follow to trigger the vulnerability:

  1. An attacker hosts a malicious website and tricks a user into pairing a Bluetooth device that exposes a partially blocklisted characteristic (e.g., 2a02 Peripheral Privacy Flag, which is EXCLUDE_WRITES).
  2. The attacker’s JavaScript connects to the GATT server and requests the partially blocklisted characteristic (e.g., service.getCharacteristic('2a02')). This succeeds because it is not fully excluded.
  3. The script retrieves the descriptors associated with this characteristic by calling characteristic.getDescriptors().
  4. The script attempts to read or write to one of these descriptors (e.g., descriptor.writeValue(new Uint8Array([...]))).
  5. The browser verifies the descriptor’s UUID against the blocklist, but fails to check the parent characteristic’s UUID.
  6. The browser allows the write operation, successfully bypassing the GATT blocklist restriction.

Suggested Fix

In WebBluetoothServiceImpl::RemoteDescriptorReadValue and WebBluetoothServiceImpl::RemoteDescriptorWriteValue, add a check for the parent characteristic’s blocklist status before allowing the operation.

For RemoteDescriptorReadValue:

  if (BluetoothBlocklist::Get().IsExcludedFromReads(
          query_result.descriptor->GetUUID()) ||
      BluetoothBlocklist::Get().IsExcludedFromReads(
          query_result.characteristic->GetUUID())) {
    std::move(callback).Run(blink::mojom::WebBluetoothResult::BLOCKLISTED_READ,
                            /*value=*/{});
    return;
  }

For RemoteDescriptorWriteValue:

  if (BluetoothBlocklist::Get().IsExcludedFromWrites(
          query_result.descriptor->GetUUID()) ||
      BluetoothBlocklist::Get().IsExcludedFromWrites(
          query_result.characteristic->GetUUID())) {
    std::move(callback).Run(
        blink::mojom::WebBluetoothResult::BLOCKLISTED_WRITE);
    return;
  }

Evaluated with Chrome root at commit: c0eb5541aebfa4ea08806eaf6e94bcc69f87ab2f


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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker