CVE-2026-9976
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 <[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
WebUSB Raw SET_CONFIGURATION Bypass of Protected Interface Class Checks
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential vulnerability in WebUSB allows a renderer to desynchronize the browser’s model of a USB device’s active configuration. By sending a raw SET_CONFIGURATION control transfer, an attacker can bypass security checks and gain access to protected interface classes like HID or Mass Storage on multi-configuration devices.
Affected files:
services/device/usb/mojo/device_impl.ccservices/device/usb/usb_device.ccservices/device/usb/usb_device_handle_usbfs.ccservices/device/usb/usb_device_handle_android.cc
Estimated timestamp from git blame: 2026-03-24
Summary
A state desynchronization vulnerability exists in the Chromium WebUSB implementation. A malicious website can send a raw SET_CONFIGURATION control transfer to a connected USB device, bypassing the browser’s active configuration tracking. This leads to a bypass of the blocked_interface_classes_ check in DeviceImpl::ClaimInterface and subsequent control transfers, allowing a website to communicate with protected device classes such as HID or Mass Storage.
Vulnerability Details
The core of the issue lies in DeviceImpl::HasControlTransferPermission (services/device/usb/mojo/device_impl.cc). This function validates whether a control transfer requested by the renderer should be allowed.
- Missing
bRequestcheck:HasControlTransferPermissionunconditionally permits allSTANDARDtype requests to theDEVICErecipient. Crucially, the function signature does not accept therequestbyte parameter, so it cannot distinguish between benign requests (likeGET_DESCRIPTOR) and state-altering requests likeSET_CONFIGURATION(bRequest = 0x09). - State Desynchronization: When a renderer calls
device.controlTransferOutwithrequest: 0x09(SET_CONFIGURATION), the request is permitted and forwarded directly to the physical device. The device changes its configuration. However, because this request bypassed the officialdevice.selectConfiguration()path (which usesDeviceImpl::SetConfiguration), Chromium’s internal model of the active configuration (device_info_->active_configuration) is never updated. It remains stuck on the previous configuration. - Security Bypass: When the renderer subsequently calls
device.claimInterface(n),DeviceImpl::ClaimInterfacevalidates the requested interface against the stale active configuration. If interfacenin the old configuration has a non-blocked class (e.g., Vendor Specific), but interfacenin the new physical configuration has a protected class (e.g., HID), the browser incorrectly permits the claim. Subsequent class-specific control transfers are also validated against this stale model, allowing full communication with the protected interface.
Note: On Linux/Android, the kernel’s usbfs also maintains a stale active configuration state because the raw USBDEVFS_SUBMITURB bypasses the kernel’s tracking (which requires USBDEVFS_SETCONFIGURATION). This allows the USBDEVFS_CLAIMINTERFACE ioctl to succeed despite the mismatch.
Impact
An attacker can gain unauthorized access to protected USB interface classes (including Audio, HID, Mass Storage, Smart Card, and Video) on multi-configuration devices. For HID devices, this can allow for keyboard/mouse emulation and arbitrary keystroke injection into the host OS, completely subverting the primary security boundary of WebUSB. This requires the user to have granted the site access to the specific USB device.
Prerequisites
- The victim must have a USB device connected that has at least two configurations.
- The device must expose an interface (e.g., interface 0) with a non-blocked class (e.g., 0xFF Vendor Specific) in one configuration, and a protected class (e.g., 0x03 HID) at the same interface index in another configuration.
- The user must grant the malicious website access to the device via the WebUSB permission prompt.
Potential Steps to Reproduce
(Note: These are suggested steps based on code analysis; a full PoC has not been executed in this environment).
- Connect a multi-configuration USB device matching the prerequisites (Config 1: Interface 0 is Vendor Specific; Config 2: Interface 0 is HID).
- On a malicious website, request WebUSB access and open the device (it starts in Config 1).
- Send a raw
SET_CONFIGURATIONtransfer to switch to Config 2:device.controlTransferOut({ requestType: 'standard', recipient: 'device', request: 0x09 /* SET_CONFIGURATION */, value: 2, index: 0 }); - Claim interface 0:
The browser validates this against the stale Config 1 model and allows it.
device.claimInterface(0); - Send class-specific control transfers to interface 0 to inject malicious payloads (e.g., HID reports):
device.controlTransferOut({ requestType: 'class', recipient: 'interface', request: 0x09 /* HID SET_REPORT */, value: 0x0200, index: 0 }, payload);
Suggested Fix
- Update the signature of
DeviceImpl::HasControlTransferPermissionto include theuint8_t requestparameter. - In
HasControlTransferPermission, explicitly block raw control transfers whererequestisSET_CONFIGURATION(0x09) orSET_INTERFACE(0x0B). - Renderers must be forced to use the dedicated Mojo methods (
SetConfiguration,SetInterfaceAlternateSetting) for these operations, ensuring that the internal configuration and interface tracking state is properly updated.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.