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 <thomasanderson@chromium.org>
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 <haraken@chromium.org>
Commit-Queue: Kentaro Hara <haraken@chromium.org>
Auto-Submit: Thomas Anderson <thomasanderson@chromium.org>
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);