CVE-2026-13882
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/usb/mojo/device_impl.ccservices/device/usb/mojo/device_impl.hservices/device/usb/mojo/device_impl_unittest.cc
Patch
From 7e97805c0fd550b2a9b7c3e1012e6680e6b72011 Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Mon, 11 May 2026 16:14:09 -0700
Subject: [PATCH] usb: Lock interface requests during pending device state changes
Fixes a TOCTOU vulnerability where a compromised renderer could pipeline
a ClaimInterface request immediately behind SetConfiguration, bypassing
the protected interface blocklist by validating against stale
configuration metadata.
Introduces a `device_state_change_in_progress_` flag to DeviceImpl to
synchronously reject interface operations while a device configuration
switch or reset is pending.
Bug: 499162550
Change-Id: Id7bbe4d655598814cdc0f8e03a92423360805732
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7831277
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1628883}
---
diff --git a/services/device/usb/mojo/device_impl.cc b/services/device/usb/mojo/device_impl.cc
index 7f34bfb..db7f9c64 100644
--- a/services/device/usb/mojo/device_impl.cc
+++ b/services/device/usb/mojo/device_impl.cc
@@ -346,16 +346,29 @@
void DeviceImpl::SetConfiguration(uint8_t value,
SetConfigurationCallback callback) {
+ if (device_state_change_in_progress_) {
+ mojo::ReportBadMessage("Device state change in progress.");
+ std::move(callback).Run(false);
+ return;
+ }
if (!device_handle_) {
std::move(callback).Run(false);
return;
}
- device_handle_->SetConfiguration(value, std::move(callback));
+ device_state_change_in_progress_ = true;
+ device_handle_->SetConfiguration(
+ value, base::BindOnce(&DeviceImpl::OnSetConfigurationComplete,
+ weak_factory_.GetWeakPtr(), std::move(callback)));
}
void DeviceImpl::ClaimInterface(uint8_t interface_number,
ClaimInterfaceCallback callback) {
+ if (device_state_change_in_progress_) {
+ mojo::ReportBadMessage("Device state change in progress.");
+ std::move(callback).Run(mojom::UsbClaimInterfaceResult::kFailure);
+ return;
+ }
if (!device_handle_) {
std::move(callback).Run(mojom::UsbClaimInterfaceResult::kFailure);
return;
@@ -390,6 +403,11 @@
void DeviceImpl::ReleaseInterface(uint8_t interface_number,
ReleaseInterfaceCallback callback) {
+ if (device_state_change_in_progress_) {
+ mojo::ReportBadMessage("Device state change in progress.");
+ std::move(callback).Run(false);
+ return;
+ }
if (!device_handle_) {
std::move(callback).Run(false);
return;
@@ -402,6 +420,11 @@
uint8_t interface_number,
uint8_t alternate_setting,
SetInterfaceAlternateSettingCallback callback) {
+ if (device_state_change_in_progress_) {
+ mojo::ReportBadMessage("Device state change in progress.");
+ std::move(callback).Run(false);
+ return;
+ }
if (!device_handle_) {
std::move(callback).Run(false);
return;
@@ -412,12 +435,20 @@
}
void DeviceImpl::Reset(ResetCallback callback) {
+ if (device_state_change_in_progress_) {
+ mojo::ReportBadMessage("Device state change in progress.");
+ std::move(callback).Run(false);
+ return;
+ }
if (!device_handle_) {
std::move(callback).Run(false);
return;
}
- device_handle_->ResetDevice(std::move(callback));
+ device_state_change_in_progress_ = true;
+ device_handle_->ResetDevice(base::BindOnce(&DeviceImpl::OnResetComplete,
+ weak_factory_.GetWeakPtr(),
+ std::move(callback)));
}
void DeviceImpl::ClearHalt(UsbTransferDirection direction,
@@ -590,6 +621,17 @@
: mojom::UsbClaimInterfaceResult::kFailure);
}
+void DeviceImpl::OnSetConfigurationComplete(SetConfigurationCallback callback,
+ bool success) {
+ device_state_change_in_progress_ = false;
+ std::move(callback).Run(success);
+}
+
+void DeviceImpl::OnResetComplete(ResetCallback callback, bool success) {
+ device_state_change_in_progress_ = false;
+ std::move(callback).Run(success);
+}
+
void DeviceImpl::OnClientConnectionError() {
// Close the connection with Blink when WebUsbServiceImpl notifies the
// permission revocation from settings UI.
diff --git a/services/device/usb/mojo/device_impl.h b/services/device/usb/mojo/device_impl.h
index e888a27..54aa1b6 100644
--- a/services/device/usb/mojo/device_impl.h
+++ b/services/device/usb/mojo/device_impl.h
@@ -122,6 +122,9 @@
void OnDeviceRemoved(scoped_refptr<device::UsbDevice> device) override;
void OnInterfaceClaimed(ClaimInterfaceCallback callback, bool success);
+ void OnSetConfigurationComplete(SetConfigurationCallback callback,
+ bool success);
+ void OnResetComplete(ResetCallback callback, bool success);
void OnClientConnectionError();
// Reject and report bad mojo messaage if `length` exceeds limit.
@@ -135,6 +138,7 @@
// has been closed. |opening_| is set to true while the asynchronous open is
// in progress.
bool opening_ = false;
+ bool device_state_change_in_progress_ = false;
scoped_refptr<UsbDeviceHandle> device_handle_;
const base::flat_set<uint8_t> blocked_interface_classes_;
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 0461cd32..1b36def 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -860,6 +860,53 @@
EXPECT_CALL(mock_handle(), Close());
}
+TEST_F(USBDeviceImplTest, ClaimInterfaceFailsDuringSetConfiguration) {
+ 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(0, 0, 1, 2, 3).Build());
+
+ UsbDeviceHandle::ResultCallback saved_callback;
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _))
+ .WillOnce([&saved_callback](int value,
+ UsbDeviceHandle::ResultCallback& callback) {
+ saved_callback = std::move(callback);
+ });
+
+ // Initiate SetConfiguration but only save the callback without invoking it.
+ base::test::TestFuture<bool> set_config_future;
+ device->SetConfiguration(1, set_config_future.GetCallback());
+
+ // Ensure the request has reached the service.
+ base::RunLoop().RunUntilIdle();
+ ASSERT_TRUE(saved_callback);
+
+ // Immediately try to claim interface; should fail synchronously in service.
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult> claim_future;
+ device->ClaimInterface(0, claim_future.GetCallback());
+ EXPECT_EQ(claim_future.Get(), mojom::UsbClaimInterfaceResult::kFailure);
+
+ // Now resolve the pending SetConfiguration.
+ mock_device().ActiveConfigurationChanged(1);
+ std::move(saved_callback).Run(true);
+ EXPECT_TRUE(set_config_future.Get());
+
+ // After SetConfiguration completes, claiming should succeed.
+ EXPECT_CALL(mock_handle(), ClaimInterfaceInternal(0, _));
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult> claim_future2;
+ device->ClaimInterface(0, claim_future2.GetCallback());
+ EXPECT_EQ(claim_future2.Get(), mojom::UsbClaimInterfaceResult::kSuccess);
+
+ EXPECT_CALL(mock_handle(), Close());
+}
+
TEST_F(USBDeviceImplTest, SetInterfaceAlternateSetting) {
mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
Regression Test / PoC
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index 0461cd32..1b36def 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -860,6 +860,53 @@
EXPECT_CALL(mock_handle(), Close());
}
+TEST_F(USBDeviceImplTest, ClaimInterfaceFailsDuringSetConfiguration) {
+ 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(0, 0, 1, 2, 3).Build());
+
+ UsbDeviceHandle::ResultCallback saved_callback;
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _))
+ .WillOnce([&saved_callback](int value,
+ UsbDeviceHandle::ResultCallback& callback) {
+ saved_callback = std::move(callback);
+ });
+
+ // Initiate SetConfiguration but only save the callback without invoking it.
+ base::test::TestFuture<bool> set_config_future;
+ device->SetConfiguration(1, set_config_future.GetCallback());
+
+ // Ensure the request has reached the service.
+ base::RunLoop().RunUntilIdle();
+ ASSERT_TRUE(saved_callback);
+
+ // Immediately try to claim interface; should fail synchronously in service.
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult> claim_future;
+ device->ClaimInterface(0, claim_future.GetCallback());
+ EXPECT_EQ(claim_future.Get(), mojom::UsbClaimInterfaceResult::kFailure);
+
+ // Now resolve the pending SetConfiguration.
+ mock_device().ActiveConfigurationChanged(1);
+ std::move(saved_callback).Run(true);
+ EXPECT_TRUE(set_config_future.Get());
+
+ // After SetConfiguration completes, claiming should succeed.
+ EXPECT_CALL(mock_handle(), ClaimInterfaceInternal(0, _));
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult> claim_future2;
+ device->ClaimInterface(0, claim_future2.GetCallback());
+ EXPECT_EQ(claim_future2.Get(), mojom::UsbClaimInterfaceResult::kSuccess);
+
+ EXPECT_CALL(mock_handle(), Close());
+}
+
TEST_F(USBDeviceImplTest, SetInterfaceAlternateSetting) {
mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
Original Bug Report
Potential WebUSB protected-interface blocklist bypass via SetConfiguration TOCTOU
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 security team.
Overview: A Time-of-Check Time-of-Use (TOCTOU) race condition in the WebUSB Device Service allows a compromised renderer to bypass the protected-interface-class blocklist. By sending a SetConfiguration Mojo request followed immediately by a ClaimInterface request, the browser validates the claim against stale configuration metadata but executes the claim against the newly set configuration. This can potentially grant an attacker raw access to protected USB interfaces like HID keyboards or smart cards.
Affected files:
services/device/usb/mojo/device_impl.ccservices/device/usb/usb_device_handle_usbfs.ccservices/device/usb/usb_device_handle_impl.ccservices/device/usb/usb_device.ccservices/device/usb/usb_device_impl.cc
Estimated timestamp from git blame: 2026-01-06
Summary
A Time-of-Check Time-of-Use (TOCTOU) vulnerability exists in the browser’s WebUSB implementation (services/device/usb/mojo/device_impl.cc). The vulnerability arises because DeviceImpl::SetConfiguration updates the device’s configuration asynchronously on a background thread, while DeviceImpl::ClaimInterface validates interface claims synchronously on the main thread using potentially stale state before posting the actual claim operation to the same background thread.
A compromised renderer can bypass Blink’s JavaScript-level concurrency checks and send pipelined Mojo messages to exploit this. By carefully timing a SetConfiguration and a ClaimInterface request, the renderer can trick the browser into validating an interface against an old, permissive configuration, while the underlying OS claims the interface under a new, restricted configuration.
Potential Steps to Trigger
These are the suggested steps an attacker would follow to trigger this vulnerability:
- Preconditions: The attacker compromises a renderer process (e.g., via a v8 bug) and the user has granted WebUSB access to a USB device with at least two configurations (Config A and Config B).
- Config A contains a harmless interface (e.g., Vendor Specific, class
0xFF) at indexN. - Config B contains a protected interface (e.g., HID, class
0x03, which is inblocked_interface_classes_) at the exact same indexN.
- Config A contains a harmless interface (e.g., Vendor Specific, class
- Bypass JS checks: The compromised renderer bypasses
USBDevice::EnsureNoDeviceOrInterfaceChangeInProgressin Blink and communicates directly with themojom::UsbDeviceinterface. - Initiate Configuration Change: The renderer sends a
SetConfiguration(Config B)Mojo message.DeviceImpl::SetConfigurationreceives this and posts the task to a sequenced background task runner (e.g.,helper_on Linux/Android orblocking_task_runner_on macOS). The browser’s active configuration state is not updated yet. - Initiate Interface Claim: The renderer immediately sends a
ClaimInterface(N)Mojo message. - Stale State Validation:
DeviceImpl::ClaimInterfacesynchronously callsdevice_->GetActiveConfiguration()to validate the interface against theblocked_interface_classes_list. Since the background task hasn’t finished, this returns the metadata for the old Config A. The check sees a benign Vendor Specific interface and permits the claim, posting it to the same background task runner. - Sequential Execution: The background thread executes the tasks sequentially:
- It issues a synchronous USB control transfer to the OS to switch the physical device to Config B.
- It immediately issues an ioctl/request to claim interface
N. Since the device is now in Config B, the OS claims the protected HID interface. Because these requests execute back-to-back, native OS drivers (likeusbhid) do not have time to asynchronously bind and lock the interface.
- State Synchronization: The completion callbacks run on the main thread in order. The configuration state is updated to Config B. Then,
ClaimInterfaceCompleteruns and callsRefreshEndpointInfo(), which caches the protected endpoints of Config B’s interfaceNinto the browser’s allowedendpoints_map. - Exploitation: The compromised renderer can now send
GenericTransferInorGenericTransferOutMojo messages to the protected endpoints. The browser allows them because they exist in theendpoints_map, granting the attacker raw USB access to the restricted interface (e.g., injecting keystrokes).
Suggested Fix
The DeviceImpl class must enforce synchronization between configuration changes and interface claims.
A potential fix is to introduce a state flag (e.g., bool device_state_change_in_progress_) in DeviceImpl that is set when a SetConfiguration or Reset operation is pending. If ClaimInterface, SetInterfaceAlternateSetting, or similar methods are called while this flag is true, they should immediately fail and return an error (or be queued until the configuration change is complete). Alternatively, the interface class validation could be deferred and performed inside the completion callback on the main thread after the configuration change has definitively occurred.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.