High chrome UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in Editing
DescriptionInteger overflow in Editing
ComponentEditing
Sub-componentHighlights
Bug ClassUAF
Tracker576984708
Fix commitc85e9bbdf214 (chromium/src) +109/-3
CISA KEVNot listed
CreditedXinyang Ge
Disclosed2026-10-29

Background

CSS Custom Highlight API / `HighlightRegistry`
Blink’s registry (third_party/blink/renderer/core/highlight/highlight_registry.cc) that maps highlight names to Highlight objects and answers highlightsFromPoint(x, y, ...), returning which highlights and ranges cover a hit-tested point.
`Highlight` and its range set
A Highlight owns a set of AbstractRange entries, stored as Member<AbstractRange> in an Oilpan-managed container (highlight->GetRanges()). addForBinding() adds a range to this set, which can reallocate the set’s backing store.
Oilpan `Member<>` / backing-store reallocation
Blink’s GC heap stores collection elements in a contiguous backing store. Growing the collection allocates a new backing store and frees the old one. A reference into the old backing store (a Member<>&) becomes dangling after reallocation, even though the pointed-to GC object itself stays live.
`ResolveEphemeralRange` / geometry computation
Converts an AbstractRange to an EphemeralRange and computes its bounds. For script-defined range subclasses this can call back into user-visible accessors (e.g. endOffset()), re-entering script that may mutate the highlight’s range set mid-iteration.

Root Cause Analysis

In HighlightRegistry::highlightsFromPoint() (around line 584 of highlight_registry.cc), the loop iterated the highlight’s range set with for (auto& abstract_range : highlight->GetRanges()). This binds abstract_range as a reference to the Member<AbstractRange> slot inside the set’s backing store. The body then dereferences that slot twice while computing geometry: DynamicTo<NodeRange>(abstract_range.Get()) and ResolveEphemeralRange(abstract_range.Get(), document). Geometry resolution can re-enter script (the regression test models this with HighlightGrowingRange::endOffset(), which calls highlight->addForBinding(...) the first time its end offset is read). Adding ranges grows the set and reallocates its backing store, freeing the memory the auto& reference points into. The subsequent abstract_range.Get() then reads a Member<> out of the freed backing store — a use-after-free read of GC heap memory.

The patch changes the loop variable from a reference-into-the-set (auto& abstract_range) to a copied raw pointer (AbstractRange* abstract_range), and drops the now-unneeded .Get() calls. Because the AbstractRange* value is copied out of the set before any geometry/script runs, reallocation of the backing store no longer invalidates it: the object itself remains live under Oilpan, and the loop no longer touches the freed slot. The commit message confirms the set’s iterators stay valid across additions, so live iteration is preserved; only the per-entry reference needed to be made stable.

Key insight
The loop held a reference into a GC collection’s backing store across a call that could grow that collection, so reallocating the backing store left the reference dangling. The advisory labels this an “Integer overflow in Editing,” but the diff substantiates no arithmetic overflow whatsoever — it is a dangling-reference use-after-free on an Oilpan backing store.

Attack Path

  1. Setup Attacker’s page registers a CSS Custom Highlight whose range set contains a scripted range subclass (or arranges a range whose geometry resolution triggers a mutation observer / script callback). (inferred: exact web-exposed trigger for script re-entry during geometry computation is modeled by the test, not fully shown by the diff.)
  2. Invoke hit-test The page calls highlightsFromPoint(x, y) over a point covered by the highlight, entering the vulnerable loop.
  3. Re-enter and grow While ResolveEphemeralRange computes a range’s geometry, attacker script runs and adds many ranges to the same highlight via addForBinding, forcing the range set’s backing store to reallocate and be freed.
  4. Dangling read Control returns to the loop, which reads the stale Member<AbstractRange> reference out of the freed backing store.
  5. Primitive Use-after-free read of freed Oilpan heap memory; with heap grooming the freed slot can be reclaimed by attacker-controlled data, yielding a controlled dereference / potential further corruption (escalation to write/RCE is inferred, not shown by the diff).

Impact Assessment

