Medium chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in Scroll
DescriptionObservable discrepancy in Scroll
ComponentScroll
Sub-componentSttf
Bug ClassInfo Disclosure
Tracker556304559
Fix commit44a404371e21 (chromium/src) +167/-0
CISA KEVNot listed
CreditedHafiizh
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/core/fragment_directive/fragment_directive.cc
modified
TEST_F
third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
modified
for
third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
modified

Files Changed

  • third_party/blink/renderer/platform/runtime_enabled_features.json5
  • third_party/blink/renderer/core/fragment_directive/fragment_directive.cc
  • third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
  • tools/metrics/histograms/metadata/blink/histograms.xml
From 44a404371e215c37f0c8b2c696672d4e372df0ea Mon Sep 17 00:00:00 2001
From: Vladimir Levin <vmpstr@chromium.org>
Date: Thu, 10 Sep 2026 15:08:12 -0700
Subject: [PATCH] sttf: Limit the number of text fragments to 16.

This limits the number to 16, adds an UMA and a killswitch.

R=bokan@chromium.org

Bug: 556304559
Change-Id: I7c6a35a756a47c3f00d473a1b7c50c2bdf096a2d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8382301
Commit-Queue: Vladimir Levin <vmpstr@chromium.org>
Reviewed-by: David Bokan <bokan@chromium.org>
Reviewed-by: Ian Clelland <iclelland@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1695701}
---
diff --git a/third_party/blink/renderer/core/fragment_directive/fragment_directive.cc b/third_party/blink/renderer/core/fragment_directive/fragment_directive.cc
index 71a2b00..58796b5a 100644
--- a/third_party/blink/renderer/core/fragment_directive/fragment_directive.cc
+++ b/third_party/blink/renderer/core/fragment_directive/fragment_directive.cc
@@ -4,6 +4,7 @@
 
 #include "third_party/blink/renderer/core/fragment_directive/fragment_directive.h"
 
+#include "base/metrics/histogram_functions.h"
 #include "components/shared_highlighting/core/common/fragment_directives_constants.h"
 #include "third_party/blink/renderer/bindings/core/v8/script_promise_resolver.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_union_range_selection.h"
@@ -23,6 +24,16 @@
 
 namespace blink {
 
+namespace {
+
+// The maximum number of directives parsed from a fragment directive string.
+// Each text directive requires a search over the document's text when
+// invoked, so the number of directives a single navigation can request must
+// be bounded.
+constexpr wtf_size_t kMaxDirectivesCount = 16;
+
+}  // namespace
+
 FragmentDirective::FragmentDirective(Document& owner_document)
     : owner_document_(&owner_document) {}
 FragmentDirective::~FragmentDirective() = default;
@@ -171,6 +182,11 @@
   HeapVector<Member<Directive>> new_directives;
   HashSet<String> text_directives;
   for (const auto& directive_string : directive_strings) {
+    if (RuntimeEnabledFeatures::ScrollToTextFragmentDirectiveLimitEnabled() &&
+        new_directives.size() >= kMaxDirectivesCount) {
+      break;
+    }
+
     if (directive_string.starts_with("text=")) {
       String value = directive_string.substr(5).ToString();
       if (value.empty() ||
@@ -186,6 +202,9 @@
     }
   }
 
+  base::UmaHistogramExactLinear("Blink.FragmentDirective.DirectiveCount",
+                                new_directives.size(), kMaxDirectivesCount + 1);
+
   directives_ = std::move(new_directives);
 }
 
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 17ce20a..99d3f16c 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -5479,6 +5479,11 @@
       name: "ScrollTopLeftInterop",
       status: "stable",
     },
