CVE-2026-13869
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/device/serial/serial_io_handler.cc |
modified |
Files Changed
services/device/public/cpp/device_features.ccservices/device/public/cpp/device_features.hservices/device/serial/serial_io_handler.ccservices/device/serial/serial_io_handler.hservices/device/serial/serial_port_impl.cc
Patch
From c1ee3a3a9caa12ee7a47d9aaed2e6d68f2a4dc2b Mon Sep 17 00:00:00 2001
From: Alvin Ji <alvinji@chromium.org>
Date: Mon, 11 May 2026 14:35:39 -0700
Subject: [PATCH] serial: Keep data pipe handles alive until pending I/O completes on Win
On Windows, abruptly closing a serial port while overlapped I/O is
pending can unmap the Mojo data pipe shared memory buffers before the
kernel finishes processing the aborted IRPs, creating a potential
Use-After-Unmap vulnerability.
This CL resolves the issue by transferring ownership of active Mojo
handles into keep-alive closures attached to SerialIoHandler. These
closures defer unmapping until the OS kernel successfully signals
completion.
This behavior is restricted entirely to Windows builds and gated behind
the enabled-by-default feature flag kSafeSerialPortImplWinClose.
Bug: 497610642
Change-Id: I906ede1c9bf77ea126829a9ff2c3cf12e64efeeb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7831801
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Alvin Ji <alvinji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1628814}
---
diff --git a/services/device/public/cpp/device_features.cc b/services/device/public/cpp/device_features.cc
index 8dedf3fc..fdffca9 100644
--- a/services/device/public/cpp/device_features.cc
+++ b/services/device/public/cpp/device_features.cc
@@ -71,6 +71,11 @@
// connection is closed.
BASE_FEATURE(kSafeHidConnectionWinClose, base::FEATURE_ENABLED_BY_DEFAULT);
+// When enabled, SerialPortImpl will ensure that shared memory buffers backing
+// pending OVERLAPPED requests are not unmapped until the kernel has signaled
+// completion, even if the port is closed.
+BASE_FEATURE(kSafeSerialPortImplWinClose, base::FEATURE_ENABLED_BY_DEFAULT);
+
// Defines a feature parameter for the `kWinSystemLocationPermission` feature.
// This parameter controls the polling interval (in milliseconds) for checking
// the permission status. The default polling interval is set to 500
diff --git a/services/device/public/cpp/device_features.h b/services/device/public/cpp/device_features.h
index 6527ca8..4826520 100644
--- a/services/device/public/cpp/device_features.h
+++ b/services/device/public/cpp/device_features.h
@@ -36,6 +36,7 @@
DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(kHidGetFeatureReportFix);
DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(kSafeUsbDeviceHandleWinClose);
DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(kSafeHidConnectionWinClose);
+DEVICE_FEATURES_EXPORT BASE_DECLARE_FEATURE(kSafeSerialPortImplWinClose);
extern const DEVICE_FEATURES_EXPORT base::FeatureParam<int>
kWinSystemLocationPermissionPollingParam;
diff --git a/services/device/serial/serial_io_handler.cc b/services/device/serial/serial_io_handler.cc
index d28e8b8..df50c134 100644
--- a/services/device/serial/serial_io_handler.cc
+++ b/services/device/serial/serial_io_handler.cc
@@ -231,12 +231,31 @@
WriteImpl();
}
+#if BUILDFLAG(IS_WIN)
+void SerialIoHandler::KeepAliveUntilReadCompletes(base::OnceClosure cleanup) {
+ DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+ CHECK(IsReadPending());
+ pending_read_cleanup_ = std::move(cleanup);
+}
+
+void SerialIoHandler::KeepAliveUntilWriteCompletes(base::OnceClosure cleanup) {
+ DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+ CHECK(IsWritePending());
+ pending_write_cleanup_ = std::move(cleanup);
+}
+#endif // BUILDFLAG(IS_WIN)
+
void SerialIoHandler::ReadCompleted(int bytes_read,
mojom::SerialReceiveError error) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
DCHECK(IsReadPending());
pending_read_buffer_ = base::span<uint8_t>();
std::move(pending_read_callback_).Run(bytes_read, error);
+#if BUILDFLAG(IS_WIN)
+ if (pending_read_cleanup_) {
+ std::move(pending_read_cleanup_).Run();
+ }
+#endif // BUILDFLAG(IS_WIN)
Release();
}
@@ -246,6 +265,11 @@
DCHECK(IsWritePending());
pending_write_buffer_ = base::span<const uint8_t>();
std::move(pending_write_callback_).Run(bytes_written, error);
+#if BUILDFLAG(IS_WIN)
+ if (pending_write_cleanup_) {
+ std::move(pending_write_cleanup_).Run();
+ }
+#endif // BUILDFLAG(IS_WIN)
Release();
}
diff --git a/services/device/serial/serial_io_handler.h b/services/device/serial/serial_io_handler.h
index 645daa3e..3f7c31c 100644
--- a/services/device/serial/serial_io_handler.h
+++ b/services/device/serial/serial_io_handler.h
@@ -74,6 +74,18 @@
// |callback| is run.
void Write(base::span<const uint8_t> buffer, WriteCompleteCallback callback);
+#if BUILDFLAG(IS_WIN)
+ // Registers a closure to be run when the pending read operation completes.
+ // This is used to ensure that memory buffers backing the pending read remain
+ // valid until the kernel has signaled completion.
+ void KeepAliveUntilReadCompletes(base::OnceClosure cleanup);
+
+ // Registers a closure to be run when the pending write operation completes.
+ // This is used to ensure that memory buffers backing the pending write remain
+ // valid until the kernel has signaled completion.
+ void KeepAliveUntilWriteCompletes(base::OnceClosure cleanup);
+#endif // BUILDFLAG(IS_WIN)
+
// Indicates whether or not a read is currently pending.
bool IsReadPending() const;
@@ -223,6 +235,11 @@
mojom::SerialSendError write_cancel_reason_;
bool write_canceled_;
+#if BUILDFLAG(IS_WIN)
+ base::OnceClosure pending_read_cleanup_;
+ base::OnceClosure pending_write_cleanup_;
+#endif // BUILDFLAG(IS_WIN)
+
// Callback to handle the completion of a pending Open() request.
OpenCompleteCallback open_complete_;
diff --git a/services/device/serial/serial_port_impl.cc b/services/device/serial/serial_port_impl.cc
index e11a4e9..ce720263 100644
--- a/services/device/serial/serial_port_impl.cc
+++ b/services/device/serial/serial_port_impl.cc
@@ -10,7 +10,9 @@
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/task/single_thread_task_runner.h"
+#include "build/build_config.h"
#include "mojo/public/cpp/system/data_pipe.h"
+#include "services/device/public/cpp/device_features.h"
#include "services/device/serial/serial_io_handler.h"
namespace device {
@@ -48,6 +50,21 @@
SerialPortImpl::~SerialPortImpl() {
// Cancel I/O operations so that |io_handler_| drops its self-reference.
io_handler_->Close(base::DoNothing());
+
+#if BUILDFLAG(IS_WIN)
+ // Prevent Use-After-Unmap by keeping the Mojo handles (and backing shared
+ // memory) alive until pending overlapped I/O completes in the OS kernel.
+ if (base::FeatureList::IsEnabled(features::kSafeSerialPortImplWinClose)) {
+ if (io_handler_->IsReadPending()) {
+ io_handler_->KeepAliveUntilReadCompletes(
+ base::DoNothingWithBoundArgs(std::move(out_stream_)));
+ }
+ if (io_handler_->IsWritePending()) {
+ io_handler_->KeepAliveUntilWriteCompletes(
+ base::DoNothingWithBoundArgs(std::move(in_stream_)));
+ }
+ }
+#endif // BUILDFLAG(IS_WIN)
}
void SerialPortImpl::OpenPort(const mojom::SerialConnectionOptions& options,
Original Bug Report
Potential Use-After-Unmap in SerialPortImpl destructor on Windows via overlapped ReadFile
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: On Windows, destroying a SerialPortImpl object synchronously unmaps its Mojo data pipe shared memory while an overlapped ReadFile operation may still be pending in the kernel. This can result in the Windows kernel writing serial data into a newly allocated, attacker-controlled object at the same virtual address, bypassing user-mode mitigations like MiraclePtr. This potential write-after-free vulnerability in the browser process could lead to a sandbox escape.
Affected files:
services/device/serial/serial_port_impl.ccservices/device/serial/serial_io_handler_win.ccservices/device/serial/serial_io_handler.ccservices/device/serial/serial_port_impl.h
Estimated timestamp from git blame: 2019-08-21
Background
When a renderer reads from a serial port using the Web Serial API, the browser process (SerialPortImpl) handles the data transfer. To avoid unnecessary memory copies, SerialPortImpl uses mojo::DataPipeProducerHandle::BeginWriteData to get a direct pointer to the Mojo data pipe’s shared memory buffer. It passes this pointer directly to the OS-level read function via SerialIoHandler::Read.
On Windows, SerialIoHandlerWin::ReadImpl passes this user-mode virtual address to the ReadFile API along with an OVERLAPPED structure to initiate an asynchronous read. The Windows kernel’s I/O manager saves this pointer in the resulting IRP (Irp->UserBuffer) and returns ERROR_IO_PENDING.
The Vulnerability
A potential Use-After-Unmap (Write-After-Free) vulnerability exists in the SerialPortImpl destructor because it fails to ensure that the Windows kernel has finished using the data pipe’s memory before unmapping it.
When a compromised renderer abruptly closes the SerialPort Mojo remote, SerialPortImpl is synchronously deleted on the browser’s IO thread. The destructor does the following:
- Calls
io_handler_->Close(), which on Windows callsPurgeComm(..., PURGE_RXABORT)to cancel the pending read. - Crucially,
PurgeCommreturns immediately without waiting for the kernel or serial driver to finish processing the IRP abort or for theOnIOCompletedcallback to fire. - The destructor then completes, destroying the
out_stream_member (mojo::ScopedDataPipeProducerHandle). - Destroying the data pipe handle synchronously unmaps the shared memory region (
UnmapViewOfFile) from the browser’s virtual address space.
At this point, the kernel still has an IRP pointing to the now-unmapped virtual address. When the IRP eventually completes (even as aborted, or if data arrived just prior to the abort), the kernel’s I/O manager executes a special kernel APC to copy the received serial data into the original user-mode virtual address. If an attacker has reallocated that virtual address, the kernel will blindly overwrite the new object, bypassing user-mode memory protections like MiraclePtr (base::raw_ptr).
Note: The developers recognized this exact danger in SerialPortImpl::Flush (serial_port_impl.cc:131-136), where they explicitly delay resetting out_stream_ if IsReadPending() is true, noting that io_handler_ still holds a pointer into the shared memory. This safety check is missing from the destruction path.
Suggested Exploitation Steps
Note: These are potential steps. Our tooling agent does not currently have the ability to run code or provide a working Proof-of-Concept.
- Prerequisite: A compromised renderer gains Web Serial API permissions (either through user interaction or by chaining another bug to spoof permissions).
- Trigger I/O: The renderer opens a serial port and initiates a continuous read operation, providing a large Mojo data pipe.
- Abrupt Disconnect: The renderer abruptly closes the Mojo connection while the serial device is actively sending data.
- Heap Grooming: Immediately after closing the connection, the renderer spams IPC messages (e.g., creating new Blobs or Data Pipes) designed to allocate objects in the browser process of the exact same size as the unmapped data pipe buffer.
- Memory Corruption: The OS reallocates the freed virtual address to one of the attacker’s new objects. Shortly after, the kernel I/O manager completes the pending serial IRP and copies the hardware data directly into the attacker’s object.
- Code Execution: By carefully crafting the serial data (e.g., using a loopback device or predictable protocol data) and grooming the heap with objects containing function pointers or vtables, the attacker hijacks execution in the browser process, escaping the sandbox.
Proposed Fix
The destruction of out_stream_ (and in_stream_) must be delayed until SerialIoHandler guarantees that no overlapped I/O operations are pending.
One approach is to have SerialPortImpl refrain from destroying the Mojo handles in its destructor if I/O is pending. Instead, it could transfer ownership of the handles to SerialIoHandler itself, or to a callback bound to the CancelRead/CancelWrite completion, ensuring the memory remains mapped until the Windows message pump fires OnIOCompleted.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.