Overview

High
Severity
β€”
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebUSB
DescriptionInsufficient policy enforcement in WebUSB
ComponentWebUSB
Bug ClassLogic Error
Tracker489711638
Fix commite30b44e5d057 (chromium/src) +150/-7
CISA KEVNot listed
CreditedAriel Simon
Disclosed2026-03-31

Changed Functions

FunctionChangeNotes
switch
services/device/usb/mojo/device_impl.cc
modified
if
services/device/usb/mojo/device_impl.cc
modified
for
services/device/usb/mojo/device_impl.cc
modified

Files Changed

  • services/device/usb/mojo/device_impl.cc
  • services/device/usb/mojo/device_impl.h
From e30b44e5d05722b43747fd04dcb8c6b52c5b56bc Mon Sep 17 00:00:00 2001
From: Rob Pitkin <[email protected]>
Date: Tue, 24 Mar 2026 12:20:55 -0700
Subject: [PATCH] [WebUSB] Add UMA for control transfer permission telemetry

This CL adds comprehensive UMA telemetry to track the impact of the
WebUSB control transfer security fix (b:489711638).

Two distinct metrics are introduced:

1. WebUsb.ControlTransferPermissionOutcome: An enumerated histogram
tracking the high-level result of every permission check (Allowed,
Blocked, Interface Not Found, or No Configuration). This provides the
denominator needed to calculate real-world breakage ratios.

2. WebUsb.ControlTransferBlocked.{Direction}.{Type}: A tokenized sparse
histogram that records the specific USB interface class code being
blocked. This allows for granular root-cause analysis, helping
distinguish between malicious tunneling attempts and legitimate,
non-standard device routing that may have been caught by the new
restrictions.

The HasControlTransferPermission method signature was updated to include
the transfer direction to support the tokenized variants.

Bug: 489711638
Test: Verified with chrome://histograms using local test page.
Change-Id: Ibf7d49eb6b06b77e0c105b722fa03e55c6833a37
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7685741
Reviewed-by: Reilly Grant <[email protected]>
Reviewed-by: Alvin Ji <[email protected]>
Commit-Queue: Rob Pitkin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1604293}
---

diff --git a/services/device/usb/mojo/device_impl.cc b/services/device/usb/mojo/device_impl.cc
index 2c6967bb..9368b8c 100644
--- a/services/device/usb/mojo/device_impl.cc
+++ b/services/device/usb/mojo/device_impl.cc
@@ -10,6 +10,7 @@
 #include <memory>
 #include <numeric>
 #include <optional>
+#include <string_view>
 #include <utility>
 #include <vector>
 
@@ -18,6 +19,8 @@
 #include "base/functional/callback.h"
 #include "base/memory/ptr_util.h"
 #include "base/memory/ref_counted_memory.h"
+#include "base/metrics/histogram_functions.h"
+#include "base/strings/strcat.h"
 #include "base/strings/stringprintf.h"
 #include "services/device/public/cpp/device_features.h"
 #include "services/device/public/cpp/usb/usb_utils.h"
@@ -28,6 +31,7 @@
 
 using mojom::UsbControlTransferParamsPtr;
 using mojom::UsbControlTransferRecipient;
+using mojom::UsbControlTransferType;
 using mojom::UsbIsochronousPacketPtr;
 using mojom::UsbTransferDirection;
 using mojom::UsbTransferStatus;
@@ -109,6 +113,32 @@
   return total_bytes;
 }
 
+// Helper to log blocked transfers to the correct variant.
+void LogBlockedControlTransfer(uint8_t class_code,
+                               UsbTransferDirection direction,
+                               UsbControlTransferType type) {
+  std::string_view direction_str =
+      (direction == UsbTransferDirection::INBOUND) ? "Inbound" : "Outbound";
+  std::string_view type_str;
+  switch (type) {
+    case UsbControlTransferType::STANDARD:
+      type_str = "Standard";
+      break;
+    case UsbControlTransferType::CLASS:
+      type_str = "Class";
+      break;
+    case UsbControlTransferType::VENDOR:
+      type_str = "Vendor";
+      break;
+    default:
+      return;  // Skip RESERVED type
+  }
+
+  base::UmaHistogramSparse(base::StrCat({"WebUsb.ControlTransferBlocked.",
+                                         direction_str, ".", type_str}),
+                           class_code);
+}
+
 }  // namespace
 
 // static
