CVE-2026-106210
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/fragment_directive/fragment_directive.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc |
modified | |
forthird_party/blink/renderer/core/fragment_directive/fragment_directive_test.cc |
modified |
Files Changed
third_party/blink/renderer/platform/runtime_enabled_features.json5third_party/blink/renderer/core/fragment_directive/fragment_directive.ccthird_party/blink/renderer/core/fragment_directive/fragment_directive_test.cctools/metrics/histograms/metadata/blink/histograms.xml
Patch
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
Regression Test / PoC
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
Original Bug Report
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:
- Scroll-to-Text-Fragment (STTF) processing;
- the absence of an effective limit on the number of
text=start,enddirectives; - synchronous text-fragment matching on the renderer main thread; and
- 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