+    // Limits the number of scroll-to-text-fragment directives parsed per URL.
+    {
+      name: "ScrollToTextFragmentDirectiveLimit",
+      status: "stable",
+    },
     // Only consider unique case-insensitive scroll-to-text fragments.
     {
       name: "ScrollToTextFragmentUniqueFragments",
diff --git a/tools/metrics/histograms/metadata/blink/histograms.xml b/tools/metrics/histograms/metadata/blink/histograms.xml
index 19202a0..22a8c1a5 100644
--- a/tools/metrics/histograms/metadata/blink/histograms.xml
+++ b/tools/metrics/histograms/metadata/blink/histograms.xml
@@ -2954,6 +2954,16 @@
   </summary>
 </histogram>
 
+<histogram name="Blink.FragmentDirective.DirectiveCount" units="directives"
+    expires_after="2027-03-10">
+  <owner>vmpstr@chromium.org</owner>
+  <owner>blink-interactions-team@google.com</owner>
+  <summary>
+    The number of valid directives parsed from a URL fragment directive.
+    Recorded once per navigation with a fragment directive.
+  </summary>
+</histogram>
+
 <histogram name="Blink.Frame.GetCanonicalUrlRendererTime" units="microseconds"
     expires_after="2023-09-03">
   <owner>carlosk@chromium.org</owner>
diff --git a/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc b/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
index aa08b68f..f8a1309f 100644
--- a/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
+++ b/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
@@ -4,11 +4,14 @@
 
 #include "third_party/blink/renderer/core/fragment_directive/fragment_directive.h"
 
+#include "base/test/metrics/histogram_tester.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/renderer/core/fragment_directive/text_directive.h"
 #include "third_party/blink/renderer/core/testing/dummy_page_holder.h"
+#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/weborigin/kurl.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
 
 namespace blink {
 
@@ -99,4 +102,134 @@
   EXPECT_EQ("Foo", text_directives[0]->textStart());
 }
 
+TEST_F(FragmentDirectiveTest, ParseTextDirectivesUpToCountLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // 16 is the maximum number of directives parsed from a fragment directive.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=t0");
+  for (int i = 1; i < 16; ++i) {
+    url_string.Append("&text=t");
+    url_string.AppendNumber(i);
+  }
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(16u, text_directives.size());
+
+  EXPECT_EQ("t0", text_directives.front()->textStart());
+  EXPECT_EQ("t15", text_directives.back()->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      16, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseTextDirectivesBeyondCountLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // Only the first 16 of these 20 distinct text directives may be parsed.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=t0");
+  for (int i = 1; i < 20; ++i) {
+    url_string.Append("&text=t");
+    url_string.AppendNumber(i);
+  }
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(16u, text_directives.size());
+
+  EXPECT_EQ("t0", text_directives.front()->textStart());
+  EXPECT_EQ("t15", text_directives.back()->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      16, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseDuplicatesDoNotCountTowardLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // 16 copies of the same text directive collapse into a single directive, so
+  // the unique directives that follow must still be parsed.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=dup");
+  for (int i = 1; i < 16; ++i) {
+    url_string.Append("&text=dup");
+  }
+  url_string.Append("&text=foo&text=bar");
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(3u, text_directives.size());
+
+  EXPECT_EQ("dup", text_directives[0]->textStart());
+  EXPECT_EQ("foo", text_directives[1]->textStart());
+  EXPECT_EQ("bar", text_directives[2]->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      3, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseInvalidDirectivesDoNotCountTowardLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // 16 distinct but invalid text directives (too many terms) are not parsed
+  // into directives, so the valid directive that follows must still be
+  // parsed.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=x0,b,c,d,e");
+  for (int i = 1; i < 16; ++i) {
+    url_string.Append("&text=x");
+    url_string.AppendNumber(i);
+    url_string.Append(",b,c,d,e");
+  }
+  url_string.Append("&text=foo");
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(1u, text_directives.size());
+
+  EXPECT_EQ("foo", text_directives[0]->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      1, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseTextDirectivesCountLimitDisabled) {
+  ScopedScrollToTextFragmentDirectiveLimitForTest scoped_feature(false);
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=t0");
+  for (int i = 1; i < 20; ++i) {
+    url_string.Append("&text=t");
+    url_string.AppendNumber(i);
+  }
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(20u, text_directives.size());
+
+  EXPECT_EQ("t0", text_directives.front()->textStart());
+  EXPECT_EQ("t19", text_directives.back()->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      17, 1);
+}
+
 }  // namespace blink
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc b/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
index aa08b68f..f8a1309f 100644
--- a/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
+++ b/third_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc
@@ -4,11 +4,14 @@
 
 #include "third_party/blink/renderer/core/fragment_directive/fragment_directive.h"
 
+#include "base/test/metrics/histogram_tester.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/renderer/core/fragment_directive/text_directive.h"
 #include "third_party/blink/renderer/core/testing/dummy_page_holder.h"
+#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/weborigin/kurl.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
 
 namespace blink {
 
@@ -99,4 +102,134 @@
   EXPECT_EQ("Foo", text_directives[0]->textStart());
 }
 
+TEST_F(FragmentDirectiveTest, ParseTextDirectivesUpToCountLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // 16 is the maximum number of directives parsed from a fragment directive.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=t0");
+  for (int i = 1; i < 16; ++i) {
+    url_string.Append("&text=t");
+    url_string.AppendNumber(i);
+  }
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(16u, text_directives.size());
+
+  EXPECT_EQ("t0", text_directives.front()->textStart());
+  EXPECT_EQ("t15", text_directives.back()->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      16, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseTextDirectivesBeyondCountLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // Only the first 16 of these 20 distinct text directives may be parsed.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=t0");
+  for (int i = 1; i < 20; ++i) {
+    url_string.Append("&text=t");
+    url_string.AppendNumber(i);
+  }
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(16u, text_directives.size());
+
+  EXPECT_EQ("t0", text_directives.front()->textStart());
+  EXPECT_EQ("t15", text_directives.back()->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      16, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseDuplicatesDoNotCountTowardLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // 16 copies of the same text directive collapse into a single directive, so
+  // the unique directives that follow must still be parsed.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=dup");
+  for (int i = 1; i < 16; ++i) {
+    url_string.Append("&text=dup");
+  }
+  url_string.Append("&text=foo&text=bar");
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(3u, text_directives.size());
+
+  EXPECT_EQ("dup", text_directives[0]->textStart());
+  EXPECT_EQ("foo", text_directives[1]->textStart());
+  EXPECT_EQ("bar", text_directives[2]->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      3, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseInvalidDirectivesDoNotCountTowardLimit) {
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  // 16 distinct but invalid text directives (too many terms) are not parsed
+  // into directives, so the valid directive that follows must still be
+  // parsed.
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=x0,b,c,d,e");
+  for (int i = 1; i < 16; ++i) {
+    url_string.Append("&text=x");
+    url_string.AppendNumber(i);
+    url_string.Append(",b,c,d,e");
+  }
+  url_string.Append("&text=foo");
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(1u, text_directives.size());
+
+  EXPECT_EQ("foo", text_directives[0]->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      1, 1);
+}
+
+TEST_F(FragmentDirectiveTest, ParseTextDirectivesCountLimitDisabled) {
+  ScopedScrollToTextFragmentDirectiveLimitForTest scoped_feature(false);
+  base::HistogramTester histogram_tester;
+  FragmentDirective* fragment_directive =
+      MakeGarbageCollected<FragmentDirective>(GetDocument());
+
+  StringBuilder url_string;
+  url_string.Append("http://example.com/#:~:text=t0");
+  for (int i = 1; i < 20; ++i) {
+    url_string.Append("&text=t");
+    url_string.AppendNumber(i);
+  }
+  KURL url(url_string.ToString());
+  fragment_directive->ConsumeFragmentDirective(url);
+
+  HeapVector<Member<TextDirective>> text_directives =
+      fragment_directive->GetDirectives<TextDirective>();
+  ASSERT_EQ(20u, text_directives.size());
+
+  EXPECT_EQ("t0", text_directives.front()->textStart());
+  EXPECT_EQ("t19", text_directives.back()->textStart());
+  histogram_tester.ExpectUniqueSample("Blink.FragmentDirective.DirectiveCount",
+                                      17, 1);
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by sa...@gmail.com

Zero-Permission Extension Infers Cross-Origin Page Text via STTF Search-Timing Side Channel (siblings of https://issues.chromium.org/issues/457771782)

Chrome VRP Security Report — Zero-Permission Extension Infers Cross-Origin Page Text via STTF Search-Timing Side Channel

Title

Zero-permission extension can infer cross-origin page text through Scroll-to-Text-Fragment search timing exposed by webNavigation.onCommitted


Summary

A malicious Chrome extension holding only the tabs and webNavigation permissions, with no host_permissions and no content scripts, can determine whether an arbitrary string appears in a cross-origin page that the extension cannot otherwise read.

The attack abuses the interaction between:

  1. Scroll-to-Text-Fragment (STTF) processing;
  2. the absence of an effective limit on the number of text=start,end directives;
  3. synchronous text-fragment matching on the renderer main thread; and
  4. the extension-visible timing of chrome.webNavigation.onCommitted.

A matching STTF directive set causes thousands of text searches to execute synchronously during initial attachment. This keeps the victim renderer’s main thread busy and measurably delays a subsequent navigation commit.

The extension cannot access the victim document’s DOM, URL contents, or text directly. Instead, it observes the elapsed time between issuing a navigation and receiving its own webNavigation.onCommitted event.

The signal was reproduced 4/4 times, while a negative control targeting text that does not exist reproduced 0/4 times.

This appears to be a variant of issue 457771782 / CVE-2026-13790. The original fix addresses the page-level history.length timing channel, but does not address the extension webNavigation timing observation channel.


Security boundary

The important security boundary is the Chrome extension permission model.

The malicious extension has:

{
  "permissions": [
    "tabs",
    "webNavigation"
  ]
}

It does not request:

host_permissions
content_scripts
<all_urls>
activeTab

Therefore the extension has no ordinary mechanism to inspect the victim page’s DOM or textual contents.

The vulnerability provides an indirect cross-origin content oracle through navigation timing.


Affected build

Field Value
Browser Chromium 153.0.7993.0
Build ASAN
is_asan true
Extra PoC flags None
Bug class Cross-origin information disclosure / UXSS-class oracle
Seed issue 457771782
Seed CVE CVE-2026-13790
Variant Extension webNavigation timing channel
Verification 4/4 positive
Negative control 0/4
Verification date 2026-09-03

The issue does not fundamentally depend on ASAN. ASAN makes the timing difference particularly easy to observe. The same mechanism is expected to exist on a normal release build, although more samples may be necessary because of scheduling and system noise.


Threat model

The attacker is an extension author.

The victim has installed the malicious extension.

The extension has only:

tabs
webNavigation

The target page can be cross-origin from the extension’s attacker-controlled origin.

The attacker does not require:

  • renderer compromise;
  • host_permissions;
  • content scripts;
  • DOM access;
  • JavaScript execution in the victim origin;
  • user interaction with the victim page;
  • same-origin access;
  • an iframe;
  • window.opener;
  • history.length.

The extension only needs to observe navigation timing through its own webNavigation.onCommitted event.


Reproduction topology

The standalone reproduction uses two independent HTTP origins:

Victim server
127.0.0.1:8001
        |
        | serves victim.html
        v
https://victim.example/   [conceptual]
        |
        | STTF navigation
        v
Chromium renderer

Attacker server
127.0.0.1:8002
        |
        | serves land.html
        v
Attacker-controlled origin

Extension
        |
        +-- opens victim URL containing thousands of STTF directives
        |
        +-- initiates probe navigation
        |
        +-- observes webNavigation.onCommitted timing
        |
        +-- classifies MATCH / NO MATCH

For the actual local reproduction, the two servers can simply use different loopback ports.


PART 1 — VICTIM SIDE

1. Create the victim web root

Create:

victim-site/
└── victim.html

The victim page should contain the secret marker:

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Victim</title>
</head>
<body>

<h1>Victim document</h1>

<p>
  This page contains the sensitive marker:
  ZZZSECRET
</p>

</body>
</html>

The important property is that the victim page contains:

ZZZSECRET

The extension must not have permission to read this page.


2. Start the victim Python server

From the directory containing victim-site:

python3 -m http.server 8001 \
  --bind 127.0.0.1 \
  --directory victim-site

The victim page is now available at:

http://127.0.0.1:8001/victim.html

Keep this terminal running.


PART 2 — ATTACKER SIDE

3. Create the attacker web root

Create:

attacker-site/
└── land.html

Example:

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Attacker Landing Page</title>
</head>
<body>
  <h1>Attacker landing page</h1>
</body>
</html>

This page contains no copy of the victim secret.


4. Start the attacker Python server

From the directory containing attacker-site:

python3 -m http.server 8002 \
  --bind 127.0.0.1 \
  --directory attacker-site

The attacker page is now available at:

http://127.0.0.1:8002/land.html

Keep this terminal running.


PART 3 — ATTACKER EXTENSION

5. Create the unpacked extension

Create:

extension/
├── manifest.json
└── background.js

The manifest must contain only the relevant permissions:

{
  "manifest_version": 3,
  "name": "STTF Timing Oracle PoC",
  "version": "1.0",
  "permissions": [
    "tabs",
    "webNavigation"
  ],
  "background": {
    "service_worker": "background.js"
  }
}

Notably, there are no:

host_permissions
content_scripts
activeTab

6. Extension attack sequence

The extension performs the following sequence:

1. Open victim.html with a large STTF fragment.
2. The STTF fragment contains thousands of distinct
   text=start,end directives.
3. The directives all search for the same target string.
4. If the target exists, many synchronous searches execute.
5. The victim renderer's main thread remains busy.
6. The extension waits for the search workload to occur.
7. The extension initiates a navigation to attacker-site/land.html.
8. The extension records tNav.
9. webNavigation.onCommitted fires for land.html.
10. Extension calculates:

       dt = onCommitted_time - tNav

11. A sufficiently large dt indicates that the victim renderer
    was busy performing STTF matching.

For the positive test, the directives target:

ZZZSECRET

For the negative control, use a marker that does not exist:

ZZZABSENT

PART 4 — RUNNING THE ATTACKER EXTENSION

7. Load the extension

Launch the test Chromium build:

/home/hafiizh/Downloads/chrome-hunter/Hermes-Hunt-Chrome/Asan/chrome \
  --user-data-dir=/tmp/sttf-ext-test \
  --no-sandbox \
  --disable-gpu \
  --disable-dev-shm-usage \
  --enable-logging=stderr \
  --v=1 \
  --disable-breakpad \
  --disable-crash-reporter

No special STTF or experimental flag is required.

Open:

chrome://extensions

Enable Developer mode.

Choose:

Load unpacked

and select:

extension/

PART 5 — POSITIVE CONTROL / VICTIM PRESENT

8. Launch the positive attack

The extension opens:

http://127.0.0.1:8001/victim.html#:~:text=ZZZSECRET,e00000&text=ZZZSECRET,e00001&...&text=ZZZSECRET,e02999

The exact number of directives used in the PoC is approximately:

3000

Each directive is syntactically distinct because the range suffix differs.

This is important because fragment-directive deduplication does not collapse the directives into a single search.

The victim document contains:

ZZZSECRET

Therefore the matching arm causes the STTF search workload to execute.


9. Probe navigation

After the STTF workload has been initiated, the extension performs the probe navigation:

http://127.0.0.1:8002/land.html

The extension records:

tNav = Date.now()

and waits for:

chrome.webNavigation.onCommitted

It then calculates:

dt = Date.now() - tNav

The observed positive arm produces a substantially larger commit delay.

The PoC uses a threshold around:

260 ms

as the classification boundary on the tested ASAN build.


10. Expected positive result

The extension should emit a line equivalent to:

EXTLAND dt=<large value>
leak: cross-origin STTF match detected by extension, commit dt=<ms>
DONE

The important security result is not the exact threshold.

The important observation is:

Victim contains ZZZSECRET
        ↓
STTF matching workload occurs
        ↓
renderer main thread remains busy
        ↓
probe navigation commit is delayed
        ↓
webNavigation.onCommitted timing changes
        ↓
extension infers victim text

PART 6 — NEGATIVE CONTROL / VICTIM ABSENT

11. Replace the target marker

Run the same experiment, but change all STTF directives from:

ZZZSECRET

to:

ZZZABSENT

The victim page still contains only:

ZZZSECRET

and therefore does not contain:

ZZZABSENT

The attacker’s extension is otherwise unchanged.


12. Repeat the probe

Perform the same navigation sequence:

victim.html
    ↓
STTF directives targeting ZZZABSENT
    ↓
probe navigation
    ↓
land.html

The webNavigation.onCommitted event should arrive substantially faster than in the matching arm.

The extension must not emit the leak marker.

Expected classification:

not_bug

Reproduction result

The test was independently repeated:

Test Result
Matching text (ZZZSECRET) 4/4
Non-matching text (ZZZABSENT) 0/4
Positive reproduction rate 100%
Negative-control false positives 0%

The signal is therefore causally correlated with whether the victim page contains the searched text, rather than merely being a consequence of the navigation sequence.


Expected behavior

An extension without host permissions should not be able to determine textual information contained in a cross-origin document.

In particular, the timing of:

chrome.webNavigation.onCommitted

should not provide an oracle that reveals whether a previous cross-origin document contained attacker-selected text.

The extension should observe navigation metadata, but that metadata should not become an indirect cross-origin content oracle.


Actual behavior

A malicious extension with only:

tabs
webNavigation

can distinguish:

victim contains ZZZSECRET

from:

victim does not contain ZZZSECRET

by measuring the time between initiating a navigation and receiving its webNavigation.onCommitted event.

The extension does not read the victim DOM.

Instead:

cross-origin content
        ↓
STTF matching cost
        ↓
renderer main-thread occupancy
        ↓
navigation commit latency
        ↓
webNavigation.onCommitted timing
        ↓
extension-readable oracle

This crosses the intended extension permission boundary.


Root cause

There are two interacting issues.

1. No effective limit on STTF text directives

TextFragmentAnchor::TryCreate() accepts the fragment directives generated by the fragment-directive parser.

The relevant implementation is:

third_party/blink/renderer/core/fragment_directive/
text_fragment_anchor.cc

approximately:

TextFragmentAnchor::TryCreate()

The constructor subsequently creates an annotation agent for each directive.

The range syntax allows the attacker to create many distinct directives targeting the same string:

text=ZZZSECRET,e00000
text=ZZZSECRET,e00001
text=ZZZSECRET,e00002
...
text=ZZZSECRET,e02999

Because the raw directives differ, they bypass simple duplicate elimination.

This amplifies the amount of synchronous text-search work performed by a single navigation.


2. Initial attachment performs synchronous work on the renderer main thread

The relevant path includes:

AnnotationAgentContainerImpl::PerformInitialAttachments()

in:

third_party/blink/renderer/core/annotation/
annotation_agent_container_impl.cc

The method iterates through the annotation agents and calls their attachment logic.

Conceptually:

for each annotation agent:
    agent->Attach()

This work occurs while the renderer’s main thread is processing the document.

When thousands of matching directives are supplied, the renderer can remain occupied with STTF matching for a measurable period.

A queued navigation cannot complete independently of that renderer workload.


Why the existing CVE-2026-13790 fix does not close this variant

Issue:

457771782

was addressed by changing when STTF activation occurs and by introducing security restrictions associated with related pages / browsing-context-group state.

The original issue primarily exposed a page-level timing channel involving:

history.length

The fix therefore prevents the relevant STTF search from running in the attacker-controlled page-level scenario.

This extension variant has a different observation primitive.

The attacker does not need:

window.opener
history.length

or access to the victim JavaScript context.

Instead, the attacker uses a browser-initiated navigation generated by the extension:

chrome.windows.create(...)

and observes the resulting navigation through:

chrome.webNavigation.onCommitted

The important distinction is:

Original issue:
STTF search
    ↓
history.length
    ↓
page-level oracle


This variant:
STTF search
    ↓
renderer main-thread delay
    ↓
navigation commit delay
    ↓
webNavigation.onCommitted
    ↓
extension oracle

Therefore, fixing the original page-level observation channel does not inherently remove this extension-level timing channel.


Relevant browser-side observation path

The extension API dispatch occurs from the browser-side WebNavigation implementation, including:

chrome/browser/extensions/api/web_navigation/
web_navigation_api.cc

The extension therefore receives an event corresponding to the navigation commit.

The extension does not need direct renderer access.

The renderer’s processing delay is indirectly reflected in the event timing visible to the extension.


Security impact

This provides an extension-level cross-origin textual oracle without host access.

A malicious extension could repeatedly test attacker-selected strings and use statistical sampling to infer whether particular strings occur in pages that it cannot read.

Potential targets include:

  • account identifiers;
  • document-specific markers;
  • one-time values;
  • CSRF-related values;
  • textual account information;
  • page-specific secrets;
  • other attacker-searchable content.

The practical attack would likely use repeated measurements and statistical classification rather than a single timing sample.

For example, conceptually:

candidate prefix A
    ↓
multiple STTF probes
    ↓
timing distribution
    ↓
MATCH / NO MATCH

candidate prefix B
    ↓
multiple STTF probes
    ↓
timing distribution
    ↓
MATCH / NO MATCH

A binary-search or prefix-search strategy could potentially reduce the number of probes required for a target that is sufficiently predictable.


Required permissions

The PoC requires only:

"permissions": [
  "tabs",
  "webNavigation"
]

No host permission is required.

This is an important aspect of the report because the vulnerability is not simply an extension with permission to read the target website.

The extension is deliberately designed without:

"host_permissions": [
  "<all_urls>"
]

and without content scripts.


What the attacker cannot do

This report does not demonstrate:

  • arbitrary JavaScript execution in the victim origin;
  • direct cross-origin DOM access;
  • renderer compromise;
  • sandbox escape;
  • direct reading of the victim page;
  • arbitrary write access to victim memory.

The demonstrated primitive is an information-disclosure oracle based on observable timing.


Suggested fix

1. Enforce a strict STTF directive-count limit

Land the pending directive-count mitigation associated with the existing STTF work, including the relevant CLs:

7871640
7871641

or an equivalent fix.

The limit should apply before the renderer creates an unbounded number of annotation agents.

This removes the large amplification factor that currently makes the timing signal reliable.


2. Remove synchronous STTF matching from the navigation-critical path

Initial text-fragment attachment should not monopolize the renderer main thread in a way that can directly delay an externally observable navigation.

Possible approaches include:

  • incremental attachment;
  • yielding between searches;
  • moving expensive matching work away from the navigation-critical path;
  • otherwise ensuring that navigation commits cannot be observably delayed based on whether attacker-selected text matches.

3. Consider defense-in-depth for extension navigation timing

Chrome could consider whether webNavigation.onCommitted needs additional timing isolation when a navigation follows expensive renderer-side processing.

The objective should be to prevent renderer workload from becoming a cross-origin content oracle through extension-observable navigation timing.


Minimal reproduction

Terminal 1 — victim

mkdir -p victim-site

cat > victim-site/victim.html <<'EOF'
<!doctype html>
<html>
<body>
<h1>Victim</h1>
<p>ZZZSECRET</p>
</body>
</html>
EOF

python3 -m http.server 8001 \
  --bind 127.0.0.1 \
  --directory victim-site

Terminal 2 — attacker

mkdir -p attacker-site

cat > attacker-site/land.html <<'EOF'
<!doctype html>
<html>
<body>
<h1>Attacker landing page</h1>
</body>
</html>
EOF

python3 -m http.server 8002 \
  --bind 127.0.0.1 \
  --directory attacker-site

Terminal 3 — Chromium

/home/hafiizh/Downloads/chrome-hunter/Hermes-Hunt-Chrome/Asan/chrome \
  --user-data-dir=/tmp/sttf-ext-test \
  --no-sandbox \
  --disable-gpu \
  --disable-dev-shm-usage \
  --enable-logging=stderr \
  --v=1 \
  --disable-breakpad \
  --disable-crash-reporter

Then load the unpacked MV3 extension containing:

tabs
webNavigation

only.


Existing automated verification

The complete PoC was also verified through the browser oracle:

python3 skills/browser-oracle/scripts/verify.py \
  findings/uxss/sttf-ext-webnav-leak/poc.json

Oracle result:

{
  "id": "sttf-ext-webnav-leak",
  "verdict": "bug",
  "confidence": 0.95,
  "notes": "Cross-origin data leaked [chrome hit run timeout; output salvaged]",
  "category": "uxss"
}

The timeout is the harness’s normal termination behavior for this non-crashing PoC. The leak marker was emitted before the timeout and was recovered from the Chrome console output.


Reproduction evidence

The following artifacts are available with the report:

land.html
origin-b/victim.html
poc.html
poc.json
oracle.json
confirm.json

SHA-256 prefixes:

File SHA-256 prefix
land.html 2bb768dd1d16427b
origin-b/victim.html 476c1b43da183722
poc.html e74a1016907621c4

poc.json contains the exact oracle input, including the unpacked extension.

oracle.json contains the oracle verdict.

confirm.json contains the repeated positive and negative-control results.


Suggested VRP classification

Security impact: Cross-origin information disclosure

Primary mechanism: Timing side channel

Security boundary crossed: Extension permission boundary → cross-origin page content

Required extension permissions:

tabs
webNavigation

Host permissions: None

User interaction: None after extension installation

Renderer compromise: Not required

Direct DOM access: Not demonstrated / not required

Reliability: 4/4 positive, 0/4 negative control

Related issue: 457771782 / CVE-2026-13790

Variant: Extension webNavigation.onCommitted timing oracle


View on issue tracker