This is a renderer-process memory-safety bug reachable from ordinary untrusted web content with no special privilege, via the CSS Custom Highlight API’s highlightsFromPoint. The demonstrated primitive is a UAF read of a reallocated GC backing store during a script-re-entrant geometry computation; the accompanying regression test deterministically reproduces the reallocation (adding 64 ranges moves the backing store). Strength beyond an info-leak/crash — i.e. a full exploit chain to RCE — is (inferred) and not established by the diff. Gating is minimal: the attacker needs only to register a highlight and trigger script re-entry during geometry resolution.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/highlight/highlight_registry.cc
modified
HighlightGrowingRange
third_party/blink/renderer/core/highlight/highlight_registry_test.cc
modified
if
third_party/blink/renderer/core/highlight/highlight_registry_test.cc
modified
for
third_party/blink/renderer/core/highlight/highlight_registry_test.cc
modified
TEST_F
third_party/blink/renderer/core/highlight/highlight_registry_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/highlight/highlight_registry_test.cc
  • third_party/blink/renderer/core/highlight/highlight_registry.cc

Audit Directions

  • References into Oilpan containers across re-entrant calls
    Audit other for (auto& x : heapCollection) loops in Blink that dereference the bound reference after calling into layout/geometry/script; the same reallocation hazard applies to any HeapVector/HeapHashSet/set backing store that can grow mid-loop.
  • Highlight and Range mutation during read paths
    Review other HighlightRegistry / Highlight methods (and selection/marker code) that iterate ranges while resolving EphemeralRanges, since ResolveEphemeralRange and offset accessors can re-enter script on scripted range subclasses.
  • Scripted `AbstractRange` subclasses
    Look for other sinks that trust AbstractRange accessors (startOffset/endOffset/startContainer) not to mutate collections being iterated.
From c85e9bbdf214926f3f4671a38610d24c377b76f5 Mon Sep 17 00:00:00 2001
From: Fernando Fiori <ffiori@microsoft.com>
Date: Fri, 23 Oct 2026 16:12:54 -0700
Subject: [PATCH] [highlights] Keep hit-test range pointers stable

highlightsFromPoint() retains a reference to a Highlight range set entry
while computing its bounds. Geometry computation can run script that
adds ranges, reallocating the set's backing storage and leaving the
reference dangling.

Copy the AbstractRange pointer before computing geometry, rather than
referencing the Member in the set. The set's iterators remain valid when
adding ranges, so this preserves live iteration while keeping the
current range safe to use.

Bug: 576984708
Change-Id: Idfe994d6b6eb7985aab356e47df32f0b19ce61e2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8640466
Reviewed-by: Stefan Zager <szager@chromium.org>
Commit-Queue: Fernando Fiori <ffiori@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1726331}
---
diff --git a/third_party/blink/renderer/core/highlight/highlight_registry.cc b/third_party/blink/renderer/core/highlight/highlight_registry.cc
index 8d3cfb8c..9437bbf8 100644
--- a/third_party/blink/renderer/core/highlight/highlight_registry.cc
+++ b/third_party/blink/renderer/core/highlight/highlight_registry.cc
@@ -584,7 +584,7 @@
     CHECK(highlights_iterator != highlights_.end());
     Highlight* highlight = highlights_iterator->Get()->highlight;
     HeapVector<Member<AbstractRange>> highlight_ranges_hit;
