Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in USB
DescriptionInsufficient validation of untrusted input in USB
ComponentUSB
Bug ClassLogic Error
Tracker497327715
Fix commit3169e981173d (chromium/src) +145/-73
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
if
services/device/usb/mojo/device_impl.cc
modified
TEST_F
services/device/usb/mojo/device_impl_unittest.cc
modified

Files Changed

  • services/device/public/cpp/device_features.cc
  • services/device/public/cpp/device_features.h
  • services/device/usb/mojo/device_impl.cc
  • services/device/usb/mojo/device_impl.h
  • services/device/usb/mojo/device_impl_unittest.cc
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());
Loading diff…

Regression Test / PoC

shipped with the fix
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;
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • services/device/usb/device_features.cc
  • services/device/usb/mojo/device_impl_unittest.cc
  • services/device/usb/usb_device_handle_usbfs.cc
  • third_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.

  1. 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 ignores bmRequestType routing bits.
  2. Have a user grant the malicious website WebUSB permission for this device via navigator.usb.requestDevice().
  3. 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);
    
  4. Observe that the browser authorizes the transfer because requestType is standard.
  5. 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.

View on issue tracker