Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in Scroll
DescriptionSide-channel information leakage in Scroll
ComponentScroll
Bug ClassLogic Error
Tracker457771782
Fix commitedf09a64a6e3 (chromium/src) +389/-36
CISA KEVNot listed
CreditedVsevolod Kokorin (Slonser) of Solidlab and Jorian Woltjer
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
modified
TextFragmentAnchorTest
third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
modified
TEST_F
third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
modified

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.cc
  • third_party/blink/public/mojom/frame/frame.mojom
  • third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
  • third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc
  • third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
From edf09a64a6e305874741d429931de797eb63c1f4 Mon Sep 17 00:00:00 2001
From: Vladimir Levin <[email protected]>
Date: Thu, 07 May 2026 12:35:15 -0700
Subject: [PATCH] Reland "STTF: Delay sttf until related pages are finalized."

Fixed the test.

Original reason for revert: TextFragmentAnchorTest.AvoidScrollingIfHasOtherRelatedPages failed at https://ci.chromium.org/ui/p/chromium/builders/ci/Linux%20UBSan%20Tests/15610/overview

Original change's description:
> STTF: Delay sttf until related pages are finalized.
>
> This patch delays sttf until related pages are finalized. It also
> sends the browser-authoritative bool indicating whether there are
> related pages associated with this renderer.
>
> [email protected]
>
> Change-Id: Ifc03488451bd3d1e99c4ab447362437778ddf96d
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7629937
> Reviewed-by: Rakina Zata Amni <[email protected]>
> Commit-Queue: Vladimir Levin <[email protected]>
> Reviewed-by: Ken Buchanan <[email protected]>
> Reviewed-by: Ari Chivukula <[email protected]>
> Cr-Commit-Position: refs/heads/main@{#1625391}

Bug: 457771782
Change-Id: I9a36bfc18d12af87e96880bd565e670a2c9068ff
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7818478
Commit-Queue: Vladimir Levin <[email protected]>
Reviewed-by: Joe Mason <[email protected]>
Reviewed-by: Rakina Zata Amni <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1627144}
---

diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index f3f4fbf..88879ec 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -16244,6 +16244,19 @@
       IsInPrimaryMainFrame(), is_same_document_navigation,
       navigation_ukm_builder);
 
+  if (is_main_frame() && !is_same_document_navigation) {
+    // The previous document is already pending delete and can't change the
+    // related pages list. However, the new document may not know about all of
+    // the related pages that were created from the previous document, see
+    // crbug.com/457771782. As a result, we let the renderer know that there are
+    // other related pages.
+    bool has_other_related_pages =
+        GetSiteInstance() &&
+        GetSiteInstance()->GetRelatedActiveContentsCount() > 1;
+    GetAssociatedLocalMainFrame()->NotifyRelatedPagesFinalized(
+        has_other_related_pages);
+  }
+
   return true;
 }
 
diff --git a/third_party/blink/public/mojom/frame/frame.mojom b/third_party/blink/public/mojom/frame/frame.mojom
index 3836008..913d342 100644
--- a/third_party/blink/public/mojom/frame/frame.mojom
+++ b/third_party/blink/public/mojom/frame/frame.mojom
@@ -1323,6 +1323,10 @@
   FinalizeNavigationConfidence(
     double randomized_trigger_rate,
     ConfidenceLevel confidence);
+
+  // Called when the related pages set is finalized and cannot change due to
+  // previous page's mutations. Called when this (new page) is being committed.
+  NotifyRelatedPagesFinalized(bool has_other_related_pages);
 };
 
 // Implemented in Browser, this interface defines local-main-frame-specific
diff --git a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
index 0f5ec51..0d8f696 100644
--- a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
+++ b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
@@ -83,7 +83,8 @@
       return false;
     }
 
-    if (frame.GetPage()->RelatedPages().size()) {
+    if (frame.GetPage()->RelatedPages().size() ||
+        frame.GetPage()->HasOtherRelatedPagesDuringCommit()) {
       TRACE_EVENT_INSTANT("blink", "CheckSecurityRestrictions", "Result",
                           "Non-Empty Browsing Context Group");
       return false;
diff --git a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc
index cf2e513..68c35d94 100644
--- a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc
+++ b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc
@@ -53,6 +53,12 @@
     return scoped_fake_ukm_recorder_.recorder();
   }
 
+  void ResetRelatedPagesFinalized() {
+    GetDocument()
+        .GetPage()
+        ->related_pages_mutation_from_previous_page_finalized_ = false;
+  }
+
   base::HistogramTester histogram_tester_;
   ScopedFakeUkmRecorder scoped_fake_ukm_recorder_;
 };