@@ -156,7 +186,8 @@
 }
 
 bool DeviceImpl::HasControlTransferPermission(
-    mojom::UsbControlTransferType type,
+    UsbTransferDirection direction,
+    UsbControlTransferType type,
     UsbControlTransferRecipient recipient,
     uint16_t index) {
   DCHECK(device_handle_);
@@ -167,14 +198,20 @@
   // 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 == mojom::UsbControlTransferType::STANDARD &&
+  if (type == UsbControlTransferType::STANDARD &&
       (recipient == UsbControlTransferRecipient::DEVICE ||
        recipient == UsbControlTransferRecipient::OTHER)) {
+    base::UmaHistogramEnumeration(
+        "WebUsb.ControlTransferPermissionOutcome",
+        WebUsbControlTransferPermissionOutcome::kAllowed);
     return true;
   }
 
   const mojom::UsbConfigurationInfo* config = device_->GetActiveConfiguration();
   if (!config) {
+    base::UmaHistogramEnumeration(
+        "WebUsb.ControlTransferPermissionOutcome",
+        WebUsbControlTransferPermissionOutcome::kError_NoConfiguration);
     return false;
   }
 
@@ -209,6 +246,10 @@
                        features::kWebUsbProtectedClassControlTransferBlock)) {
     for (const auto& alternate : interface->alternates) {
       if (blocked_interface_classes_.contains(alternate->class_code)) {
+        LogBlockedControlTransfer(alternate->class_code, direction, type);
+        base::UmaHistogramEnumeration(
+            "WebUsb.ControlTransferPermissionOutcome",
+            WebUsbControlTransferPermissionOutcome::kBlocked);
         return false;
       }
     }
@@ -218,12 +259,25 @@
   // must actually exist in the current configuration.
   if (recipient == UsbControlTransferRecipient::INTERFACE ||
       recipient == UsbControlTransferRecipient::ENDPOINT) {
-    return interface != nullptr;
+    bool has_permission = interface != nullptr;
+    if (has_permission) {
+      base::UmaHistogramEnumeration(
+          "WebUsb.ControlTransferPermissionOutcome",
+          WebUsbControlTransferPermissionOutcome::kAllowed);
+    } else {
+      base::UmaHistogramEnumeration(
+          "WebUsb.ControlTransferPermissionOutcome",
+          WebUsbControlTransferPermissionOutcome::kError_InterfaceNotFound);
+    }
+    return has_permission;
   }
 
   // For DEVICE and OTHER recipients, if we reached here, it means either no
   // interface was identified by wIndex, or the interface it identified is
   // not protected. These requests are allowed for device-level management.
+  base::UmaHistogramEnumeration(
+      "WebUsb.ControlTransferPermissionOutcome",
+      WebUsbControlTransferPermissionOutcome::kAllowed);
   return true;
 }
 
@@ -387,8 +441,8 @@
     return;
   }
 
