Medium watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in AdFilter
DescriptionOut of bounds read in AdFilter
ComponentAdFilter
Sub-componentAdTracker
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker501745798
Fix commit131c0478d52a (chromium/src) +60/-0
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-11 (per vendor advisory)
Fix landed2026-04-14 (public git)
Disclosed2026-05-05
Patch gap21 days — fix public in source before the advisory

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 equals end() 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.id onto ancestry.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.

Key insight
The core mistake was dereferencing the iterator from 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

  1. 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’s script_id_a is known only to ad_tracker_a).
  2. 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).
  3. Trigger ancestry resolution Code calls GetAncestry on the second tracker, whose lambda walks the chain and calls ad_script_data_.find(script_id) for the foreign id, getting end().
  4. Out-of-bounds read Pre-patch, it->value.id dereferences the end() iterator, reading out of bounds of the map’s valid storage.
  5. 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

A page that causes an ad-script ancestry lookup to reference a script id registered in a different frame’s 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

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

Audit Directions

  • Unchecked `find()` dereference
    grep 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 identifiers
    audit 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 walks
    review 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.
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
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 vm...@google.com

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