@@ -250,6 +256,9 @@
     <!DOCTYPE html>
     <p>This is a test page</p>
   )HTML");
+  GetDocument().GetPage()->NotifyRelatedPagesFinalized(false);
+  GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
+  Compositor().BeginFrame();
   Compositor().BeginFrame();
 
   // The anchor should have been found and finalized.
@@ -370,6 +379,8 @@
       <!DOCTYPE html>
       <p id="element">This is a test page</p>
     )HTML");
+    GetDocument().GetPage()->NotifyRelatedPagesFinalized(false);
+    GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
     FragmentAnchor* anchor =
         GetDocument().GetFrame()->View()->GetFragmentAnchor();
     if (anchor && anchor->IsTextFragmentAnchor()) {
@@ -668,6 +679,7 @@
   }
 
   {
+    ResetRelatedPagesFinalized();
     SimRequest request("https://example.com/shadowtest.html#:~:text=ShadowDOM",
                        "text/html");
     LoadURL("https://example.com/shadowtest.html#:~:text=ShadowDOM");
diff --git a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
index adce4c0..098e431 100644
--- a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
+++ b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
@@ -66,6 +66,11 @@
   TextFragmentAnchorTestController() = default;
 
   void BeginEmptyFrame() {
+    if (auto_finalize_) {
+      GetDocument().GetPage()->NotifyRelatedPagesFinalized(false);
+      GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
+    }
+
     // If a test case doesn't find a match and therefore doesn't schedule the
     // beforematch event, we should still render a second frame as if we did
     // schedule the event to retain test coverage.
@@ -76,6 +81,9 @@
     Compositor().BeginFrame();
   }
 
+  void SetAutoFinalize(bool auto_finalize) { auto_finalize_ = auto_finalize; }
+
+ public:
   ScrollableArea* LayoutViewport() {
     return GetDocument().View()->LayoutViewport();
   }
@@ -170,6 +178,8 @@
                       "Implement others if new modality is needed.";
     }
   }
+
+  bool auto_finalize_ = true;
 };
 
 class TextFragmentAnchorTest : public TextFragmentAnchorTestController {
@@ -198,7 +208,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& p = *GetDocument().getElementById(AtomicString("text"));
 
@@ -208,6 +218,109 @@
       << LayoutViewport()->GetScrollOffset().ToString();
 }
 
+// Test that scroll to text fragment is deferred until related pages are
+// finalized.
+TEST_F(TextFragmentAnchorTest, DeferUntilRelatedPagesFinalized) {
+  SetAutoFinalize(false);
+  SimRequest::Params params;
+  params.requestor_origin =
+      WebSecurityOrigin::CreateFromString(WebString("https://example.org"));
+  SimRequest request("https://example.com/test.html#:~:text=test", "text/html",
+                     params);
+  LoadURL("https://example.com/test.html#:~:text=test");
+
+  ASSERT_FALSE(
+      GetDocument().GetPage()->RelatedPagesMutationFromPreviousPageFinalized());
+
+  request.Complete(R"HTML(
+    <!DOCTYPE html>
+    <style>
+      body {
+        height: 1200px;
+      }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc
index cf2e513..68c35d94 100644
--- a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc
+++ b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_metrics_test.cc
@@ -53,6 +53,12 @@
     return scoped_fake_ukm_recorder_.recorder();
   }
 
+  void ResetRelatedPagesFinalized() {
+    GetDocument()
+        .GetPage()
+        ->related_pages_mutation_from_previous_page_finalized_ = false;
+  }
+
   base::HistogramTester histogram_tester_;
   ScopedFakeUkmRecorder scoped_fake_ukm_recorder_;
 };
@@ -250,6 +256,9 @@
     <!DOCTYPE html>
     <p>This is a test page</p>
   )HTML");
+  GetDocument().GetPage()->NotifyRelatedPagesFinalized(false);
+  GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
+  Compositor().BeginFrame();
   Compositor().BeginFrame();
 
   // The anchor should have been found and finalized.
@@ -370,6 +379,8 @@
       <!DOCTYPE html>
       <p id="element">This is a test page</p>
     )HTML");
