Medium watch CVSS 5.3 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in WebProtect
DescriptionRace condition in WebProtect
ComponentWebProtect
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker502888857
Fix commit0f84a3df80b9 (chromium/src) +438/-25
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-15 (per vendor advisory)
Fix landed2026-07-15 (public git)
Disclosed2026-08-25
Patch gap41 days — fix public in source before the advisory

Background

`ClipboardHostImpl`
the browser-process implementation of blink::mojom::ClipboardHost that serves renderer clipboard reads (text, HTML, SVG, RTF, PNG, files, custom formats) and passes the data through policy checks before returning it.
`PasteIfPolicyAllowed`
the ClipboardHostImpl helper that builds clipboard metadata (including a sequence number) and asks the embedder (IsClipboardPasteAllowedByPolicy) whether enterprise data-protection policy allows the paste.
`ui::ClipboardSequenceNumberToken`
a token from ui::Clipboard::GetSequenceNumber() that identifies one clipboard content generation and changes on every write. Policy code uses it to tie a paste decision to a specific clipboard snapshot.
WebProtect / enterprise paste policy
enterprise-configured data-loss-prevention checks (for example content analysis or data controls) that decide whether clipboard data may be pasted into a destination.

Root Cause Analysis

Before the patch, each ClipboardHostImpl read path started an asynchronous platform read (ExtractText, clipboard->ReadHTML, the SVG, RTF, PNG, files and custom-data reads, and the custom-format name extraction). The completion callback (OnReadText, OnReadHtml, OnReadPngWithText, OnReadFiles, OnReadUnsanitizedCustomFormat, etc.) then called PasteIfPolicyAllowed. As the removed lines show, PasteIfPolicyAllowed read the clipboard sequence number itself at that later point, with ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(clipboard_buffer). If the clipboard was written while the read was in flight, the policy check received the newer clipboard generation’s token together with data that came from the older generation. The data and the identity used to evaluate policy on it no longer matched, which is the time-of-check/time-of-use race named in the commit message.

The fix captures the token when each read callback is bound, before the async read runs. It threads the token as a new seqno parameter through every OnRead* callback, including the two-stage PNG path (OnReadPng to OnReadPngWithText) and the custom-format path (OnExtractCustomPlatformNames to OnReadUnsanitizedCustomFormat), and makes PasteIfPolicyAllowed take seqno as an argument instead of sampling it.

Key insight
The policy check sampled the clipboard sequence number after an asynchronous read had finished, so a concurrent clipboard write could attach a different generation’s token to the data being checked. The fix captures the token at the start of the read and passes it explicitly, so the data and the policy metadata describe the same snapshot.

Attack Path

  1. Enterprise policy in effect The browser has clipboard paste policy (WebProtect / data-protection rules) configured. Without such policy the sequence number has no security role here.
  2. Renderer triggers an async clipboard read A page reads the clipboard (for example a paste or Async Clipboard API read, subject to the usual permission or user-activation requirements), which reaches one of the ClipboardHostImpl read methods and starts an asynchronous platform read.
  3. Clipboard changes mid-read While the read is in flight, the clipboard is written, either by the page itself or by another application. Before the patch, PasteIfPolicyAllowed then sampled the new ClipboardSequenceNumberToken.
  4. Policy evaluated against the wrong snapshot The token for the new contents is sent to IsClipboardPasteAllowedByPolicy together with data from the old contents. This could let data be judged by the source, or by any cached verdict, belonging to the other clipboard generation, and so be pasted when policy should block it (inferred; the patch shows only the token mismatch, not how the embedder uses it).

Impact Assessment

At worst this is a data-loss-prevention policy bypass in the browser process: clipboard content could reach a destination page even though enterprise policy should have blocked or analysed it (inferred from the commit’s description of “a potential race condition for enterprise policy checks”). It applies only to managed browsers with clipboard paste policies enabled, and it needs a clipboard write to land inside the async read window. It does not show memory corruption. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.3.

Changed Functions

FunctionChangeNotes
SequenceNumberInterceptBrowserClient
content/browser/renderer_host/clipboard_host_impl_unittest.cc
modified
RaceConditionTestClipboard
content/browser/renderer_host/clipboard_host_impl_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/clipboard_host_impl.h
  • content/browser/renderer_host/clipboard_host_impl.cc
  • content/browser/renderer_host/clipboard_host_impl_unittest.cc