-  if (HasControlTransferPermission(params->type, params->recipient,
-                                   params->index)) {
+  if (HasControlTransferPermission(UsbTransferDirection::INBOUND, params->type,
+                                   params->recipient, params->index)) {
     auto buffer = base::MakeRefCounted<base::RefCountedBytes>(length);
     device_handle_->ControlTransfer(
         UsbTransferDirection::INBOUND, params->type, params->recipient,
@@ -411,8 +465,8 @@
     return;
   }
 
-  if (HasControlTransferPermission(params->type, params->recipient,
-                                   params->index) &&
+  if (HasControlTransferPermission(UsbTransferDirection::OUTBOUND, params->type,
+                                   params->recipient, 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 8651ece..e888a27 100644
--- a/services/device/usb/mojo/device_impl.h
+++ b/services/device/usb/mojo/device_impl.h
@@ -23,6 +23,19 @@
 
 namespace device::usb {
 
+// These values are persisted to logs. Entries should not be renumbered and
+// numeric values should never be reused.
+// LINT.IfChange(WebUsbControlTransferPermissionOutcome)
Loading diff…

Original Bug Report

reported by [email protected]

WebUSB bug bypasses Chromium's protected USB class restrictions, giving any webpage unrestricted access to USB devices.


Report description

WebUSB bug bypasses Chromium’s protected USB class restrictions, 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

WebUSB’s controlTransferOut and controlTransferIn allow any webpage to send arbitrary USB control transfers to all interfaces on a device β€” including protected classes (Bluetooth 0xE0, mass storage, smart cards, WiFi adapters and more) β€” by setting recipient: "device". This completely bypasses Chromium’s blocked_interface_classes_ enforcement, which was introduced in CVE-2018-6125 (https://issues.chromium.org/issues/40090681) to prevent web pages from accessing security-sensitive USB interfaces.

The attack requires only one click from the user β€” selecting any USB device in the WebUSB chooser. After that single click, the webpage has full, unrestricted control over the device, including all protected interfaces the user never intended to expose.

A single call to device.controlTransferOut({requestType: 'class', recipient: 'device', request: 0, value: 0, index: 0}, hciPayload) sends arbitrary HCI commands to a USB Bluetooth adapter (for example) β€” despite class 0xE0 being blocked. The same bypass works for any protected USB device class.

PoC attached: bt_exploit_poc.html β€” single-page exploit that demonstrates full, unrestricted HCI control of a USB Bluetooth adapter from a webpage (I chose to focus on BT dongle for this PoC because I did not have any other hardware to test this on, but this is valid for smart cards, WiFi chips, and many other protected devices). The PoC shows:

  1. Connect to any USB BT dongle via the WebUSB chooser.
  2. Verify claimInterface(0) is blocked (class 0xE0 protection active).
  3. Bypass the protection: send HCI_Write_Scan_Enable + HCI_Write_Simple_Pairing_Mode via controlTransferOut(recipient:'device') β€” the dongle becomes discoverable + connectable to nearby devices.
  4. Change the dongle’s Bluetooth name to an arbitrary string (sends HCI_Write_Local_Name + HCI_Write_Extended_Inquiry_Response) β€” visible on any nearby phone/laptop scanning for Bluetooth.
  5. Send additional HCI commands: disable authentication, disable encryption, read BD_ADDR β€” proving full HCI control. (Of course, after the first click from the user that chooses the device, the next clicks are not needed, and I added the buttons in the PoC page for ease of understanding the attack steps.)

PoC video - https://youtu.be/o7nxTAua9wA Demonstrates the exploit end-to-end on two machines. A second computer’s Bluetooth scan initially doesn’t show the target device. The victim machine runs the PoC: connects the USB BT dongle, verifies claimInterface is blocked, then clicks “Make Connectable.” The second computer’s scan now shows the dongle appearing as “Bluetooth - Ariel’s MacBook Pro.” The PoC then changes the name to “HACKED_BY_WEBUSB” β€” the second computer’s rescan confirms the name change. Finally, bonus commands disable authentication and encryption, proving unrestricted HCI access.

Tested on: Chrome Version 145.0.7632.117 macOS 26.3 and Ubuntu 24.04. Affects all platforms with WebUSB support (Windows, macOS, Linux, ChromeOS, etc).

Root cause:

The CVE-2018-6125 fix added blocked_interface_classes_ to prevent web access to sensitive USB classes (Bluetooth 0xE0, mass storage 0x08, etc.). This protection is correctly enforced in ClaimInterface() (device_impl.cc:277–282), which checks every alternate setting’s class code before allowing an interface to be claimed.

However, HasControlTransferPermission() was implemented with an early-return that exempts DEVICE and OTHER recipients from all validation:

// device_impl.cc:162-164
if (recipient != UsbControlTransferRecipient::INTERFACE &&
    recipient != UsbControlTransferRecipient::ENDPOINT) {
  return true;  // ← BUG: skips blocked_interface_classes_ check entirely
}

This creates a 2-layer bypass:

  1. Renderer (Blink): ConvertControlTransferParameters performs no validation for recipient: "device" β€” no EnsureInterfaceClaimed, no class check.
  2. Browser (device service): HasControlTransferPermission returns true unconditionally for DEVICE/OTHER recipients β€” no blocked_interface_classes_ check.

The fix should validate that DEVICE-recipient control transfers do not target functionality of blocked interface classes. For class-specific requests (requestType: "class"), the index field typically identifies the target interface β€” this should be checked against blocked_interface_classes_ regardless of recipient type.

Impact analysis

Any website can exploit this β€” no special permissions, no extensions, no user login required. The user only needs to click once in the WebUSB device chooser. Obtaining this click is straightforward via social engineering β€” a phishing page posing as a legitimate USB device configuration tool, firmware updater, or printer setup wizard naturally prompts the user to “select your device” from the chooser - no security warning present.

What the attacker gains β€” full control over protected USB device classes:

  • Bluetooth adapter takeover: As shown in the PoC video - send arbitrary HCI commands to take over the system’s Bluetooth radio β€” make it discoverable, disable authentication and encryption, change its name. This likely enables CVE-2023-45866-style keyboard injection: a nearby attacker pairs as a fake HID keyboard via SSP Just Works (no user prompt) and injects keystrokes for remote code execution.
  • WiFi adapter firmware overwrite: USB WiFi chipsets (Realtek RTL8xxxU, Atheros, MediaTek) accept firmware upload via vendor control transfers on EP0. A webpage could theoretically push a backdoored firmware image, achieving persistent code execution on the WiFi chip that survives browser restarts.
  • Many other attacks that I did not verify; Smart card readers, FIDO keys, and mass storage devices all accept commands via control transfers β€” theoretically allowing certificate exfiltration, data theft, or unauthorized signing from a webpage. DFU-capable devices (keyboards, mice, dongles) could have their firmware overwritten for persistent hardware compromise.

The core issue: the user clicks “Connect” for one device, but the attacker silently accesses every protected interface on it. The CVE-2018-6125 protection that was supposed to prevent exactly this is completely bypassed.


The cause

What version of Chrome have you found the security issue in?

Version 145.0.7632.117 Stable

No, it is not related to a crash.

Choose the type of vulnerability

Exploit Mitigation Bypass

How would you like to be publicly acknowledged for your report?

Ariel Simon

View on issue tracker