+    GetDocument().GetPage()->NotifyRelatedPagesFinalized(false);
+    GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
     FragmentAnchor* anchor =
         GetDocument().GetFrame()->View()->GetFragmentAnchor();
     if (anchor && anchor->IsTextFragmentAnchor()) {
@@ -668,6 +679,7 @@
   }
 
   {
+    ResetRelatedPagesFinalized();
     SimRequest request("https://example.com/shadowtest.html#:~:text=ShadowDOM",
                        "text/html");
     LoadURL("https://example.com/shadowtest.html#:~:text=ShadowDOM");
diff --git a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
index adce4c0..098e431 100644
--- a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
+++ b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor_test.cc
@@ -66,6 +66,11 @@
   TextFragmentAnchorTestController() = default;
 
   void BeginEmptyFrame() {
+    if (auto_finalize_) {
+      GetDocument().GetPage()->NotifyRelatedPagesFinalized(false);
+      GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
+    }
+
     // If a test case doesn't find a match and therefore doesn't schedule the
     // beforematch event, we should still render a second frame as if we did
     // schedule the event to retain test coverage.
@@ -76,6 +81,9 @@
     Compositor().BeginFrame();
   }
 
+  void SetAutoFinalize(bool auto_finalize) { auto_finalize_ = auto_finalize; }
+
+ public:
   ScrollableArea* LayoutViewport() {
     return GetDocument().View()->LayoutViewport();
   }
@@ -170,6 +178,8 @@
                       "Implement others if new modality is needed.";
     }
   }
+
+  bool auto_finalize_ = true;
 };
 
 class TextFragmentAnchorTest : public TextFragmentAnchorTestController {
@@ -198,7 +208,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& p = *GetDocument().getElementById(AtomicString("text"));
 
@@ -208,6 +218,109 @@
       << LayoutViewport()->GetScrollOffset().ToString();
 }
 
+// Test that scroll to text fragment is deferred until related pages are
+// finalized.
+TEST_F(TextFragmentAnchorTest, DeferUntilRelatedPagesFinalized) {
+  SetAutoFinalize(false);
+  SimRequest::Params params;
+  params.requestor_origin =
+      WebSecurityOrigin::CreateFromString(WebString("https://example.org"));
+  SimRequest request("https://example.com/test.html#:~:text=test", "text/html",
+                     params);
+  LoadURL("https://example.com/test.html#:~:text=test");
+
+  ASSERT_FALSE(
+      GetDocument().GetPage()->RelatedPagesMutationFromPreviousPageFinalized());
+
+  request.Complete(R"HTML(
+    <!DOCTYPE html>
+    <style>
+      body {
+        height: 1200px;
+      }
+      p {
+        position: absolute;
+        top: 1000px;
+      }
+    </style>
+    <p id="text">This is a test page</p>
+  )HTML");
+
+  ThreadScheduler::Current()
+      ->ToMainThreadScheduler()
+      ->StartIdlePeriodForTesting();
+  task_environment().FastForwardUntilNoTasksRemain();
+  BeginEmptyFrame();
+
+  ASSERT_FALSE(
+      GetDocument().GetPage()->RelatedPagesMutationFromPreviousPageFinalized());
+
+  Element& p = *GetDocument().getElementById(AtomicString("text"));
+
+  EXPECT_EQ(nullptr, GetDocument().CssTarget());
+  EXPECT_FALSE(ViewportRect().Contains(BoundingRectInFrame(p)));
+
+  GetDocument().GetPage()->NotifyRelatedPagesFinalized(
+      /* has_other_related_pages= */ false);
+  GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
+
+  RunAsyncMatchingTasks();
+  BeginEmptyFrame();
+
+  EXPECT_EQ(p, *GetDocument().CssTarget());
+  EXPECT_TRUE(ViewportRect().Contains(BoundingRectInFrame(p)));
+}
+
+TEST_F(TextFragmentAnchorTest, AvoidScrollingIfHasOtherRelatedPages) {
+  SetAutoFinalize(false);
+  SimRequest::Params params;
+  params.requestor_origin =
+      WebSecurityOrigin::CreateFromString(WebString("https://example.org"));
+  SimRequest request("https://example.com/test.html#:~:text=test", "text/html",
+                     params);
+  LoadURL("https://example.com/test.html#:~:text=test");
+
+  ASSERT_FALSE(
+      GetDocument().GetPage()->RelatedPagesMutationFromPreviousPageFinalized());
+
+  request.Complete(R"HTML(
+    <!DOCTYPE html>
+    <style>
+      body {
+        height: 1200px;
+      }
+      p {
+        position: absolute;
+        top: 1000px;
+      }
+    </style>
+    <p id="text">This is a test page</p>
+  )HTML");
+
+  ThreadScheduler::Current()
+      ->ToMainThreadScheduler()
+      ->StartIdlePeriodForTesting();
+  task_environment().FastForwardUntilNoTasksRemain();
+  BeginEmptyFrame();
+
+  ASSERT_FALSE(
+      GetDocument().GetPage()->RelatedPagesMutationFromPreviousPageFinalized());
+
+  Element& p = *GetDocument().getElementById(AtomicString("text"));
+
+  EXPECT_EQ(nullptr, GetDocument().CssTarget());
+  EXPECT_FALSE(ViewportRect().Contains(BoundingRectInFrame(p)));
+
+  GetDocument().GetPage()->NotifyRelatedPagesFinalized(
+      /* has_other_related_pages= */ true);
+  GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
+
+  test::RunPendingTasks();
+
+  EXPECT_EQ(nullptr, GetDocument().CssTarget());
+  EXPECT_FALSE(ViewportRect().Contains(BoundingRectInFrame(p)));
+}
+
 // Basic test case for silent scroll directives, ensure we scroll the matching
 // text into view but do NOT apply :target or markers.
 TEST_F(TextFragmentAnchorTest, BasicSilentScrollTest) {
@@ -388,7 +501,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   EXPECT_EQ(nullptr, GetDocument().CssTarget());
   EXPECT_FALSE(GetDocument().View()->GetFragmentAnchor());
@@ -423,7 +536,7 @@
   // Force a layout
   GetDocument().body()->setAttribute(html_names::kStyleAttr,
                                      AtomicString("height: 1300px"));
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   EXPECT_EQ(nullptr, GetDocument().CssTarget());
   EXPECT_TRUE(GetDocument().Markers().Markers().empty());
@@ -453,7 +566,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& first = *GetDocument().getElementById(AtomicString("first"));
 
@@ -488,7 +601,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& match = *GetDocument().getElementById(AtomicString("match"));
 
@@ -524,7 +637,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& first = *GetDocument().getElementById(AtomicString("first"));
 
@@ -561,7 +674,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& second = *GetDocument().getElementById(AtomicString("second"));
 
@@ -594,7 +707,7 @@
   )HTML");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& p = *GetDocument().getElementById(AtomicString("text"));
 
