Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in WebProtect
DescriptionRace condition in WebProtect
ComponentWebProtect
Bug ClassRace
Tracker502888857
Fix commit0f84a3df80b9 (chromium/src) +438/-25
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • content/browser/renderer_host/clipboard_host_impl.cc
From 0f84a3df80b9ec4ba4774c4a66ecc00cce39bcd3 Mon Sep 17 00:00:00 2001
From: Dominique Fauteux-Chapleau <domfc@chromium.org>
Date: Wed, 15 Jul 2026 12:51:37 -0700
Subject: [PATCH] Capture clipboard sequence number at start of read in ClipboardHostImpl

Previously, `ClipboardHostImpl` retrieved the clipboard sequence number
inside `PasteIfPolicyAllowed` after asynchronous read operations (such
as reading PNGs, files, or custom formats) completed. If a clipboard
write occurred while an async read was in flight, `PasteIfPolicyAllowed`
would use the newer sequence number rather than the token associated
with the read data, creating a potential race condition for enterprise
policy checks. This change fixes the race condition by capturing the
`ui::ClipboardSequenceNumberToken` at the start of clipboard read
operations in `ClipboardHostImpl` and passing it through the async
callbacks into `PasteIfPolicyAllowed`.

Bug: 502888857
Change-Id: I6d493ddefe50c051af1cab911dec687a9d513bab
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8099582
Reviewed-by: Avi Drissman <avi@chromium.org>
Commit-Queue: Dominique Fauteux-Chapleau <domfc@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662801}
---

diff --git a/content/browser/renderer_host/clipboard_host_impl.cc b/content/browser/renderer_host/clipboard_host_impl.cc
index dc1dd4f..9738c912 100644
--- a/content/browser/renderer_host/clipboard_host_impl.cc
+++ b/content/browser/renderer_host/clipboard_host_impl.cc
@@ -252,20 +252,24 @@
     return;
   }
 