-    for (auto& abstract_range : highlight->GetRanges()) {
+    for (AbstractRange* abstract_range : highlight->GetRanges()) {
       // If the range starts and ends in a different tree scope than the hit
       // node (i.e., the range encloses a shadow tree), do not return it when
       // the hit is on a node inside that shadow tree. Only consider ranges
@@ -594,14 +594,14 @@
         continue;
       }
 
-      auto* node_range = DynamicTo<NodeRange>(abstract_range.Get());
+      auto* node_range = DynamicTo<NodeRange>(abstract_range);
       if (node_range && node_range->startContainer()->GetTreeScope() !=
                             hit_node->GetTreeScope()) {
         continue;
       }
 
       std::optional<EphemeralRange> ephemeral_range =
-          ResolveEphemeralRange(abstract_range.Get(), document);
+          ResolveEphemeralRange(abstract_range, document);
       if (!ephemeral_range) {
         continue;
       }
diff --git a/third_party/blink/renderer/core/highlight/highlight_registry_test.cc b/third_party/blink/renderer/core/highlight/highlight_registry_test.cc
index 142f85d..dc2d30fee 100644
--- a/third_party/blink/renderer/core/highlight/highlight_registry_test.cc
+++ b/third_party/blink/renderer/core/highlight/highlight_registry_test.cc
@@ -12,6 +12,7 @@
 #include "third_party/blink/renderer/bindings/core/v8/v8_highlights_from_point_options.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_static_range_init.h"
 #include "third_party/blink/renderer/core/dom/document.h"
+#include "third_party/blink/renderer/core/dom/node_range.h"
 #include "third_party/blink/renderer/core/dom/range.h"
 #include "third_party/blink/renderer/core/dom/static_range.h"
 #include "third_party/blink/renderer/core/dom/text.h"
@@ -1103,6 +1104,111 @@
   EXPECT_EQ(highlight_hit_results_at_point.size(), 0u);
 }
 