@@ -637,7 +750,7 @@
   // Force a layout
   GetDocument().body()->setAttribute(html_names::kStyleAttr,
                                      AtomicString("height: 1300px"));
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   EXPECT_EQ(nullptr, GetDocument().CssTarget());
   EXPECT_TRUE(GetDocument().Markers().Markers().empty());
@@ -1147,6 +1260,9 @@
   )HTML");
 
   GetDocument().View()->UpdateAllLifecyclePhasesForTest();
+  GetDocument().GetPage()->NotifyRelatedPagesFinalized(false);
+  GetDocument().GetFrame()->Loader().ProcessPendingCrossDocumentFragment();
+
   mojom::blink::ScrollType scroll_type = GetParam();
   cc::ScrollSourceType source_type =
       (scroll_type == mojom::blink::ScrollType::kAnchoring ||
@@ -1161,7 +1277,7 @@
   img_request.Complete("");
   RunAsyncMatchingTasks();
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   Element& p = *GetDocument().getElementById(AtomicString("text"));
 
@@ -1227,7 +1343,7 @@
   LayoutViewport()->SetScrollOffset(ScrollOffset(0, -10), scroll_type,
                                     source_type);
 
-  Compositor().BeginFrame();
+  BeginEmptyFrame();
 
   EXPECT_EQ(2u, GetDocument().Markers().Markers().size());
 }
@@ -1256,7 +1372,7 @@
     </p>
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

STTF allows to leaking text char by char from cross-origin page.

Steps to reproduce the problem

Steps to reproduce:

  1. Download attached files
  2. Change TARGET, PREFIX, FOUND, NOT_FOUND if you want to test on another text/website.
  3. Host this files on localhost/site
  4. open exploit_chrome.html
  5. Start typing text

You will see that symbols will gradually start appearing on the page. See example in PoC.mov.

Problem Description

We have discovered that using STTF (Scroll To Text Fragment) makes it possible to detect and exfiltrate text from another page. This is possible due to several factors:

Bypass of Empty BCG check

