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

Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebUSB
DescriptionInsufficient policy enforcement in WebUSB
ComponentWebUSB
Sub-componentWebUSB
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.
Tracker489711638
Fix commit89b42d2d3326 (chromium/src) +248/-8
CWECWE-693
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedAriel Simon
Reported2026-03-04 (per vendor advisory)
Fix landed2026-03-19 (public git)
Disclosed2026-03-31
Patch gap12 days — fix public in source before the advisory

Background

WebUSB control transfer
A low-level USB request from a web page to a device, carrying a type (STANDARD/CLASS/VENDOR), a recipient (DEVICE/INTERFACE/ENDPOINT/OTHER), and a 16-bit index (wIndex) that for interface/endpoint routing encodes the target interface or endpoint in its low byte.
Protected interface class
A USB interface class (e.g. HID, mass storage, smartcard) that WebUSB policy forbids a site from communicating with; these codes are held in the blocked_interface_classes_ set on DeviceImpl.
`HasControlTransferPermission`
The DeviceImpl gatekeeper that decides, before a control transfer is forwarded to the real device, whether the requesting page is allowed to issue it.
Recipient-field routing
The USB spec assigns wIndex meaning per recipient, but a non-conforming device may ignore the recipient field and route a request to an interface anyway based on wIndex.

Root Cause Analysis

Pre-patch, HasControlTransferPermission short-circuited to return true for any request whose recipient was not INTERFACE or ENDPOINT (i.e. DEVICE or OTHER), regardless of transfer type. Because this early-exit ran before any interface lookup, it never consulted blocked_interface_classes_, so a site could direct a VENDOR or CLASS control transfer at a protected interface simply by setting recipient = DEVICE and index = <interface number>; a device that ignores the recipient field would then route the request to the protected interface, defeating the WebUSB protected-class policy. The invariant violated is that the interface-class blocklist must gate every control transfer capable of reaching a protected interface, not only those explicitly addressed to INTERFACE/ENDPOINT.

The fix narrows the unconditional allow to only STANDARD-type requests to DEVICE/OTHER (legitimate device discovery such as GET_DESCRIPTOR, where wIndex is 0 or a Language ID), then for all other requests treats the low byte of index as a candidate interface number, looks up that interface, and returns false if any of its alternates names a blocked class (guarded by the new kWebUsbProtectedClassControlTransferBlock feature). Only after that class check do DEVICE/OTHER requests fall through to the permissive return true, so the enforcement now runs before the sink. The change is a tightening of the permission check.

Key insight
The core mistake was trusting the recipient field as the sole determinant of whether a transfer could reach an interface, letting DEVICE/OTHER requests bypass the protected-class blocklist entirely; the fix re-derives the target interface from wIndex for those recipients too and blocks the transfer when that interface belongs to a protected class.

Attack Path

  1. Obtain device permission A web origin gets user consent to a WebUSB device that exposes both an allowed interface and a protected-class interface (e.g. HID or mass storage) in its active configuration.
  2. Craft a mis-recipiented control transfer The page issues a ControlTransferIn/ControlTransferOut with type = VENDOR (or CLASS), recipient = DEVICE, and index set to the protected interface’s number (e.g. 7).
  3. Bypass the stale check Pre-patch HasControlTransferPermission sees a non-INTERFACE/ENDPOINT recipient and returns true immediately, never checking blocked_interface_classes_.
  4. Reach the protected interface The transfer is forwarded to the device; a device that ignores the recipient field routes it by wIndex to the protected interface, giving the site communication with hardware WebUSB policy was meant to keep off-limits.
  5. Policy bypass achieved The site now interacts with the protected-class interface it was never permitted to use; any further escalation (e.g. driving a security key or storage device) is device-specific and not demonstrated by the patch (inferred).

Impact Assessment

