CVE-2026-7995
Overview
Background
- `AdTracker`
- a per-local-frame-root Blink component that tracks which scripts are ad-related and reconstructs ancestry chains of ad scripts for attribution.
- `ad_script_data_`
- a map keyed by script id holding each known ad script’s data (including
value.id);find()returns an iterator that equalsend()when the id is absent. - `GetAncestry` / ancestry chain
- the routine (reached via the lambda in the hunk) that walks a script’s parent chain, pushing each ancestor’s
value.idontoancestry.ancestry_chain. - Cross-tracker references
- a DOM element carrying a script id can be moved to a different frame served by a different
AdTracker, so a stored parent script id may name a script the current tracker never registered.
Root Cause Analysis
Inside the ancestry-walking lambda (enclosing function not named in the @@ header; the regression test exercises AdTracker::GetAncestry), the code did auto it = this->ad_script_data_.find(script_id); and then immediately executed ancestry.ancestry_chain.push_back(it->value.id); without checking whether it was valid. When a script id originates from a different AdTracker — e.g. an element referencing a parent script id that only the other tracker registered — find returns ad_script_data_.end(), and dereferencing that end iterator (it->value) is an out-of-bounds/invalid read of the hash map’s sentinel, as the advisory’s Out of bounds read in AdFilter describes. The invariant that find succeeded before dereference was simply not enforced.
The fix adds if (it == this->ad_script_data_.end()) { return true; } before the dereference, so an unknown (foreign) script id terminates the walk cleanly instead of reading past a valid entry; the regression test confirms ad_tracker_b returns a 2-element chain (c, b) and stops at the unknown script_id_a rather than dereferencing it.
ad_script_data_.find(script_id) without checking it against end(), causing an out-of-bounds read when a script id came from a different tracker; the fix adds the end() guard and returns early.Attack Path
- Register ad scripts in two frames
Two frames (with distinct
AdTrackers) each register ad scripts; one tracker learns a script id the other does not (as the test’sscript_id_ais known only toad_tracker_a). - Create a cross-tracker parent reference An element/script in the second frame references, as its ancestor, a script id that belongs only to the first tracker (moving a node between frames, per the commit message).
- Trigger ancestry resolution
Code calls
GetAncestryon the second tracker, whose lambda walks the chain and callsad_script_data_.find(script_id)for the foreign id, gettingend(). - Out-of-bounds read
Pre-patch,
it->value.iddereferences theend()iterator, reading out of bounds of the map’s valid storage. - Consequence
The patch demonstrates an OOB read only; no write primitive or escalation is shown, so anything beyond the invalid read is
(inferred).
Impact Assessment
AdTracker can induce an out-of-bounds read in the renderer process, matching the advisory’s Out of bounds read in AdFilter at medium severity. Preconditions are multiple frames with ad tracking and a cross-frame script-id reference (e.g. an element moved between frames). The advisory does not report in-the-wild exploitation, and any information-disclosure or further impact beyond the OOB read is (inferred).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
Audit Directions
- Unchecked `find()` dereferencegrep for
.find(...)results immediately dereferenced (it->,*it) without an== end()check, especially in map/set lookups keyed by ids crossing component or frame boundaries. - Cross-object/cross-frame identifiersaudit code paths where ids or handles minted by one object (tracker, frame, isolate) are looked up in another’s tables, which can legitimately miss and must handle absence.
- Ancestry/graph walksreview recursive or iterative parent-chain walks (
ancestry_chain-style) for safe termination when a referenced node is unknown, rather than assuming every referenced id resolves.
Patch
From 131c0478d52a6952e11f53980338f6343d2e221f Mon Sep 17 00:00:00 2001 From: Josh Karlin <[email protected]> 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 <[email protected]> Commit-Queue: Josh Karlin <[email protected]> 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.