CVE-2026-17868
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forservices/device/usb/mojo/device_impl.cc |
modified | |
ifservices/device/usb/mojo/device_impl.cc |
modified |
Files Changed
services/device/usb/mojo/device_impl.cc
Patch
From 872fabf8796f3be9f54534158bf40a23732cfd7e Mon Sep 17 00:00:00 2001 From: Alvin Ji <[email protected]> Date: Mon, 15 Jun 2026 11:07:42 -0700 Subject: [PATCH] usb: Fix protected class bypass via crafted wIndex in CLASS requests For CLASS requests targeting DEVICE or OTHER recipients, if the target interface cannot be identified from the wIndex field (e.g. because it specifies an invalid interface number like 0xFF), and the device has at least one protected interface in its active configuration, block the request. This prevents websites from bypassing the protected class blocklist by sending class requests with an invalid wIndex to devices that ignore the wIndex field and execute the request on a protected interface anyway. Bug: 520743499 Change-Id: Ie54ffa14fdc248ae6d591bcef6ea61b08c7e0b0a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7913487 Reviewed-by: Reilly Grant <[email protected]> Commit-Queue: Alvin Ji <[email protected]> Cr-Commit-Position: refs/heads/main@{#1646947} --- diff --git a/services/device/usb/mojo/device_impl.cc b/services/device/usb/mojo/device_impl.cc index 5960f5b..b5df00a 100644 --- a/services/device/usb/mojo/device_impl.cc +++ b/services/device/usb/mojo/device_impl.cc @@ -199,6 +199,50 @@ device_handle_ = nullptr; } +const mojom::UsbInterfaceInfo* DeviceImpl::FindInterface( + const mojom::UsbConfigurationInfo* config, + uint8_t interface_number) const { + auto it = std::ranges::find(config->interfaces, interface_number, + &mojom::UsbInterfaceInfo::interface_number); + return it == config->interfaces.end() ? nullptr : it->get(); +} + +std::optional<uint8_t> DeviceImpl::FindBlockedClass( + const mojom::UsbInterfaceInfo* interface) const { + if (!base::FeatureList::IsEnabled( + features::kWebUsbProtectedClassControlTransferBlock)) { + return std::nullopt; + } + for (const auto& alternate : interface->alternates) { + if (blocked_interface_classes_.contains(alternate->class_code)) { + return alternate->class_code; + } + } + return std::nullopt; +} + +bool DeviceImpl::HasProtectedInterface( + const mojom::UsbConfigurationInfo* config) const { + for (const auto& interface : config->interfaces) { + if (FindBlockedClass(interface.get())) { + return true; + } + } + return false; +} + +bool DeviceImpl::AllowAndLog(WebUsbControlTransferPermissionOutcome outcome) { + base::UmaHistogramEnumeration("WebUsb.ControlTransferPermissionOutcome", + outcome); + return true; +} + +bool DeviceImpl::BlockAndLog(WebUsbControlTransferPermissionOutcome outcome) { + base::UmaHistogramEnumeration("WebUsb.ControlTransferPermissionOutcome", + outcome); + return false; +} + bool DeviceImpl::HasControlTransferPermission( UsbTransferDirection direction, UsbControlTransferType type, @@ -207,6 +251,15 @@ uint16_t index) { DCHECK(device_handle_); + const mojom::UsbConfigurationInfo* config = device_->GetActiveConfiguration(); + if (!config) { + return BlockAndLog( + WebUsbControlTransferPermissionOutcome::kError_NoConfiguration); + } + + // ========================================== + // 1. STANDARD Requests + // ========================================== if (type == UsbControlTransferType::STANDARD) { if (base::FeatureList::IsEnabled( features::kWebUsbEnforceStandardRequestAllowlist)) { @@ -221,102 +274,119 @@ request == kUsbRequestGetConfiguration || request == kUsbRequestGetInterface || request == kUsbRequestSynchFrame)) { - base::UmaHistogramEnumeration( - "WebUsb.ControlTransferPermissionOutcome", - WebUsbControlTransferPermissionOutcome::kAllowed); - return true; - } else { - base::UmaHistogramEnumeration( - "WebUsb.ControlTransferPermissionOutcome", - WebUsbControlTransferPermissionOutcome::kBlocked); - return false; + return AllowAndLog(WebUsbControlTransferPermissionOutcome::kAllowed); } + return BlockAndLog(WebUsbControlTransferPermissionOutcome::kBlocked); + } + + // Legacy fallback behavior. + if (recipient == UsbControlTransferRecipient::DEVICE || + recipient == UsbControlTransferRecipient::OTHER) { + return AllowAndLog(WebUsbControlTransferPermissionOutcome::kAllowed); + } + + // Fall through case: allowlist is disabled, and recipient is + // INTERFACE/ENDPOINT. We must validate the interface. + const mojom::UsbInterfaceInfo* interface = nullptr; + if (recipient == UsbControlTransferRecipient::ENDPOINT) { + interface = device_handle_->FindInterfaceByEndpoint(index & 0xff); + } else if (recipient == UsbControlTransferRecipient::INTERFACE) { + interface = FindInterface(config, index & 0xff); + } + + if (interface) { + auto blocked_class = FindBlockedClass(interface); + if (blocked_class) { + LogBlockedControlTransfer(*blocked_class, direction, type); + return BlockAndLog(WebUsbControlTransferPermissionOutcome::kBlocked); + } + return AllowAndLog(WebUsbControlTransferPermissionOutcome::kAllowed); + } + + return BlockAndLog( + WebUsbControlTransferPermissionOutcome::kError_InterfaceNotFound); + } + + // ========================================== + // 2. CLASS Requests + // ========================================== + if (type == UsbControlTransferType::CLASS) { + const mojom::UsbInterfaceInfo* interface = nullptr; + if (recipient == UsbControlTransferRecipient::ENDPOINT) { + interface = device_handle_->FindInterfaceByEndpoint(index & 0xff); } else { - // Legacy fallback behavior. - if (recipient == UsbControlTransferRecipient::DEVICE || - recipient == UsbControlTransferRecipient::OTHER) { - base::UmaHistogramEnumeration( - "WebUsb.ControlTransferPermissionOutcome", - WebUsbControlTransferPermissionOutcome::kAllowed); - return true; + // For CLASS requests, we assume index identifies the interface for all + // other recipients (INTERFACE, DEVICE, OTHER). + interface = FindInterface(config, index & 0xff); + } + + // Block if the targeted interface is protected. + if (interface) { + auto blocked_class = FindBlockedClass(interface); + if (blocked_class) { + LogBlockedControlTransfer(*blocked_class, direction, type); + return BlockAndLog(WebUsbControlTransferPermissionOutcome::kBlocked); } } - } - const mojom::UsbConfigurationInfo* config = device_->GetActiveConfiguration(); - if (!config) { - base::UmaHistogramEnumeration( - "WebUsb.ControlTransferPermissionOutcome", - WebUsbControlTransferPermissionOutcome::kError_NoConfiguration); - 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 if (recipient == UsbControlTransferRecipient::INTERFACE || - type == UsbControlTransferType::CLASS) { - // For the INTERFACE recipient, `index` identifies the target interface. - // For CLASS requests to DEVICE/OTHER recipients, `index` is often used to - // identify an interface as defined in class-specific specs (e.g. HID, Mass - // Storage, Audio). For VENDOR requests, `index` is manufacturer-defined and - // highly variable (e.g. AOA string index), so it is not treated as an - // interface ID. - auto interface_it = - std::ranges::find(config->interfaces, index & 0xff, - &mojom::UsbInterfaceInfo::interface_number); - if (interface_it != config->interfaces.end()) { - interface = interface_it->get(); + // For requests explicitly targeting an INTERFACE or ENDPOINT, the interface + // must actually exist in the current configuration. + if (recipient == UsbControlTransferRecipient::INTERFACE ||
Regression Test / PoC
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 8cd82e8b..7df57bd 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -1482,6 +1482,124 @@
EXPECT_CALL(mock_handle(), Close());
}
+TEST_F(USBDeviceImplTest,
+ ClassControlTransferToDeviceWithProtectedInterfaceBypass) {
+ // 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 1 has class 2 (blocked).
+ AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/1,
+ /*alternate_setting=*/0,
+ /*class_code=*/2, /*subclass_code=*/0,
+ /*protocol_code=*/0)
+ .Build());
+
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+ {
+ base::test::TestFuture<bool> future;
+ device->SetConfiguration(1, future.GetCallback());
+ EXPECT_TRUE(future.Get());
+ }
+
+ {
+ // A CLASS request to the DEVICE with index 0xFF (not matching any
+ // interface) should be BLOCKED because the device has a protected interface
+ // (interface 1).
+ auto params = mojom::UsbControlTransferParams::New();
+ params->type = UsbControlTransferType::CLASS;
+ params->recipient = UsbControlTransferRecipient::DEVICE;
+ params->request = 5;
+ params->value = 6;
+ params->index = 0xFF; // Does not exist
+
+ std::vector<uint8_t> fake_data = {1, 2, 3};
+ AddMockInboundData(fake_data);
+
+ base::RunLoop loop;
+ device->ControlTransferIn(
+ std::move(params), static_cast<uint32_t>(fake_data.size()), 0,
+ base::BindOnce(&ExpectTransferInAndThen,
+ mojom::UsbTransferStatus::PERMISSION_DENIED,
+ std::vector<uint8_t>(), loop.QuitClosure()));
+ loop.Run();
+ }
+
+ EXPECT_CALL(mock_handle(), Close());
+}
+
+TEST_F(USBDeviceImplTest,
+ ClassControlTransferToDeviceWithoutProtectedInterface) {
+ // Block interface class 2 (but device won't have it).
+ 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 1 has class 3 (NOT blocked).
+ AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/1,
+ /*alternate_setting=*/0,
+ /*class_code=*/3, /*subclass_code=*/0,
+ /*protocol_code=*/0)
+ .Build());
+
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+ {
+ base::test::TestFuture<bool> future;
+ device->SetConfiguration(1, future.GetCallback());
+ EXPECT_TRUE(future.Get());
+ }
+
+ {
+ // A CLASS request to the DEVICE with index 0xFF (not matching any
+ // interface) should be ALLOWED because the device has no protected
+ // interfaces.
+ std::vector<uint8_t> fake_data = {1, 2, 3};
+ AddMockInboundData(fake_data);
+
+ EXPECT_CALL(mock_handle(),
+ ControlTransferInternal(UsbTransferDirection::INBOUND,
+ UsbControlTransferType::CLASS,
+ UsbControlTransferRecipient::DEVICE, 5,
+ 6, 0xFF, _, 0, _));
+
+ auto params = mojom::UsbControlTransferParams::New();
+ params->type = UsbControlTransferType::CLASS;
+ params->recipient = UsbControlTransferRecipient::DEVICE;
+ params->request = 5;
+ params->value = 6;
+ params->index = 0xFF; // Does not exist
+
+ 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, ControlTransferProtectedClassBlockDisabled) {
base::test::ScopedFeatureList feature_list;
feature_list.InitAndDisableFeature(
Original Bug Report
WebUSB protected-interface-class protection is bypassable via crafted wIndex, giving any webpage unrestricted access to USB devices.
Report description
WebUSB protected-interface-class protection is bypassable via crafted wIndex, 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
The mitigation that fixed CVE-2026-5276 β blocking WebUSB class control transfers sent to recipient: "device"/"other" that target a protected interface class β can be bypassed by choosing a wIndex whose low byte does not match the protected interface’s number. The browser then fails to associate the transfer with the protected interface, allows it, and the request reaches the protected interface anyway (the device ignores wIndex). This fully restores the original CVE-2026-5276 capability: arbitrary HCI control of a USB Bluetooth adapter (and equivalent access to other protected-class devices) from any web page after a single chooser click.
Background β the original bug and its fix
- CVE-2018-6125 introduced
blocked_interface_classes_to stop web pages from reaching security-sensitive USB interface classes (Bluetooth0xE0, HID0x03, Mass Storage0x08, Smart Card/CCID0x0B, Audio/Video, etc.). - CVE-2026-5276 (issue 489711638, my prior report) showed that
controlTransferOut/In({recipient:'device'})skipped that enforcement entirely (an unconditional earlyreturn true), letting a page drive a protected interface (e.g. send HCI to a BT dongle) despite the block. - The fix (gated by
features::kWebUsbProtectedClassControlTransferBlock,FEATURE_ENABLED_BY_DEFAULT) added logic so that, even for class requests todevice/other, Chrome resolves the target interface and rejects the transfer if that interface’s class is protected.
Why the fix is insufficient (the bug)
The new check identifies the “target interface” solely from the renderer-controlled wIndex:
// services/device/usb/mojo/device_impl.cc (HasControlTransferPermission)
// L260-274: for CLASS requests (incl. recipient DEVICE/OTHER), look up the interface by index & 0xff
auto interface_it = std::ranges::find(config->interfaces, index & 0xff,
&mojom::UsbInterfaceInfo::interface_number);
if (interface_it != config->interfaces.end()) interface = interface_it->get();
...
// L283-294: block only if THAT interface is a protected class
if (interface && base::FeatureList::IsEnabled(kWebUsbProtectedClassControlTransferBlock)) {
for (const auto& alternate : interface->alternates)
if (blocked_interface_classes_.contains(alternate->class_code)) return false; // blocked
}
...
// L319: otherwise allowed
return true;
If index & 0xff does not equal the interface_number of a protected interface, interface stays nullptr, the block at L283-294 is skipped, and the function falls through to return true (L319). The web page fully controls index; Blink copies it verbatim (third_party/blink/renderer/modules/webusb/usb_device.cc, ConvertControlTransferParameters) and the backend writes it straight to the wire (services/device/usb/usb_device_handle_usbfs.cc:133, setup.wIndex = index). So the page simply sends a class request with a wIndex whose low byte matches no protected interface (e.g. 0xFF), and the device β which ignores wIndex for device-scoped class requests β executes it.
This is exactly the threat model the feature’s own comment names (services/device/public/cpp/device_features.cc:49-51): the protection is meant “to protect devices which ignore this field.” But the mitigation’s only mechanism for the device/other recipient is to match that same field against interface numbers. For the precise devices it is meant to defend, the check is contingent on a field those devices ignore and the attacker controls β so it is self-defeating and trivially bypassable. (By contrast, ClaimInterface (L427) keys on the real interface number, not on wIndex, and remains sound.)
Reproduction (PoC attached: bt_exploit_poc_new.html)
Identical to the CVE-2026-5276 PoC, changing only the control transfer’s wIndex (index: 0 β index: 0xFF). On a USB Bluetooth dongle (CSR8510 A10), interfaces 0/1 are class 0xE0, so 0xFF matches no interface.
- Connect the dongle via the WebUSB chooser (one click).
- Control (fix is active): send
HCI_Write_Local_Nameas{requestType:'class', recipient:'device', index:0}β rejected (the fix blocks it, becauseindex&0xff = 0resolves to the0xE0interface). - Bypass: send the identical command with
index:0xFFβstatus:"ok", and the dongle’s Bluetooth name visibly changes β confirmed on a nearby device scanning for Bluetooth. NoclaimInterface()is performed.
The delivery of the class request to the protected interface β despite the block being enabled β is the boundary failure.
I tested this on latest Chrome (149.0.7827.54) on MacOS.
Suggested fix
Do not derive the protected-class decision from the renderer-controlled wIndex for device/other recipients. For a class control transfer to recipient: device/other, block it whenever the device exposes any protected-class interface (the “device ignores wIndex” case cannot be safely allowed), or require a claimed non-protected interface and reject otherwise. The enforcement must not be contingent on a field that the protected devices ignore and the caller controls.
References
- Incomplete-fix target: https://issues.chromium.org/issues/489711638 β CVE-2026-5276
- Original protection: CVE-2018-6125 (
blocked_interface_classes_) services/device/usb/mojo/device_impl.ccβHasControlTransferPermission(L202-320;wIndexinterface lookup L260-274; protected-class block L283-294; fall-throughreturn trueL319);ClaimInterface(correct enforcement) L427services/device/public/cpp/device_features.cc:49-53βkWebUsbProtectedClassControlTransferBlock(enabled by default) + commentthird_party/blink/renderer/modules/webusb/usb_device.ccβConvertControlTransferParameters(forwardsindexunvalidated fordevice/other)services/device/usb/usb_device_handle_usbfs.cc:133βsetup.wIndex = index(verbatim to the wire) </content>
Impact analysis
Identical to CVE-2026-5276. After one chooser click, any web origin regains arbitrary class control-transfer access to protected USB interfaces: full HCI control of Bluetooth adapters (make discoverable, change name, disable authentication/encryption β enabling CVE-2023-45866-style HID keystroke injection), and equivalent access to smart-card/CCID readers, mass storage, and other protected classes. Web Platform Privilege Escalation β the protection believed to be enforced is not, for the exact device population it targets.
The cause
What version of Chrome have you found the security issue in?
149.0.7827.54 stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Privilege Escalation
How would you like to be publicly acknowledged for your report?
Ariel Simon