An attacker-controlled origin with permission to a WebUSB device gains the ability to send arbitrary VENDOR/CLASS control transfers to a protected-class interface on that device, bypassing WebUSB’s interface-class policy in the renderer-facing device service. Preconditions are a prior user grant to the device and a device that honors requests despite a mismatched recipient field. The vendor advisory rates this high severity and describes it as insufficient policy enforcement in WebUSB; it does not report in-the-wild exploitation, and any concrete consequence beyond reaching the protected interface (device compromise, data access) is device-dependent (inferred).

Changed Functions

FunctionChangeNotes
if
services/device/usb/mojo/device_impl.cc
modified
for
services/device/usb/mojo/device_impl.cc
modified
TEST_F
services/device/usb/mojo/device_impl_unittest.cc
modified

Files Changed

  • services/device/public/cpp/device_features.cc
  • services/device/public/cpp/device_features.h
  • services/device/usb/mojo/BUILD.gn
  • services/device/usb/mojo/device_impl.cc
  • services/device/usb/mojo/device_impl.h
  • services/device/usb/mojo/device_impl_unittest.cc

Audit Directions

  • Recipient/addressing-field trust
    Audit any permission check that keys off a protocol-declared recipient, scope, or routing field (USB, HID, Bluetooth GATT) and assume the device or peer may ignore it, deriving the real target from secondary fields like wIndex before enforcing policy.
  • Early-return allowlists before resource lookup
    Look for gatekeepers that return true on a broad category before resolving the concrete target object, since the actual allow/deny resource (here the interface class) is then never consulted.
  • Blocklist coverage across all entry points
    Where a protected-resource blocklist exists (blocked_interface_classes_ and analogous HID/serial blocklists), verify every API path that can reach the resource passes through it, not just the “obvious” interface-addressed path.
diff --git a/services/device/public/cpp/device_features.cc b/services/device/public/cpp/device_features.cc
index 036a7c5..0cd4b3f 100644
--- a/services/device/public/cpp/device_features.cc
+++ b/services/device/public/cpp/device_features.cc
@@ -34,6 +34,12 @@
              "WebUSBBlocklist",
              base::FEATURE_ENABLED_BY_DEFAULT);
 
