CVE-2026-78967
Overview
Background
- `ClipboardHostImpl`
- the browser-process implementation of
blink::mojom::ClipboardHost, which serves a frame’s clipboard read, write and change-listener requests; it is aDocumentServicetied to oneRenderFrameHost. - Inactive document
- a
RenderFrameHostthat is not currently shown to the user, for example one stored in the back/forward cache (BFCache) or pending deletion; its Mojo receivers can stay bound while it is inactive. - `IsInactiveAndDisallowActivation(reason)`
- a
RenderFrameHostcheck that returns true when the frame is inactive and also makes sure it is never reactivated (for example by evicting it from BFCache), recordingreasonfor metrics. - `DisallowActivationReasonId::kClipboard`
- a new reason value (47) added to identify clipboard IPCs that arrive for inactive frames.
Root Cause Analysis
ClipboardHostImpl handlers checked whether a paste was permitted (IsRendererPasteAllowed) but never checked the document’s lifecycle state. The receiver stays bound while the frame sits in BFCache, so IPCs from a document the user had navigated away from were still processed in full. Such a document could read or write the system clipboard and register a clipboardchange listener. According to the commit message, it could also benefit from the tab-wide recent-interaction state of the page currently in the foreground. In addition, OnClipboardDataChanged() kept notifying a registered listener even after its document became inactive.
The fix adds an IsInactiveAndDisallowActivation(DisallowActivationReasonId::kClipboard) guard at the start of every mojom::ClipboardHost entry point, including the macOS WriteStringToFindPboard. Reads now reply with empty or default values, writes and listener registration are dropped, and CommitWrite() resets any staged writer state. OnClipboardDataChanged() now returns early when !render_frame_host().IsActive(). Each guard runs before the existing permission check and before any clipboard access, so this is a pure tightening, and an offending page is also prevented from being restored from BFCache.
Attack Path
- Document becomes inactive
a page with a bound
ClipboardHostconnection is navigated away from and stored in BFCache (the regression tests simulate this withLifecycleStateImpl::kInBackForwardCache). - Clipboard IPC arrives after deactivation
the renderer sends
ReadText,WriteText/CommitWrite,RegisterClipboardListeneror a similar message. Because JavaScript in a BFCached page is frozen, this realistically needs a compromised renderer or a race with the lifecycle transition (inferred). - Browser processes the request
before the fix,
ClipboardHostImplread or changed the system clipboard for the inactive document, subject only toIsRendererPasteAllowed, which may count the foreground page’s recent interaction. - Ongoing observation
a listener registered earlier kept receiving
OnClipboardDataChangednotifications while the user worked in another page.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/clipboard_host_impl.cc |
modified | |
ifcontent/browser/renderer_host/clipboard_host_impl_mac.mm |
modified | |
TEST_Fcontent/browser/renderer_host/clipboard_host_impl_unittest.cc |
modified |
Files Changed
content/browser/renderer_host/clipboard_host_impl.hcontent/browser/renderer_host/clipboard_host_impl.cccontent/browser/renderer_host/clipboard_host_impl_mac.mmtools/metrics/histograms/enums.xmlcontent/browser/renderer_host/clipboard_host_impl_unittest.cccontent/public/browser/disallow_activation_reason.h
Audit Directions
- `DocumentService` handlers without lifecycle checksreview other browser-side
DocumentServiceandRenderFrameHost-scoped Mojo hosts that touch user-visible or system resources (for example file system access, permissions, sensors and window management) for missingIsActive()orIsInactiveAndDisallowActivation()guards. - Tab-wide state used for per-document authorisationlook for permission checks that consult
WebContents-level signals (recent interaction, focus, visibility) when the requesting document may not be the one in the foreground. - Persistent listeners and subscriptionsfind observer registrations, such as change listeners and event subscriptions, whose notifications continue after the registering frame enters BFCache or pending deletion.
Patch
From d5674ed78a8c68ca800217c5a3eda5ddfbbc8a2c Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <elkurin@google.com>
Date: Wed, 08 Jul 2026 00:51:12 -0700
Subject: [PATCH] ClipboardHostImpl: ignore requests from inactive documents
ClipboardHostImpl is a DocumentService whose Mojo receiver stays bound
while its RenderFrameHost is inactive (e.g. stored in the back/forward
cache). The IPC handlers previously did not check the document
lifecycle, so a renderer could keep reading or writing the system
clipboard, register a clipboardchange listener, or piggy-back on the
tab-wide recent-interaction state of the foreground page after its
document had become inactive.
Add render_frame_host().IsActive() guards to every mojom::ClipboardHost
entry point (including the macOS find- pasteboard write) and to
OnClipboardDataChanged(). Read methods now reply with empty results,
write methods are dropped, and CommitWrite() discards any staged writer
state when the document is not active.
Bug: 514529599
Change-Id: Iad5186e86b5d5e9fb2342614700aac2178b275b0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8030502
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Commit-Queue: Eriko Kurimoto <elkurin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1658557}
---
diff --git a/content/browser/renderer_host/clipboard_host_impl.cc b/content/browser/renderer_host/clipboard_host_impl.cc
index eecfccb8..dc1dd4f 100644
--- a/content/browser/renderer_host/clipboard_host_impl.cc
+++ b/content/browser/renderer_host/clipboard_host_impl.cc
@@ -117,12 +117,22 @@
void ClipboardHostImpl::GetSequenceNumber(ui::ClipboardBuffer clipboard_buffer,
GetSequenceNumberCallback callback) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ std::move(callback).Run(absl::uint128());
+ return;
+ }
std::move(callback).Run(GetSequenceNumberImpl(clipboard_buffer));
}
void ClipboardHostImpl::ReadAvailableTypes(
ui::ClipboardBuffer clipboard_buffer,
ReadAvailableTypesCallback callback) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ std::move(callback).Run({});
+ return;
+ }
auto* clipboard = ui::Clipboard::GetForCurrentThread();
auto data_endpoint = CreateDataEndpoint(render_frame_host());
@@ -190,6 +200,11 @@
void ClipboardHostImpl::IsFormatAvailable(blink::mojom::ClipboardFormat format,
ui::ClipboardBuffer clipboard_buffer,
IsFormatAvailableCallback callback) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ std::move(callback).Run(false);
+ return;
+ }
ui::Clipboard* clipboard = ui::Clipboard::GetForCurrentThread();
auto data_endpoint = CreateDataEndpoint(render_frame_host());
clipboard->GetAllAvailableFormats(
@@ -230,7 +245,9 @@
void ClipboardHostImpl::ReadText(ui::ClipboardBuffer clipboard_buffer,
ReadTextCallback callback) {
- if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
std::move(callback).Run(std::u16string());
return;
}
@@ -264,7 +281,9 @@
void ClipboardHostImpl::ReadHtml(ui::ClipboardBuffer clipboard_buffer,
ReadHtmlCallback callback) {
- if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
std::move(callback).Run(std::u16string(), GURL(), 0, 0);
return;
}
@@ -305,7 +324,9 @@
void ClipboardHostImpl::ReadSvg(ui::ClipboardBuffer clipboard_buffer,
ReadSvgCallback callback) {
- if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
std::move(callback).Run(std::u16string());
return;
}
@@ -340,7 +361,9 @@
void ClipboardHostImpl::ReadRtf(ui::ClipboardBuffer clipboard_buffer,
ReadRtfCallback callback) {
- if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
std::move(callback).Run(std::string());
return;
}
@@ -376,7 +399,9 @@
void ClipboardHostImpl::ReadPng(ui::ClipboardBuffer clipboard_buffer,
ReadPngCallback callback) {
- if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
std::move(callback).Run(mojo_base::BigBuffer());
return;
}
@@ -425,7 +450,9 @@
void ClipboardHostImpl::ReadFiles(ui::ClipboardBuffer clipboard_buffer,
ReadFilesCallback callback) {
- if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
std::move(callback).Run(blink::mojom::ClipboardFiles::New());
return;
}
@@ -511,7 +538,9 @@
ui::ClipboardBuffer clipboard_buffer,
const std::u16string& type,
ReadDataTransferCustomDataCallback callback) {
- if (!IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(clipboard_buffer, render_frame_host())) {
std::move(callback).Run(std::u16string());
return;
}
@@ -549,6 +578,10 @@
}
void ClipboardHostImpl::WriteText(const std::u16string& text) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
ClipboardPasteData data;
data.text = text;
++pending_writes_;
@@ -565,6 +598,10 @@
void ClipboardHostImpl::WriteHtml(const std::u16string& markup,
const GURL& url) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
ClipboardPasteData data;
data.html = markup;
++pending_writes_;
@@ -580,6 +617,10 @@
}
void ClipboardHostImpl::WriteSvg(const std::u16string& markup) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
ClipboardPasteData data;
data.svg = markup;
++pending_writes_;
@@ -595,11 +636,19 @@
}
void ClipboardHostImpl::WriteSmartPasteMarker() {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
clipboard_writer_->WriteWebSmartPaste();
}
void ClipboardHostImpl::WriteDataTransferCustomData(
const base::flat_map<std::u16string, std::u16string>& data) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
ClipboardPasteData clipboard_paste_data;
clipboard_paste_data.custom_data = data;
@@ -624,11 +673,19 @@
void ClipboardHostImpl::WriteBookmark(const std::string& url,
const std::u16string& title) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
clipboard_writer_->WriteURL(
ui::ClipboardUrlInfo{.url = GURL(url), .title = title});
}
void ClipboardHostImpl::WriteImage(const SkBitmap& bitmap) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
ClipboardPasteData data;
data.bitmap = bitmap;
++pending_writes_;
@@ -644,6 +701,13 @@
}
void ClipboardHostImpl::CommitWrite() {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ clipboard_writer_->Reset();
+ ResetClipboardWriter();
+ pending_commit_write_ = false;
+ return;
+ }
if (pending_writes_ != 0) {
// This branch indicates that some callbacks passed to
// `IsClipboardCopyAllowedByPolicy` still haven't been called, so committing
@@ -668,7 +732,9 @@
void ClipboardHostImpl::ReadAvailableCustomAndStandardFormats(
ReadAvailableCustomAndStandardFormatsCallback callback) {
- if (!IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
render_frame_host())) {
std::move(callback).Run(std::vector<std::u16string>());
return;
@@ -690,7 +756,9 @@
void ClipboardHostImpl::ReadUnsanitizedCustomFormat(
const std::u16string& format,
ReadUnsanitizedCustomFormatCallback callback) {
- if (!IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard) ||
+ !IsRendererPasteAllowed(ui::ClipboardBuffer::kCopyPaste,
render_frame_host())) {
std::move(callback).Run(mojo_base::BigBuffer());
return;
@@ -772,6 +840,10 @@
void ClipboardHostImpl::WriteUnsanitizedCustomFormat(
const std::u16string& format,
mojo_base::BigBuffer data) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
// `kMaxFormatSize` & `kMaxDataSize` includes the null terminator.
if (format.length() >= blink::mojom::ClipboardHost::kMaxFormatSize)
return;
@@ -943,7 +1015,8 @@
}
void ClipboardHostImpl::OnClipboardDataChanged() {
- if (!listening_to_clipboard_ || !clipboard_listener_) {
+ if (!listening_to_clipboard_ || !clipboard_listener_ ||
+ !render_frame_host().IsActive()) {
return;
}
@@ -1020,6 +1093,10 @@
void ClipboardHostImpl::RegisterClipboardListener(
mojo::PendingRemote<blink::mojom::ClipboardListener> listener) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
// Replace the current listener with the new one
clipboard_listener_.reset();
clipboard_listener_.Bind(std::move(listener));
diff --git a/content/browser/renderer_host/clipboard_host_impl.h b/content/browser/renderer_host/clipboard_host_impl.h
index d297a61..413b2a3f 100644
--- a/content/browser/renderer_host/clipboard_host_impl.h
+++ b/content/browser/renderer_host/clipboard_host_impl.h
@@ -18,6 +18,7 @@
#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/common/content_export.h"
#include "content/public/browser/clipboard_types.h"
+#include "content/public/browser/disallow_activation_reason.h"
#include "content/public/browser/document_service.h"
#include "mojo/public/cpp/base/big_buffer.h"
#include "mojo/public/cpp/bindings/remote.h"
@@ -102,6 +103,8 @@
FRIEND_TEST_ALL_PREFIXES(ClipboardHostImplChangeTest, AddClipboardListener);
FRIEND_TEST_ALL_PREFIXES(ClipboardHostImplChangeTest,
ClipboardListenerDisconnect);
+ FRIEND_TEST_ALL_PREFIXES(ClipboardHostImplChangeTest,
+ NoNotificationToInactiveDocument);
// mojom::ClipboardHost
void RegisterClipboardListener(
diff --git a/content/browser/renderer_host/clipboard_host_impl_mac.mm b/content/browser/renderer_host/clipboard_host_impl_mac.mm
index 7bb1d48..0e8d4f29 100644
--- a/content/browser/renderer_host/clipboard_host_impl_mac.mm
+++ b/content/browser/renderer_host/clipboard_host_impl_mac.mm
@@ -24,6 +24,10 @@
} // namespace
void ClipboardHostImpl::WriteStringToFindPboard(const std::u16string& text) {
+ if (render_frame_host().IsInactiveAndDisallowActivation(
+ DisallowActivationReasonId::kClipboard)) {
+ return;
+ }
if (text.length() <= kMaxFindPboardStringLength) {
NSString* nsText = base::SysUTF16ToNSString(text);
if (nsText) {
diff --git a/content/public/browser/disallow_activation_reason.h b/content/public/browser/disallow_activation_reason.h
index 14a0da4..d9a25581 100644
--- a/content/public/browser/disallow_activation_reason.h
+++ b/content/public/browser/disallow_activation_reason.h
@@ -68,6 +68,7 @@
kDidCommitNavigation = 44,
kStartDragging = 45,
kWindowPlacement = 46,
+ kClipboard = 47,
// New entries go above here. New entries should be added to
// tools/metrics/histograms/enums.xml .
kMinEmbedderDisallowActivationReason = 2 << 16,
diff --git a/tools/metrics/histograms/enums.xml b/tools/metrics/histograms/enums.xml
index bdb3fe4c..ac8182b7 100644
--- a/tools/metrics/histograms/enums.xml
+++ b/tools/metrics/histograms/enums.xml
@@ -5970,6 +5970,10 @@
A window.moveTo() / moveBy() / resizeTo() / resizeBy() / SetWindowRect() IPC
arrived for a frame in BFCache or pending deletion.
</int>
+ <int value="47" label="kClipboard">
+ A clipboard read/write or listener registration IPC arrived for an inactive
+ frame.
+ </int>
</enum>
<enum name="IncidentType">
diff --git a/content/browser/renderer_host/clipboard_host_impl_unittest.cc b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
index 285c297f..9c8cb86c 100644
--- a/content/browser/renderer_host/clipboard_host_impl_unittest.cc
+++ b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
@@ -168,6 +168,48 @@
&unused_sequence_number);
}
+TEST_F(ClipboardHostImplTest, WriteFromInactiveDocumentIsIgnored) {
+ const std::u16string kInitial = u"initial";
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteText(kInitial);
+ }
+
+ static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+ ->SetLifecycleState(
+ RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+ ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+ mojo_clipboard()->WriteText(u"from-inactive-document");
+ mojo_clipboard()->CommitWrite();
+ mojo_clipboard().FlushForTesting();
+
+ base::test::TestFuture<std::u16string> future;
+ system_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste,
+ /*data_dst=*/std::nullopt, future.GetCallback());
+ EXPECT_EQ(kInitial, future.Take());
+}
+
+TEST_F(ClipboardHostImplTest, ReadFromInactiveDocumentIsIgnored) {
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteText(u"clipboard-text");
+ }
+
+ static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+ ->SetLifecycleState(
+ RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+ ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+ std::u16string result = u"non-empty";
+ mojo_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste, &result);
+ EXPECT_TRUE(result.empty());
+
+ std::vector<std::u16string> types = {u"non-empty"};
+ mojo_clipboard()->ReadAvailableTypes(ui::ClipboardBuffer::kCopyPaste, &types);
+ EXPECT_TRUE(types.empty());
+}
+
TEST_F(ClipboardHostImplTest, ReadAvailableTypes_TextUriList) {
std::vector<std::u16string> types;
@@ -1076,8 +1118,10 @@
... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/renderer_host/clipboard_host_impl_unittest.cc b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
index 285c297f..9c8cb86c 100644
--- a/content/browser/renderer_host/clipboard_host_impl_unittest.cc
+++ b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
@@ -168,6 +168,48 @@
&unused_sequence_number);
}
+TEST_F(ClipboardHostImplTest, WriteFromInactiveDocumentIsIgnored) {
+ const std::u16string kInitial = u"initial";
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteText(kInitial);
+ }
+
+ static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+ ->SetLifecycleState(
+ RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+ ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+ mojo_clipboard()->WriteText(u"from-inactive-document");
+ mojo_clipboard()->CommitWrite();
+ mojo_clipboard().FlushForTesting();
+
+ base::test::TestFuture<std::u16string> future;
+ system_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste,
+ /*data_dst=*/std::nullopt, future.GetCallback());
+ EXPECT_EQ(kInitial, future.Take());
+}
+
+TEST_F(ClipboardHostImplTest, ReadFromInactiveDocumentIsIgnored) {
+ {
+ ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+ writer.WriteText(u"clipboard-text");
+ }
+
+ static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+ ->SetLifecycleState(
+ RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+ ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+ std::u16string result = u"non-empty";
+ mojo_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste, &result);
+ EXPECT_TRUE(result.empty());
+
+ std::vector<std::u16string> types = {u"non-empty"};
+ mojo_clipboard()->ReadAvailableTypes(ui::ClipboardBuffer::kCopyPaste, &types);
+ EXPECT_TRUE(types.empty());
+}
+
TEST_F(ClipboardHostImplTest, ReadAvailableTypes_TextUriList) {
std::vector<std::u16string> types;
@@ -1076,8 +1118,10 @@
return fake_clipboard_host_impl_;
}
- private:
+ protected:
mojo::Remote<blink::mojom::ClipboardHost> remote_;
+
+ private:
// `ClipboardHostImpl` is a `DocumentService` and manages its own
// lifetime.
raw_ptr<ClipboardHostImpl> fake_clipboard_host_impl_;
@@ -1131,6 +1175,22 @@
base::RunLoop().RunUntilIdle();
}
+TEST_F(ClipboardHostImplChangeTest, NoNotificationToInactiveDocument) {
+ auto mock_listener = std::make_unique<MockClipboardListener>();
+ EXPECT_CALL(*mock_listener, OnClipboardDataChanged).Times(0);
+
+ clipboard_host_impl()->RegisterClipboardListener(mock_listener->GetRemote());
+ EXPECT_TRUE(clipboard_host_impl()->listening_to_clipboard_);
+
+ static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
+ ->SetLifecycleState(
+ RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+ ASSERT_FALSE(web_contents()->GetPrimaryMainFrame()->IsActive());
+
+ ui::ClipboardMonitor::GetInstance()->NotifyClipboardDataChanged();
+ remote_.FlushForTesting();
+}
+
TEST_F(ClipboardHostImplChangeTest, ClipboardListenerDisconnect) {
// Initially, the clipboard host should not be listening to clipboard changes
EXPECT_FALSE(clipboard_host_impl()->listening_to_clipboard_);
Original Bug Report
Clipboard access by background (BFCached) pages via ClipboardHostImpl
Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A compromised renderer in the Back/Forward Cache (BFCache) can potentially read, write, or monitor the system clipboard. The ClipboardHostImpl Mojo interface lacks active document checks, and background pages can exploit tab-wide user interaction states to bypass clipboard read permissions.
Affected files:
content/browser/renderer_host/clipboard_host_impl.cc
Estimated timestamp from git blame: 2021-02-12
Description
The ClipboardHostImpl class (content/browser/renderer_host/clipboard_host_impl.cc) handles clipboard-related Mojo IPCs from renderer processes. It inherits from DocumentService, which ties the Mojo connection to a RenderFrameHost. However, when a document enters the Back/Forward Cache (BFCache), the RenderFrameHost is preserved, and the Mojo connection is not severed or paused.
Because ClipboardHostImpl does not verify if the calling document is actively displayed (e.g., using render_frame_host().IsActive()), a compromised renderer can continue to invoke clipboard methods from the background.
This lack of lifecycle checking exposes three distinct potential attack vectors:
- Bypassing Paste Restrictions (Read): Operations like
ReadTextrequire a user gesture. The browser checks this viaIsRendererPasteAllowed(), which delegates toChromeContentBrowserClient::IsClipboardPasteAllowed(). This function checksWebContents::HasRecentInteraction(). Crucially, interaction state is tracked at the tab (WebContents) level. If the user interacts with the newly loaded, active page in the same tab, the BFCached document inherits this interaction state and can silently read the clipboard. - Silent Clipboard Writes: Methods like
WriteTextandCommitWritelack both user interaction checks and active document checks in the browser process. A background page can silently overwrite the user’s clipboard. - Silent Clipboard Monitoring: A background page can call
RegisterClipboardListener(). Because there are no active checks during registration or when events fire (OnClipboardDataChanged), the BFCached renderer receives continuous, asynchronous notifications whenever the user copies new data across the OS.
Suggested Attacker Steps
Note: These are potential steps based on code analysis; a working proof of concept has not been executed.
- An attacker lures the victim to a malicious website.
- The attacker exploits a separate memory corruption vulnerability to gain Remote Code Execution (RCE) within the sandboxed renderer process.
- The victim navigates away to a legitimate site in the same tab. Chrome places the malicious page into the BFCache.
- The attacker uses their RCE to bypass the renderer’s application-level freeze, keeping their threads alive.
- To steal clipboard data (Read): The user clicks or types on the new, legitimate page. Within 5 seconds, the attacker’s BFCached renderer sends a
ReadTextMojo IPC. The browser process checks the tab’s interaction state, sees the recent interaction, and returns the clipboard contents to the attacker. - To monitor the clipboard: The attacker sends a
RegisterClipboardListenerIPC from the BFCache and silently receives all subsequent clipboard changes.
Suggested Fix
The most direct fix is to ensure ClipboardHostImpl does not process IPCs for inactive documents. Add checks at the beginning of all IPC entry points in content/browser/renderer_host/clipboard_host_impl.cc:
if (!render_frame_host().IsActive()) {
// Run callback with empty/default data or report a bad message.
return;
}
Alternatively, consider evaluating if the Mojo Binder Policy for ClipboardHost should be updated to defer or cancel messages when a document enters the BFCache, similar to how it handles Prerendering.
Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.