Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in AdFilter
DescriptionOut of bounds read in AdFilter
ComponentAdFilter
Bug ClassOOB
Tracker501745798
Fix commit131c0478d52a (chromium/src) +60/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
TEST
third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/ad_tracker/ad_tracker.cc
  • third_party/blink/renderer/core/ad_tracker/ad_tracker_test.cc
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • third_party/blink/renderer/bindings/core/v8/js_event_handler_for_content_attribute.cc
  • third_party/blink/renderer/core/dom/node.cc
  • third_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.

  1. Setup: The attacker controls Page A and executes a script that is tracked by AdTracker A (e.g., matching a Subresource Filter rule).
  2. Capture Provenance: In Page A, the ad script creates a div and sets an onclick handler. The parser synchronously calls JSEventHandlerForContentAttribute::Create, which checks IsAdScriptInStack on Tracker A. Finding an ad script, it stores its V8ScriptId in the parent_ad_script_ member.
  3. Cross-Root Move: The attacker opens Page B (a new LocalRoot with an independent Tracker B) and uses document.adoptNode() to move the div into Page B. The event listeners are preserved, carrying the stale parent_ad_script_ ID from Page A.
  4. Heap Grooming: The attacker uses JavaScript in Page B to groom the PartitionAlloc heap. For a HashMap capacity of 1024 bytes, PartitionAlloc’s kDenser distribution 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.
  5. Trigger Execution: The attacker triggers the onclick handler 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 stale parent_ad_script_ ID.
  6. IFrame Creation: The onclick handler’s payload creates an iframe. The new LocalFrame constructor checks !IsMainFrame() && ad_tracker_ and calls ad_tracker_->IsAdScriptInStack to check if an ad script created the frame.
  7. The Bug: Tracker B identifies the running onclick script and calls GetAncestry. It reads the provenance ID (from Page A) and attempts to find() it in Tracker B’s map. It fails, returning end(). The uninitialized PartitionAlloc padding is read as an AdScriptIdentifier.
  8. Arbitrary Increment: The push_back operation copies the OOB memory into the ancestry chain. Because AdScriptIdentifier contains a WTF::String (which contains a scoped_refptr), copying it invokes StringImpl::AddRef(). This performs a fetch_add(1) on the forged pointer residing in the groomed padding.
  9. Exploitation: The incremented reference count is permanently stored in LocalFrame::ad_script_ancestry_. By targeting the size field of a JavaScript ArrayBuffer, 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.

View on issue tracker