+// When enabled, WebUSB control transfers are blocked if they target a
+// protected interface class, even if the recipient is not set to interface
+// or endpoint. This protects devices which ignore this field.
+BASE_FEATURE(kWebUsbProtectedClassControlTransferBlock,
+             base::FEATURE_ENABLED_BY_DEFAULT);
+
 // When enabled, accessing the navigator.hid attribute does not prevent the
 // frame from entering the back forward cache.
 BASE_FEATURE(kWebHidAttributeAllowsBackForwardCache,
diff --git a/services/device/public/cpp/device_features.h b/services/device/public/cpp/device_features.h
index 5663293..91ae0c2 100644
--- a/services/device/public/cpp/device_features.h
+++ b/services/device/public/cpp/device_features.h
@@ -25,6 +25,8 @@
 DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(kSerialPortConnected);
 DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(kWebUsbBlocklist);
 DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(
+    kWebUsbProtectedClassControlTransferBlock);
+DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(
     kWebHidAttributeAllowsBackForwardCache);
 #if BUILDFLAG(IS_WIN)
 DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(kWinSystemLocationPermission);
diff --git a/services/device/usb/mojo/device_impl.cc b/services/device/usb/mojo/device_impl.cc
index 3accd453..2c6967bb 100644
--- a/services/device/usb/mojo/device_impl.cc
+++ b/services/device/usb/mojo/device_impl.cc
@@ -19,6 +19,7 @@
 #include "base/memory/ptr_util.h"
 #include "base/memory/ref_counted_memory.h"
 #include "base/strings/stringprintf.h"
+#include "services/device/public/cpp/device_features.h"
 #include "services/device/public/cpp/usb/usb_utils.h"
 #include "services/device/usb/usb_device.h"
 #include "third_party/blink/public/common/features.h"
@@ -155,31 +156,75 @@
 }
 
 bool DeviceImpl::HasControlTransferPermission(
+    mojom::UsbControlTransferType type,
     UsbControlTransferRecipient recipient,
     uint16_t index) {
   DCHECK(device_handle_);
 
-  if (recipient != UsbControlTransferRecipient::INTERFACE &&
-      recipient != UsbControlTransferRecipient::ENDPOINT) {
+  // STANDARD requests to the DEVICE or OTHER recipients (e.g. GET_DESCRIPTOR)
+  // are fundamental for device discovery and management. These requests are
+  // always permitted because the USB 2.0 spec (Section 9.3) defines the usage
+  // of the `index` field (wIndex in the spec) for these types as either 0 or a
+  // Language ID. Since they are not used for interface-based routing, they
+  // are always allowed.
+  if (type == mojom::UsbControlTransferType::STANDARD &&
+      (recipient == UsbControlTransferRecipient::DEVICE ||
+       recipient == UsbControlTransferRecipient::OTHER)) {
     return true;
   }
 
   const mojom::UsbConfigurationInfo* config = device_->GetActiveConfiguration();
-  if (!config)
+  if (!config) {
     return false;
+  }
 
+  // Identify the interface targeted by this request.
   const mojom::UsbInterfaceInfo* interface = nullptr;
   if (recipient == UsbControlTransferRecipient::ENDPOINT) {
+    // For the ENDPOINT recipient, the low byte of `index` is the endpoint
+    // address. We look up the interface that owns this endpoint.
     interface = device_handle_->FindInterfaceByEndpoint(index & 0xff);
   } else {
+    // For the INTERFACE recipient, the low byte of `index` is the interface
+    // number.
+    // For DEVICE and OTHER recipients, the USB spec allows `index` to be used
+    // arbitrarily by the vendor/class. We treat the low byte of `index` as a
+    // candidate interface ID to prevent routing bypasses.
     auto interface_it =
         std::ranges::find(config->interfaces, index & 0xff,
                           &mojom::UsbInterfaceInfo::interface_number);
-    if (interface_it != config->interfaces.end())
+    if (interface_it != config->interfaces.end()) {
       interface = interface_it->get();
+    }
   }
 
-  return interface != nullptr;
+  // If the request targets a protected interface class (e.g. HID, Mass
+  // Storage), it must be blocked. This prevents a site from communicating
+  // with a protected interface,
+  // 1. by explicitly targeting an INTERFACE or ENDPOINT recipient, or
+  // 2. VENDOR or CLASS requests to the DEVICE or OTHER recipient where
+  //    index looks like an interface number in case the device will
+  //    respond to these requests despite an incorrectly set recipient.
+  if (interface && base::FeatureList::IsEnabled(
+                       features::kWebUsbProtectedClassControlTransferBlock)) {
+    for (const auto& alternate : interface->alternates) {
+      if (blocked_interface_classes_.contains(alternate->class_code)) {
+        return false;
+      }
+    }
+  }
+
+  // For requests explicitly targeting an INTERFACE or ENDPOINT, the interface
+  // must actually exist in the current configuration.
+  if (recipient == UsbControlTransferRecipient::INTERFACE ||
+      recipient == UsbControlTransferRecipient::ENDPOINT) {
+    return interface != nullptr;
+  }
+
+  // For DEVICE and OTHER recipients, if we reached here, it means either no
+  // interface was identified by wIndex, or the interface it identified is
+  // not protected. These requests are allowed for device-level management.
+  return true;
 }
 
 // static
@@ -342,7 +387,8 @@
     return;
   }
 