+namespace {
+
+// A range that, once armed, adds ranges to a Highlight the first time its end
+// offset is read. highlightsFromPoint() reads the end offset while resolving a
+// range's geometry, so this grows the range set at a point where the registry
+// is in the middle of iterating it, as would happen if script ran there.
+class HighlightGrowingRange final : public NodeRange {
+ public:
+  HighlightGrowingRange(Text* text, unsigned start_offset, unsigned end_offset)
+      : text_(text), start_offset_(start_offset), end_offset_(end_offset) {}
+
+  void GrowHighlightOnNextEndOffset(Highlight* highlight, unsigned count) {
+    highlight_to_grow_ = highlight;
+    count_to_add_ = count;
+  }
+  bool did_grow() const { return did_grow_; }
+
+  Node* startContainer() const override { return text_.Get(); }
+  unsigned startOffset() const override { return start_offset_; }
+  Node* endContainer() const override { return text_.Get(); }
+  unsigned endOffset() const override {
+    MaybeGrowHighlight();
+    return end_offset_;
+  }
+  bool collapsed() const override { return start_offset_ == end_offset_; }
+  bool IsStaticRange() const override { return false; }
+  Document& OwnerDocument() const override { return text_->GetDocument(); }
+
+  void Trace(Visitor* visitor) const override {
+    visitor->Trace(text_);
+    visitor->Trace(highlight_to_grow_);
+    ScriptWrappable::Trace(visitor);
+  }
+
+ private:
+  void MaybeGrowHighlight() const {
+    if (!highlight_to_grow_ || did_grow_) {
+      return;
+    }
+    did_grow_ = true;
+    for (unsigned i = 0; i < count_to_add_; ++i) {
+      highlight_to_grow_->addForBinding(
+          nullptr,
+          MakeGarbageCollected<Range>(text_->GetDocument(), text_.Get(), 0,
+                                      text_.Get(), 0),
+          ASSERT_NO_EXCEPTION);
+    }
+  }
+
+  Member<Text> text_;
+  unsigned start_offset_;
+  unsigned end_offset_;
+  Member<Highlight> highlight_to_grow_;
+  unsigned count_to_add_ = 0;
+  mutable bool did_grow_ = false;
+};
+
+}  // namespace
+
+// Regression test for crbug.com/576984708. Growing a Highlight's range set
+// reallocates its backing store, so highlightsFromPoint() must not hold on to
+// a reference to one of its entries while it resolves that range's geometry.
+TEST_F(HighlightsFromPointTest, RangeSetGrowsWhileResolvingRangeGeometry) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("1234");
+  auto* text = To<Text>(GetDocument().body()->firstChild());
+
+  auto* growing_range = MakeGarbageCollected<HighlightGrowingRange>(text, 0, 4);
+  auto* trailing_range =
+      MakeGarbageCollected<Range>(GetDocument(), text, 1, text, 3);
+  HeapVector<Member<AbstractRange>> range_vector;
+  range_vector.push_back(growing_range);
+  range_vector.push_back(trailing_range);
+  auto* highlight = Highlight::Create(range_vector);
+  HighlightRegistry* registry =
+      HighlightRegistry::From(*GetDocument().domWindow());
+  registry->SetForTesting(AtomicString("TestHighlight"), highlight);
+  UpdateAllLifecyclePhasesForTest();
+
+  // The center of |trailing_range|, which is inside both ranges.
+  gfx::Rect rect =
+      ComputeTextRect(EphemeralRange(Position(text, 1), Position(text, 3)));
+  const float x = rect.CenterPoint().x() / GetDocument().DevicePixelRatio();
+  const float y = rect.CenterPoint().y() / GetDocument().DevicePixelRatio();
+
+  const Member<AbstractRange>* const first_entry =
+      &*highlight->GetRanges().begin();
+  constexpr unsigned kRangesToAdd = 64;
+  growing_range->GrowHighlightOnNextEndOffset(highlight, kRangesToAdd);
+
+  HeapVector<Member<HighlightHitResult>> hit_results =
+      registry->highlightsFromPoint(x, y, nullptr);
+
+  ASSERT_TRUE(growing_range->did_grow());
+  EXPECT_EQ(highlight->size(), 2u + kRangesToAdd);
+  // The test is only meaningful if growing the set moved its backing store,
+  // which is what leaves references to its previous entries dangling.
+  EXPECT_NE(first_entry, &*highlight->GetRanges().begin());
+
+  ASSERT_EQ(hit_results.size(), 1u);
+  EXPECT_EQ(hit_results[0]->highlight(), highlight);
+  ASSERT_EQ(hit_results[0]->ranges().size(), 2u);
+  EXPECT_EQ(hit_results[0]->ranges()[0].Get(), growing_range);
+  EXPECT_EQ(hit_results[0]->ranges()[1].Get(), trailing_range);
+}
+
 TEST_F(HighlightRegistryTest, LiveIterationBasic) {
   GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("1234");
   auto* dom_window = GetDocument().domWindow();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/highlight/highlight_registry_test.cc b/third_party/blink/renderer/core/highlight/highlight_registry_test.cc
index 142f85d..dc2d30fee 100644
--- a/third_party/blink/renderer/core/highlight/highlight_registry_test.cc
+++ b/third_party/blink/renderer/core/highlight/highlight_registry_test.cc
@@ -12,6 +12,7 @@
 #include "third_party/blink/renderer/bindings/core/v8/v8_highlights_from_point_options.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_static_range_init.h"
 #include "third_party/blink/renderer/core/dom/document.h"
+#include "third_party/blink/renderer/core/dom/node_range.h"
 #include "third_party/blink/renderer/core/dom/range.h"
 #include "third_party/blink/renderer/core/dom/static_range.h"
 #include "third_party/blink/renderer/core/dom/text.h"
@@ -1103,6 +1104,111 @@
   EXPECT_EQ(highlight_hit_results_at_point.size(), 0u);
 }
 
