CVE-2025-10201
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Pcontent/browser/indexed_db/indexed_db_browsertest.cc |
modified | |
forcontent/browser/indexed_db/instance/database.cc |
modified |
Files Changed
content/browser/indexed_db/BUILD.gncontent/browser/indexed_db/indexed_db_browsertest.cccontent/browser/indexed_db/instance/database.cccontent/test/content_test_bundle_data.filelistcontent/test/data/indexeddb/get_all_large_values.html
Patch
From 8b3c59daea285175a4f1a5f174b388f5ac263149 Mon Sep 17 00:00:00 2001 From: Abhishek Shanthkumar <[email protected]> Date: Thu, 30 Jul 2026 12:46:16 -0700 Subject: [PATCH] Reland "Reland "IDB: SQLite - Conditionally limit large values in getAll() response"" This is a reland of commit b2ddead35aac45fc0bfd90621607f9227f896833 The new test was too slow on MSAN, hence disabled it there. Original change's description: > Reland "IDB: SQLite - Conditionally limit large values in getAll() response" > > This is a reland of commit dd058e0bf9e5b4ce30fa2c4fd89a8e51c0153d3b > > It turns out that Fuchsia also has a limit on the number of handles that > can be sent in one mojo message; this reland respects the limit on > Fuchsia too. > > Original change's description: > > IDB: SQLite - Conditionally limit large values in getAll() response > > > > POSIX (minus macOS) currently does not support >128 file descriptors in > > one mojo message, triggering a CHECK - see crbug.com/439305148. Since > > SQLite-backed IDB does not wrap large values, each value may end up > > using shared memory, which requires file descriptors on POSIX. This CL > > limit the number of such values sent in each response chunk of a > > getAll() request. > > > > Bug: 523912081 > > Change-Id: I5797da537f57cd6de8e65fa8e33929a3f7a93ce2 > > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8083924 > > Reviewed-by: Steve Becker <[email protected]> > > Commit-Queue: Abhishek Shanthkumar <[email protected]> > > Cr-Commit-Position: refs/heads/main@{#1668478} > > Bug: 523912081 > Change-Id: I0b4fd0708d38b0fae8f751c44d7cb3e849fa8212 > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8160922 > Reviewed-by: Steve Becker <[email protected]> > Auto-Submit: Abhishek Shanthkumar <[email protected]> > Commit-Queue: Abhishek Shanthkumar <[email protected]> > Cr-Commit-Position: refs/heads/main@{#1669558} Bug: 523912081 Change-Id: Ic409b9d211a2def992eaf08c78bb7cdb1d148ff7 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8161063 Reviewed-by: Steve Becker <[email protected]> Commit-Queue: Abhishek Shanthkumar <[email protected]> Auto-Submit: Abhishek Shanthkumar <[email protected]> Cr-Commit-Position: refs/heads/main@{#1671338} --- diff --git a/content/browser/indexed_db/BUILD.gn b/content/browser/indexed_db/BUILD.gn index 1f7a146..d5fae33 100644 --- a/content/browser/indexed_db/BUILD.gn +++ b/content/browser/indexed_db/BUILD.gn @@ -99,6 +99,7 @@ "//components/services/storage/public/mojom", "//content:export", "//content/public/common:common_sources", + "//mojo:buildflags", "//net", "//storage/browser", "//third_party/blink/public/common", diff --git a/content/browser/indexed_db/indexed_db_browsertest.cc b/content/browser/indexed_db/indexed_db_browsertest.cc index e6e5e828..a99c994 100644 --- a/content/browser/indexed_db/indexed_db_browsertest.cc +++ b/content/browser/indexed_db/indexed_db_browsertest.cc @@ -1663,6 +1663,18 @@ blink::mojom::kIDBGetAllChunkSize))); } +// Too slow on MSAN. +#if defined(MEMORY_SANITIZER) +#define MAYBE_GetAllLargeValues DISABLED_GetAllLargeValues +#else +#define MAYBE_GetAllLargeValues GetAllLargeValues +#endif +// Verifies that getAll() succeeds over an object store containing many large +// values, testing platform limits. Regression test for crbug.com/523912081. +IN_PROC_BROWSER_TEST_P(IndexedDBBrowserTest, MAYBE_GetAllLargeValues) { + SimpleTest(GetTestUrl("indexeddb", "get_all_large_values.html")); +} + // Large values are NOT wrapped when using SQLite, but are wrapped when using // LevelDB. IN_PROC_BROWSER_TEST_P(IndexedDBBrowserTest, LargeValueIsWrapped) { diff --git a/content/browser/indexed_db/instance/database.cc b/content/browser/indexed_db/instance/database.cc index 65da8c9..10ae6cd 100644 --- a/content/browser/indexed_db/instance/database.cc +++ b/content/browser/indexed_db/instance/database.cc @@ -27,6 +27,7 @@ #include "base/trace_event/trace_event.h" #include "base/types/expected_macros.h" #include "base/unguessable_token.h" +#include "build/build_config.h" #include "components/services/storage/indexed_db/locks/partitioned_lock_id.h" #include "components/services/storage/indexed_db/locks/partitioned_lock_manager.h" #include "components/services/storage/privileged/mojom/indexed_db_client_state_checker.mojom.h" @@ -46,6 +47,8 @@ #include "content/browser/indexed_db/instance/transaction.h" #include "content/browser/indexed_db/status.h" #include "ipc/constants.mojom.h" +#include "mojo/buildflags.h" +#include "mojo/public/cpp/base/big_buffer.h" #include "mojo/public/cpp/bindings/associated_remote.h" #include "mojo/public/cpp/bindings/pending_associated_receiver.h" #include "mojo/public/cpp/bindings/pending_associated_remote.h" @@ -738,13 +741,45 @@ "Value wrapping threshold is higher than BigBuffer inline size; " "BigBuffer may use shared memory with LevelDB backing store"); - const size_t max_values_before_sending = blink::mojom::kIDBGetAllChunkSize; + const size_t kMaxValuesBeforeSending = blink::mojom::kIDBGetAllChunkSize; + + // Mojo limits the number of handles/file descriptors in a single message on + // certain platforms. Since SQLite does not wrap large values in blobs, the + // `mojo_base::BigBuffer` holding the value may be backed by shared memory + // (which uses handles/file descriptors - see + // `mojo::core::ipcz_driver::SharedBuffer::Serialize()`), and hence need a + // separate cap. + std::optional<uint32_t> max_shared_memory_values_in_chunk; + uint32_t shared_memory_values_in_chunk = 0; + + if (bucket_context_->IsUsingSqlite()) { +#if BUILDFLAG(IS_POSIX) && !BUILDFLAG(MOJO_USE_APPLE_CHANNEL) + // channel_posix limits the number of file descriptors per message to 128: + // crbug.com/439305148. +#if BUILDFLAG(IS_ANDROID) + // 1 file descriptor is used per shared memory buffer. + max_shared_memory_values_in_chunk = 128; +#else + // 2 file descriptors are used per shared memory buffer. + max_shared_memory_values_in_chunk = 64; +#endif +#elif BUILDFLAG(IS_FUCHSIA) + // channel_fuchsia.cc limits the number of handles per message to 64 + // (ZX_CHANNEL_MAX_MSG_HANDLES): crbug.com/508116627. + // 1 platform handle is used per shared memory buffer. + max_shared_memory_values_in_chunk = 64; +#endif + } for (uint32_t i = 0; i < max_count; ++i) { // Periodically stream records if we have too many. - if (found_records.size() >= max_values_before_sending) { + if (found_records.size() >= kMaxValuesBeforeSending || + (max_shared_memory_values_in_chunk.has_value() && + shared_memory_values_in_chunk >= + max_shared_memory_values_in_chunk.value())) { result_sink->SendResults(std::move(found_records), /*done=*/false); found_records.clear(); + shared_memory_values_in_chunk = 0; } // Cursor creation performs the first seek, returning a nullptr cursor when @@ -795,6 +830,12 @@ NOTREACHED(); } + if (return_record->return_value && + return_record->return_value->value->bits.storage_type() == + mojo_base::BigBuffer::StorageType::kSharedMemory) { + ++shared_memory_values_in_chunk; + } + found_records.emplace_back(std::move(return_record)); } diff --git a/content/test/content_test_bundle_data.filelist b/content/test/content_test_bundle_data.filelist index b9b0c99..b12410f 100644 --- a/content/test/content_test_bundle_data.filelist +++ b/content/test/content_test_bundle_data.filelist @@ -7927,6 +7927,8 @@ data/indexeddb/force_close_event.js data/indexeddb/get_all_chunking.html data/indexeddb/get_all_chunking.js +data/indexeddb/get_all_large_values.html +data/indexeddb/get_all_large_values.js data/indexeddb/index_test.html data/indexeddb/index_test.js data/indexeddb/key_path_test.html diff --git a/content/test/data/indexeddb/get_all_large_values.html b/content/test/data/indexeddb/get_all_large_values.html new file mode 100644 index 0000000..f1ee18a --- /dev/null +++ b/content/test/data/indexeddb/get_all_large_values.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> +<!-- +Copyright 2026 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +--> +<head> +<title>Test getAll() over a store containing many large values</title> +<script type="text/javascript" src="common.js"></script> +<script type="text/javascript" src="get_all_large_values.js"></script> +</head>
Regression Test / PoC
diff --git a/content/browser/indexed_db/indexed_db_browsertest.cc b/content/browser/indexed_db/indexed_db_browsertest.cc
index e6e5e828..a99c994 100644
--- a/content/browser/indexed_db/indexed_db_browsertest.cc
+++ b/content/browser/indexed_db/indexed_db_browsertest.cc
@@ -1663,6 +1663,18 @@
blink::mojom::kIDBGetAllChunkSize)));
}
+// Too slow on MSAN.
+#if defined(MEMORY_SANITIZER)
+#define MAYBE_GetAllLargeValues DISABLED_GetAllLargeValues
+#else
+#define MAYBE_GetAllLargeValues GetAllLargeValues
+#endif
+// Verifies that getAll() succeeds over an object store containing many large
+// values, testing platform limits. Regression test for crbug.com/523912081.
+IN_PROC_BROWSER_TEST_P(IndexedDBBrowserTest, MAYBE_GetAllLargeValues) {
+ SimpleTest(GetTestUrl("indexeddb", "get_all_large_values.html"));
+}
+
// Large values are NOT wrapped when using SQLite, but are wrapped when using
// LevelDB.
IN_PROC_BROWSER_TEST_P(IndexedDBBrowserTest, LargeValueIsWrapped) {
diff --git a/content/test/content_test_bundle_data.filelist b/content/test/content_test_bundle_data.filelist
index b9b0c99..b12410f 100644
--- a/content/test/content_test_bundle_data.filelist
+++ b/content/test/content_test_bundle_data.filelist
@@ -7927,6 +7927,8 @@
data/indexeddb/force_close_event.js
data/indexeddb/get_all_chunking.html
data/indexeddb/get_all_chunking.js
+data/indexeddb/get_all_large_values.html
+data/indexeddb/get_all_large_values.js
data/indexeddb/index_test.html
data/indexeddb/index_test.js
data/indexeddb/key_path_test.html
diff --git a/content/test/data/indexeddb/get_all_large_values.html b/content/test/data/indexeddb/get_all_large_values.html
new file mode 100644
index 0000000..f1ee18a
--- /dev/null
+++ b/content/test/data/indexeddb/get_all_large_values.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<!--
+Copyright 2026 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+-->
+<head>
+<title>Test getAll() over a store containing many large values</title>
+<script type="text/javascript" src="common.js"></script>
+<script type="text/javascript" src="get_all_large_values.js"></script>
+</head>
+<body onLoad="test()">
+<div id="status">Starting...</div>
+</body>
+</html>
diff --git a/content/test/data/indexeddb/get_all_large_values.js b/content/test/data/indexeddb/get_all_large_values.js
new file mode 100644
index 0000000..7f682a5
--- /dev/null
+++ b/content/test/data/indexeddb/get_all_large_values.js
@@ -0,0 +1,43 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+'use strict';
+
+const DB_NAME = 'large-values-db';
+const STORE_NAME = 'store';
+
+// Each record exceeds the threshold for inlining bytes in BigBuffer.
+const kRecordSize = 64 * 1024;
+const kRecordCount = 128 + 1;
+const kRecords = [];
+
+function createDb(db) {
+ const store = db.createObjectStore(STORE_NAME);
+ for (let i = 0; i < kRecordCount; i++) {
+ const record = new Uint8Array(kRecordSize);
+ crypto.getRandomValues(record);
+ kRecords.push(record);
+ store.put(record, i);
+ }
+}
+
+async function test() {
+ const db = await promiseDeleteThenOpenDb(DB_NAME, createDb);
+ const request =
+ db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll();
+ request.onerror = unexpectedErrorCallback;
+ request.onsuccess = () => {
+ if (request.result.length !== kRecordCount) {
+ fail(
+ 'getAll() returned wrong number of records: ' +
+ request.result.length + ' vs ' + kRecordCount);
+ return;
+ }
+ if (request.result.toString() !== kRecords.toString()) {
+ fail('getAll() returned wrong records');
+ return;
+ }
+ done();
+ };
+}
Original Bug Report
Mojo’s ChannelPosix incorrectly handles >128 file descriptors in a message, leading to fd confusion
VULNERABILITY DETAILS If more than 128 file descriptors are sent in a single Mojo message on platforms using ChannelPosix (Linux, Android, ChromeOS), the extra file descriptors are silently dropped and never seen by the receiver. If more file descriptors are subsequently transmitted on the same channel, the receiver will incorrectly combine the new file descriptors with the original incomplete set, resulting in a corrupted message.
The problem affects all process types using Mojo (browser, renderer, GPU, etc.). Its impact varies depending on the Mojo interface being attacked; we provide three reproduction cases as examples, but in general any interface using an arbitrary number of file descriptors (e.g., handle<shared_buffer>, BigBuffer, BigString16, etc.) in a single message may be vulnerable.
The root cause is that ChannelPosix::WriteNoLock[1] splits transmitted file descriptors into batches of 128 (kMaxSendmsgHandles). The first batch of 128 is sent correctly, but if all of the message data was also sent with the first message, subsequent batches only consist of file descriptors and have zero bytes of actual message data.
[1] https://source.chromium.org/chromium/chromium/src/+/main:mojo/core/channel_posix.cc;l=341;bpv=1
The underlying message conduit (sendmsg(2)) does not support sending zero bytes of actual data together with non-zero ancillary data (cmsg(3)), because the socket is created with SOCK_STREAM instead of SOCK_DGRAM. Therefore, the subsequent file descriptor batches will be silently dropped.
On the receiving side, the original message with >128 file descriptors will correctly receive the first batch of 128 file descriptors, but then remain stuck (DispatchResult::kMissingHandles) until enough other file descriptors are received from subsequent messages.
This bug was introduced in 2019 by https://source.chromium.org/chromium/chromium/src/+/5e25b795ba30601fbd543fb311e28c946de52560.
VERSION Chrome Version: 139.0.7258.5 (Official Build) beta (64-bit) Operating System: Linux (Debian 13), Android (16), ChromeOS (unconfirmed but presumed vulnerable)
REPRODUCTION CASE
Three reproduction cases are attached. To reproduce, download all the .html files to a directory, run python -m http.server, and navigate to each specific PoC page.
###########################################################################
poc1.html
###########################################################################
Simplest case; triggers an immediate browser or renderer segfault.
Sequence of events when clicking “Crash browser”:
-
Page uses postMessage to send 65 large ImageBitmaps to an iframe hosted by another renderer (requiring a trip through the browser process). Because each in-transit ArrayBuffer is represented by 2 file descriptors (1 on Android), this means sending 130 fds in a single message, causing the last 2 to become lost.
-
Page sends 1 small ImageBitmap to the same destination. This unblock processing of the message in the browser process.
-
The browser receives the incoming message (RemoteFrameHost.RouteMessageEvent) and attempts to copy image bitmaps into non-shared memory in CreateSkBitmapForPixelData.
-
Copying the first 64 bitmaps works normally, but the 65th bitmap is now pointing to the smaller shared memory segment sent in step 2, leading to an out-of-bounds read.
Sequence of events when clicking “Crash renderer”:
-
The page sends 65 transferable ArrayBuffers to the destination renderer. Again, the last one gets dropped, so the browser enqueues the message until another fd arrives.
-
The page sends 1 ArrayBuffer. The fd from this message unblocks the message enqueued in step one, so the browser tries to send 65 ArrayBuffers to the destination renderer. The last one is dropped, so the renderer is now holding the message in a queue until the next fd arrives. The message itself sent in this step is enqueued by the browser, because it is now missing an fd.
-
The page sends 1 smaller ArrayBuffer. The fd from this message unblocks the message queued by the browser in step 2, which in turn unblocks the message queued by the renderer in that same step.
-
The target renderer receives 65 ArrayBuffers, but the fd of the last one points to the smaller ArrayBuffer sent in step 2. The target renderer crashes when trying to copy the ArrayBuffer contents from shared memory.
Crucially, unlike ImageBitmaps, ArrayBuffers aren’t copied by the browser during transit, so the corrupted ArrayBuffer passes through the browser unnoticed.
###########################################################################
poc2.html
###########################################################################
This variant uses fd confusion to disrupt the ipcz handshake sequence when a MessageChannel is transferred between two renderers. The effect is that the destination renderer is terminated immediately by ipcz with this error:
[257966:5:0815/230712.588877:ERROR:third_party/ipcz/src/ipcz/router.cc:1124] Disconnecting Router due to failed introduction
Note that the destination frame can be a purely cross-origin page without any scripting or messaging access. Any other page/tab sharing its renderer will also be terminated. The PoC uses this to remove Google ads from bbc.com in another tab.
Note: This example requires the bbc page to load a google ad to show the exploit. There is a chance that bbc.com won’t load any google ads due to pure chance, if that occurs please refresh until it does.
Sequence of events:
-
Page creates an iframe with the victim page.
-
Page sends 65 transferable ArrayBuffers to the target page with postMessage. Since the last one is lost, the message remains queued in the browser process.
-
Page sends a MessageChannel port to the target page. Its file descriptor will fill the remaining slot in the message from step 2, allowing that message to be sent to the target renderer. Because of the bug described in the issue, the last ArrayBuffer will again be lost on its way from the browser to the renderer, so the incoming message will remain queued in the destination renderer. The second message (with the MessageChannel port) will remain queued in the browser, because it no longer has a file descriptor.
-
Page sends one ArrayBuffer to the target page. This unblocks the MessageChannel port to be sent to the target renderer from the browser. However, the file descriptor from that message will now take the place of the last ArrayBuffer for the original message sent to the renderer. All 65 ArrayBuffers will now finally be delivered to the renderer, although the last one now points to the file descriptor of the MessageChannel.
-
Page sends one final ArrayBuffer, which unblocks the ArrayBuffer from step 4 to be sent to the renderer, which in turn unblocks the MessageChannel port from step 3 to finally be delivered to the renderer. However, that port’s file descriptor is now pointing to the ArrayBuffer from step 4.
-
Ipcz starts a handshake in the target renderer for the MessageChannel port. This fails because the MessageChannel’s file descriptor is actually an ArrayBuffer, and the renderer is killed.
#############################################################################
poc3.html
###########################################################################
Demonstrates file descriptor confusion by reading console.log() output from a frame (poc3-victim.html). Note: in this case the victim must be sharing the renderer process with the attacker, so this attack is more realistic on platforms with limited site isolation (Android & Android WebView).
While this example uses console.log(), similar fd confusion can be achieved with other mojo frame APIs which use BigString16 or other types of shared memory (e.g., copy & paste, printing, drag & drop).
Sequence of events:
-
Page sends the following to a sandboxed iframe created by the attacker: 65 transferables (A), 1 transferable (B), and 1 transferable (C). This results in the following mojo channel states:
- Main page => browser channel: transferable C waiting for 1 fd.
- Browser => evil frame: transferable B waiting for 1 fd.
-
Victim page is loaded in another frame and does a console.log(). The fd from this unblocks transferable C in the main page => browser channel, which in turn unblocks transferable B in the browser => evil frame channel.
-
Evil frame receives a message with transferable B, but its fd actually points to the console.log() message from the victim frame. The evil frame is able to extract the message by reading the ArrayBuffer contents.
#############################################################################
poc4.html
###########################################################################
Still being developed at the time of this submission. We believe it may be possible to do a sandboxed ACE/possible sandbox-escaping ACE by confusing mojo into believing that a user controlled transferable is instead a privileged transferable.
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION Type of crash: tab + browser + frame Crash State: attached
CREDIT INFORMATION Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: Sahan Fernando & Anon