CVE-2026-76044
Overview
Background
- `DeviceImpl`
- Browser-side Mojo implementation of one WebUSB connection to a physical device; multiple instances (tabs/frames) can bind to the same underlying
UsbDevice. - `state_change_in_progress` flag
- A single boolean stored on the shared
UsbDevicethat serializes configuration/interface changes across allDeviceImplconnections to that device. - `ReportBadMessage`
- Mojo primitive that terminates the calling renderer process for a protocol violation, used previously when a state change was already in progress.
- `RecordStateChangeBlocked`
- New metric/telemetry hook recording when an operation is refused because a conflicting state change is active.
Root Cause Analysis
State-change serialization for WebUSB SetConfiguration, ClaimInterface, and ReleaseInterface relied on a single state_change_in_progress boolean owned by the shared UsbDevice rather than by the per-connection DeviceImpl. Because that flag was shared across every DeviceImpl bound to the same physical device, concurrent connections from different tabs or frames could race on it: one connection could observe or clear a flag set by another, permitting conflicting state changes to proceed on the same device. Worse, if a DeviceImpl was destroyed (tab closed) while its change was in progress, the shared flag was never reset and leaked as permanently true, wedging the device for all future connections. The coarse, device-global blocking also rejected legitimately-unrelated operations (e.g., a change on a different interface) and used ReportBadMessage to kill the renderer, so a benign concurrent connection racing on the shared device would crash an innocent process.
The fix tracks state changes per DeviceImpl and per interface, clears the shared flag in the destructor, and returns graceful callback(false) errors instead of bad-message kills.
Attack Path
- Open concurrent connections
A malicious page opens two frames/tabs that each connect to the same physical WebUSB device, producing two
DeviceImplinstances over one sharedUsbDevice. - Race the shared flag
Both frames issue
SetConfiguration/ClaimInterfaceso their reads and writes of the sharedstate_change_in_progressinterleave, letting conflicting state changes slip through the coarse check. - Leak on teardown
One frame is closed (destroying its
DeviceImpl) while its change is still in progress, so the shared flag is left permanentlytrueand no further state changes succeed on that device. - Wedge or crash
Subsequent legitimate operations from any connection are blocked by the leaked flag, or a benign concurrent operation triggers
ReportBadMessage, killing an innocent renderer (denial of service).
Impact Assessment
ReportBadMessage allows one page to induce termination of another connection’s renderer. The race on the shared flag additionally permits conflicting, out-of-order state transitions on the same device, leaving USB configuration/interface state inconsistent across connections. Because the trigger is cross-connection concurrency on one physical device, exploitation requires a device the page is already permitted to access, bounding severity to DoS and state corruption rather than direct memory unsafety.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/device/usb/mojo/device_impl.cc |
modified |
Files Changed
services/device/usb/mojo/device_impl.hservices/device/usb/mojo/device_impl_unittest.ccservices/device/usb/usb_device.hservices/device/usb/mojo/device_impl.ccthird_party/blink/renderer/modules/webusb/usb_device.cc
Audit Directions
- Shared-state serialization flagsAudit boolean/mutex “in progress” flags stored on an object shared by multiple Mojo endpoints; ownership and cleanup must be per-connection, with destructor reset, to avoid leaks and cross-connection races.
- `ReportBadMessage` on concurrencyFlag uses of
ReportBadMessagetriggered by legitimate concurrent access rather than genuine protocol violations, since one page can then crash another’s renderer; prefer graceful error callbacks. - Coarse-grained resource lockingReview WebUSB/device paths that block at device granularity when operations target unrelated endpoints/interfaces; verify endpoint-readiness and per-interface checks scope blocking correctly.
Patch
From 25a76a0415efe731f1c29318a66896d3396f104d Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Wed, 08 Jul 2026 19:16:19 -0700
Subject: [PATCH] usb: Refine browser-side blocking during state changes
Refine WebUSB browser-side security checks in DeviceImpl to allow
concurrent operations on unrelated interfaces. Previously, all interface
operations and transfers were blocked if any state change (device-wide
or on any interface) was in progress. DeviceImpl now only blocks
interface-specific operations and transfers if a device-wide state
change (SetConfiguration, Reset) is in progress, or if a state change is
active on the specific interface being targeted.
BUG=522732244
Change-Id: I5f8742e698a332c0ff6d6839b6c00c228a8d61ab
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7927353
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1659227}
---
diff --git a/services/device/usb/mojo/device_impl.cc b/services/device/usb/mojo/device_impl.cc
index 9b3492bd..a0d8d49 100644
--- a/services/device/usb/mojo/device_impl.cc
+++ b/services/device/usb/mojo/device_impl.cc
@@ -447,8 +447,8 @@
void DeviceImpl::SetConfiguration(uint8_t value,
SetConfigurationCallback callback) {
- if (device_->state_change_in_progress()) {
- mojo::ReportBadMessage("Device state change in progress.");
+ if (any_state_change_in_progress()) {
+ mojo::ReportBadMessage("Device or interface state change in progress.");
std::move(callback).Run(false);
return;
}
@@ -457,7 +457,7 @@
return;
}
- device_->set_state_change_in_progress(true);
+ device_->set_device_state_change_in_progress(true);
device_handle_->SetConfiguration(
value, base::BindOnce(&DeviceImpl::OnSetConfigurationComplete,
weak_factory_.GetWeakPtr(), std::move(callback)));
@@ -465,8 +465,9 @@
void DeviceImpl::ClaimInterface(uint8_t interface_number,
ClaimInterfaceCallback callback) {
- if (device_->state_change_in_progress()) {
- mojo::ReportBadMessage("Device state change in progress.");
+ if (device_->device_state_change_in_progress() ||
+ IsInterfaceStateChangeInProgress(interface_number)) {
+ mojo::ReportBadMessage("Device or interface state change in progress.");
std::move(callback).Run(mojom::UsbClaimInterfaceResult::kFailure);
return;
}
@@ -496,17 +497,18 @@
}
}
- device_->set_state_change_in_progress(true);
+ SetInterfaceStateChangeInProgress(interface_number, true);
device_handle_->ClaimInterface(
- interface_number,
- base::BindOnce(&DeviceImpl::OnInterfaceClaimed,
- weak_factory_.GetWeakPtr(), std::move(callback)));
+ interface_number, base::BindOnce(&DeviceImpl::OnInterfaceClaimed,
+ weak_factory_.GetWeakPtr(),
+ std::move(callback), interface_number));
}
void DeviceImpl::ReleaseInterface(uint8_t interface_number,
ReleaseInterfaceCallback callback) {
- if (device_->state_change_in_progress()) {
- mojo::ReportBadMessage("Device state change in progress.");
+ if (device_->device_state_change_in_progress() ||
+ IsInterfaceStateChangeInProgress(interface_number)) {
+ mojo::ReportBadMessage("Device or interface state change in progress.");
std::move(callback).Run(false);
return;
}
@@ -515,15 +517,20 @@
return;
}
- device_handle_->ReleaseInterface(interface_number, std::move(callback));
+ SetInterfaceStateChangeInProgress(interface_number, true);
+ device_handle_->ReleaseInterface(
+ interface_number, base::BindOnce(&DeviceImpl::OnInterfaceReleased,
+ weak_factory_.GetWeakPtr(),
+ std::move(callback), interface_number));
}
void DeviceImpl::SetInterfaceAlternateSetting(
uint8_t interface_number,
uint8_t alternate_setting,
SetInterfaceAlternateSettingCallback callback) {
- if (device_->state_change_in_progress()) {
- mojo::ReportBadMessage("Device state change in progress.");
+ if (device_->device_state_change_in_progress() ||
+ IsInterfaceStateChangeInProgress(interface_number)) {
+ mojo::ReportBadMessage("Device or interface state change in progress.");
std::move(callback).Run(false);
return;
}
@@ -550,16 +557,17 @@
return;
}
- device_->set_state_change_in_progress(true);
+ SetInterfaceStateChangeInProgress(interface_number, true);
device_handle_->SetInterfaceAlternateSetting(
interface_number, alternate_setting,
base::BindOnce(&DeviceImpl::OnSetInterfaceAlternateSettingComplete,
- weak_factory_.GetWeakPtr(), std::move(callback)));
+ weak_factory_.GetWeakPtr(), std::move(callback),
+ interface_number));
}
void DeviceImpl::Reset(ResetCallback callback) {
- if (device_->state_change_in_progress()) {
- mojo::ReportBadMessage("Device state change in progress.");
+ if (any_state_change_in_progress()) {
+ mojo::ReportBadMessage("Device or interface state change in progress.");
std::move(callback).Run(false);
return;
}
@@ -568,7 +576,7 @@
return;
}
- device_->set_state_change_in_progress(true);
+ device_->set_device_state_change_in_progress(true);
device_handle_->ResetDevice(base::BindOnce(&DeviceImpl::OnResetComplete,
weak_factory_.GetWeakPtr(),
std::move(callback)));
@@ -577,7 +585,14 @@
void DeviceImpl::ClearHalt(UsbTransferDirection direction,
uint8_t endpoint_number,
ClearHaltCallback callback) {
- if (!device_handle_) {
+ uint8_t endpoint_address = endpoint_number;
+ if (direction == UsbTransferDirection::INBOUND) {
+ endpoint_address |= 0x80;
+ }
+ if (!IsEndpointReadyForTransfer(endpoint_address)) {
+ mojo::ReportBadMessage(
+ "Device or interface state change in progress or interface is not "
+ "claimed.");
std::move(callback).Run(false);
return;
}
@@ -589,6 +604,11 @@
uint32_t length,
uint32_t timeout,
ControlTransferInCallback callback) {
+ if (any_state_change_in_progress()) {
+ mojo::ReportBadMessage("Device or interface state change in progress.");
+ std::move(callback).Run(mojom::UsbTransferStatus::PERMISSION_DENIED, {});
+ return;
+ }
if (!device_handle_) {
std::move(callback).Run(mojom::UsbTransferStatus::TRANSFER_ERROR, {});
return;
@@ -614,6 +634,11 @@
base::span<const uint8_t> data,
uint32_t timeout,
ControlTransferOutCallback callback) {
+ if (any_state_change_in_progress()) {
+ mojo::ReportBadMessage("Device or interface state change in progress.");
+ std::move(callback).Run(mojom::UsbTransferStatus::PERMISSION_DENIED);
+ return;
+ }
if (!device_handle_) {
std::move(callback).Run(mojom::UsbTransferStatus::TRANSFER_ERROR);
return;
@@ -641,15 +666,18 @@
uint32_t length,
uint32_t timeout,
GenericTransferInCallback callback) {
- if (!device_handle_) {
- std::move(callback).Run(mojom::UsbTransferStatus::TRANSFER_ERROR, {});
+ uint8_t endpoint_address = endpoint_number | 0x80;
+ if (!IsEndpointReadyForTransfer(endpoint_address)) {
+ mojo::ReportBadMessage(
+ "Device or interface state change in progress or interface is not "
+ "claimed.");
+ std::move(callback).Run(mojom::UsbTransferStatus::PERMISSION_DENIED, {});
return;
}
if (ShouldRejectUsbTransferLengthAndReportBadMessage(length)) {
return;
}
- uint8_t endpoint_address = endpoint_number | 0x80;
auto buffer = base::MakeRefCounted<base::RefCountedBytes>(length);
device_handle_->GenericTransfer(
UsbTransferDirection::INBOUND, endpoint_address, buffer, timeout,
@@ -660,15 +688,18 @@
base::span<const uint8_t> data,
uint32_t timeout,
GenericTransferOutCallback callback) {
- if (!device_handle_) {
- std::move(callback).Run(mojom::UsbTransferStatus::TRANSFER_ERROR);
+ uint8_t endpoint_address = endpoint_number;
+ if (!IsEndpointReadyForTransfer(endpoint_address)) {
+ mojo::ReportBadMessage(
+ "Device or interface state change in progress or interface is not "
+ "claimed.");
+ std::move(callback).Run(mojom::UsbTransferStatus::PERMISSION_DENIED);
return;
}
if (ShouldRejectUsbTransferLengthAndReportBadMessage(data.size())) {
return;
}
- uint8_t endpoint_address = endpoint_number;
auto buffer = base::MakeRefCounted<base::RefCountedBytes>(data);
device_handle_->GenericTransfer(
UsbTransferDirection::OUTBOUND, endpoint_address, buffer, timeout,
@@ -680,10 +711,14 @@
const std::vector<uint32_t>& packet_lengths,
uint32_t timeout,
IsochronousTransferInCallback callback) {
- if (!device_handle_) {
+ uint8_t endpoint_address = endpoint_number | 0x80;
+ if (!IsEndpointReadyForTransfer(endpoint_address)) {
+ mojo::ReportBadMessage(
+ "Device or interface state change in progress or interface is not "
+ "claimed.");
std::move(callback).Run(
{}, BuildIsochronousPacketArray(
- packet_lengths, mojom::UsbTransferStatus::TRANSFER_ERROR));
+ packet_lengths, mojom::UsbTransferStatus::PERMISSION_DENIED));
return;
}
@@ -699,7 +734,6 @@
return;
}
- uint8_t endpoint_address = endpoint_number | 0x80;
device_handle_->IsochronousTransferIn(
endpoint_address, packet_lengths, timeout,
base::BindOnce(&OnIsochronousTransferIn, std::move(callback)));
@@ -711,9 +745,13 @@
const std::vector<uint32_t>& packet_lengths,
uint32_t timeout,
IsochronousTransferOutCallback callback) {
- if (!device_handle_) {
+ uint8_t endpoint_address = endpoint_number;
+ if (!IsEndpointReadyForTransfer(endpoint_address)) {
+ mojo::ReportBadMessage(
+ "Device or interface state change in progress or interface is not "
+ "claimed.");
std::move(callback).Run(BuildIsochronousPacketArray(
- packet_lengths, mojom::UsbTransferStatus::TRANSFER_ERROR));
+ packet_lengths, mojom::UsbTransferStatus::PERMISSION_DENIED));
return;
}
@@ -728,7 +766,6 @@
return;
}
- uint8_t endpoint_address = endpoint_number;
auto buffer = base::MakeRefCounted<base::RefCountedBytes>(data);
device_handle_->IsochronousTransferOut(
endpoint_address, buffer, packet_lengths, timeout,
@@ -741,27 +778,36 @@
}
void DeviceImpl::OnInterfaceClaimed(ClaimInterfaceCallback callback,
+ uint8_t interface_number,
bool success) {
- device_->set_state_change_in_progress(false);
+ SetInterfaceStateChangeInProgress(interface_number, false);
std::move(callback).Run(success ? mojom::UsbClaimInterfaceResult::kSuccess
: mojom::UsbClaimInterfaceResult::kFailure);
}
+void DeviceImpl::OnInterfaceReleased(ReleaseInterfaceCallback callback,
+ uint8_t interface_number,
+ bool success) {
+ SetInterfaceStateChangeInProgress(interface_number, false);
+ std::move(callback).Run(success);
+}
+
void DeviceImpl::OnSetInterfaceAlternateSettingComplete(
SetInterfaceAlternateSettingCallback callback,
+ uint8_t interface_number,
bool success) {
- device_->set_state_change_in_progress(false);
+ SetInterfaceStateChangeInProgress(interface_number, false);
std::move(callback).Run(success);
}
void DeviceImpl::OnSetConfigurationComplete(SetConfigurationCallback callback,
bool success) {
- device_->set_state_change_in_progress(false);
+ device_->set_device_state_change_in_progress(false);
std::move(callback).Run(success);
}
void DeviceImpl::OnResetComplete(ResetCallback callback, bool success) {
- device_->set_state_change_in_progress(false);
+ device_->set_device_state_change_in_progress(false);
std::move(callback).Run(success);
}
@@ -786,5 +832,43 @@
return true;
}
+bool DeviceImpl::IsEndpointReadyForTransfer(uint8_t endpoint_address) {
+ if (device_->device_state_change_in_progress() || !device_handle_) {
+ return false;
+ }
+
+ const mojom::UsbInterfaceInfo* interface =
+ device_handle_->FindInterfaceByEndpoint(endpoint_address);
+ if (!interface ||
+ IsInterfaceStateChangeInProgress(interface->interface_number)) {
+ return false;
+ }
+
+ return true;
+}
+
+bool DeviceImpl::IsInterfaceStateChangeInProgress(
+ uint8_t interface_number) const {
+ return interface_state_changes_in_progress_.contains(interface_number);
+}
+
+bool DeviceImpl::any_interface_state_change_in_progress() const {
+ return !interface_state_changes_in_progress_.empty();
+}
+
+bool DeviceImpl::any_state_change_in_progress() const {
+ return device_->device_state_change_in_progress() ||
+ any_interface_state_change_in_progress();
+}
+
+void DeviceImpl::SetInterfaceStateChangeInProgress(uint8_t interface_number,
+ bool in_progress) {
+ if (in_progress) {
+ interface_state_changes_in_progress_.insert(interface_number);
+ } else {
+ interface_state_changes_in_progress_.erase(interface_number);
+ }
+}
+
} // namespace usb
} // namespace device
diff --git a/services/device/usb/mojo/device_impl.h b/services/device/usb/mojo/device_impl.h
index 4c00fd2..5d78c1d 100644
--- a/services/device/usb/mojo/device_impl.h
+++ b/services/device/usb/mojo/device_impl.h
@@ -133,9 +133,15 @@
// device::UsbDevice::Observer implementation:
void OnDeviceRemoved(scoped_refptr<device::UsbDevice> device) override;
- void OnInterfaceClaimed(ClaimInterfaceCallback callback, bool success);
+ void OnInterfaceClaimed(ClaimInterfaceCallback callback,
+ uint8_t interface_number,
+ bool success);
+ void OnInterfaceReleased(ReleaseInterfaceCallback callback,
+ uint8_t interface_number,
+ bool success);
void OnSetInterfaceAlternateSettingComplete(
SetInterfaceAlternateSettingCallback callback,
+ uint8_t interface_number,
bool success);
void OnSetConfigurationComplete(SetConfigurationCallback callback,
bool success);
@@ -145,6 +151,20 @@
// Reject and report bad mojo messaage if `length` exceeds limit.
bool ShouldRejectUsbTransferLengthAndReportBadMessage(size_t length);
+ // Returns whether the endpoint is ready for a transfer.
+ // A transfer is allowed if the device is open, the target interface is
+ // claimed, and neither a device-wide state change nor an interface-specific
+ // state change for the target interface is in progress.
+ // This is used for non-control transfers (Generic and Isochronous) and
+ // ClearHalt.
+ bool IsEndpointReadyForTransfer(uint8_t endpoint_address);
+
+ bool IsInterfaceStateChangeInProgress(uint8_t interface_number) const;
+ bool any_interface_state_change_in_progress() const;
+ bool any_state_change_in_progress() const;
+ void SetInterfaceStateChangeInProgress(uint8_t interface_number,
+ bool in_progress);
+
const scoped_refptr<device::UsbDevice> device_;
base::ScopedObservation<device::UsbDevice, device::UsbDevice::Observer>
observation_{this};
@@ -155,6 +175,8 @@
bool opening_ = false;
scoped_refptr<UsbDeviceHandle> device_handle_;
... (file diff truncated)
Regression Test / PoC
diff --git a/services/device/usb/mojo/device_impl_unittest.cc b/services/device/usb/mojo/device_impl_unittest.cc
index cea062d..cdb54c7 100644
--- a/services/device/usb/mojo/device_impl_unittest.cc
+++ b/services/device/usb/mojo/device_impl_unittest.cc
@@ -82,6 +82,27 @@
return *this;
}
+ ConfigBuilder& AddEndpoint(uint8_t interface_number,
+ uint8_t alternate_setting,
+ uint8_t endpoint_address,
+ uint8_t attributes,
+ uint16_t maximum_packet_size,
+ uint8_t polling_interval) {
+ for (auto& interface : config_->interfaces) {
+ if (interface->interface_number == interface_number) {
+ for (auto& alternate : interface->alternates) {
+ if (alternate->alternate_setting == alternate_setting) {
+ alternate->endpoints.push_back(
+ BuildUsbEndpointInfoPtr(endpoint_address, attributes,
+ maximum_packet_size, polling_interval));
+ return *this;
+ }
+ }
+ }
+ }
+ return *this;
+ }
+
mojom::UsbConfigurationInfoPtr Build() { return std::move(config_); }
private:
@@ -233,6 +254,9 @@
ON_CALL(mock_handle(), IsochronousTransferOutInternal(_, _, _, _, _))
.WillByDefault(
Invoke(this, &USBDeviceImplTest::IsochronousTransferOut));
+ ON_CALL(mock_handle(), FindInterfaceByEndpoint(_))
+ .WillByDefault(
+ Invoke(this, &USBDeviceImplTest::FindInterfaceByEndpoint));
return proxy;
}
@@ -291,6 +315,25 @@
mock_outbound_packets_.push(std::move(packets));
}
+ void ConfigureAndClaimInterface(mojom::UsbDevice* device,
+ uint8_t configuration_value,
+ uint8_t interface_number) {
+ EXPECT_CALL(mock_handle(),
+ SetConfigurationInternal(configuration_value, _));
+ base::test::TestFuture<bool> set_config_future;
+ device->SetConfiguration(configuration_value,
+ set_config_future.GetCallback());
+ EXPECT_TRUE(set_config_future.Get());
+
+ EXPECT_CALL(mock_handle(), ClaimInterfaceInternal(interface_number, _));
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult>
+ claim_interface_future;
+ device->ClaimInterface(interface_number,
+ claim_interface_future.GetCallback());
+ EXPECT_EQ(claim_interface_future.Get(),
+ mojom::UsbClaimInterfaceResult::kSuccess);
+ }
+
private:
void OpenMockHandle(UsbDevice::OpenCallback& callback) {
open_count_++;
@@ -339,6 +382,34 @@
}
}
+ const mojom::UsbInterfaceInfo* FindInterfaceByEndpoint(
+ uint8_t endpoint_address) {
+ const mojom::UsbConfigurationInfo* config =
+ mock_device().GetActiveConfiguration();
+ if (!config) {
+ return nullptr;
+ }
+
+ for (const auto& interface : config->interfaces) {
+ if (!claimed_interfaces_.contains(interface->interface_number)) {
+ continue;
+ }
+
+ for (const auto& alternate : interface->alternates) {
+ for (const auto& endpoint : alternate->endpoints) {
+ uint8_t address = endpoint->endpoint_number;
+ if (endpoint->direction == mojom::UsbTransferDirection::INBOUND) {
+ address |= 0x80;
+ }
+ if (address == endpoint_address) {
+ return interface.get();
+ }
+ }
+ }
+ }
+ return nullptr;
+ }
+
void SetInterfaceAlternateSetting(uint8_t interface_number,
uint8_t alternate_setting,
UsbDeviceHandle::ResultCallback& callback) {
@@ -907,6 +978,453 @@
EXPECT_CALL(mock_handle(), Close());
}
+// Verify that ControlTransferIn requests are blocked and rejected as a bad
+// message if a device configuration change is currently in progress.
+TEST_F(USBDeviceImplTest, ControlTransferInBlockedDuringSetConfiguration) {
+ mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> open_future;
+ device->Open(open_future.GetCallback());
+ EXPECT_TRUE(open_future.Get()->is_success());
+
+ AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/0,
+ /*alternate_setting=*/0,
+ /*class_code=*/1,
+ /*subclass_code=*/2,
+ /*protocol_code=*/3)
+ .Build());
+
+ base::test::TestFuture<UsbDeviceHandle::ResultCallback>
+ set_configuration_future;
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _))
+ .WillOnce([&](int value, UsbDeviceHandle::ResultCallback& callback) {
+ set_configuration_future.SetValue(std::move(callback));
+ });
+
+ // Initiate SetConfiguration but only save the callback without invoking it.
+ device->SetConfiguration(1, base::NullCallback());
+
+ // Ensure the request has reached the service.
+ ASSERT_TRUE(set_configuration_future.Wait());
+
+ // Immediately try to do a control transfer; should fail as a bad message.
+ auto params = mojom::UsbControlTransferParams::New();
+ params->type = mojom::UsbControlTransferType::STANDARD;
+ params->recipient = mojom::UsbControlTransferRecipient::DEVICE;
+ params->request = 0;
+ params->value = 0;
+ params->index = 0;
+
+ EXPECT_CALL(mock_handle(), Close());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ device->ControlTransferIn(std::move(params), 8, 0, base::DoNothing());
+
+ EXPECT_EQ("Device or interface state change in progress.",
+ bad_message_observer.WaitForBadMessage());
+
+ device.reset();
+ EXPECT_TRUE(base::test::RunUntil([&]() { return !is_device_open(); }));
+}
+
+// Verify that ControlTransferOut requests are blocked and rejected as a bad
+// message if a device configuration change is currently in progress.
+TEST_F(USBDeviceImplTest, ControlTransferOutBlockedDuringSetConfiguration) {
+ mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> open_future;
+ device->Open(open_future.GetCallback());
+ EXPECT_TRUE(open_future.Get()->is_success());
+
+ AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/0,
+ /*alternate_setting=*/0,
+ /*class_code=*/1,
+ /*subclass_code=*/2,
+ /*protocol_code=*/3)
+ .Build());
+
+ base::test::TestFuture<UsbDeviceHandle::ResultCallback>
+ set_configuration_future;
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _))
+ .WillOnce([&](int value, UsbDeviceHandle::ResultCallback& callback) {
+ set_configuration_future.SetValue(std::move(callback));
+ });
+
+ // Initiate SetConfiguration but only save the callback without invoking it.
+ device->SetConfiguration(1, base::NullCallback());
+
+ // Ensure the request has reached the service.
+ ASSERT_TRUE(set_configuration_future.Wait());
+
+ // Immediately try to do a control transfer; should fail as a bad message.
+ auto params = mojom::UsbControlTransferParams::New();
+ params->type = mojom::UsbControlTransferType::STANDARD;
+ params->recipient = mojom::UsbControlTransferRecipient::DEVICE;
+ params->request = 0;
+ params->value = 0;
+ params->index = 0;
+
+ EXPECT_CALL(mock_handle(), Close());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ std::vector<uint8_t> data = {1, 2, 3, 4};
+ device->ControlTransferOut(std::move(params), data, 0, base::DoNothing());
+
+ EXPECT_EQ("Device or interface state change in progress.",
+ bad_message_observer.WaitForBadMessage());
+
+ device.reset();
+ EXPECT_TRUE(base::test::RunUntil([&]() { return !is_device_open(); }));
+}
+
+TEST_F(USBDeviceImplTest, ControlTransferInBlockedDuringReleaseInterface) {
+ mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> open_future;
+ device->Open(open_future.GetCallback());
+ EXPECT_TRUE(open_future.Get()->is_success());
+
+ AddMockConfig(ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/1,
+ /*alternate_setting=*/0,
+ /*class_code=*/1,
+ /*subclass_code=*/2,
+ /*protocol_code=*/3)
+ .Build());
+
+ EXPECT_CALL(mock_handle(), SetConfigurationInternal(1, _));
+
+ base::test::TestFuture<bool> set_config_future;
+ device->SetConfiguration(1, set_config_future.GetCallback());
+ EXPECT_TRUE(set_config_future.Get());
+
+ EXPECT_CALL(mock_handle(), ClaimInterfaceInternal(1, _));
+
+ base::test::TestFuture<mojom::UsbClaimInterfaceResult> claim_interface_future;
+ device->ClaimInterface(1, claim_interface_future.GetCallback());
+ EXPECT_EQ(claim_interface_future.Get(),
+ mojom::UsbClaimInterfaceResult::kSuccess);
+
+ base::test::TestFuture<int, UsbDeviceHandle::ResultCallback>
+ release_interface_future;
+ EXPECT_CALL(mock_handle(), ReleaseInterfaceInternal(1, _))
+ .WillOnce([&](int value, UsbDeviceHandle::ResultCallback& callback) {
+ release_interface_future.SetValue(value, std::move(callback));
+ });
+
+ // Initiate ReleaseInterface but only save the callback without invoking it.
+ device->ReleaseInterface(1, base::NullCallback());
+
+ // Ensure the request has reached the service.
+ ASSERT_TRUE(release_interface_future.Wait());
+
+ // Immediately try to do a control transfer; should fail as a bad message.
+ auto params = mojom::UsbControlTransferParams::New();
+ params->type = mojom::UsbControlTransferType::STANDARD;
+ params->recipient = mojom::UsbControlTransferRecipient::DEVICE;
+ params->request = 0;
+ params->value = 0;
+ params->index = 0;
+
+ EXPECT_CALL(mock_handle(), Close());
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ device->ControlTransferIn(std::move(params), 8, 0, base::DoNothing());
+
+ EXPECT_EQ("Device or interface state change in progress.",
+ bad_message_observer.WaitForBadMessage());
+
+ device.reset();
+ EXPECT_TRUE(base::test::RunUntil([&]() { return !is_device_open(); }));
+}
+
+TEST_F(USBDeviceImplTest, GenericTransferAllowedDuringReleaseOtherInterface) {
+ mojo::Remote<mojom::UsbDevice> device = GetMockDeviceProxy();
+
+ EXPECT_CALL(mock_device(), OpenInternal(_));
+
+ base::test::TestFuture<mojom::UsbOpenDeviceResultPtr> open_future;
+ device->Open(open_future.GetCallback());
+ EXPECT_TRUE(open_future.Get()->is_success());
+
+ // Create config with two interfaces and endpoints.
+ // Create configuration 1 with:
+ // - Interface 1 (alt=0, class=1, subclass=2, protocol=3)
+ // - Endpoint 1 OUT (Bulk, 64-byte packets)
+ // - Endpoint 1 IN (Bulk, 64-byte packets)
+ // - Interface 2 (alt=0, class=1, subclass=2, protocol=3)
+ // - Endpoint 2 OUT (Bulk, 64-byte packets)
+ // - Endpoint 2 IN (Bulk, 64-byte packets)
+ auto config = ConfigBuilder(/*configuration_value=*/1)
+ .AddInterface(/*interface_number=*/1,
+ /*alternate_setting=*/0,
+ /*class_code=*/1,
+ /*subclass_code=*/2,
+ /*protocol_code=*/3)
+ .AddEndpoint(/*interface_number=*/1,
+ /*alternate_setting=*/0,
... (truncated)
Original Bug Report
Potential Sandbox Escape via TOCTOU in WebUSB DeviceImpl transfers
Flapjack, 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: A Time-of-Check to Time-of-Use (TOCTOU) race condition exists in the WebUSB DeviceImpl class. Configuration-altering methods set a state change flag, but data transfer methods fail to check this flag before performing synchronous permission checks against stale configuration data. By pipelining a configuration change with transfer requests over Mojo, a compromised renderer could bypass interface class protections and communicate with protected USB interfaces.
Affected files:
services/device/usb/mojo/device_impl.ccservices/device/usb/usb_device_handle_impl.cc
Estimated timestamp from git blame: 2026-05-11
Summary
A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the WebUSB implementation in Chrome. The DeviceImpl class fails to check the device_state_change_in_progress status in its data transfer methods. This allows a compromised renderer to bypass interface class protections (e.g., WebUsbProtectedClassControlTransferBlock) by pipelining a SetConfiguration request followed immediately by transfer requests over the Mojo IPC pipe.
Vulnerability Details
When a SetConfiguration request is made through WebUSB, the DeviceImpl::SetConfiguration method (services/device/usb/mojo/device_impl.cc) initiates a configuration change. To prevent races, it sets a flag (device_->set_state_change_in_progress(true)) that blocks subsequent conflicting operations like ClaimInterface or Reset.
However, this flag is not checked in the following data transfer methods:
ControlTransferIn/ControlTransferOutGenericTransferIn/GenericTransferOutIsochronousTransferIn/IsochronousTransferOut
When a transfer method like ControlTransferOut is called, it performs synchronous permission checks using HasControlTransferPermission. This function retrieves the active configuration using device_->GetActiveConfiguration(). If a SetConfiguration task is currently pending in the background, GetActiveConfiguration() still returns the stale, old configuration.
Potential Exploitation Steps
Our tooling agent cannot currently run code to provide a working PoC, but the theoretical attack flow is as follows:
- Prerequisites: A compromised renderer connects directly to the
device::mojom::UsbDeviceMojo interface, bypassing client-side blink constraints. A target USB device is attached that supports at least two configurations: Config A (no protected interfaces) and Config B (contains a protected interface class like HID). - Pipelining IPC: The attacker pipelines two Mojo messages sequentially without waiting for replies:
SetConfiguration(Config B)followed instantly byControlTransferOuttargeting an index belonging to the protected interface in Config B. - State Check Bypass: The browser processes
SetConfiguration, setting the state change flag, and posts the actual configuration change to the backend. It immediately processesControlTransferOut. - Time-of-Check Bypass:
ControlTransferOutfails to check the state change flag.HasControlTransferPermissionevaluates the transfer against the stale Config A. Because Config A does not identify the target as a protected class, permission is granted. - Time-of-Use Exploitation:
- On macOS (
libusb): The configuration change (SetConfigurationBlocking) and the transfer submission (Transfer::Submit) are posted sequentially to the exact same background sequence (blocking_task_runner_). This guarantees the device physically switches to Config B before the transfer is submitted to the OS, routing the payload directly to the protected interface. - On Linux/Android (
usbfs): The configuration change hops to a background helper thread, while the control transfer is submitted synchronously viaioctlon the main thread. This introduces a race condition, but still allows exploitation under favorable thread scheduling.
- On macOS (
Impact
This vulnerability potentially allows a compromised renderer to bypass WebUSB security boundaries and communicate with protected USB interface classes (e.g., HID, Mass Storage). This constitutes a potential sandbox escape.
Suggested Fix
Add a check for device_->state_change_in_progress() at the beginning of all transfer methods in DeviceImpl (services/device/usb/mojo/device_impl.cc). If the flag is set, the method should return an error and report a bad message, consistent with the existing implementation of ClaimInterface and Reset.
if (device_->state_change_in_progress()) {
mojo::ReportBadMessage("Device state change in progress.");
std::move(callback).Run(mojom::UsbTransferStatus::PERMISSION_DENIED, {});
return;
}
Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff
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.