Audit Directions

  • State re-sampled after async hops
    In browser-side IPC handlers, look for security-relevant identity (sequence numbers, origins, frame or process IDs, URLs) that is read again in a completion callback instead of being captured with the data it describes.
  • Remaining capture window
    The token is still sampled just before the platform read is issued. Check whether any ui::Clipboard backend can observe a write between GetSequenceNumber() and the actual data fetch, and whether readers should verify that the sequence number is unchanged after the read.
  • Multi-stage clipboard reads
    Paths that chain several async reads (PNG plus text, custom-format name lookup plus data read, file reads) are where context is most easily lost. Audit similar chains in drag-and-drop, DataTransfer, and other policy-gated hosts.
From 0f84a3df80b9ec4ba4774c4a66ecc00cce39bcd3 Mon Sep 17 00:00:00 2001
From: Dominique Fauteux-Chapleau <[email protected]>
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 <[email protected]>
Commit-Queue: Dominique Fauteux-Chapleau <[email protected]>
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,
       base::BindOnce(&ClipboardHostImpl::OnReadDataTransferCustomData,
                      weak_ptr_factory_.GetWeakPtr(), clipboard_buffer, type,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         clipboard_buffer),
                      std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadDataTransferCustomData(
     ui::ClipboardBuffer clipboard_buffer,
     const std::u16string& type,
+    ui::ClipboardSequenceNumberToken seqno,
     ReadDataTransferCustomDataCallback callback,
     std::u16string data) {
   ClipboardPasteData clipboard_paste_data;
@@ -563,7 +588,7 @@
 
   PasteIfPolicyAllowed(
       clipboard_buffer, ui::ClipboardFormatType::DataTransferCustomType(),
-      std::move(clipboard_paste_data),
+      seqno, std::move(clipboard_paste_data),
       base::BindOnce(
           [](ReadDataTransferCustomDataCallback callback,
              const std::u16string& type,
@@ -779,12 +804,15 @@
       ui::ClipboardBuffer::kCopyPaste, data_endpoint,
       base::BindOnce(&ClipboardHostImpl::OnExtractCustomPlatformNames,
                      weak_ptr_factory_.GetWeakPtr(), format_name, data_endpoint,
+                     ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(
+                         ui::ClipboardBuffer::kCopyPaste),
                      std::move(callback)));
 }
 
 void ClipboardHostImpl::OnExtractCustomPlatformNames(
     const std::string& format_name,
     std::optional<ui::DataTransferEndpoint> data_endpoint,
+    ui::ClipboardSequenceNumberToken seqno,
     ReadUnsanitizedCustomFormatCallback callback,
     std::map<std::string, std::string> custom_format_names) {
   std::string web_custom_format_string;
@@ -801,10 +829,12 @@
       ui::ClipboardFormatType::CustomPlatformType(web_custom_format_string),
       data_endpoint,
       base::BindOnce(&ClipboardHostImpl::OnReadUnsanitizedCustomFormat,
-                     weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
+                     weak_ptr_factory_.GetWeakPtr(), seqno,
+                     std::move(callback)));
 }
 
 void ClipboardHostImpl::OnReadUnsanitizedCustomFormat(
+    ui::ClipboardSequenceNumberToken seqno,
     ReadUnsanitizedCustomFormatCallback callback,
     std::string result) {
   if (result.size() >= blink::mojom::ClipboardHost::kMaxDataSize) {
@@ -817,7 +847,7 @@
   ClipboardPasteData clipboard_paste_data;
   PasteIfPolicyAllowed(
       ui::ClipboardBuffer::kCopyPaste,
-      ui::ClipboardFormatType::WebCustomFormatMap(),
+      ui::ClipboardFormatType::WebCustomFormatMap(), seqno,
       std::move(clipboard_paste_data),
       base::BindOnce(
           [](std::string result, ReadUnsanitizedCustomFormatCallback callback,
@@ -895,6 +925,7 @@
 void ClipboardHostImpl::PasteIfPolicyAllowed(
     ui::ClipboardBuffer clipboard_buffer,
     const ui::ClipboardFormatType& data_type,
+    ui::ClipboardSequenceNumberToken seqno,
     ClipboardPasteData clipboard_paste_data,
     IsClipboardPasteAllowedCallback callback) {
   std::optional<size_t> data_size;
@@ -902,9 +933,6 @@
     data_size = clipboard_paste_data.size();
   }
 
-  ui::ClipboardSequenceNumberToken seqno =
-      ui::Clipboard::GetForCurrentThread()->GetSequenceNumber(clipboard_buffer);
-
   auto data_dst = CreateClipboardEndpoint(render_frame_host());
   const ui::DataTransferEndpoint* data_dst_endpoint =
       base::OptionalToPtr(data_dst.data_transfer_endpoint());
diff --git a/content/browser/renderer_host/clipboard_host_impl.h b/content/browser/renderer_host/clipboard_host_impl.h
index 413b2a3f..975253d 100644
--- a/content/browser/renderer_host/clipboard_host_impl.h
+++ b/content/browser/renderer_host/clipboard_host_impl.h
@@ -65,6 +65,7 @@
   // policies and invokes FinishPasteIfAllowed upon completion.
   void PasteIfPolicyAllowed(ui::ClipboardBuffer clipboard_buffer,
                             const ui::ClipboardFormatType& data_type,
+                            ui::ClipboardSequenceNumberToken seqno,
                             ClipboardPasteData clipboard_paste_data,
                             IsClipboardPasteAllowedCallback callback);
 
@@ -213,19 +214,23 @@
       base::flat_set<ui::ClipboardFormatType> formats);
 
   void OnReadPng(ui::ClipboardBuffer clipboard_buffer,
+                 ui::ClipboardSequenceNumberToken seqno,
                  ReadPngCallback callback,
                  const std::vector<uint8_t>& data);
 
   void OnReadPngWithText(ui::ClipboardBuffer clipboard_buffer,
+                         ui::ClipboardSequenceNumberToken seqno,
                          ReadPngCallback callback,
                          std::vector<uint8_t> data,
                          std::u16string text);
 
   void OnReadText(ui::ClipboardBuffer clipboard_buffer,
+                  ui::ClipboardSequenceNumberToken seqno,
                   ReadTextCallback callback,
                   std::u16string text);
 
   void OnReadHtml(ui::ClipboardBuffer clipboard_buffer,
+                  ui::ClipboardSequenceNumberToken seqno,
                   ReadHtmlCallback callback,
                   std::u16string markup,
                   GURL src_url,
@@ -233,19 +238,23 @@
                   uint32_t fragment_end);
 
   void OnReadSvg(ui::ClipboardBuffer clipboard_buffer,
+                 ui::ClipboardSequenceNumberToken seqno,
                  ReadSvgCallback callback,
                  std::u16string svg);
 
   void OnReadRtf(ui::ClipboardBuffer clipboard_buffer,
+                 ui::ClipboardSequenceNumberToken seqno,
                  ReadRtfCallback callback,
                  std::string rtf);
 
   void OnReadFiles(ui::ClipboardBuffer clipboard_buffer,
+                   ui::ClipboardSequenceNumberToken seqno,
                    ReadFilesCallback callback,
                    std::vector<ui::FileInfo> filenames);
 
   void OnReadDataTransferCustomData(ui::ClipboardBuffer clipboard_buffer,
                                     const std::u16string& type,
+                                    ui::ClipboardSequenceNumberToken seqno,
                                     ReadDataTransferCustomDataCallback callback,
                                     std::u16string data);
 
@@ -258,12 +267,14 @@
                                     content::ClipboardEndpoint source);
 
   void OnReadUnsanitizedCustomFormat(
+      ui::ClipboardSequenceNumberToken seqno,
       ReadUnsanitizedCustomFormatCallback callback,
       std::string data);
 
   void OnExtractCustomPlatformNames(
       const std::string& format_name,
       std::optional<ui::DataTransferEndpoint> data_endpoint,
+      ui::ClipboardSequenceNumberToken seqno,
       ReadUnsanitizedCustomFormatCallback callback,
       std::map<std::string, std::string> custom_format_names);
 
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;
+
... (file diff truncated)
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 [email protected]

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