-  ExtractText(clipboard_buffer, CreateDataEndpoint(render_frame_host()),
-              base::BindOnce(&ClipboardHostImpl::OnReadText,
-                             weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
-                             std::move(callback)));
+  ExtractText(
+      clipboard_buffer, CreateDataEndpoint(render_frame_host()),
+      base::BindOnce(&ClipboardHostImpl::OnReadText,
+                     weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         clipboard_buffer),
+                     std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadText(ui::ClipboardBuffer clipboard_buffer,
+                                   ui::ClipboardSequenceNumberToken seqno,
                                    ReadTextCallback callback,
                                    std::u16string text) {
   ClipboardPasteData clipboard_paste_data;
   clipboard_paste_data.text = std::move(text);
 
   PasteIfPolicyAllowed(
-      clipboard_buffer, ui::ClipboardFormatType::PlainTextType(),
+      clipboard_buffer, ui::ClipboardFormatType::PlainTextType(), seqno,
       std::move(clipboard_paste_data),
       base::BindOnce(
           [](ReadTextCallback callback,
@@ -289,13 +293,17 @@
   }
   ui::Clipboard* clipboard = ui::Clipboard::GetForCurrentThread();
   auto data_dst = CreateDataEndpoint(render_frame_host());
-  clipboard->ReadHTML(clipboard_buffer, data_dst,
-                      base::BindOnce(&ClipboardHostImpl::OnReadHtml,
-                                     weak_ptr_factory_.GetWeakPtr(),
-                                     clipboard_buffer, std::move(callback)));
+  clipboard->ReadHTML(
+      clipboard_buffer, data_dst,
+      base::BindOnce(&ClipboardHostImpl::OnReadHtml,
+                     weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         clipboard_buffer),
+                     std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadHtml(ui::ClipboardBuffer clipboard_buffer,
+                                   ui::ClipboardSequenceNumberToken seqno,
                                    ReadHtmlCallback callback,
                                    std::u16string markup,
                                    GURL src_url,
@@ -305,7 +313,7 @@
   clipboard_paste_data.html = std::move(markup);
 
   PasteIfPolicyAllowed(
-      clipboard_buffer, ui::ClipboardFormatType::HtmlType(),
+      clipboard_buffer, ui::ClipboardFormatType::HtmlType(), seqno,
       std::move(clipboard_paste_data),
       base::BindOnce(
           [](GURL src_url, uint32_t fragment_start, uint32_t fragment_end,
@@ -335,17 +343,20 @@
       clipboard_buffer, data_dst,
       base::BindOnce(&ClipboardHostImpl::OnReadSvg,
                      weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         clipboard_buffer),
                      std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadSvg(ui::ClipboardBuffer clipboard_buffer,
+                                  ui::ClipboardSequenceNumberToken seqno,
                                   ReadSvgCallback callback,
                                   std::u16string svg) {
   ClipboardPasteData clipboard_paste_data;
   clipboard_paste_data.svg = std::move(svg);
 
   PasteIfPolicyAllowed(
-      clipboard_buffer, ui::ClipboardFormatType::SvgType(),
+      clipboard_buffer, ui::ClipboardFormatType::SvgType(), seqno,
       std::move(clipboard_paste_data),
       base::BindOnce(
           [](ReadSvgCallback callback,
@@ -373,17 +384,20 @@
       clipboard_buffer, data_dst,
       base::BindOnce(&ClipboardHostImpl::OnReadRtf,
                      weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         clipboard_buffer),
                      std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadRtf(ui::ClipboardBuffer clipboard_buffer,
+                                  ui::ClipboardSequenceNumberToken seqno,
                                   ReadRtfCallback callback,
                                   std::string rtf) {
   ClipboardPasteData clipboard_paste_data;
   clipboard_paste_data.rtf = std::move(rtf);
 
   PasteIfPolicyAllowed(
-      clipboard_buffer, ui::ClipboardFormatType::RtfType(),
+      clipboard_buffer, ui::ClipboardFormatType::RtfType(), seqno,
       std::move(clipboard_paste_data),
       base::BindOnce(
           [](ReadRtfCallback callback,
@@ -410,30 +424,35 @@
       clipboard_buffer, data_dst,
       base::BindOnce(&ClipboardHostImpl::OnReadPng,
                      weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         clipboard_buffer),
                      std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadPng(ui::ClipboardBuffer clipboard_buffer,
+                                  ui::ClipboardSequenceNumberToken seqno,
                                   ReadPngCallback callback,
                                   const std::vector<uint8_t>& data) {
   // Pass both image and associated text for content analysis.
   ExtractText(clipboard_buffer, CreateDataEndpoint(render_frame_host()),
               base::BindOnce(&ClipboardHostImpl::OnReadPngWithText,
                              weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
-                             std::move(callback), std::move(data)));
+                             seqno, std::move(callback), std::move(data)));
 }
 
-void ClipboardHostImpl::OnReadPngWithText(ui::ClipboardBuffer clipboard_buffer,
-                                          ReadPngCallback callback,
-                                          std::vector<uint8_t> data,
-                                          std::u16string text) {
+void ClipboardHostImpl::OnReadPngWithText(
+    ui::ClipboardBuffer clipboard_buffer,
+    ui::ClipboardSequenceNumberToken seqno,
+    ReadPngCallback callback,
+    std::vector<uint8_t> data,
+    std::u16string text) {
   // Pass both image and associated text for content analysis.
   ClipboardPasteData clipboard_paste_data;
   clipboard_paste_data.text = std::move(text);
   clipboard_paste_data.png = std::move(data);
 
   PasteIfPolicyAllowed(
-      clipboard_buffer, ui::ClipboardFormatType::PngType(),
+      clipboard_buffer, ui::ClipboardFormatType::PngType(), seqno,
       std::move(clipboard_paste_data),
       base::BindOnce(
           [](ReadPngCallback callback,
@@ -463,10 +482,13 @@
       clipboard_buffer, data_dst,
       base::BindOnce(&ClipboardHostImpl::OnReadFiles,
                      weak_ptr_factory_.GetWeakPtr(), clipboard_buffer,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         clipboard_buffer),
                      std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadFiles(ui::ClipboardBuffer clipboard_buffer,
+                                    ui::ClipboardSequenceNumberToken seqno,
                                     ReadFilesCallback callback,
                                     std::vector<ui::FileInfo> filenames) {
   blink::mojom::ClipboardFilesPtr result = blink::mojom::ClipboardFiles::New();
@@ -503,7 +525,7 @@
   std::move(files.begin(), files.end(), std::back_inserter(result->files));
 
   PasteIfPolicyAllowed(
-      clipboard_buffer, ui::ClipboardFormatType::FilenamesType(),
+      clipboard_buffer, ui::ClipboardFormatType::FilenamesType(), seqno,
       std::move(clipboard_paste_data),
       base::BindOnce(
           [](blink::mojom::ClipboardFilesPtr result, ReadFilesCallback callback,
@@ -550,12 +572,15 @@
       clipboard_buffer, type, data_dst,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/clipboard_host_impl_unittest.cc b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
index 9c8cb86c..47b94ea 100644
--- a/content/browser/renderer_host/clipboard_host_impl_unittest.cc
+++ b/content/browser/renderer_host/clipboard_host_impl_unittest.cc
@@ -382,8 +382,11 @@
   bool is_policy_callback_called = false;
   ClipboardHostImpl::ClipboardPasteData clipboard_paste_data;
   clipboard_paste_data.text = u"data";
+
   fake_clipboard_host_impl_grandchild->PasteIfPolicyAllowed(
       ui::ClipboardBuffer::kCopyPaste, ui::ClipboardFormatType::PlainTextType(),
+      ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+          ui::ClipboardBuffer::kCopyPaste),
       clipboard_paste_data,
       base::BindLambdaForTesting(
           [&is_policy_callback_called](
@@ -1322,4 +1325,375 @@
   EXPECT_EQ(0u, future.Get().size());
 }
 
+class SequenceNumberInterceptBrowserClient : public TestContentBrowserClient {
+ public:
+  SequenceNumberInterceptBrowserClient() = default;
+  ~SequenceNumberInterceptBrowserClient() override = default;
+
+  void IsClipboardPasteAllowedByPolicy(
+      const ClipboardEndpoint& source,
+      const ClipboardEndpoint& destination,
+      const ui::ClipboardMetadata& metadata,
+      ClipboardPasteData data,
+      IsClipboardPasteAllowedCallback callback) override {
+    last_seqno_ = metadata.seqno;
+    std::move(callback).Run(std::move(data));
+  }
+
+  ui::ClipboardSequenceNumberToken last_seqno() const { return last_seqno_; }
+
+ private:
+  ui::ClipboardSequenceNumberToken last_seqno_;
+};
+
+class RaceConditionTestClipboard : public ui::TestClipboard {
+ public:
+  RaceConditionTestClipboard() = default;
+  ~RaceConditionTestClipboard() override = default;
+
+  void SetCallbackOnRead(base::RepeatingClosure callback) {
+    on_read_callback_ = std::move(callback);
+  }
+
+  void ReadText(ui::ClipboardBuffer buffer,
+                const std::optional<ui::DataTransferEndpoint>& data_dst,
+                ReadTextCallback callback) const override {
+    ui::TestClipboard::ReadText(
+        buffer, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadTextCallback callback, std::u16string result) {
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(result));
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+  void ReadHTML(ui::ClipboardBuffer buffer,
+                const std::optional<ui::DataTransferEndpoint>& data_dst,
+                ReadHtmlCallback callback) const override {
+    ui::TestClipboard::ReadHTML(
+        buffer, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadHtmlCallback callback, std::u16string markup, GURL src_url,
+               uint32_t fragment_start, uint32_t fragment_end) {
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(markup), std::move(src_url),
+                                      fragment_start, fragment_end);
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+  void ReadSvg(ui::ClipboardBuffer buffer,
+               const std::optional<ui::DataTransferEndpoint>& data_dst,
+               ReadSvgCallback callback) const override {
+    ui::TestClipboard::ReadSvg(
+        buffer, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadSvgCallback callback, std::u16string svg) {
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(svg));
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+  void ReadRTF(ui::ClipboardBuffer buffer,
+               const std::optional<ui::DataTransferEndpoint>& data_dst,
+               ReadRTFCallback callback) const override {
+    ui::TestClipboard::ReadRTF(
+        buffer, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadRTFCallback callback, std::string rtf) {
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(rtf));
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+  void ReadPng(ui::ClipboardBuffer buffer,
+               const std::optional<ui::DataTransferEndpoint>& data_dst,
+               ReadPngCallback callback) const override {
+    ui::TestClipboard::ReadPng(
+        buffer, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadPngCallback callback, const std::vector<uint8_t>& data) {
+              std::vector<uint8_t> png_copy = data;
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(png_copy));
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+  void ReadFilenames(ui::ClipboardBuffer buffer,
+                     const std::optional<ui::DataTransferEndpoint>& data_dst,
+                     ReadFilenamesCallback callback) const override {
+    ui::TestClipboard::ReadFilenames(
+        buffer, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadFilenamesCallback callback,
+               std::vector<ui::FileInfo> filenames) {
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(filenames));
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+  void ReadDataTransferCustomData(
+      ui::ClipboardBuffer buffer,
+      const std::u16string& type,
+      const std::optional<ui::DataTransferEndpoint>& data_dst,
+      ReadDataTransferCustomDataCallback callback) const override {
+    ui::TestClipboard::ReadDataTransferCustomData(
+        buffer, type, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadDataTransferCustomDataCallback callback,
+               std::u16string result) {
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(result));
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+  void ReadData(const ui::ClipboardFormatType& format,
+                const std::optional<ui::DataTransferEndpoint>& data_dst,
+                ReadDataCallback callback) const override {
+    ui::TestClipboard::ReadData(
+        format, data_dst,
+        base::BindOnce(
+            [](base::RepeatingClosure on_read_callback,
+               ReadDataCallback callback, std::string result) {
+              if (on_read_callback) {
+                on_read_callback.Run();
+              }
+              std::move(callback).Run(std::move(result));
+            },
+            on_read_callback_, std::move(callback)));
+  }
+
+ private:
+  base::RepeatingClosure on_read_callback_;
+};
+
+class ClipboardHostImplRaceConditionTest : public ClipboardHostImplTest {
+ protected:
+  void SetUp() override {
+    ClipboardHostImplTest::SetUp();
+    browser_client_setting_ =
+        std::make_unique<ScopedContentBrowserClientSetting>(&browser_client_);
+
+    ui::Clipboard::DestroyClipboardForCurrentThread();
+    auto test_clipboard = std::make_unique<RaceConditionTestClipboard>();
+    test_clipboard_ = test_clipboard.get();
+    ui::Clipboard::SetClipboardForCurrentThread(std::move(test_clipboard));
+  }
+
+  void TearDown() override {
+    test_clipboard_ = nullptr;
+    DeleteAndRecreateClipboard();
+    browser_client_setting_.reset();
+    ClipboardHostImplTest::TearDown();
+  }
+
+  SequenceNumberInterceptBrowserClient& browser_client() {
+    return browser_client_;
+  }
+
+  RaceConditionTestClipboard* test_clipboard() { return test_clipboard_; }
+
+ private:
+  SequenceNumberInterceptBrowserClient browser_client_;
+  std::unique_ptr<ScopedContentBrowserClientSetting> browser_client_setting_;
+  raw_ptr<RaceConditionTestClipboard> test_clipboard_ = nullptr;
+};
+
+TEST_F(ClipboardHostImplRaceConditionTest, ReadTextUsesCapturedSequenceNumber) {
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(u"initial text");
+  }
+
+  ui::ClipboardSequenceNumberToken expected_seqno =
+      test_clipboard()->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste);
+
+  test_clipboard()->SetCallbackOnRead(base::BindLambdaForTesting([]() {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(u"Benign text to increment sequence number");
+  }));
+
+  std::u16string result;
+  mojo_clipboard()->ReadText(ui::ClipboardBuffer::kCopyPaste, &result);
+
+  EXPECT_EQ(browser_client().last_seqno(), expected_seqno);
+  EXPECT_EQ(result, u"initial text");
+}
+
+TEST_F(ClipboardHostImplRaceConditionTest, ReadHtmlUsesCapturedSequenceNumber) {
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteHTML(u"<b>html</b>", "https://example.com");
+  }
+
+  ui::ClipboardSequenceNumberToken expected_seqno =
+      test_clipboard()->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste);
+
+  test_clipboard()->SetCallbackOnRead(base::BindLambdaForTesting([]() {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(u"Benign text to increment sequence number");
+  }));
+
+  std::u16string markup;
+  GURL url;
+  uint32_t start = 0;
+  uint32_t end = 0;
+  mojo_clipboard()->ReadHtml(ui::ClipboardBuffer::kCopyPaste, &markup, &url,
+                             &start, &end);
+
+  EXPECT_EQ(browser_client().last_seqno(), expected_seqno);
+  EXPECT_EQ(markup, u"<b>html</b>");
+}
+
+TEST_F(ClipboardHostImplRaceConditionTest, ReadSvgUsesCapturedSequenceNumber) {
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteSvg(u"<svg></svg>");
+  }
+
+  ui::ClipboardSequenceNumberToken expected_seqno =
+      test_clipboard()->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste);
+
+  test_clipboard()->SetCallbackOnRead(base::BindLambdaForTesting([]() {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteText(u"Benign text to increment sequence number");
+  }));
+
+  base::test::TestFuture<const std::u16string&> future;
+  mojo_clipboard()->ReadSvg(ui::ClipboardBuffer::kCopyPaste,
+                            future.GetCallback());
+  ASSERT_TRUE(future.Wait());
+
+  EXPECT_EQ(browser_client().last_seqno(), expected_seqno);
+  EXPECT_EQ(future.Get(), u"<svg></svg>");
+}
+
+TEST_F(ClipboardHostImplRaceConditionTest, ReadRtfUsesCapturedSequenceNumber) {
+  {
+    ui::ScopedClipboardWriter writer(ui::ClipboardBuffer::kCopyPaste);
+    writer.WriteRTF("{\\rtf1\\ansi}");
+  }
+
+  ui::ClipboardSequenceNumberToken expected_seqno =
+      test_clipboard()->GetSequenceNumber(ui::ClipboardBuffer::kCopyPaste);
+
+  test_clipboard()->SetCallbackOnRead(base::BindLambdaForTesting([]() {
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential DLP bypass via TOCTOU in Enterprise paste caching

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 Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A race condition in Chrome’s clipboard handling allows a compromised renderer to bypass Enterprise Data Loss Prevention (DLP) checks. An attacker can write benign data to the clipboard while a slow image read is occurring, priming a cache that subsequently allows the sensitive image data through uninspected.

Affected files:

  • chrome/browser/enterprise/data_protection/paste_allowed_request.cc
  • content/browser/renderer_host/clipboard_host_impl.cc
  • content/public/browser/clipboard_types.cc

Estimated timestamp from git blame: 2024-04-26

Summary

A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in Chrome’s clipboard processing that can allow a compromised renderer to bypass Enterprise Data Loss Prevention (DLP) content analysis when reading images from the clipboard.

The issue stems from the fact that reading an image from the clipboard (e.g., via ReadPng) is an asynchronous operation that may involve slow background-thread encoding. However, the sequence number used to cache the DLP policy decision is fetched after this asynchronous work finishes. A malicious renderer can exploit this window to modify the clipboard content, triggering a fast, benign policy check that caches an “allowed” verdict. When the slow image encoding finally completes, it re-uses this newly cached “allowed” verdict, permitting the sensitive image data to be delivered without inspection.

Detailed Analysis

  1. Initiating a Slow Read: The user copies a confidential image (e.g., a bitmap) to the OS clipboard. The clipboard is currently at sequence number S1. A compromised renderer calls navigator.clipboard.read() or uses direct Mojo access to send a ReadPng message to the browser.
  2. Background Processing: ClipboardHostImpl::ReadPng initiates a read from the OS clipboard. On Windows (features::kNonBlockingOsClipboardReads) or standard Non-Backed clipboards, reading a bitmap and converting it to PNG format involves dispatching a time-consuming task to a background base::ThreadPool (ui/base/clipboard/clipboard_win.cc:814). The UI thread does not block and continues processing Mojo messages.
  3. The Race (Writing Benign Data): While the background encoding task is running, the renderer sends a WriteSmartPasteMarker message followed by a CommitWrite message. Unlike WriteText, these specific write methods do not trigger asynchronous policy checks and immediately commit to the clipboard (content/browser/renderer_host/clipboard_host_impl.cc:597). This updates the OS clipboard state and increments the global sequence number to S2.
  4. Priming the Cache: The renderer immediately sends a ReadText message. Because the clipboard now only contains a smart paste marker, ExtractText quickly returns an empty string. The browser proceeds to check DLP policies for this empty text by calling PasteIfPolicyAllowed (content/browser/renderer_host/clipboard_host_impl.cc:833).
  5. Caching the Benign Verdict: PasteIfPolicyAllowed captures the current OS sequence number, which is now S2. It initiates an Enterprise Content Analysis request. The check concludes that an empty string is safe and calls PasteAllowedRequest::Complete (chrome/browser/enterprise/data_protection/paste_allowed_request.cc:153). This populates the cache in RequestsMapStorage with an “allowed” entry for sequence number S2 containing empty data.
  6. The Bypass: Eventually, the original background task encoding the confidential PNG completes. Execution resumes on the UI thread at ClipboardHostImpl::OnReadPngWithText (content/browser/renderer_host/clipboard_host_impl.cc:401), which bundles the sensitive PNG data into a ClipboardPasteData object.
  7. Re-fetching the Sequence Number: OnReadPngWithText calls PasteIfPolicyAllowed. Crucially, this function calls GetSequenceNumber again. Since the clipboard hasn’t changed since step 3, it receives S2.
  8. Data Merging: PasteAllowedRequest::StartPasteAllowedRequest is called with the sensitive PNG data and the sequence number S2. It finds the completed, “allowed” cache entry from step 5. Because the entry is marked complete, it skips the DLP scan and immediately calls request.InvokeCallback(...).
  9. Delivery: Inside InvokeCallback, the incoming sensitive data is merged with the cached data (data.Merge(data_)). Because the cached data consists of empty fields, the Merge function (content/public/browser/clipboard_types.cc:41) leaves the sensitive PNG data intact. The callback delivers the confidential image directly to the attacker’s renderer.

Impact

A compromised renderer or malicious web application with clipboard read permissions could reliably extract confidential images or other data types from the clipboard, bypassing active Enterprise DLP content analysis policies.

Suggested Fix

The root cause is that PasteIfPolicyAllowed retrieves the clipboard sequence number at the end of the read pipeline, rather than capturing the sequence number that was associated with the data when it was originally extracted from the OS clipboard.

The ui::Clipboard::ReadPng (and related async read functions) should be modified to return the sequence number that was current when the read began. This captured sequence number should then be passed down the callback chain to ClipboardHostImpl::OnReadPng -> ExtractText -> OnReadPngWithText -> PasteIfPolicyAllowed. This ensures the DLP cache key strictly matches the generation of the data being analyzed.

Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09


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.

Raised in root component due to access or custom field issues on 1208119

View on issue tracker