CVE-2026-79246
Overview
Background
- `SystemClipboard::Snapshot`
- a per-scope cache in the renderer (Blink) that memoizes clipboard reads so repeated reads within one scope do not re-query the browser’s clipboard host.
- Clipboard buffers
mojom::blink::ClipboardBuffer::kStandard(the normal clipboard) andkSelection(the Linux/X11 primary-selection clipboard), which hold independent contents.- `ScopedSystemClipboardSnapshot`
- a scope object that enables the
Snapshotcache while a paste/read operation runs. - `BindToBuffer` (removed)
- the old routine that locked a snapshot to a single buffer and
DCHECK_EQ’d any subsequent buffer, implicitly assuming one buffer per snapshot.
Root Cause Analysis
The Snapshot stored one flat set of cached fields (plain_text_, html_, png_, files_, custom_data_, etc.) plus a single buffer_, and every Has* accessor tested only buffer_.has_value() && <field>.has_value() — buffer-blind. On Linux both kStandard and kSelection can be queried within a single snapshot scope, so once the snapshot cached one buffer’s data, a read for the other buffer saw the cached field as present and returned it: cross-buffer cache poisoning in which a kSelection read incorrectly returned cached kStandard data. The invariant that cached data belongs to the buffer being queried was not enforced (the old BindToBuffer merely asserted a single buffer, which the multi-buffer Linux path violated).
The fix partitions the cache into a per-buffer BufferData struct (standard_data_, selection_data_) selected by GetBufferData/GetOrCreateBufferData, so kStandard and kSelection lookups stay strictly isolated and a miss in one buffer no longer returns the other’s data — as the added CrossBufferSnapshotIsolation test verifies via ReadTextCallCountForTesting.
ClipboardBuffer.Attack Path
- Enter a snapshot scope
a paste/read flow opens a
ScopedSystemClipboardSnapshot, enabling the cache. - Prime the cache from one buffer
a read of
kStandardcaches the standard clipboard’s contents in the flat snapshot fields and setsbuffer_. - Read the other buffer
a subsequent read of
kSelectionfindsbuffer_.has_value() && <field>.has_value()true and returns the cachedkStandarddata instead of the selection buffer’s real contents. - Observe leaked data the page receives standard-clipboard contents through a selection-buffer read (or vice versa), disclosing data across buffers.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/clipboard/system_clipboard.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/clipboard/system_clipboard_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/clipboard/system_clipboard.hthird_party/blink/renderer/core/clipboard/system_clipboard_test.ccthird_party/blink/renderer/core/clipboard/system_clipboard.cc
Audit Directions
- Key-blind cachesaudit memoization caches whose hit test omits a dimension that distinguishes the data (here
ClipboardBuffer); a cache must key on every dimension that changes its contents. - `DCHECK`-enforced single-value assumptionslook for code that assumes “one X per scope” via a
DCHECK_EQ(like the oldBindToBuffer) where the real code path can legitimately use multiple values. - Cross-context clipboard/data-transfer readsreview other clipboard/DataTransfer accessors for places where standard vs. selection (or origin-scoped) data could be conflated within a single scope.
Patch
From 29cb4e0d1ae84a8e76ad6d5699281bcdeaa2c3ab Mon Sep 17 00:00:00 2001 From: Tom Anderson <[email protected]> Date: Thu, 16 Jul 2026 09:57:14 -0700 Subject: [PATCH] Partition SystemClipboard::Snapshot cache per-buffer. Currently, SystemClipboard::Snapshot has buffer-blind HasPng, HasPlainText, etc. accessors. On Linux, standard and selection buffers can be queried within a single snapshot scope, leading to cross-buffer cache poisoning (where selection buffer reads incorrectly return cached standard clipboard data). This CL partitions the snapshot cache into a per-buffer data structure, ensuring that kStandard and kSelection lookups are kept strictly isolated. Bug: 525311654 Change-Id: Ie50e0b81597e9c68f91c9bafdbc046e32c2aacc1 Fixed: 525311654 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8033382 Reviewed-by: Kentaro Hara <[email protected]> Commit-Queue: Kentaro Hara <[email protected]> Auto-Submit: Thomas Anderson <[email protected]> Cr-Commit-Position: refs/heads/main@{#1663224} --- diff --git a/third_party/blink/renderer/core/clipboard/system_clipboard.cc b/third_party/blink/renderer/core/clipboard/system_clipboard.cc index a945987e..6872571 100644 --- a/third_party/blink/renderer/core/clipboard/system_clipboard.cc +++ b/third_party/blink/renderer/core/clipboard/system_clipboard.cc @@ -521,49 +521,48 @@ bool SystemClipboard::Snapshot::HasPlainText( mojom::blink::ClipboardBuffer buffer) const { - return buffer_.has_value() && plain_text_.has_value(); + return GetBufferData(buffer)->plain_text_.has_value(); } const String& SystemClipboard::Snapshot::PlainText( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasPlainText(buffer)); - return plain_text_.value(); + return GetBufferData(buffer)->plain_text_.value(); } void SystemClipboard::Snapshot::SetPlainText( mojom::blink::ClipboardBuffer buffer, const String& text) { - BindToBuffer(buffer); - plain_text_ = text; + GetOrCreateBufferData(buffer)->plain_text_ = text; } bool SystemClipboard::Snapshot::HasHtml( mojom::blink::ClipboardBuffer buffer) const { - return buffer_.has_value() && html_.has_value(); + return GetBufferData(buffer)->html_.has_value(); } const KURL& SystemClipboard::Snapshot::Url( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasHtml(buffer)); - return url_; + return GetBufferData(buffer)->url_; } unsigned SystemClipboard::Snapshot::FragmentStart( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasHtml(buffer)); - return fragment_start_; + return GetBufferData(buffer)->fragment_start_; } unsigned SystemClipboard::Snapshot::FragmentEnd( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasHtml(buffer)); - return fragment_end_; + return GetBufferData(buffer)->fragment_end_; } const String& SystemClipboard::Snapshot::Html( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasHtml(buffer)); - return html_.value(); + return GetBufferData(buffer)->html_.value(); } void SystemClipboard::Snapshot::SetHtml(mojom::blink::ClipboardBuffer buffer, @@ -571,89 +570,87 @@ const KURL& url, unsigned fragment_start, unsigned fragment_end) { - BindToBuffer(buffer); - html_ = html; - url_ = url; - fragment_start_ = fragment_start; - fragment_end_ = fragment_end; + BufferData* data = GetOrCreateBufferData(buffer); + data->html_ = html; + data->url_ = url; + data->fragment_start_ = fragment_start; + data->fragment_end_ = fragment_end; } bool SystemClipboard::Snapshot::HasRtf( mojom::blink::ClipboardBuffer buffer) const { - return buffer_.has_value() && rtf_.has_value(); + return GetBufferData(buffer)->rtf_.has_value(); } const String& SystemClipboard::Snapshot::Rtf( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasRtf(buffer)); - return rtf_.value(); + return GetBufferData(buffer)->rtf_.value(); } void SystemClipboard::Snapshot::SetRtf(mojom::blink::ClipboardBuffer buffer, const String& rtf) { - BindToBuffer(buffer); - rtf_ = rtf; + GetOrCreateBufferData(buffer)->rtf_ = rtf; } bool SystemClipboard::Snapshot::HasPng( mojom::blink::ClipboardBuffer buffer) const { - return buffer_.has_value() && png_.has_value(); + return GetBufferData(buffer)->png_.has_value(); } mojo_base::BigBuffer SystemClipboard::Snapshot::Png( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasPng(buffer)); // Make an owning copy of the png to return to user. - base::span<const uint8_t> span = base::span(png_.value()); + base::span<const uint8_t> span = + base::span(GetBufferData(buffer)->png_.value()); return mojo_base::BigBuffer(span); } // TODO(https://crbug.com/1412180): Reduce data copies. void SystemClipboard::Snapshot::SetPng(mojom::blink::ClipboardBuffer buffer, const mojo_base::BigBuffer& png) { - BindToBuffer(buffer); + BufferData* data = GetOrCreateBufferData(buffer); // Make an owning copy of the png to save locally. base::span<const uint8_t> span = base::span(png); - png_ = mojo_base::BigBuffer(span); + data->png_ = mojo_base::BigBuffer(span); } bool SystemClipboard::Snapshot::HasFiles( mojom::blink::ClipboardBuffer buffer) const { - return buffer_.has_value() && files_.has_value(); + return GetBufferData(buffer)->files_.has_value(); } mojom::blink::ClipboardFilesPtr SystemClipboard::Snapshot::Files( mojom::blink::ClipboardBuffer buffer) const { DCHECK(HasFiles(buffer)); - return CloneFiles(files_.value()); + return CloneFiles(GetBufferData(buffer)->files_.value()); } void SystemClipboard::Snapshot::SetFiles( mojom::blink::ClipboardBuffer buffer, mojom::blink::ClipboardFilesPtr& files) { - BindToBuffer(buffer); - files_ = CloneFiles(files); + GetOrCreateBufferData(buffer)->files_ = CloneFiles(files); } bool SystemClipboard::Snapshot::HasCustomData( mojom::blink::ClipboardBuffer buffer, const String& type) const { - return buffer_.has_value() && custom_data_.Contains(type); + return GetBufferData(buffer)->custom_data_.Contains(type); } String SystemClipboard::Snapshot::CustomData( mojom::blink::ClipboardBuffer buffer, const String& type) const { DCHECK(HasCustomData(buffer, type)); - return custom_data_.at(type); + return GetBufferData(buffer)->custom_data_.at(type); } void SystemClipboard::Snapshot::SetCustomData( mojom::blink::ClipboardBuffer buffer, const String& type, const String& data) { - BindToBuffer(buffer); - custom_data_.Set(type, data); + GetOrCreateBufferData(buffer)->custom_data_.Set(type, data); } void SystemClipboard::OnClipboardDataChanged(const Vector<String>& types, @@ -703,13 +700,22 @@ files->file_system_id); } -void SystemClipboard::Snapshot::BindToBuffer( - mojom::blink::ClipboardBuffer buffer) { - if (!buffer_) { - buffer_ = buffer; - } else { - DCHECK_EQ(*buffer_, buffer); +const SystemClipboard::Snapshot::BufferData* +SystemClipboard::Snapshot::GetBufferData( + mojom::blink::ClipboardBuffer buffer) const { + if (buffer == mojom::blink::ClipboardBuffer::kSelection) { + return &selection_data_; } + return &standard_data_; +} + +SystemClipboard::Snapshot::BufferData* +SystemClipboard::Snapshot::GetOrCreateBufferData( + mojom::blink::ClipboardBuffer buffer) { + if (buffer == mojom::blink::ClipboardBuffer::kSelection) { + return &selection_data_; + } + return &standard_data_; } ScopedSystemClipboardSnapshot::ScopedSystemClipboardSnapshot( diff --git a/third_party/blink/renderer/core/clipboard/system_clipboard.h b/third_party/blink/renderer/core/clipboard/system_clipboard.h index fe49f82..37f2103 100644 --- a/third_party/blink/renderer/core/clipboard/system_clipboard.h +++ b/third_party/blink/renderer/core/clipboard/system_clipboard.h @@ -209,26 +209,28 @@ mojom::blink::ClipboardFilesPtr& files); private: - // Called in the set methods to bind this snapshot to the specified buffer. - // All calls to set data for all types need to specify the same buffer. - void BindToBuffer(mojom::blink::ClipboardBuffer buffer); + struct BufferData { + std::optional<String> plain_text_; - std::optional<mojom::blink::ClipboardBuffer> buffer_; + std::optional<String> html_; + KURL url_; + unsigned fragment_start_ = 0; + unsigned fragment_end_ = 0; - std::optional<String> plain_text_; + std::optional<String> rtf_; - std::optional<String> html_; - KURL url_; - unsigned fragment_start_ = 0; - unsigned fragment_end_ = 0; + std::optional<mojo_base::BigBuffer> png_; - std::optional<String> rtf_; + mutable std::optional<mojom::blink::ClipboardFilesPtr> files_; - std::optional<mojo_base::BigBuffer> png_; + HashMap<String, String> custom_data_; + }; - mutable std::optional<mojom::blink::ClipboardFilesPtr> files_; + const BufferData* GetBufferData(mojom::blink::ClipboardBuffer buffer) const; + BufferData* GetOrCreateBufferData(mojom::blink::ClipboardBuffer buffer); - HashMap<String, String> custom_data_; + BufferData standard_data_; + BufferData selection_data_; }; bool IsValidBufferType(mojom::blink::ClipboardBuffer); diff --git a/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc b/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc index 927d8b7..717ab835 100644 --- a/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc +++ b/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc @@ -95,6 +95,9 @@ EXPECT_TRUE( system_clipboard().IsValidBufferType(system_clipboard().buffer_)); } + void SetSelectionBufferAvailable(bool available) { + system_clipboard().is_selection_buffer_available_ = available; + } void RunUntilIdle() { test::RunPendingTasks(); } @@ -733,6 +736,43 @@ EXPECT_TRUE(data.empty()); } +TEST_F(SystemClipboardTest, CrossBufferSnapshotIsolation) { + SetSelectionBufferAvailable(true); + + // Populate standard clipboard text. + clipboard_host()->WriteText("standard_text"); + clipboard_host()->CommitWrite(); + + // Enter snapshot scope. + ScopedSystemClipboardSnapshot snapshot(system_clipboard()); + + // 1. Read plain text from kStandard. + // This should call MockClipboardHost::ReadText. + int initial_calls = mock_clipboard_host()->ReadTextCallCountForTesting(); + EXPECT_EQ(system_clipboard().ReadPlainText( + mojom::blink::ClipboardBuffer::kStandard), + "standard_text"); + EXPECT_EQ(mock_clipboard_host()->ReadTextCallCountForTesting(), + initial_calls + 1); + + // 2. Read plain text from kStandard again. + // This must hit the cache and not call MockClipboardHost::ReadText. + EXPECT_EQ(system_clipboard().ReadPlainText( + mojom::blink::ClipboardBuffer::kStandard), + "standard_text"); + EXPECT_EQ(mock_clipboard_host()->ReadTextCallCountForTesting(), + initial_calls + 1); + + // 3. Read plain text from kSelection. + // Because they are in different buffers, the cache must not hit across + // buffers. This should call MockClipboardHost::ReadText. + EXPECT_EQ(system_clipboard().ReadPlainText( + mojom::blink::ClipboardBuffer::kSelection), + "standard_text"); + EXPECT_EQ(mock_clipboard_host()->ReadTextCallCountForTesting(), + initial_calls + 2); +} + #if BUILDFLAG(IS_OZONE) TEST_F(SystemClipboardTest, DataObjectItemGetAsFileRespectsSelectionMode) { ScopedClipboardPasteImageRespectBufferForTest scoped_feature(true);
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc b/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc
index 927d8b7..717ab835 100644
--- a/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc
+++ b/third_party/blink/renderer/core/clipboard/system_clipboard_test.cc
@@ -95,6 +95,9 @@
EXPECT_TRUE(
system_clipboard().IsValidBufferType(system_clipboard().buffer_));
}
+ void SetSelectionBufferAvailable(bool available) {
+ system_clipboard().is_selection_buffer_available_ = available;
+ }
void RunUntilIdle() { test::RunPendingTasks(); }
@@ -733,6 +736,43 @@
EXPECT_TRUE(data.empty());
}
+TEST_F(SystemClipboardTest, CrossBufferSnapshotIsolation) {
+ SetSelectionBufferAvailable(true);
+
+ // Populate standard clipboard text.
+ clipboard_host()->WriteText("standard_text");
+ clipboard_host()->CommitWrite();
+
+ // Enter snapshot scope.
+ ScopedSystemClipboardSnapshot snapshot(system_clipboard());
+
+ // 1. Read plain text from kStandard.
+ // This should call MockClipboardHost::ReadText.
+ int initial_calls = mock_clipboard_host()->ReadTextCallCountForTesting();
+ EXPECT_EQ(system_clipboard().ReadPlainText(
+ mojom::blink::ClipboardBuffer::kStandard),
+ "standard_text");
+ EXPECT_EQ(mock_clipboard_host()->ReadTextCallCountForTesting(),
+ initial_calls + 1);
+
+ // 2. Read plain text from kStandard again.
+ // This must hit the cache and not call MockClipboardHost::ReadText.
+ EXPECT_EQ(system_clipboard().ReadPlainText(
+ mojom::blink::ClipboardBuffer::kStandard),
+ "standard_text");
+ EXPECT_EQ(mock_clipboard_host()->ReadTextCallCountForTesting(),
+ initial_calls + 1);
+
+ // 3. Read plain text from kSelection.
+ // Because they are in different buffers, the cache must not hit across
+ // buffers. This should call MockClipboardHost::ReadText.
+ EXPECT_EQ(system_clipboard().ReadPlainText(
+ mojom::blink::ClipboardBuffer::kSelection),
+ "standard_text");
+ EXPECT_EQ(mock_clipboard_host()->ReadTextCallCountForTesting(),
+ initial_calls + 2);
+}
+
#if BUILDFLAG(IS_OZONE)
TEST_F(SystemClipboardTest, DataObjectItemGetAsFileRespectsSelectionMode) {
ScopedClipboardPasteImageRespectBufferForTest scoped_feature(true);