+namespace {
+
+// A range that, once armed, adds ranges to a Highlight the first time its end
+// offset is read. highlightsFromPoint() reads the end offset while resolving a
+// range's geometry, so this grows the range set at a point where the registry
+// is in the middle of iterating it, as would happen if script ran there.
+class HighlightGrowingRange final : public NodeRange {
+ public:
+  HighlightGrowingRange(Text* text, unsigned start_offset, unsigned end_offset)
+      : text_(text), start_offset_(start_offset), end_offset_(end_offset) {}
+
+  void GrowHighlightOnNextEndOffset(Highlight* highlight, unsigned count) {
+    highlight_to_grow_ = highlight;
+    count_to_add_ = count;
+  }
+  bool did_grow() const { return did_grow_; }
+
+  Node* startContainer() const override { return text_.Get(); }
+  unsigned startOffset() const override { return start_offset_; }
+  Node* endContainer() const override { return text_.Get(); }
+  unsigned endOffset() const override {
+    MaybeGrowHighlight();
+    return end_offset_;
+  }
+  bool collapsed() const override { return start_offset_ == end_offset_; }
+  bool IsStaticRange() const override { return false; }
+  Document& OwnerDocument() const override { return text_->GetDocument(); }
+
+  void Trace(Visitor* visitor) const override {
+    visitor->Trace(text_);
+    visitor->Trace(highlight_to_grow_);
+    ScriptWrappable::Trace(visitor);
+  }
+
+ private:
+  void MaybeGrowHighlight() const {
+    if (!highlight_to_grow_ || did_grow_) {
+      return;
+    }
+    did_grow_ = true;
+    for (unsigned i = 0; i < count_to_add_; ++i) {
+      highlight_to_grow_->addForBinding(
+          nullptr,
+          MakeGarbageCollected<Range>(text_->GetDocument(), text_.Get(), 0,
+                                      text_.Get(), 0),
+          ASSERT_NO_EXCEPTION);
+    }
+  }
+
+  Member<Text> text_;
+  unsigned start_offset_;
+  unsigned end_offset_;
+  Member<Highlight> highlight_to_grow_;
+  unsigned count_to_add_ = 0;
+  mutable bool did_grow_ = false;
+};
+
+}  // namespace
+
+// Regression test for crbug.com/576984708. Growing a Highlight's range set
+// reallocates its backing store, so highlightsFromPoint() must not hold on to
+// a reference to one of its entries while it resolves that range's geometry.
+TEST_F(HighlightsFromPointTest, RangeSetGrowsWhileResolvingRangeGeometry) {
+  GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("1234");
+  auto* text = To<Text>(GetDocument().body()->firstChild());
+
+  auto* growing_range = MakeGarbageCollected<HighlightGrowingRange>(text, 0, 4);
+  auto* trailing_range =
+      MakeGarbageCollected<Range>(GetDocument(), text, 1, text, 3);
+  HeapVector<Member<AbstractRange>> range_vector;
+  range_vector.push_back(growing_range);
+  range_vector.push_back(trailing_range);
+  auto* highlight = Highlight::Create(range_vector);
+  HighlightRegistry* registry =
+      HighlightRegistry::From(*GetDocument().domWindow());
+  registry->SetForTesting(AtomicString("TestHighlight"), highlight);
+  UpdateAllLifecyclePhasesForTest();
+
+  // The center of |trailing_range|, which is inside both ranges.
+  gfx::Rect rect =
+      ComputeTextRect(EphemeralRange(Position(text, 1), Position(text, 3)));
+  const float x = rect.CenterPoint().x() / GetDocument().DevicePixelRatio();
+  const float y = rect.CenterPoint().y() / GetDocument().DevicePixelRatio();
+
+  const Member<AbstractRange>* const first_entry =
+      &*highlight->GetRanges().begin();
+  constexpr unsigned kRangesToAdd = 64;
+  growing_range->GrowHighlightOnNextEndOffset(highlight, kRangesToAdd);
+
+  HeapVector<Member<HighlightHitResult>> hit_results =
+      registry->highlightsFromPoint(x, y, nullptr);
+
+  ASSERT_TRUE(growing_range->did_grow());
+  EXPECT_EQ(highlight->size(), 2u + kRangesToAdd);
+  // The test is only meaningful if growing the set moved its backing store,
+  // which is what leaves references to its previous entries dangling.
+  EXPECT_NE(first_entry, &*highlight->GetRanges().begin());
+
+  ASSERT_EQ(hit_results.size(), 1u);
+  EXPECT_EQ(hit_results[0]->highlight(), highlight);
+  ASSERT_EQ(hit_results[0]->ranges().size(), 2u);
+  EXPECT_EQ(hit_results[0]->ranges()[0].Get(), growing_range);
+  EXPECT_EQ(hit_results[0]->ranges()[1].Get(), trailing_range);
+}
+
 TEST_F(HighlightRegistryTest, LiveIterationBasic) {
   GetDocument().body()->SetInnerHTMLWithoutTrustedTypes("1234");
   auto* dom_window = GetDocument().domWindow();
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.