Firstly, by default, Chromium does not allow the use of STTF if there are other windows in the current Browser Context Group and our page is cross-origin. This check can be found in CheckSecurityResrictions:

    if (frame.GetPage()->RelatedPages().size()) {
      TRACE_EVENT_INSTANT("blink", "CheckSecurityRestrictions", "Result",
                          "Non-Empty Browsing Context Group");
      return false;
    }

This check can be bypassed as follows:

window.location.href=`https://cross-origin-site/#:~:text=...`
window.open('/same-origin-with-attacker')

Based on my tests, this happens because the check of frame.GetPage()->RelatedPages().size() occurs during the redirect, and only after that does the new window begin to open.

Time difference

We discovered that when Chromium opens a link with an STTF, redirects may fail to execute if the text search is under heavy load. This happens because:

  1. First, the text is searched using void TextAnnotationSelector::FindRange, which performs a linear search for text.

  2. When the text is found (even partially), bool IsWordBounded and other checks are performed.

  3. Only after these steps does the highlight and scroll to text occur.

It can be observed that the number of instructions executed will differ, but for a simple search, the difference is negligible.

Therefore, we used another STTF feature to increase the number of instructions and thus slow down the search time — :~:text=...&text=...&text=...&text=....... Since STTF allows us to insert an unlimited number of text fragments, we can add tens of thousands of identical text fragments in a single URL. This slows execution down, as the operation will be performed tens of thousands of times instead of just once.

Detect time difference by history length

There is no traditional way to detect STTF time. However, we have discovered a way to do this:

let w = window.opener
await sleep(TIME);
w.location = TARGET + "#1";
...
w.location = TARGET + "#5";
w.location = 'about:blank'
            
while(true){
    try{
        w.origin;
        ...
        if (w.history.length - previous_history_length==2){
            ... Triggered match
        }
    }catch(e){
        await sleep(100);
    }
}

We found that while STTF is being processed, the redirect process is blocked, and if we choose the correct timing, only the last redirect to about:blank will execute immediately. At this point, we can detect how many redirects actually occurred using history.length.

If sleep(TIME) ends before the STTF finishes, we get 2. Otherwise, we get a higher value. So, to guess the correct value, we need to choose a timing where we consistently get 2 only for the correct prefix.

Timing estimate

Since each user will have different loading times (due to varying internet connections, OS, etc.), I had to implement dynamic timing detection directly in the exploit on the client side.

How it works: Suppose we have a page that contains the text:

Your code: 21346375

So, you need to highlight the text fragment with FOUND='Your cod' and NOT_FOUND='Your coA' (that is, a valid and an invalid prefix before the target text you want to find; it’s also important that neither triggers the boundary check).

After that, I use a binary search to find the appropriate timing.

I simply run the test with the current timing on both the FOUND and NOT_FOUND strings:

  1. If the function succeeds for both strings, I decrease the upper bound (we are waiting too long)
  2. If the function finds only one result and it is FOUND, I increase the lower bound
  3. I repeat this with (MIN_TIME + MAX_TIME) / 2 When the difference between the upper and lower bounds is minimal (in my tests, this difference was 100 milliseconds), I stop the search — we have found the timing.

Tested

This exploit was tested by us on 3 different devices in different internet networks. 2 on MacOS, 1 on Windows 11 – on each device we were able to successfully exfiltrate the code.

Additional Comments

Other issues and limitations of the exploit

The limitations imposed on STTF can be found in bool TextFragmentAnchor::GenerateNewToken:

  return loader.LastNavigationHadTransientUserActivation() ||
         loader.IsBrowserInitiated();

Therefore, I used onkeydown on the textarea so that while the user is typing, a window appears in the lower right corner.

To hide the popup window and allow the user to continue typing text, I open a new tab in that window and immediately close it, which returns focus to exploit window:

            setTimeout(()=>{var a = window.open("about:blank");a.close();},100);
            setTimeout(()=>{var a = window.open("about:blank");a.close();},700);

Also, for this exploit to work, the window must be allowed to open other windows. This can also be bypassed, but I didn’t do this to avoid making the PoC even more complicated (since it is already fairly complex). So for testing, make sure your origin is allowed to use window.open.

Summary

STTF allows to leaking text char by char from cross-origin page.

Custom Questions

Reporter credit:

Vsevolod Kokorin (Slonser) of Solidlab and Jorian Woltjer

Additional Data

Category: Security
Chrome Channel: Stable
Regression: N/A \

View on issue tracker