CVE-2026-13879
Overview
Background
- `bluetooth::Socket::SendMore`
- the Bluetooth service routine that drains bytes pending in a Mojo data-pipe consumer and forwards them to the underlying
BluetoothSocket::Sendfor transmission. - `net::WrappedIOBuffer`
- a non-owning
IOBufferthat merely points at externally owned memory and does not extend that memory’s lifetime. - `net::IOBufferWithSize`
- an owning, ref-counted
IOBufferthat allocates and holds its own heap storage for the duration of any task that retains a reference. - Mojo shared-memory ring buffer
- the memory region backing a Mojo data pipe; when the pipe is closed or reset, Mojo Core synchronously unmaps this region, invalidating any pointer into it.
Root Cause Analysis
In Socket::SendMore, the pending read buffer (pending_read_buffer, a base::span pointing directly into the Mojo Core shared-memory ring buffer) was wrapped in a non-owning net::WrappedIOBuffer and handed to bluetooth_socket_->Send, which performs the actual write asynchronously on a background socket thread. Because WrappedIOBuffer does not own the memory, the invariant that the buffer must remain valid until the async write completes was not enforced. If the Mojo data pipe was closed or reset before the background write finished, Mojo Core synchronously unmapped the shared memory, leaving the background thread holding a dangling pointer — a use-after-unmap.
The fix allocates an owning net::IOBufferWithSize, copies the pending bytes into it via io_buffer->span().copy_from(pending_read_buffer) before posting the write, and passes that owning buffer; since the buffer is ref-counted by the background task, its heap storage stays valid for the entire asynchronous write regardless of the Mojo pipe or Socket lifecycle.
IOBufferWithSize that copies the data up front guarantees the memory outlives the async operation.Attack Path
- Initiate a Bluetooth socket send
A web page (via Web Bluetooth) or client writes data into the Mojo data pipe feeding
Socket::SendMore, which wraps the ring-buffer span in aWrappedIOBufferand posts an async write to the background socket thread. - Close the pipe mid-send
Before the background write completes, the data pipe / send stream is reset (the regression test
TestSend_ClosedDuringSenddoes exactly this viasend_stream_.reset()), causing Mojo Core to synchronously unmap the shared-memory ring buffer. - Dangling access on the socket thread The background socket write, still holding the non-owning pointer into the now-unmapped region, reads freed/unmapped memory — the use-after-unmap.
- Crash or corruption The access faults or reads stale data; any further primitive beyond the unmap-triggered crash is not shown by the patch. (inferred)
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fdevice/bluetooth/socket_unittest.cc |
modified |
Files Changed
device/bluetooth/socket.ccdevice/bluetooth/socket_unittest.cc
Audit Directions
- Non-owning buffers across async boundariesSearch for
WrappedIOBuffer(and other non-owning span/pointer wrappers) passed into asynchronous or cross-thread operations, where the backing memory may be released or unmapped before completion. - Mojo shared-memory aliasingReview code that hands raw spans into Mojo data-pipe / shared-memory regions to consumers that outlive the pipe, since pipe close triggers synchronous unmap.
- Copy-before-post disciplineAudit producer/consumer paths that post work to background threads to confirm data is copied into owning, ref-counted storage rather than aliased from caller- or IPC-owned memory.
Patch
From e940e425d4fadb301240e0c6aa144b15cf96a735 Mon Sep 17 00:00:00 2001
From: Hongchan Choi <hongchan@chromium.org>
Date: Fri, 29 May 2026 17:12:12 -0700
Subject: [PATCH] [Bluetooth] Fix Use-After-Unmap vulnerability in bluetooth::Socket
Resolve a Use-After-Unmap vulnerability in Socket::SendMore() by
replacing the non-owning net::WrappedIOBuffer with an owning
net::IOBufferWithSize for asynchronous socket writes.
Previously, Socket::SendMore() wrapped a base::span pointing directly
into the Mojo Core shared memory ring buffer with a
net::WrappedIOBuffer. Because WrappedIOBuffer is non-owning and the
socket write occurs asynchronously on a background socket thread, the
buffer's lifetime could exceed the mapping of the Mojo shared memory
ring buffer. If the Mojo data pipe was closed or reset before the
background socket write completed, Mojo Core synchronously unmapped the
shared memory, leaving the background thread with a dangling pointer,
resulting in a Use-After-Unmap crash.
To resolve this, we replace WrappedIOBuffer with an owning
IOBufferWithSize. Before posting the async write operation, the data is
copied from the Mojo ring buffer into the newly allocated
IOBufferWithSize. Since the IOBufferWithSize owns its heap storage and
is ref-counted by the background socket task, the memory remains valid
throughout the asynchronous write execution regardless of the Mojo
stream or Socket lifecycle.
Bug: 499022239
Test: autoninja -C out/Default device_unittests && ./out/Default/device_unittests --gtest_filter='SocketTest.*' --single-process-tests
Change-Id: I4c0f2d86c76f31031c2df80343405eb9f42bf916
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7886436
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Commit-Queue: Hongchan Choi <hongchan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1638838}
---
diff --git a/device/bluetooth/socket.cc b/device/bluetooth/socket.cc
index c15adbd..05ba38f 100644
--- a/device/bluetooth/socket.cc
+++ b/device/bluetooth/socket.cc
@@ -6,15 +6,12 @@
#include <string>
#include <utility>
-#include <vector>
#include "base/containers/span.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/logging.h"
-#include "base/memory/ptr_util.h"
#include "base/numerics/safe_conversions.h"
-#include "base/strings/string_view_util.h"
#include "device/bluetooth/bluetooth_socket.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/receiver.h"
@@ -169,9 +166,11 @@
return;
}
- std::string_view chars = base::as_string_view(pending_read_buffer);
- bluetooth_socket_->Send(base::MakeRefCounted<net::WrappedIOBuffer>(chars),
- chars.size(),
+ auto io_buffer =
+ base::MakeRefCounted<net::IOBufferWithSize>(pending_read_buffer.size());
+ io_buffer->span().copy_from(pending_read_buffer);
+ const int buffer_size = io_buffer->size();
+ bluetooth_socket_->Send(std::move(io_buffer), buffer_size,
base::BindOnce(&Socket::OnBluetoothSocketSend,
weak_ptr_factory_.GetWeakPtr()),
base::BindOnce(&Socket::OnBluetoothSocketSendError,
diff --git a/device/bluetooth/socket_unittest.cc b/device/bluetooth/socket_unittest.cc
index 5a0e4aac..08a15f9 100644
--- a/device/bluetooth/socket_unittest.cc
+++ b/device/bluetooth/socket_unittest.cc
@@ -202,4 +202,29 @@
WriteAndVerifySend("message_6", /*success=*/true);
}
+TEST_F(SocketTest, TestSend_ClosedDuringSend) {
+ EXPECT_FALSE(send_stream_->QuerySignalsState().never_writable());
+ EXPECT_FALSE(fake_bluetooth_socket_->HasSendArgs());
+
+ std::string message = "test_message";
+ size_t actually_written_bytes = 0;
+ EXPECT_EQ(MOJO_RESULT_OK,
+ send_stream_->WriteData(base::as_byte_span(message),
+ MOJO_WRITE_DATA_FLAG_NONE,
+ actually_written_bytes));
+ EXPECT_EQ(message.size(), actually_written_bytes);
+
+ base::RunLoop().RunUntilIdle();
+
+ EXPECT_TRUE(fake_bluetooth_socket_->HasSendArgs());
+ auto send_args = fake_bluetooth_socket_->TakeSendArgs();
+
+ send_stream_.reset();
+
+ base::RunLoop().RunUntilIdle();
+
+ auto success_callback = std::move(std::get<2>(*send_args));
+ std::move(success_callback).Run(/*num_bytes_sent=*/message.size());
+}
+
} // namespace bluetooth
Regression Test / PoC
diff --git a/device/bluetooth/socket_unittest.cc b/device/bluetooth/socket_unittest.cc
index 5a0e4aac..08a15f9 100644
--- a/device/bluetooth/socket_unittest.cc
+++ b/device/bluetooth/socket_unittest.cc
@@ -202,4 +202,29 @@
WriteAndVerifySend("message_6", /*success=*/true);
}
+TEST_F(SocketTest, TestSend_ClosedDuringSend) {
+ EXPECT_FALSE(send_stream_->QuerySignalsState().never_writable());
+ EXPECT_FALSE(fake_bluetooth_socket_->HasSendArgs());
+
+ std::string message = "test_message";
+ size_t actually_written_bytes = 0;
+ EXPECT_EQ(MOJO_RESULT_OK,
+ send_stream_->WriteData(base::as_byte_span(message),
+ MOJO_WRITE_DATA_FLAG_NONE,
+ actually_written_bytes));
+ EXPECT_EQ(message.size(), actually_written_bytes);
+
+ base::RunLoop().RunUntilIdle();
+
+ EXPECT_TRUE(fake_bluetooth_socket_->HasSendArgs());
+ auto send_args = fake_bluetooth_socket_->TakeSendArgs();
+
+ send_stream_.reset();
+
+ base::RunLoop().RunUntilIdle();
+
+ auto success_callback = std::move(std::get<2>(*send_args));
+ std::move(success_callback).Run(/*num_bytes_sent=*/message.size());
+}
+
} // namespace bluetooth
Original Bug Report
Potential Use-After-Unmap Information Leak via Bluetooth Socket Send
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 Use-After-Unmap vulnerability exists in device/bluetooth/Socket when sending data over a Bluetooth socket. The browser wraps a Mojo shared memory pointer in a non-owning net::WrappedIOBuffer and passes it to an asynchronous socket thread, leading to a potential information leak if the Mojo data pipe is closed while the write is pending.
Affected files:
device/bluetooth/socket.ccdevice/bluetooth/bluetooth_socket_net.cc
Estimated timestamp from git blame: 2024-06-13
Mechanism
In device/bluetooth/socket.cc, Socket::SendMore() reads outgoing data from a Mojo data pipe. It calls send_stream_->BeginReadData(), which returns a base::span pointing directly into Mojo Core’s underlying shared memory ring buffer.
This span is then wrapped in a net::WrappedIOBuffer and passed to bluetooth_socket_->Send() for asynchronous transmission:
void Socket::SendMore() {
// ...
MojoResult result = send_stream_->BeginReadData(MOJO_WRITE_DATA_FLAG_NONE, pending_read_buffer);
// ...
std::string_view chars = base::as_string_view(pending_read_buffer);
bluetooth_socket_->Send(base::MakeRefCounted<net::WrappedIOBuffer>(chars),
chars.size(), ...);
}
net::WrappedIOBuffer is explicitly designed as a non-owning wrapper and is documented as “ONLY safe for synchronous use.” However, on platforms using BluetoothSocketNet (such as Linux and ChromeOS), the Send() method queues the request and posts a task to a background socket_thread_ to perform the blocking I/O.
The Vulnerability
Because the WrappedIOBuffer is passed across threads for an asynchronous operation, its lifetime outlives the synchronous scope of SendMore(). If the Mojo data pipe consumer handle (send_stream_) is reset before the background thread completes the socket write, Mojo Core will immediately and synchronously unmap (munmap) the shared memory ring buffer from the browser’s address space.
Because this memory is allocated via mmap rather than PartitionAlloc, it is not protected by MiraclePtr (BackupRefPtr). The background socket_thread_ retains a dangling pointer inside the WrappedIOBuffer parked in net::SocketPosix::write_buf_.
Potential Attack Scenario
- An attacker compromises a process with access to
device::mojom::Adapter(e.g., the utility process handling Nearby Share on ChromeOS). - The attacker uses
ConnectToServiceInsecurelyto establish a Bluetooth connection to an attacker-controlled remote device, yielding amojom::Socketand its associated data pipes. - The attacker writes up to 64KB (the default pipe capacity) into the send stream.
- The attacker manipulates the remote Bluetooth device to stall L2CAP/RFCOMM network acknowledgments. This fills the kernel’s send buffer, causing the browser’s background
socket_thread_to hitEWOULDBLOCKduring thesend()call.net::SocketPosixstores theWrappedIOBufferinwrite_buf_and waits for the socket to become writable. - The compromised utility process closes its end of the Mojo send stream.
- The browser’s UI thread processes the closure, calls
ShutdownSend(), and resetssend_stream_. This causes Mojo Core to synchronouslymunmapthe 64KB shared memory ring buffer. - The attacker sprays the browser’s virtual address space (e.g., via other IPC channels) to reallocate the newly unmapped 64KB block with sensitive browser memory.
- The attacker’s remote device resumes sending acknowledgments, unblocking the socket.
- The
socket_thread_wakes up and re-attempts thesend()call using the danglingWrappedIOBuffer, reading up to 64KB of the reallocated, sensitive memory and transmitting it to the attacker’s Bluetooth device.
Suggested Fix
To fix this issue, Socket::SendMore() should not use net::WrappedIOBuffer for asynchronous operations. The data obtained from BeginReadData() should be copied into an owning buffer type before being passed to bluetooth_socket_->Send().
Alternatively, if avoiding a copy is strictly necessary, Socket::SendMore() should use a specialized adapter (similar to network::MojoToNetIOBuffer) that takes ownership of a MojoHandle to ensure the Mojo memory remains mapped until the IO buffer is destroyed.
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.