CVE-2026-10021
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/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/device_impl.ccservices/device/usb/mojo/device_impl.hservices/device/usb/mojo/device_impl_unittest.cc
Patch
From 3169e981173dd4cba83f34fca13a05d773e0b7a1 Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Fri, 15 May 2026 14:15:00 -0700
Subject: [PATCH] usb: Reject forbidden Standard control transfers
Update DeviceImpl::HasControlTransferPermission to inspect bRequest,
allowing read-only Standard requests (e.g., GET_STATUS, GET_DESCRIPTOR,
GET_CONFIGURATION, GET_INTERFACE, SYNCH_FRAME). Legitimate
SET_CONFIGURATION and SET_INTERFACE must be performed via dedicated
WebIDL methods.
Bug: 511732828, 497327715
Change-Id: I13d48d0b8729fda4f5c051774e23a114d9c0fade
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7843233
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1631554}
---
diff --git a/services/device/public/cpp/device_features.cc b/services/device/public/cpp/device_features.cc
index 76c7de1e..c5765957 100644
--- a/services/device/public/cpp/device_features.cc
+++ b/services/device/public/cpp/device_features.cc
@@ -48,6 +48,13 @@
BASE_FEATURE(kWebUsbProtectedClassControlTransferBlock,
base::FEATURE_ENABLED_BY_DEFAULT);
+// When enabled, WebUSB control transfers enforce a positive matching allowlist
+// for Standard requests (permitting only GET_STATUS, GET_DESCRIPTOR,
+// GET_CONFIGURATION, GET_INTERFACE, SYNCH_FRAME). All other Standard requests
+// are strictly blocked.
+BASE_FEATURE(kWebUsbEnforceStandardRequestAllowlist,
+ 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 eaee0a1b..f817240 100644
--- a/services/device/public/cpp/device_features.h
+++ b/services/device/public/cpp/device_features.h
@@ -30,6 +30,8 @@
DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(
kWebUsbProtectedClassControlTransferBlock);
DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(
+ kWebUsbEnforceStandardRequestAllowlist);
+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 db7f9c64..b3451b47 100644
--- a/services/device/usb/mojo/device_impl.cc
+++ b/services/device/usb/mojo/device_impl.cc
@@ -42,6 +42,19 @@
constexpr size_t kUsbTransferLengthLimit = 32 * 1024 * 1024; // 32 MiB
+// USB 2.0 Specification Table 9-4: Standard Request Codes
+constexpr uint8_t kUsbRequestGetStatus = 0x00;
+[[maybe_unused]] constexpr uint8_t kUsbRequestClearFeature = 0x01;
+[[maybe_unused]] constexpr uint8_t kUsbRequestSetFeature = 0x03;
+[[maybe_unused]] constexpr uint8_t kUsbRequestSetAddress = 0x05;
+constexpr uint8_t kUsbRequestGetDescriptor = 0x06;
+[[maybe_unused]] constexpr uint8_t kUsbRequestSetDescriptor = 0x07;
+constexpr uint8_t kUsbRequestGetConfiguration = 0x08;
+[[maybe_unused]] constexpr uint8_t kUsbRequestSetConfiguration = 0x09;
+constexpr uint8_t kUsbRequestGetInterface = 0x0A;
+[[maybe_unused]] constexpr uint8_t kUsbRequestSetInterface = 0x0B;
+constexpr uint8_t kUsbRequestSynchFrame = 0x0C;
+
void OnTransferIn(mojom::UsbDevice::GenericTransferInCallback callback,
UsbTransferStatus status,
scoped_refptr<base::RefCountedBytes> buffer,
@@ -189,22 +202,43 @@
UsbTransferDirection direction,
UsbControlTransferType type,
UsbControlTransferRecipient recipient,
+ uint8_t request,
uint16_t index) {
DCHECK(device_handle_);
- // 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 == UsbControlTransferType::STANDARD &&
- (recipient == UsbControlTransferRecipient::DEVICE ||
- recipient == UsbControlTransferRecipient::OTHER)) {
- base::UmaHistogramEnumeration(
- "WebUsb.ControlTransferPermissionOutcome",
- WebUsbControlTransferPermissionOutcome::kAllowed);
- return true;
+ if (type == UsbControlTransferType::STANDARD) {
+ if (base::FeatureList::IsEnabled(
+ features::kWebUsbEnforceStandardRequestAllowlist)) {
+ // Reject all Standard requests except fundamental inspection and
+ // discovery commands (GET_STATUS, GET_DESCRIPTOR, GET_CONFIGURATION,
+ // GET_INTERFACE, SYNCH_FRAME). Legitimate configuration and feature
+ // management must be performed via dedicated WebIDL methods (e.g.,
+ // selectConfiguration).
+ if (request == kUsbRequestGetStatus ||
+ request == kUsbRequestGetDescriptor ||
+ request == kUsbRequestGetConfiguration ||
+ request == kUsbRequestGetInterface ||
+ request == kUsbRequestSynchFrame) {
+ base::UmaHistogramEnumeration(
+ "WebUsb.ControlTransferPermissionOutcome",
+ WebUsbControlTransferPermissionOutcome::kAllowed);
+ return true;
+ } else {
+ base::UmaHistogramEnumeration(
+ "WebUsb.ControlTransferPermissionOutcome",
+ WebUsbControlTransferPermissionOutcome::kBlocked);
+ return false;
+ }
+ } else {
+ // Legacy fallback behavior.
+ if (recipient == UsbControlTransferRecipient::DEVICE ||
+ recipient == UsbControlTransferRecipient::OTHER) {
+ base::UmaHistogramEnumeration(
+ "WebUsb.ControlTransferPermissionOutcome",
+ WebUsbControlTransferPermissionOutcome::kAllowed);
+ return true;
+ }
+ }
}
const mojom::UsbConfigurationInfo* config = device_->GetActiveConfiguration();
@@ -475,7 +509,8 @@
}
if (HasControlTransferPermission(UsbTransferDirection::INBOUND, params->type,
- params->recipient, params->index)) {
+ params->recipient, params->request,
+ params->index)) {
auto buffer = base::MakeRefCounted<base::RefCountedBytes>(length);
device_handle_->ControlTransfer(
UsbTransferDirection::INBOUND, params->type, params->recipient,
@@ -499,7 +534,8 @@
}
if (HasControlTransferPermission(UsbTransferDirection::OUTBOUND, params->type,
- params->recipient, params->index) &&
+ params->recipient, params->request,
+ 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 54aa1b6..8a852db 100644
--- a/services/device/usb/mojo/device_impl.h
+++ b/services/device/usb/mojo/device_impl.h
@@ -67,6 +67,7 @@
mojom::UsbTransferDirection direction,
mojom::UsbControlTransferType type,
mojom::UsbControlTransferRecipient recipient,
+ uint8_t request,
uint16_t index);
// Handles completion of an open request.
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 1b36def..28ef373 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -946,7 +946,9 @@
EXPECT_CALL(mock_handle(), Close());
}
-TEST_F(USBDeviceImplTest, ControlTransfer) {
+// Verify that standard read/get requests (e.g., GET_DESCRIPTOR) are
+// successfully permitted for STANDARD control transfers.
+TEST_F(USBDeviceImplTest, ControlTransferStandardReadAllowed) {
mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
EXPECT_CALL(mock_device(), OpenInternal(_));
@@ -978,14 +980,14 @@
EXPECT_CALL(mock_handle(),
ControlTransferInternal(UsbTransferDirection::INBOUND,
UsbControlTransferType::STANDARD,
- UsbControlTransferRecipient::DEVICE, 5, 6,
+ UsbControlTransferRecipient::DEVICE, 6, 6,
7, _, 0, _));
{
auto params = mojom::UsbControlTransferParams::New();
params->type = UsbControlTransferType::STANDARD;
params->recipient = UsbControlTransferRecipient::DEVICE;
- params->request = 5;
+ params->request = 6;
params->value = 6;
params->index = 7;
base::RunLoop loop;
@@ -997,21 +999,90 @@
loop.Run();
}
+ EXPECT_CALL(mock_handle(), Close());
Regression Test / PoC
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 1b36def..28ef373 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -946,7 +946,9 @@
EXPECT_CALL(mock_handle(), Close());
}
-TEST_F(USBDeviceImplTest, ControlTransfer) {
+// Verify that standard read/get requests (e.g., GET_DESCRIPTOR) are
+// successfully permitted for STANDARD control transfers.
+TEST_F(USBDeviceImplTest, ControlTransferStandardReadAllowed) {
mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
EXPECT_CALL(mock_device(), OpenInternal(_));
@@ -978,14 +980,14 @@
EXPECT_CALL(mock_handle(),
ControlTransferInternal(UsbTransferDirection::INBOUND,
UsbControlTransferType::STANDARD,
- UsbControlTransferRecipient::DEVICE, 5, 6,
+ UsbControlTransferRecipient::DEVICE, 6, 6,
7, _, 0, _));
{
auto params = mojom::UsbControlTransferParams::New();
params->type = UsbControlTransferType::STANDARD;
params->recipient = UsbControlTransferRecipient::DEVICE;
- params->request = 5;
+ params->request = 6;
params->value = 6;
params->index = 7;
base::RunLoop loop;
@@ -997,21 +999,90 @@
loop.Run();
}
+ EXPECT_CALL(mock_handle(), Close());
+}
+
+// Verify that standard modifying/write requests (e.g., SET_CONFIGURATION) are
+// strictly blocked with PERMISSION_DENIED for STANDARD control transfers.
+TEST_F(USBDeviceImplTest, ControlTransferStandardWriteBlocked) {
+ mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ {
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+ device->Open(future.GetCallback());
+ EXPECT_TRUE(future.Get()->is_success());
+ }
+
+ std::vector<uint8_t> fake_data = {1, 2, 3};
+
+ {
+ // A STANDARD outbound request (e.g., SET_CONFIGURATION 9) should be
+ // blocked.
+ auto params = mojom::UsbControlTransferParams::New();
+ params->type = UsbControlTransferType::STANDARD;
+ params->recipient = UsbControlTransferRecipient::DEVICE;
+ params->request = 9;
+ params->value = 1;
+ params->index = 0;
+ base::RunLoop loop;
+ device->ControlTransferOut(
+ std::move(params), fake_data, 0,
+ base::BindOnce(&ExpectTransferStatusAndThen,
+ mojom::UsbTransferStatus::PERMISSION_DENIED,
+ loop.QuitClosure()));
+ loop.Run();
+ }
+
+ EXPECT_CALL(mock_handle(), Close());
+}
+
+// Verify that when kWebUsbEnforceStandardRequestAllowlist is disabled, standard
+// modifying/write requests (e.g., SET_CONFIGURATION) fall back to legacy
+// behavior and are allowed.
+TEST_F(USBDeviceImplTest, ControlTransferLegacyStandardWriteAllowed) {
+ base::test::ScopedFeatureList feature_list;
+ feature_list.InitAndDisableFeature(
+ features::kWebUsbEnforceStandardRequestAllowlist);
+
+ mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ {
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+ device->Open(future.GetCallback());
+ EXPECT_TRUE(future.Get()->is_success());
+ }
+
+ AddMockConfig(ConfigBuilder(1).AddInterface(7, 0, 1, 2, 3).Build());
+
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+ {
+ base::RunLoop loop;
+ device->SetConfiguration(
+ 1, base::BindOnce(&ExpectResultAndThen, true, loop.QuitClosure()));
+ loop.Run();
+ }
+
+ std::vector<uint8_t> fake_data = {1, 2, 3};
AddMockOutboundData(fake_data);
EXPECT_CALL(mock_handle(),
ControlTransferInternal(UsbTransferDirection::OUTBOUND,
UsbControlTransferType::STANDARD,
- UsbControlTransferRecipient::INTERFACE, 5,
- 6, 7, _, 0, _));
+ UsbControlTransferRecipient::DEVICE, 9, 1,
+ 0, _, 0, _));
{
auto params = mojom::UsbControlTransferParams::New();
params->type = UsbControlTransferType::STANDARD;
- params->recipient = UsbControlTransferRecipient::INTERFACE;
- params->request = 5;
- params->value = 6;
- params->index = 7;
+ params->recipient = UsbControlTransferRecipient::DEVICE;
+ params->request = 9;
+ params->value = 1;
+ params->index = 0;
base::RunLoop loop;
device->ControlTransferOut(
std::move(params), fake_data, 0,
@@ -1024,8 +1095,8 @@
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 control transfers to an interface with a protected class should be
+// blocked for VENDOR or CLASS types.
TEST_F(USBDeviceImplTest, ControlTransferProtectedClassBlock) {
// Block interface class 2.
mojo::Remote<mojom::UsbDevice> device =
@@ -1057,55 +1128,10 @@
}
{
- // A CLASS request to the DEVICE with index 7 (targeting the blocked
+ // A VENDOR request to the INTERFACE 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->type = UsbControlTransferType::VENDOR;
params->recipient = UsbControlTransferRecipient::INTERFACE;
params->request = 5;
params->value = 6;
Original Bug Report
Potential bypass of WebUSB protected class block via unvalidated STANDARD requests
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic flaw in WebUSB’s permission checks allows websites to potentially bypass the kWebUsbProtectedClassControlTransferBlock security feature. By sending a STANDARD type request with an arbitrary, non-standard bRequest code, the transfer skips protected interface heuristics and is forwarded to the device. This allows attackers to send vendor-specific commands to protected interfaces (like HID) on devices with non-compliant firmware.
Affected files:
services/device/usb/mojo/device_impl.ccservices/device/usb/device_features.ccservices/device/usb/mojo/device_impl_unittest.ccservices/device/usb/usb_device_handle_usbfs.ccthird_party/blink/renderer/modules/webusb/usb_device.cc
Estimated timestamp from git blame: 2026-03-24
Description
The WebUSB feature kWebUsbProtectedClassControlTransferBlock is designed to prevent websites from communicating with protected interface classes (such as HID, Mass Storage, or Smart Card). Because some device firmware ignores the recipient bits in the bmRequestType byte and routes requests based on wIndex, Chrome blocks VENDOR and CLASS requests to the DEVICE recipient if wIndex matches a protected interface.
However, a logic flaw exists in DeviceImpl::HasControlTransferPermission (services/device/usb/mojo/device_impl.cc). The function contains an early-return optimization that always grants permission if the request type is STANDARD and the recipient is DEVICE or OTHER. This optimization completely skips the wIndex heuristic check against the blocked_interface_classes_ list.
Crucially, the WebUSB implementation does not validate that the bRequest parameter provided by the website is actually a legitimate USB 2.0 standard request (e.g., 0x00-0x0C). A malicious site can specify requestType: 'standard' in JavaScript but provide an arbitrary, vendor-specific request code (e.g., 0x52).
Because the type (bits 5-6) and recipient (bits 0-4) reside in the exact same bmRequestType byte, firmware that is non-compliant with one is highly likely to be non-compliant with the other. The firmware will receive the setup packet, ignore the STANDARD type and DEVICE recipient bits, and execute the arbitrary bRequest on the protected interface specified by wIndex.
Potential Reproduction Steps
Note: These are suggested steps based on code analysis; our tooling agent cannot execute live code.
- Attach a composite USB device that exposes both an unprotected interface (which can be claimed) and a protected-class interface (e.g., an HID interface at
index: 1). The device firmware must be of the type that ignoresbmRequestTyperouting bits. - Have a user grant the malicious website WebUSB permission for this device via
navigator.usb.requestDevice(). - From the website, execute a control transfer targeting the protected interface:
device.controlTransferOut({ requestType: 'standard', recipient: 'device', request: 0x52, // Arbitrary vendor-specific request code value: 0, index: 1 // Targets the protected interface }, payload); - Observe that the browser authorizes the transfer because
requestTypeisstandard. - The setup packet (
bmRequestType=0x00,bRequest=0x52,wIndex=1) is sent to the device, successfully bypassing the protected class block and compromising the HID interface.
Suggested Fix
To remediate this issue, DeviceImpl::HasControlTransferPermission (or the Blink-side ConvertControlTransferParameters) must strictly validate bRequest when requestType is STANDARD.
The early return in HasControlTransferPermission should only apply if bRequest is a known, safe standard request code (e.g., USB_REQUEST_GET_DESCRIPTOR). Otherwise, the transfer should fall through to the wIndex heuristic checks, or be rejected entirely as an invalid standard request.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.