CVE-2026-13951
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/device/usb/mojo/device_impl.cc |
modified | |
TEST_Fservices/device/usb/mojo/device_impl_unittest.cc |
modified | |
MockBlockingTaskRunnerHelperservices/device/usb/usb_device_handle_usbfs_unittest.cc |
modified |
Files Changed
services/device/usb/mojo/device_impl.ccservices/device/usb/mojo/device_impl_unittest.ccservices/device/usb/usb_device_handle_usbfs.ccservices/device/usb/usb_device_handle_usbfs.hservices/device/usb/usb_device_handle_usbfs_unittest.cc
Patch
From 2796b8e6aee7866747b38cb7fadd203783b6a203 Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Mon, 18 May 2026 20:25:36 -0700
Subject: [PATCH] usb: Block SetInterfaceAlternateSetting on protected interfaces
Fixes a WebUSB policy bypass where a compromised renderer could issue
unauthorized SET_INTERFACE commands to blocked interface classes (e.g.,
HID) on Linux-based platforms.
To prevent this, we now validate the target interface against blocked
interface classes at the Mojo layer in DeviceImpl, and enforce that the
interface must be claimed first in the UsbDeviceHandleUsbfs backend to
prevent exploiting the Linux kernel's auto-claim behavior.
Bug: 513394321
Change-Id: Ic2f851f3fc68ed714406cd09773b8dce636d39ec
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7855216
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1632597}
---
diff --git a/services/device/usb/mojo/device_impl.cc b/services/device/usb/mojo/device_impl.cc
index b3451b47..79e0e258 100644
--- a/services/device/usb/mojo/device_impl.cc
+++ b/services/device/usb/mojo/device_impl.cc
@@ -24,6 +24,7 @@
#include "base/strings/stringprintf.h"
#include "services/device/public/cpp/device_features.h"
#include "services/device/public/cpp/usb/usb_utils.h"
+#include "services/device/usb/usb_descriptors.h"
#include "services/device/usb/usb_device.h"
#include "third_party/blink/public/common/features.h"
@@ -464,6 +465,24 @@
return;
}
+ const mojom::UsbConfigurationInfo* config = device_->GetActiveConfiguration();
+ if (!config) {
+ std::move(callback).Run(false);
+ return;
+ }
+
+ CombinedInterfaceInfo interface =
+ FindInterfaceInfoFromConfig(config, interface_number, alternate_setting);
+ if (!interface.IsValid()) {
+ std::move(callback).Run(false);
+ return;
+ }
+
+ if (blocked_interface_classes_.contains(interface.alternate->class_code)) {
+ std::move(callback).Run(false);
+ return;
+ }
+
device_handle_->SetInterfaceAlternateSetting(
interface_number, alternate_setting, std::move(callback));
}
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 28ef373..61989a1 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -924,23 +924,84 @@
.AddInterface(2, 0, 1, 2, 3)
.Build());
+ // The device must be configured because SetInterfaceAlternateSetting now
+ // retrieves the active configuration to validate the interface class code.
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+ {
+ base::test::TestFuture<bool> future;
+ device->SetConfiguration(1, future.GetCallback());
+ EXPECT_TRUE(future.Get());
+ }
+
EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 42, _));
{
- base::RunLoop loop;
- device->SetInterfaceAlternateSetting(
- 1, 42, base::BindOnce(&ExpectResultAndThen, true, loop.QuitClosure()));
- loop.Run();
+ base::test::TestFuture<bool> future;
+ device->SetInterfaceAlternateSetting(1, 42, future.GetCallback());
+ EXPECT_TRUE(future.Get());
}
- EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 100, _));
+ EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 100, _))
+ .Times(0);
{
- base::RunLoop loop;
- device->SetInterfaceAlternateSetting(
- 1, 100,
- base::BindOnce(&ExpectResultAndThen, false, loop.QuitClosure()));
- loop.Run();
+ base::test::TestFuture<bool> future;
+ device->SetInterfaceAlternateSetting(1, 100, future.GetCallback());
+ EXPECT_FALSE(future.Get());
+ }
+
+ EXPECT_CALL(mock_handle(), Close());
+}
+
+TEST_F(USBDeviceImplTest, SetInterfaceAlternateSettingProtectedClassBypass) {
+ mojo::Remote<mojom::UsbDevice> device =
+ GetMockDeviceProxyWithBlockedInterfaces(base::span_from_ref(uint8_t{3}));
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ {
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+ device->Open(future.GetCallback());
+ EXPECT_TRUE(future.Get()->is_success());
+ }
+
+ AddMockConfig(
+ ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/0, /*alternate_setting=*/0,
+ /*class_code=*/0xFF, /*subclass_code=*/0,
+ /*protocol_code=*/0)
+ .AddInterface(/*interface_number=*/1, /*alternate_setting=*/0,
+ /*class_code=*/3, /*subclass_code=*/0,
+ /*protocol_code=*/0)
+ .AddInterface(/*interface_number=*/1, /*alternate_setting=*/1,
+ /*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());
+ }
+
+ EXPECT_CALL(mock_handle(), ClaimInterfaceInternal(1, _)).Times(0);
+
+ {
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult> future;
+ device->ClaimInterface(1, future.GetCallback());
+ EXPECT_EQ(future.Get(), mojom::UsbClaimInterfaceResult::kProtectedClass);
+ }
+
+ EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 1, _))
+ .Times(0);
+
+ {
+ base::test::TestFuture<bool> future;
+ device->SetInterfaceAlternateSetting(1, 1, future.GetCallback());
+ EXPECT_FALSE(future.Get());
}
EXPECT_CALL(mock_handle(), Close());
diff --git a/services/device/usb/usb_device_handle_usbfs.cc b/services/device/usb/usb_device_handle_usbfs.cc
index 1ce133ce..5b20401e 100644
--- a/services/device/usb/usb_device_handle_usbfs.cc
+++ b/services/device/usb/usb_device_handle_usbfs.cc
@@ -689,6 +689,13 @@
return;
}
+ if (!IsInterfaceClaimedByThis(interface_number)) {
+ USB_LOG(DEBUG) << "Interface " << interface_number << " not claimed.";
+ task_runner_->PostTask(FROM_HERE,
+ base::BindOnce(std::move(callback), false));
+ return;
+ }
+
// USBDEVFS_SETINTERFACE is synchronous because it issues a SET_INTERFACE
// request to the device so it must be performed on a thread where it is okay
// to block.
diff --git a/services/device/usb/usb_device_handle_usbfs.h b/services/device/usb/usb_device_handle_usbfs.h
index 6f689eb0..c2dbc86 100644
--- a/services/device/usb/usb_device_handle_usbfs.h
+++ b/services/device/usb/usb_device_handle_usbfs.h
@@ -211,7 +211,7 @@
bool SetConfiguration(int configuration_value);
virtual bool ClaimInterface(int interface_number);
virtual bool ReleaseInterface(int interface_number);
- bool SetInterface(int interface_number, int alternate_setting);
+ virtual bool SetInterface(int interface_number, int alternate_setting);
bool ResetDevice();
bool ClearHalt(uint8_t endpoint_address);
void DiscardUrb(Transfer* transfer);
diff --git a/services/device/usb/usb_device_handle_usbfs_unittest.cc b/services/device/usb/usb_device_handle_usbfs_unittest.cc
index 8010c57..f8c40e15 100644
--- a/services/device/usb/usb_device_handle_usbfs_unittest.cc
+++ b/services/device/usb/usb_device_handle_usbfs_unittest.cc
@@ -28,6 +28,7 @@
MockBlockingTaskRunnerHelper() {
ON_CALL(*this, ClaimInterface).WillByDefault(testing::Return(true));
ON_CALL(*this, ReleaseInterface).WillByDefault(testing::Return(true));
+ ON_CALL(*this, SetInterface).WillByDefault(testing::Return(true));
#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_LINUX)
ON_CALL(*this, DetachInterface).WillByDefault(testing::Return(true));
Regression Test / PoC
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 28ef373..61989a1 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -924,23 +924,84 @@
.AddInterface(2, 0, 1, 2, 3)
.Build());
+ // The device must be configured because SetInterfaceAlternateSetting now
+ // retrieves the active configuration to validate the interface class code.
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+ {
+ base::test::TestFuture<bool> future;
+ device->SetConfiguration(1, future.GetCallback());
+ EXPECT_TRUE(future.Get());
+ }
+
EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 42, _));
{
- base::RunLoop loop;
- device->SetInterfaceAlternateSetting(
- 1, 42, base::BindOnce(&ExpectResultAndThen, true, loop.QuitClosure()));
- loop.Run();
+ base::test::TestFuture<bool> future;
+ device->SetInterfaceAlternateSetting(1, 42, future.GetCallback());
+ EXPECT_TRUE(future.Get());
}
- EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 100, _));
+ EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 100, _))
+ .Times(0);
{
- base::RunLoop loop;
- device->SetInterfaceAlternateSetting(
- 1, 100,
- base::BindOnce(&ExpectResultAndThen, false, loop.QuitClosure()));
- loop.Run();
+ base::test::TestFuture<bool> future;
+ device->SetInterfaceAlternateSetting(1, 100, future.GetCallback());
+ EXPECT_FALSE(future.Get());
+ }
+
+ EXPECT_CALL(mock_handle(), Close());
+}
+
+TEST_F(USBDeviceImplTest, SetInterfaceAlternateSettingProtectedClassBypass) {
+ mojo::Remote<mojom::UsbDevice> device =
+ GetMockDeviceProxyWithBlockedInterfaces(base::span_from_ref(uint8_t{3}));
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ {
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> future;
+ device->Open(future.GetCallback());
+ EXPECT_TRUE(future.Get()->is_success());
+ }
+
+ AddMockConfig(
+ ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/0, /*alternate_setting=*/0,
+ /*class_code=*/0xFF, /*subclass_code=*/0,
+ /*protocol_code=*/0)
+ .AddInterface(/*interface_number=*/1, /*alternate_setting=*/0,
+ /*class_code=*/3, /*subclass_code=*/0,
+ /*protocol_code=*/0)
+ .AddInterface(/*interface_number=*/1, /*alternate_setting=*/1,
+ /*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());
+ }
+
+ EXPECT_CALL(mock_handle(), ClaimInterfaceInternal(1, _)).Times(0);
+
+ {
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult> future;
+ device->ClaimInterface(1, future.GetCallback());
+ EXPECT_EQ(future.Get(), mojom::UsbClaimInterfaceResult::kProtectedClass);
+ }
+
+ EXPECT_CALL(mock_handle(), SetInterfaceAlternateSettingInternal(1, 1, _))
+ .Times(0);
+
+ {
+ base::test::TestFuture<bool> future;
+ device->SetInterfaceAlternateSetting(1, 1, future.GetCallback());
+ EXPECT_FALSE(future.Get());
}
EXPECT_CALL(mock_handle(), Close());
diff --git a/services/device/usb/usb_device_handle_usbfs_unittest.cc b/services/device/usb/usb_device_handle_usbfs_unittest.cc
index 8010c57..f8c40e15 100644
--- a/services/device/usb/usb_device_handle_usbfs_unittest.cc
+++ b/services/device/usb/usb_device_handle_usbfs_unittest.cc
@@ -28,6 +28,7 @@
MockBlockingTaskRunnerHelper() {
ON_CALL(*this, ClaimInterface).WillByDefault(testing::Return(true));
ON_CALL(*this, ReleaseInterface).WillByDefault(testing::Return(true));
+ ON_CALL(*this, SetInterface).WillByDefault(testing::Return(true));
#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_LINUX)
ON_CALL(*this, DetachInterface).WillByDefault(testing::Return(true));
ON_CALL(*this, ReattachInterface).WillByDefault(testing::Return(true));
@@ -44,6 +45,7 @@
(override));
MOCK_METHOD(bool, ClaimInterface, (int), (override));
MOCK_METHOD(bool, ReleaseInterface, (int), (override));
+ MOCK_METHOD(bool, SetInterface, (int, int), (override));
#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_LINUX)
MOCK_METHOD(bool,
DetachInterface,
@@ -187,6 +189,22 @@
ASSERT_TRUE(release_interface_future2.Get());
}
+TEST_F(UsbDeviceHandleUsbfsTest, SetInterfaceAlternateSettingUnclaimed) {
+ TestFuture<bool> set_interface_future;
+ handle1_->SetInterfaceAlternateSetting(1, 0,
+ set_interface_future.GetCallback());
+ ASSERT_FALSE(set_interface_future.Get());
+
+ TestFuture<bool> claim_interface_future;
+ handle1_->ClaimInterface(1, claim_interface_future.GetCallback());
+ ASSERT_TRUE(claim_interface_future.Get());
+
+ TestFuture<bool> set_interface_future2;
+ handle1_->SetInterfaceAlternateSetting(1, 0,
+ set_interface_future2.GetCallback());
+ ASSERT_TRUE(set_interface_future2.Get());
+}
+
} // namespace
} // namespace device
Original Bug Report
Potential WebUSB Protected Interface Policy Bypass on Linux-based Platforms
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The SetInterfaceAlternateSetting Mojo method in the USB Device service lacks a policy check for protected interface classes. On Linux, Android, and ChromeOS, this allows a compromised renderer to bypass WebUSB protections and issue SET_INTERFACE commands to blocked devices.
Affected files:
services/device/usb/mojo/device_impl.ccservices/device/usb/usb_device_handle_usbfs.cc
Estimated timestamp from git blame: 2021-03-03
Description
A vulnerability in the WebUSB implementation potentially allows a compromised renderer to bypass protected interface class restrictions (e.g., HID, Smart Card, Audio) on Linux-based platforms, including ChromeOS and Android. The issue stems from a missing policy check in the Mojo handler combined with platform-specific behavior in the Linux kernel’s usbfs driver.
The WebUSB security model relies on a blocked_interface_classes_ list to prevent access to sensitive device interfaces. While methods like ClaimInterface and ControlTransfer (via HasControlTransferPermission) correctly enforce this policy, the SetInterfaceAlternateSetting handler in services/device/usb/mojo/device_impl.cc fails to perform this check.
Furthermore, on Linux, Android, and ChromeOS, the backend implementation in services/device/usb/usb_device_handle_usbfs.cc does not verify if an interface has been claimed before attempting the operation. This is significant because the Linux kernel usbfs driver automatically claims an interface for the calling process if it is currently unclaimed when a USBDEVFS_SETINTERFACE ioctl is issued. This allows an attacker to bypass the userspace “claim” requirement, which is the primary gate where the protected class check is normally enforced.
Potential Attack Steps
- Renderer Compromise: An attacker first gains code execution within a Chromium renderer process.
- Mojo Interface Acquisition: The attacker obtains a
device.mojom.UsbDeviceMojo remote for a device the user has previously granted access to (e.g., a composite device with both vendor and protected interfaces). - Target Selection: The attacker identifies a protected-class interface (e.g., HID) on the device.
- Mojo Call Injection: The attacker directly invokes
SetInterfaceAlternateSettingtargeting the protected interface number, bypassing higher-level Blink checks. - Policy Bypass: On Linux-based platforms, the browser process receives the call, fails to validate it against the protected class list, and issues the
USBDEVFS_SETINTERFACEioctl. The kernel then auto-claims the interface and sends theSET_INTERFACEcontrol transfer to the physical hardware.
Impact
This could allow an attacker to bypass the kWebUsbProtectedClassControlTransferBlock policy. While the impact is limited to triggering SET_INTERFACE standard control transfers, this represents a direct violation of the WebUSB security model and allows manipulation of protected device states.
Suggested Fix
- In
services/device/usb/mojo/device_impl.cc, updateDeviceImpl::SetInterfaceAlternateSettingto validate the target interface againstblocked_interface_classes_, consistent with the logic inClaimInterface. - In
services/device/usb/usb_device_handle_usbfs.cc, updateUsbDeviceHandleUsbfs::SetInterfaceAlternateSettingto verify that the interface is already claimed by the current handle (by checking theinterfaces_map) before proceeding with the ioctl.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.