CVE-2026-7995
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTthird_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/ad_tracker/ad_tracker.ccthird_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
Patch
From 131c0478d52a6952e11f53980338f6343d2e221f Mon Sep 17 00:00:00 2001
From: Josh Karlin <jkarlin@chromium.org>
Date: Mon, 13 Apr 2026 20:18:12 -0700
Subject: [PATCH] [AdTracker] Handle script ids from foreign ad trackers
It's possible for an element which holds a script id to be moved to
a different frame with a different ad tracker. Handle lookups of such
references correctly.
Bug: 501745798
Change-Id: I505830be45c1f84599babf145223678305103ee4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7757302
Reviewed-by: Yao Xiao <yaoxia@chromium.org>
Commit-Queue: Josh Karlin <jkarlin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1614184}
---
diff --git a/third_party/blink/renderer/core/ad_tracker/ad_tracker.cc b/third_party/blink/renderer/core/ad_tracker/ad_tracker.cc
index c175094d..9c66bf4 100644
--- a/third_party/blink/renderer/core/ad_tracker/ad_tracker.cc
+++ b/third_party/blink/renderer/core/ad_tracker/ad_tracker.cc
@@ -748,6 +748,12 @@
}
auto it = this->ad_script_data_.find(script_id);
+ if (it == this->ad_script_data_.end()) {
+ // This can happen if an element is moved from one
+ // AdTracker to another, and it references a script
+ // id that this tracker doesn't know about.
+ return true;
+ }
ancestry.ancestry_chain.push_back(it->value.id);
// Move on to the next ancestor.
diff --git a/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc b/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
index 4de0df97..f8836ed 100644
--- a/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
+++ b/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
@@ -4287,4 +4287,58 @@
EXPECT_TRUE(ad_tracker_->RequestWithUrlTaggedAsAd(image_url));
}
+// Test that when a script ID from one AdTracker is used in
+// another AdTracker (e.g. if a node is moved between frames), the tracker
+// correctly handles the case where it doesn't recognize the script ID.
+TEST(AdTrackerTest, AdScriptAncestry_ScriptIdFromDifferentTracker) {
+ test::TaskEnvironment task_environment;
+ auto page_holder_a = std::make_unique<DummyPageHolder>();
+ auto page_holder_b = std::make_unique<DummyPageHolder>();
+
+ AdTracker* ad_tracker_a = MakeGarbageCollected<AdTracker>(
+ &page_holder_a->GetFrame().LocalFrameRoot());
+ AdTracker* ad_tracker_b = MakeGarbageCollected<AdTracker>(
+ &page_holder_b->GetFrame().LocalFrameRoot());
+
+ V8ScriptId script_id_a(1001);
+ v8::Isolate* isolate = v8::Isolate::GetCurrent();
+ v8::HandleScope scope(isolate);
+
+ // Register `script_id_a` in `ad_tracker_a`, which is the only tracker to
+ // learn about this script id.
+ ad_tracker_a->RegisterAdScript(
+ page_holder_a->GetFrame().DomWindow()->GetIsolate()->GetCurrentContext(),
+ script_id_a, std::nullopt);
+
+ // Get the `script_a` identifier.
+ AdScriptIdentifier id_a(v8_inspector::V8DebuggerId(), script_id_a,
+ "script_a");
+
+ // In `ad_tracker_b`, register `script_id_b` with `id_a` as parent.
+ // `ad_tracker_b` doesn't actually know about `id_a` though.
+ V8ScriptId script_id_b(2001);
+ ad_tracker_b->RegisterAdScript(
+ page_holder_b->GetFrame().DomWindow()->GetIsolate()->GetCurrentContext(),
+ script_id_b, id_a);
+
+ // Register `script_id_c` with `script_id_b` as parent in `ad_tracker_b`.
+ V8ScriptId script_id_c(3001);
+ AdScriptIdentifier id_b(v8_inspector::V8DebuggerId(), script_id_b,
+ "script_b");
+ ad_tracker_b->RegisterAdScript(
+ page_holder_b->GetFrame().DomWindow()->GetIsolate()->GetCurrentContext(),
+ script_id_c, id_b);
+
+ // `ad_tracker_b` knows `script_id_c` and `script_id_b`, but not
+ // `script_id_a`. `GetAncestry(script_id_c)` should return a chain of length 2
+ // (c and b).
+ AdTracker::AdScriptAncestry ancestry = ad_tracker_b->GetAncestry(script_id_c);
+ EXPECT_EQ(ancestry.ancestry_chain.size(), 2u);
+ EXPECT_EQ(ancestry.ancestry_chain[0].id, script_id_c);
+ EXPECT_EQ(ancestry.ancestry_chain[1].id, script_id_b);
+
+ ad_tracker_a->Shutdown();
+ ad_tracker_b->Shutdown();
+}
+
} // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc b/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
index 4de0df97..f8836ed 100644
--- a/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
+++ b/third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
@@ -4287,4 +4287,58 @@
EXPECT_TRUE(ad_tracker_->RequestWithUrlTaggedAsAd(image_url));
}
+// Test that when a script ID from one AdTracker is used in
+// another AdTracker (e.g. if a node is moved between frames), the tracker
+// correctly handles the case where it doesn't recognize the script ID.
+TEST(AdTrackerTest, AdScriptAncestry_ScriptIdFromDifferentTracker) {
+ test::TaskEnvironment task_environment;
+ auto page_holder_a = std::make_unique<DummyPageHolder>();
+ auto page_holder_b = std::make_unique<DummyPageHolder>();
+
+ AdTracker* ad_tracker_a = MakeGarbageCollected<AdTracker>(
+ &page_holder_a->GetFrame().LocalFrameRoot());
+ AdTracker* ad_tracker_b = MakeGarbageCollected<AdTracker>(
+ &page_holder_b->GetFrame().LocalFrameRoot());
+
+ V8ScriptId script_id_a(1001);
+ v8::Isolate* isolate = v8::Isolate::GetCurrent();
+ v8::HandleScope scope(isolate);
+
+ // Register `script_id_a` in `ad_tracker_a`, which is the only tracker to
+ // learn about this script id.
+ ad_tracker_a->RegisterAdScript(
+ page_holder_a->GetFrame().DomWindow()->GetIsolate()->GetCurrentContext(),
+ script_id_a, std::nullopt);
+
+ // Get the `script_a` identifier.
+ AdScriptIdentifier id_a(v8_inspector::V8DebuggerId(), script_id_a,
+ "script_a");
+
+ // In `ad_tracker_b`, register `script_id_b` with `id_a` as parent.
+ // `ad_tracker_b` doesn't actually know about `id_a` though.
+ V8ScriptId script_id_b(2001);
+ ad_tracker_b->RegisterAdScript(
+ page_holder_b->GetFrame().DomWindow()->GetIsolate()->GetCurrentContext(),
+ script_id_b, id_a);
+
+ // Register `script_id_c` with `script_id_b` as parent in `ad_tracker_b`.
+ V8ScriptId script_id_c(3001);
+ AdScriptIdentifier id_b(v8_inspector::V8DebuggerId(), script_id_b,
+ "script_b");
+ ad_tracker_b->RegisterAdScript(
+ page_holder_b->GetFrame().DomWindow()->GetIsolate()->GetCurrentContext(),
+ script_id_c, id_b);
+
+ // `ad_tracker_b` knows `script_id_c` and `script_id_b`, but not
+ // `script_id_a`. `GetAncestry(script_id_c)` should return a chain of length 2
+ // (c and b).
+ AdTracker::AdScriptAncestry ancestry = ad_tracker_b->GetAncestry(script_id_c);
+ EXPECT_EQ(ancestry.ancestry_chain.size(), 2u);
+ EXPECT_EQ(ancestry.ancestry_chain[0].id, script_id_c);
+ EXPECT_EQ(ancestry.ancestry_chain[1].id, script_id_b);
+
+ ad_tracker_a->Shutdown();
+ ad_tracker_b->Shutdown();
+}
+
} // namespace blink
Original Bug Report
Renderer RCE via OOB Read in AdTracker::GetAncestry missing end() iterator check
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: A missing check for the end() iterator in AdTracker::GetAncestry can lead to an out-of-bounds read when a script ID from a different local root is encountered. This can be triggered by moving an ad-tagged event handler across local roots, allowing an attacker to trigger an arbitrary memory increment primitive. This is a high severity issue that can likely be used to achieve Remote Code Execution in the renderer process.
Affected files:
third_party/blink/renderer/core/ad_tracker/ad_tracker.ccthird_party/blink/renderer/bindings/core/v8/js_event_handler_for_content_attribute.ccthird_party/blink/renderer/core/dom/node.ccthird_party/blink/renderer/core/frame/local_frame.cc
Estimated timestamp from git blame: 2025-08-28
Summary
The AdTracker::GetAncestry function in Blink’s AdTracker implementation contains a missing check for the end() iterator after a HashMap::find call. This allows an out-of-bounds read from the HashMap’s backing buffer. Because the AdScriptIdentifier structure contains a WTF::String, copy-constructing it from the OOB memory triggers StringImpl::AddRef() on a pointer read from uninitialized PartitionAlloc padding. This provides a highly reliable 32-bit atomic increment primitive at an arbitrary address in the Renderer process, which can be leveraged for Remote Code Execution.
Vulnerability Details
In third_party/blink/renderer/core/ad_tracker/ad_tracker.cc, the GetAncestry function iterates through a script’s provenance chain. At line 750, it performs a lookup and immediately pushes the result without checking if the item was found:
auto it = this->ad_script_data_.find(script_id);
ancestry.ancestry_chain.push_back(it->value.id); // L751: it is not checked against end()
If the script ID is not found, find() returns end(). In WTF::HashMap, the end() iterator internally points to the memory immediately following the backing buffer. Dereferencing it results in an Out-of-Bounds (OOB) read.
An attacker can reliably trigger this state because AdTracker instances are per-LocalRoot, but JSEventHandlerForContentAttribute captures the ad provenance of the context where it was created and retains it, even if the element is moved to a different LocalRoot via adoptNode.
Potential Exploit Flow
Note: These are suggested steps to trigger the vulnerability, as a working exploit has not been verified yet.
- Setup: The attacker controls Page A and executes a script that is tracked by
AdTrackerA (e.g., matching a Subresource Filter rule). - Capture Provenance: In Page A, the ad script creates a
divand sets anonclickhandler. The parser synchronously callsJSEventHandlerForContentAttribute::Create, which checksIsAdScriptInStackon Tracker A. Finding an ad script, it stores itsV8ScriptIdin theparent_ad_script_member. - Cross-Root Move: The attacker opens Page B (a new
LocalRootwith an independent Tracker B) and usesdocument.adoptNode()to move thedivinto Page B. The event listeners are preserved, carrying the staleparent_ad_script_ID from Page A. - Heap Grooming: The attacker uses JavaScript in Page B to groom the PartitionAlloc heap. For a
HashMapcapacity of 1024 bytes, PartitionAlloc’skDenserdistribution allocates an 1152-byte bucket, leaving 128 bytes of uninitialized padding. The attacker fills freed 1152-byte buckets with forged pointers at the precise offset where the OOB read will occur. - Trigger Execution: The attacker triggers the
onclickhandler in Page B. It lazy-compiles the handler, sees it is ad-related, and registers it with Tracker B. Crucially, it registers it using the staleparent_ad_script_ID. - IFrame Creation: The
onclickhandler’s payload creates an iframe. The newLocalFrameconstructor checks!IsMainFrame() && ad_tracker_and callsad_tracker_->IsAdScriptInStackto check if an ad script created the frame. - The Bug: Tracker B identifies the running
onclickscript and callsGetAncestry. It reads the provenance ID (from Page A) and attempts tofind()it in Tracker B’s map. It fails, returningend(). The uninitialized PartitionAlloc padding is read as anAdScriptIdentifier. - Arbitrary Increment: The
push_backoperation copies the OOB memory into the ancestry chain. BecauseAdScriptIdentifiercontains aWTF::String(which contains ascoped_refptr), copying it invokesStringImpl::AddRef(). This performs afetch_add(1)on the forged pointer residing in the groomed padding. - Exploitation: The incremented reference count is permanently stored in
LocalFrame::ad_script_ancestry_. By targeting the size field of a JavaScriptArrayBuffer, the attacker can permanently increase its bounds, achieving arbitrary memory read/write and ultimately RCE in the Renderer.
Suggested Fix
Add a check for end() before dereferencing the iterator in AdTracker::GetAncestry:
auto it = this->ad_script_data_.find(script_id);
if (it != this->ad_script_data_.end()) {
ancestry.ancestry_chain.push_back(it->value.id);
} else {
// Handle missing provenance safely, perhaps by breaking the loop or logging.
break;
}
Additionally, consider auditing if JSEventHandlerForContentAttribute should reset its ad provenance when moved to a new execution context via adoptNode.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.