-  if (HasControlTransferPermission(params->recipient, params->index)) {
+  if (HasControlTransferPermission(params->type, params->recipient,
+                                   params->index)) {
     auto buffer = base::MakeRefCounted<base::RefCountedBytes>(length);
     device_handle_->ControlTransfer(
         UsbTransferDirection::INBOUND, params->type, params->recipient,
@@ -365,7 +411,8 @@
     return;
   }
 
-  if (HasControlTransferPermission(params->recipient, params->index) &&
+  if (HasControlTransferPermission(params->type, params->recipient,
+                                   params->index) &&
       (allow_security_key_requests_ ||
        !IsAndroidSecurityKeyRequest(params, data))) {
     auto buffer = base::MakeRefCounted<base::RefCountedBytes>(data);
diff --git a/services/device/usb/mojo/device_impl.h b/services/device/usb/mojo/device_impl.h
index ddcd3b4..8651ece 100644
--- a/services/device/usb/mojo/device_impl.h
+++ b/services/device/usb/mojo/device_impl.h
@@ -51,6 +51,7 @@
 
   // Checks interface permissions for control transfers.
   bool HasControlTransferPermission(
+      mojom::UsbControlTransferType type,
       mojom::UsbControlTransferRecipient recipient,
       uint16_t index);
 
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 99a5d149..ea2d6bc 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -26,11 +26,13 @@
 #include "base/strings/stringprintf.h"
 #include "base/task/sequenced_task_runner.h"
 #include "base/test/bind.h"
+#include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
 #include "base/test/test_future.h"
 #include "mojo/public/cpp/bindings/receiver.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "mojo/public/cpp/test_support/test_utils.h"
+#include "services/device/public/cpp/device_features.h"
 #include "services/device/usb/mock_usb_device.h"
 #include "services/device/usb/mock_usb_device_handle.h"
 #include "services/device/usb/usb_descriptors.h"
@@ -813,7 +815,7 @@
 
   // The second interface implements a class which has been blocked above.
   AddMockConfig(
-      ConfigBuilder(/*value=*/1)
+      ConfigBuilder(/*configuration_value=*/1)
           .AddInterface(/*interface_number=*/0, /*alternate_setting=*/0,
                         /*class_code=*/1, /*subclass_code=*/0,
                         /*protocol_code=*/0)
@@ -975,6 +977,187 @@
   EXPECT_CALL(mock_handle(), Close());
 }
 
+// Test control transfers to an interface with a protected class only work for
+// STANDARD type, not VENDOR or CLASS.
+TEST_F(USBDeviceImplTest, ControlTransferProtectedClassBlock) {
+  // Block interface class 2.
+  mojo::Remote<mojom::UsbDevice> device =
+      GetMockDeviceProxyWithBlockedInterfaces(base::span_from_ref(uint8_t{2}));
+
+  EXPECT_CALL(mock_device(), OpenInternal(_));
+
+  {
+    base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+    device->Open(future.GetCallback());
+    EXPECT_TRUE(future.Get()->is_success());
+  }
+
+  // Interface 7 has class 2 (blocked).
+  AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+                    .AddInterface(/*interface_number=*/7,
+                                  /*alternate_setting=*/0,
+                                  /*class_code=*/2, /*subclass_code=*/0,
+                                  /*protocol_code=*/0)
+                    .Build());
+
+  EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+  {
+    base::RunLoop loop;
+    device->SetConfiguration(
+        1, base::BindOnce(&ExpectResultAndThen, true, loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A VENDOR request to the DEVICE with index 7 (targeting the blocked
+    // interface) should be blocked.
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::VENDOR;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), 8, 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::PERMISSION_DENIED,
+                       std::vector<uint8_t>(), loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A CLASS request to the DEVICE with index 7 (targeting the blocked
+    // interface) should be blocked.
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::CLASS;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), 8, 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::PERMISSION_DENIED,
+                       std::vector<uint8_t>(), loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A STANDARD request to the DEVICE with index 7 should still be allowed
+    // even if index 7 matches a blocked interface.
+    std::vector<uint8_t> fake_data = {1, 2, 3};
+    AddMockInboundData(fake_data);
+
+    EXPECT_CALL(mock_handle(),
+                ControlTransferInternal(UsbTransferDirection::INBOUND,
+                                        UsbControlTransferType::STANDARD,
+                                        UsbControlTransferRecipient::DEVICE, 5,
+                                        6, 7, _, 0, _));
+
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::STANDARD;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), static_cast<uint32_t>(fake_data.size()), 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::COMPLETED, fake_data,
+                       loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A STANDARD request to the INTERFACE with index 7 (targeting the blocked
+    // interface) should be blocked.
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::STANDARD;
+    params->recipient = UsbControlTransferRecipient::INTERFACE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), 8, 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::PERMISSION_DENIED,
+                       std::vector<uint8_t>(), loop.QuitClosure()));
+    loop.Run();
+  }
+
+  EXPECT_CALL(mock_handle(), Close());
+}
+
+TEST_F(USBDeviceImplTest, ControlTransferProtectedClassBlockDisabled) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndDisableFeature(
+      features::kWebUsbProtectedClassControlTransferBlock);
+
+  // Block interface class 2.
+  mojo::Remote<mojom::UsbDevice> device =
+      GetMockDeviceProxyWithBlockedInterfaces(base::span_from_ref(uint8_t{2}));
+
+  EXPECT_CALL(mock_device(), OpenInternal(_));
+
+  {
+    base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+    device->Open(future.GetCallback());
+    EXPECT_TRUE(future.Get()->is_success());
+  }
+
+  // Interface 7 has class 2 (blocked).
+  AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+                    .AddInterface(/*interface_number=*/7,
+                                  /*alternate_setting=*/0,
+                                  /*class_code=*/2, /*subclass_code=*/0,
+                                  /*protocol_code=*/0)
+                    .Build());
+
+  EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+  {
+    base::RunLoop loop;
+    device->SetConfiguration(
+        1, base::BindOnce(&ExpectResultAndThen, true, loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A VENDOR request to the DEVICE with index 7 targeting the blocked
+    // interface should be ALLOWED because
+    // `kWebUsbProtectedClassControlTransferBlock` is disabled.
+    std::vector<uint8_t> fake_data = {1, 2, 3};
+    AddMockInboundData(fake_data);
+
+    EXPECT_CALL(mock_handle(),
+                ControlTransferInternal(UsbTransferDirection::INBOUND,
+                                        UsbControlTransferType::VENDOR,
+                                        UsbControlTransferRecipient::DEVICE, 5,
+                                        6, 7, _, 0, _));
+
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::VENDOR;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), static_cast<uint32_t>(fake_data.size()), 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::COMPLETED, fake_data,
+                       loop.QuitClosure()));
+    loop.Run();
+  }
+
+  EXPECT_CALL(mock_handle(), Close());
+}
+
 TEST_F(USBDeviceImplTest, GenericTransfer) {
   mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
 
diff --git a/services/device/usb/mojo/BUILD.gn b/services/device/usb/mojo/BUILD.gn
index d2398c6e..5c2f1ab 100644
--- a/services/device/usb/mojo/BUILD.gn
+++ b/services/device/usb/mojo/BUILD.gn
@@ -17,6 +17,7 @@
   deps = [
     "//mojo/public/cpp/bindings",
     "//net",
+    "//services/device/public/cpp:device_features",
     "//services/device/public/cpp/usb",
     "//services/device/public/mojom:usb",
     "//services/device/public/mojom:usb_test",
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 99a5d149..ea2d6bc 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -26,11 +26,13 @@
 #include "base/strings/stringprintf.h"
 #include "base/task/sequenced_task_runner.h"
 #include "base/test/bind.h"
+#include "base/test/scoped_feature_list.h"
 #include "base/test/task_environment.h"
 #include "base/test/test_future.h"
 #include "mojo/public/cpp/bindings/receiver.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "mojo/public/cpp/test_support/test_utils.h"
+#include "services/device/public/cpp/device_features.h"
 #include "services/device/usb/mock_usb_device.h"
 #include "services/device/usb/mock_usb_device_handle.h"
 #include "services/device/usb/usb_descriptors.h"
@@ -813,7 +815,7 @@
 
   // The second interface implements a class which has been blocked above.
   AddMockConfig(
-      ConfigBuilder(/*value=*/1)
+      ConfigBuilder(/*configuration_value=*/1)
           .AddInterface(/*interface_number=*/0, /*alternate_setting=*/0,
                         /*class_code=*/1, /*subclass_code=*/0,
                         /*protocol_code=*/0)
@@ -975,6 +977,187 @@
   EXPECT_CALL(mock_handle(), Close());
 }
 
+// Test control transfers to an interface with a protected class only work for
+// STANDARD type, not VENDOR or CLASS.
+TEST_F(USBDeviceImplTest, ControlTransferProtectedClassBlock) {
+  // Block interface class 2.
+  mojo::Remote<mojom::UsbDevice> device =
+      GetMockDeviceProxyWithBlockedInterfaces(base::span_from_ref(uint8_t{2}));
+
+  EXPECT_CALL(mock_device(), OpenInternal(_));
+
+  {
+    base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+    device->Open(future.GetCallback());
+    EXPECT_TRUE(future.Get()->is_success());
+  }
+
+  // Interface 7 has class 2 (blocked).
+  AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+                    .AddInterface(/*interface_number=*/7,
+                                  /*alternate_setting=*/0,
+                                  /*class_code=*/2, /*subclass_code=*/0,
+                                  /*protocol_code=*/0)
+                    .Build());
+
+  EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+  {
+    base::RunLoop loop;
+    device->SetConfiguration(
+        1, base::BindOnce(&ExpectResultAndThen, true, loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A VENDOR request to the DEVICE with index 7 (targeting the blocked
+    // interface) should be blocked.
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::VENDOR;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), 8, 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::PERMISSION_DENIED,
+                       std::vector<uint8_t>(), loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A CLASS request to the DEVICE with index 7 (targeting the blocked
+    // interface) should be blocked.
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::CLASS;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), 8, 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::PERMISSION_DENIED,
+                       std::vector<uint8_t>(), loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A STANDARD request to the DEVICE with index 7 should still be allowed
+    // even if index 7 matches a blocked interface.
+    std::vector<uint8_t> fake_data = {1, 2, 3};
+    AddMockInboundData(fake_data);
+
+    EXPECT_CALL(mock_handle(),
+                ControlTransferInternal(UsbTransferDirection::INBOUND,
+                                        UsbControlTransferType::STANDARD,
+                                        UsbControlTransferRecipient::DEVICE, 5,
+                                        6, 7, _, 0, _));
+
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::STANDARD;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), static_cast<uint32_t>(fake_data.size()), 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::COMPLETED, fake_data,
+                       loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A STANDARD request to the INTERFACE with index 7 (targeting the blocked
+    // interface) should be blocked.
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::STANDARD;
+    params->recipient = UsbControlTransferRecipient::INTERFACE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), 8, 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::PERMISSION_DENIED,
+                       std::vector<uint8_t>(), loop.QuitClosure()));
+    loop.Run();
+  }
+
+  EXPECT_CALL(mock_handle(), Close());
+}
+
+TEST_F(USBDeviceImplTest, ControlTransferProtectedClassBlockDisabled) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndDisableFeature(
+      features::kWebUsbProtectedClassControlTransferBlock);
+
+  // Block interface class 2.
+  mojo::Remote<mojom::UsbDevice> device =
+      GetMockDeviceProxyWithBlockedInterfaces(base::span_from_ref(uint8_t{2}));
+
+  EXPECT_CALL(mock_device(), OpenInternal(_));
+
+  {
+    base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+    device->Open(future.GetCallback());
+    EXPECT_TRUE(future.Get()->is_success());
+  }
+
+  // Interface 7 has class 2 (blocked).
+  AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+                    .AddInterface(/*interface_number=*/7,
+                                  /*alternate_setting=*/0,
+                                  /*class_code=*/2, /*subclass_code=*/0,
+                                  /*protocol_code=*/0)
+                    .Build());
+
+  EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+  {
+    base::RunLoop loop;
+    device->SetConfiguration(
+        1, base::BindOnce(&ExpectResultAndThen, true, loop.QuitClosure()));
+    loop.Run();
+  }
+
+  {
+    // A VENDOR request to the DEVICE with index 7 targeting the blocked
+    // interface should be ALLOWED because
+    // `kWebUsbProtectedClassControlTransferBlock` is disabled.
+    std::vector<uint8_t> fake_data = {1, 2, 3};
+    AddMockInboundData(fake_data);
+
+    EXPECT_CALL(mock_handle(),
+                ControlTransferInternal(UsbTransferDirection::INBOUND,
+                                        UsbControlTransferType::VENDOR,
+                                        UsbControlTransferRecipient::DEVICE, 5,
+                                        6, 7, _, 0, _));
+
+    auto params = mojom::UsbControlTransferParams::New();
+    params->type = UsbControlTransferType::VENDOR;
+    params->recipient = UsbControlTransferRecipient::DEVICE;
+    params->request = 5;
+    params->value = 6;
+    params->index = 7;
+    base::RunLoop loop;
+    device->ControlTransferIn(
+        std::move(params), static_cast<uint32_t>(fake_data.size()), 0,
+        base::BindOnce(&ExpectTransferInAndThen,
+                       mojom::UsbTransferStatus::COMPLETED, fake_data,
+                       loop.QuitClosure()));
+    loop.Run();
+  }
+
+  EXPECT_CALL(mock_handle(), Close());
+}
+
 TEST_F(USBDeviceImplTest, GenericTransfer) {
   mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
Loading diff…

Original Bug Report

reported by ar...@gmail.com

WebUSB bug bypasses Chromium's protected USB class restrictions, giving any webpage unrestricted access to USB devices.


Report description

WebUSB bug bypasses Chromium’s protected USB class restrictions, giving any webpage unrestricted access to USB devices.


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/chromium/src/+/main/services/device/usb/mojo/device_impl.cc


The problem

Please describe the technical details of the vulnerability

WebUSB’s controlTransferOut and controlTransferIn allow any webpage to send arbitrary USB control transfers to all interfaces on a device — including protected classes (Bluetooth 0xE0, mass storage, smart cards, WiFi adapters and more) — by setting recipient: "device". This completely bypasses Chromium’s blocked_interface_classes_ enforcement, which was introduced in CVE-2018-6125 (https://issues.chromium.org/issues/40090681) to prevent web pages from accessing security-sensitive USB interfaces.

The attack requires only one click from the user — selecting any USB device in the WebUSB chooser. After that single click, the webpage has full, unrestricted control over the device, including all protected interfaces the user never intended to expose.

A single call to device.controlTransferOut({requestType: 'class', recipient: 'device', request: 0, value: 0, index: 0}, hciPayload) sends arbitrary HCI commands to a USB Bluetooth adapter (for example) — despite class 0xE0 being blocked. The same bypass works for any protected USB device class.

PoC attached: bt_exploit_poc.html — single-page exploit that demonstrates full, unrestricted HCI control of a USB Bluetooth adapter from a webpage (I chose to focus on BT dongle for this PoC because I did not have any other hardware to test this on, but this is valid for smart cards, WiFi chips, and many other protected devices). The PoC shows:

  1. Connect to any USB BT dongle via the WebUSB chooser.
  2. Verify claimInterface(0) is blocked (class 0xE0 protection active).
  3. Bypass the protection: send HCI_Write_Scan_Enable + HCI_Write_Simple_Pairing_Mode via controlTransferOut(recipient:'device') — the dongle becomes discoverable + connectable to nearby devices.
  4. Change the dongle’s Bluetooth name to an arbitrary string (sends HCI_Write_Local_Name + HCI_Write_Extended_Inquiry_Response) — visible on any nearby phone/laptop scanning for Bluetooth.
  5. Send additional HCI commands: disable authentication, disable encryption, read BD_ADDR — proving full HCI control. (Of course, after the first click from the user that chooses the device, the next clicks are not needed, and I added the buttons in the PoC page for ease of understanding the attack steps.)

PoC video - https://youtu.be/o7nxTAua9wA Demonstrates the exploit end-to-end on two machines. A second computer’s Bluetooth scan initially doesn’t show the target device. The victim machine runs the PoC: connects the USB BT dongle, verifies claimInterface is blocked, then clicks “Make Connectable.” The second computer’s scan now shows the dongle appearing as “Bluetooth - Ariel’s MacBook Pro.” The PoC then changes the name to “HACKED_BY_WEBUSB” — the second computer’s rescan confirms the name change. Finally, bonus commands disable authentication and encryption, proving unrestricted HCI access.

Tested on: Chrome Version 145.0.7632.117 macOS 26.3 and Ubuntu 24.04. Affects all platforms with WebUSB support (Windows, macOS, Linux, ChromeOS, etc).

Root cause:

The CVE-2018-6125 fix added blocked_interface_classes_ to prevent web access to sensitive USB classes (Bluetooth 0xE0, mass storage 0x08, etc.). This protection is correctly enforced in ClaimInterface() (device_impl.cc:277–282), which checks every alternate setting’s class code before allowing an interface to be claimed.

However, HasControlTransferPermission() was implemented with an early-return that exempts DEVICE and OTHER recipients from all validation:

// device_impl.cc:162-164
if (recipient != UsbControlTransferRecipient::INTERFACE &&
    recipient != UsbControlTransferRecipient::ENDPOINT) {
  return true;  // ← BUG: skips blocked_interface_classes_ check entirely
}

This creates a 2-layer bypass:

  1. Renderer (Blink): ConvertControlTransferParameters performs no validation for recipient: "device" — no EnsureInterfaceClaimed, no class check.
  2. Browser (device service): HasControlTransferPermission returns true unconditionally for DEVICE/OTHER recipients — no blocked_interface_classes_ check.

The fix should validate that DEVICE-recipient control transfers do not target functionality of blocked interface classes. For class-specific requests (requestType: "class"), the index field typically identifies the target interface — this should be checked against blocked_interface_classes_ regardless of recipient type.

Impact analysis

Any website can exploit this — no special permissions, no extensions, no user login required. The user only needs to click once in the WebUSB device chooser. Obtaining this click is straightforward via social engineering — a phishing page posing as a legitimate USB device configuration tool, firmware updater, or printer setup wizard naturally prompts the user to “select your device” from the chooser - no security warning present.

What the attacker gains — full control over protected USB device classes:

  • Bluetooth adapter takeover: As shown in the PoC video - send arbitrary HCI commands to take over the system’s Bluetooth radio — make it discoverable, disable authentication and encryption, change its name. This likely enables CVE-2023-45866-style keyboard injection: a nearby attacker pairs as a fake HID keyboard via SSP Just Works (no user prompt) and injects keystrokes for remote code execution.
  • WiFi adapter firmware overwrite: USB WiFi chipsets (Realtek RTL8xxxU, Atheros, MediaTek) accept firmware upload via vendor control transfers on EP0. A webpage could theoretically push a backdoored firmware image, achieving persistent code execution on the WiFi chip that survives browser restarts.
  • Many other attacks that I did not verify; Smart card readers, FIDO keys, and mass storage devices all accept commands via control transfers — theoretically allowing certificate exfiltration, data theft, or unauthorized signing from a webpage. DFU-capable devices (keyboards, mice, dongles) could have their firmware overwritten for persistent hardware compromise.

The core issue: the user clicks “Connect” for one device, but the attacker silently accesses every protected interface on it. The CVE-2018-6125 protection that was supposed to prevent exactly this is completely bypassed.


The cause

What version of Chrome have you found the security issue in?

Version 145.0.7632.117 Stable

No, it is not related to a crash.

Choose the type of vulnerability

Exploit Mitigation Bypass

How would you like to be publicly acknowledged for your report?

Ariel Simon

View on issue tracker