CVE-2026-10021
Overview
Background
- `DeviceImpl::HasControlTransferPermission`
- the WebUSB permission gate (
services/device/usb/mojo/device_impl.cc) deciding whether a control transfer may reach the device. - `kWebUsbProtectedClassControlTransferBlock`
- the feature that blocks transfers targeting protected interface classes (HID, Mass Storage, etc.).
- `bRequest` / `request`
- the control-transfer request code; USB 2.0 standard requests occupy
0x00–0x0C. - STANDARD type / DEVICE recipient
bmRequestTypefields the pre-fix code trusted to mean a safe discovery request.
Root Cause Analysis
In the pre-fix DeviceImpl::HasControlTransferPermission (services/device/usb/mojo/device_impl.cc:188-208), the first check unconditionally returned true for any UsbControlTransferType::STANDARD request whose recipient was DEVICE or OTHER (device_impl.cc:201-208), logging kAllowed and skipping the blocked_interface_classes_ heuristic that follows at :247-257. The function never inspected request (bRequest) — the method had no request parameter (device_impl.h:66-70) — so a site could label a transfer requestType: 'standard' while supplying an arbitrary vendor request code, and the transfer was forwarded regardless of whether index (wIndex) targeted a protected interface. The advisory notes that firmware ignoring the bmRequestType routing bits would then execute that arbitrary request against a protected interface.
The fix adds a request parameter to HasControlTransferPermission (header and both call sites in ControlTransferIn/ControlTransferOut) and, for STANDARD requests, when the new kWebUsbEnforceStandardRequestAllowlist feature is enabled, permits only a positive allowlist of read-only standard codes — kUsbRequestGetStatus, kUsbRequestGetDescriptor, kUsbRequestGetConfiguration, kUsbRequestGetInterface, kUsbRequestSynchFrame — returning false for all other STANDARD requests; a legacy branch preserves the old recipient-based behavior when the feature is disabled. This tightens the early return into a bRequest allowlist.
bRequest, so an arbitrary request code disguised as standard bypassed the protected-class block; the fix inspects bRequest and permits only a small read-only allowlist.Attack Path
- Gain device access A user grants a site WebUSB permission to a composite device exposing a protected-class interface.
- Disguise a vendor command
The site issues a control transfer with
requestType: 'standard',recipient: 'device', an arbitraryrequestcode, andindexpointing at the protected interface. - Bypass the block
Pre-fix
HasControlTransferPermissionreturnedtrueatdevice_impl.cc:201-208before theblocked_interface_classes_check, so the transfer was authorized. - Reach the protected interface Non-compliant firmware ignores the routing bits and executes the arbitrary request on the protected interface. (inferred — device-firmware behavior, the reporter’s claim.)
Impact Assessment
kWebUsbProtectedClassControlTransferBlock. The new enforcement is gated behind kWebUsbEnforceStandardRequestAllowlist (enabled by default), with a legacy fallback retained.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
Audit Directions
- Trusting request-type labelsValidate the actual request/opcode, not just the declared type/recipient, before granting access to protected device functionality.
- Allowlist over early-returnPrefer positive allowlists for “always safe” fast paths so unanticipated values fall through to full checks instead of being auto-approved.
Patch
From 3169e981173dd4cba83f34fca13a05d773e0b7a1 Mon Sep 17 00:00:00 2001 From: Alvin Ji <[email protected]> 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 <[email protected]> Commit-Queue: Alvin Ji <[email protected]> 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.