CVE-2026-5276
Overview
Background
- WebUSB control transfer
- A low-level USB request from a web page to a device, carrying a
type(STANDARD/CLASS/VENDOR), arecipient(DEVICE/INTERFACE/ENDPOINT/OTHER), and a 16-bitindex(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 onDeviceImpl. - `HasControlTransferPermission`
- The
DeviceImplgatekeeper 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
wIndexmeaning per recipient, but a non-conforming device may ignore therecipientfield and route a request to an interface anyway based onwIndex.
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.
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
- 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.
- Craft a mis-recipiented control transfer
The page issues a
ControlTransferIn/ControlTransferOutwithtype = VENDOR(orCLASS),recipient = DEVICE, andindexset to the protected interface’s number (e.g.7). - Bypass the stale check
Pre-patch
HasControlTransferPermissionsees a non-INTERFACE/ENDPOINTrecipient and returnstrueimmediately, never checkingblocked_interface_classes_. - Reach the protected interface
The transfer is forwarded to the device; a device that ignores the recipient field routes it by
wIndexto the protected interface, giving the site communication with hardware WebUSB policy was meant to keep off-limits. - 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
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
| Function | Change | Notes |
|---|---|---|
ifservices/device/usb/mojo/device_impl.cc |
modified | |
forservices/device/usb/mojo/device_impl.cc |
modified | |
TEST_Fservices/device/usb/mojo/device_impl_unittest.cc |
modified |
Files Changed
services/device/public/cpp/device_features.ccservices/device/public/cpp/device_features.hservices/device/usb/mojo/BUILD.gnservices/device/usb/mojo/device_impl.ccservices/device/usb/mojo/device_impl.hservices/device/usb/mojo/device_impl_unittest.cc
Audit Directions
- Recipient/addressing-field trustAudit 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
wIndexbefore enforcing policy. - Early-return allowlists before resource lookupLook for gatekeepers that
return trueon 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 pointsWhere 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.
Patch
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",
Regression Test / PoC
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();
Original Bug Report
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:
- Connect to any USB BT dongle via the WebUSB chooser.
- Verify
claimInterface(0)is blocked (class 0xE0 protection active). - Bypass the protection: send
HCI_Write_Scan_Enable+HCI_Write_Simple_Pairing_ModeviacontrolTransferOut(recipient:'device')— the dongle becomes discoverable + connectable to nearby devices. - 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. - 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:
- Renderer (Blink):
ConvertControlTransferParametersperforms no validation forrecipient: "device"— noEnsureInterfaceClaimed, no class check. - Browser (device service):
HasControlTransferPermissionreturnstrueunconditionally for DEVICE/OTHER recipients — noblocked_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
Is the security issue related to a crash?
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