CVE-2026-13846
Overview
Files Changed
services/device/usb/usb_device_handle_impl.cc
Patch
From 91cbfc99801d49d4e7d8a6349552870f95b48cb0 Mon Sep 17 00:00:00 2001 From: Alvin Ji <[email protected]> Date: Thu, 28 May 2026 12:39:58 -0700 Subject: [PATCH] usb: prevent UAF in macOS WebUSB isochronous transfers Add endpoint verification to IsochronousTransferIn and IsochronousTransferOut to ensure the target endpoint is part of a claimed interface before creating the transfer. This prevents transfers from being created with a null claimed interface, which allowed them to bypass cancellation during ReleaseInterface and lead to a Use-After-Free on macOS. BUG=516999424 Change-Id: Ie957a81626631ee914a685860debab357f8191aa Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7879472 Commit-Queue: Alvin Ji <[email protected]> Reviewed-by: Matt Reynolds <[email protected]> Cr-Commit-Position: refs/heads/main@{#1637882} --- diff --git a/services/device/usb/usb_device_handle_impl.cc b/services/device/usb/usb_device_handle_impl.cc index 8a75062f..7e94e425 100644 --- a/services/device/usb/usb_device_handle_impl.cc +++ b/services/device/usb/usb_device_handle_impl.cc @@ -783,6 +783,15 @@ uint8_t endpoint_address = ConvertTransferDirection(UsbTransferDirection::INBOUND) | endpoint_number; + if (!endpoint_map_.contains(endpoint_address)) { + USB_LOG(ERROR) << "Failed to submit isochronous transfer because endpoint " + << static_cast<int>(endpoint_address) + << " is not part of a claimed interface."; + ReportIsochronousTransferError(std::move(callback), packet_lengths, + UsbTransferStatus::TRANSFER_ERROR); + return; + } + size_t length = std::accumulate(packet_lengths.begin(), packet_lengths.end(), 0u); auto buffer = base::MakeRefCounted<base::RefCountedBytes>(length); @@ -810,6 +819,15 @@ uint8_t endpoint_address = ConvertTransferDirection(UsbTransferDirection::OUTBOUND) | endpoint_number; + if (!endpoint_map_.contains(endpoint_address)) { + USB_LOG(ERROR) << "Failed to submit isochronous transfer because endpoint " + << static_cast<int>(endpoint_address) + << " is not part of a claimed interface."; + ReportIsochronousTransferError(std::move(callback), packet_lengths, + UsbTransferStatus::TRANSFER_ERROR); + return; + } + size_t length = std::accumulate(packet_lengths.begin(), packet_lengths.end(), 0u); std::unique_ptr<Transfer> transfer = Transfer::CreateIsochronousTransfer( @@ -838,9 +856,9 @@ ConvertTransferDirection(direction) | endpoint_number; const auto endpoint_it = endpoint_map_.find(endpoint_address); if (endpoint_it == endpoint_map_.end()) { - USB_LOG(DEBUG) << "Failed to submit transfer because endpoint " + USB_LOG(ERROR) << "Failed to submit transfer because endpoint " << static_cast<int>(endpoint_address) - << " not part of a claimed interface."; + << " is not part of a claimed interface."; task_runner_->PostTask( FROM_HERE, base::BindOnce(std::move(callback), UsbTransferStatus::TRANSFER_ERROR,
Original Bug Report
Potential Use-After-Free in macOS WebUSB via race in ClaimInterface and IsochronousTransfer
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: A potential race condition exists in the macOS WebUSB implementation during the asynchronous window of interface claiming. An isochronous transfer issued during this gap is created with a null claimed interface, bypassing cancellation filters during ReleaseInterface. This can lead to a Use-After-Free of an IOKit COM proxy inside the Device Service on macOS.
Affected files:
services/device/usb/usb_device_handle_impl.ccthird_party/libusb/src/libusb/os/darwin_usb.c
Estimated timestamp from git blame: 2016-02-04
Potential Security Issue: Use-After-Free in macOS WebUSB
There is a potential Use-After-Free (UAF) vulnerability in the macOS WebUSB implementation. This issue stems from a combination of incomplete state checking when initializing isochronous transfers and a lack of thread synchronization inside the underlying libusb Darwin backend during concurrent transfer cancellation and interface release.
Root Cause Analysis
-
Unchecked Transfer Initialization: Unlike
GenericTransfer, which explicitly verifies that the target endpoint is present inendpoint_map_before proceeding (seeservices/device/usb/usb_device_handle_impl.ccline 831),IsochronousTransferInandIsochronousTransferOutdo not checkendpoint_map_. Instead, they immediately callTransfer::CreateIsochronousTransfer(lines 781, 807).If an isochronous transfer is requested immediately after
ClaimInterface()but beforeClaimInterfaceComplete()has executed on the sequence thread,endpoint_map_is empty. As a result,GetClaimedInterfaceForEndpoint(line 1072) returnsnullptr. The transfer is then constructed with itsclaimed_interface_member set tonullptr(line 355). -
Bypassing Cancellation Filters: When
ReleaseInterface()is called, it attempts to cancel all active transfers associated with the target interface claimer (line 642). Because the isochronous transfer’sclaimed_interface_isnullptr, it bypasses this filter and remains active and in-flight. -
Unsynchronized Thread Concurrency in
libusbon Darwin: WhenClose()is called, it attempts to cancel the active transfer synchronously on the sequence thread vialibusb_cancel_transfer()(line 543). Concurrently, the releasedInterfaceClaimerdestructor runs on the blocking thread pool sequence and executeslibusb_release_interface()(line 162).These two operations run concurrently without adequate synchronization:
libusb_release_interface()acquires the device-level lockdev->lock(core.cline 1581) and calls the Darwin backend’sdarwin_release_interface()(darwin_usb.cline 1246), which closes and releases the IOKitIOUSBInterfaceInterfaceCOM proxy.libusb_cancel_transfer()only acquires the transfer lockitransfer->lock(io.cline 1493) and does not block ondev->lock.- In
darwin_abort_transfers(), the sequence thread callsep_to_pipeRef(), which sees that the interface bit is still set insidedev->claimed_interfaces(as the release thread has not yet returned to clear it incore.c). It retrievescInterfaceand executes a blocking Mach IPC callAbortPipe(line 1691). - While the sequence thread is blocked, the blocking thread completes
Release(), freeing the underlying COM proxy. - Upon returning from the IPC, the sequence thread attempts to call
ClearPipeStallBothEnds(line 1696) on the freed interface proxy pointer, resulting in a potential Use-After-Free (UAF).
Potential Step-by-Step Reproduction Scenario
Note: These steps are based on static code analysis and represent a potential exploit path, as our tooling does not currently have the capability to execute code.
- A compromised renderer process obtains permission for a WebUSB device with an isochronous endpoint.
- The renderer calls
ClaimInterface(0)and immediately invokesIsochronousTransferIn()before the Mojo callback forClaimInterfaceis resolved. - This creates the transfer with a
nullptrassociatedclaimed_interface_. - Once
ClaimInterfacecompletes, the renderer immediately callsReleaseInterface(0)followed byClose()on the Mojo interface. - If the concurrent threads align, the
Release()call on the blocking thread pool deallocates the COM proxy while the sequence thread is blocked on the Mach IPC ofAbortPipeduring cancellation, leading to a UAF upon IPC completion.
Suggested Fix
To remediate this issue, add an explicit check to IsochronousTransferIn and IsochronousTransferOut to verify that the target endpoint exists in endpoint_map_ before attempting to create the transfer, matching the logic in GenericTransfer:
const auto endpoint_it = endpoint_map_.find(endpoint_address);
if (endpoint_it == endpoint_map_.end()) {
USB_LOG(DEBUG) << "Failed to submit isochronous transfer because endpoint "
<< static_cast<int>(endpoint_address)
<< " not part of a claimed interface.";
ReportIsochronousTransferError(std::move(callback), packet_lengths,
UsbTransferStatus::TRANSFER_ERROR);
return;
}
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.