Medium CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DOM
DescriptionUse after free in DOM
ComponentDOM
Bug ClassUAF
Tracker496280532
Fix commit155b785d0e4f (chromium/src) +34/-43
CWECWE-416 (Use-after-free)
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-03-25 (per vendor advisory)
Fix landed2026-05-05 (public git)
Disclosed2026-05-19
Patch gapNot computed: several commits cite this bug and it is not yet established which one is the fix.

Background

`AttributeCollection` / `AttributeVector`
Blink stores attributes in a WTF::Vector<Attribute, 4> backing buffer on the PartitionAlloc heap; iterating with a range-based for yields raw const Attribute* iterators into that buffer (not MiraclePtr-protected; per report).
`AttributeChanged` → popover handling
Changing the popover attribute can route into HTMLElement::UpdatePopoverAttribute → HidePopoverInternal, which synchronously dispatches a beforetoggle event (runs script).
`ParserSetAttributes`
A sibling batch-attribute path, safe only for newly-created elements where AttributeChanged cannot run script.

Root Cause Analysis

The invariant is that CloneAttributesFrom (and ParserSetAttributes) batch-apply attributes and fire multiple AttributeChanged notifications, which is only memory-safe if no synchronous script can run during the loop — i.e. only on a freshly created, unconnected, childless element. Pre-patch, ReplaceNodeWithSpanCommand called new_element->CloneAttributesFrom(element_to_replace) on a <span> that had already had children appended. Copying the source attributes iterated the attribute collection, and when the popover attribute changed, AttributeChanged → UpdatePopoverAttribute → HidePopoverInternal synchronously dispatched beforetoggle. Attacker script in that handler could setAttribute repeatedly on the element, growing its WTF::Vector<Attribute,4> past capacity and reallocating/freeing the backing buffer — invalidating the raw const Attribute* iterator still held by the loop, a use-after-free on PartitionAlloc memory (per report, escalatable via heap spray to arbitrary R/W).

The patch enforces the “newly-created element only” invariant with hard checks and removes the unsafe caller. CloneAttributesFrom now asserts !isConnected(), !parentNode(), !element_data_, !HasChildren(), and CHECKs attribute_or_class_bloom_ == 0, !hasAttributes(), !GetAttrNodeList() before copying, so it can never run on an element where AttributeChanged could reenter script; the now-impossible DetachAllAttrNodesFromElement() cleanup and the post-loop bloom-filter recomputation are deleted, and the loop already uses a stable index (element_data_->Attributes().at(i)). ParserSetAttributes gains matching DCHECK(!HasChildren())/DCHECK_EQ(attribute_or_class_bloom_, 0u) guards. Crucially, the offending ReplaceNodeWithSpanCommand caller is rewritten to not use CloneAttributesFrom on a populated element: it calls new_element->RemoveAllAttributes() (new helper) and then copies from an explicit snapshot AttributeVector attributes(element_to_replace.Attributes()), applying each via SetAttributeWithoutValidation. Iterating a by-value copy means reallocation of the live element’s vector by reentrant script can no longer dangle the loop’s iterator.

Key insight
CloneAttributesFrom iterated an element’s attribute vector with raw-pointer iterators while AttributeChanged (via a popover change firing a synchronous beforetoggle) could run script that reallocated and freed that vector; the fix forbids CloneAttributesFrom on non-newly-created elements and makes the editing caller iterate a snapshot copy instead.

Attack Path

  1. Build the trap element Create a target with popover="manual" and ≥5 attributes (heap-allocated attribute vector), with an <iframe> child carrying an unload listener. (from report)
  2. Invoke the editing command Trigger ReplaceNodeWithSpanCommand (e.g. via insertHTML), which creates a <span>, appends the moved children, then (pre-patch) calls CloneAttributesFrom. (shown by the pre-patch caller)
  3. Pre-arm via `unload` Moving the iframe fires synchronous unload; script sets the span to popover="auto" and shows it. (from report)
  4. Hit the popover attribute Cloning reaches the popover attribute; AttributeChanged → UpdatePopoverAttribute sees a type mismatch and calls HidePopoverInternal, synchronously dispatching beforetoggle. (from report)
  5. Reallocate the vector The beforetoggle handler calls setAttribute repeatedly, growing/reallocating the WTF::Vector and freeing the old PartitionAlloc buffer while the loop’s raw iterator points into it. (root cause)
  6. Reclaim + resume loop Attacker sprays the freed chunk with fake Attribute/StringImpl data; the C++ loop advances the stale iterator over attacker data. (from report)
  7. Primitive Use-after-free yielding attacker-controlled reads/writes → potential renderer RCE (escalation inferred).

Impact Assessment

Renderer process, web-reachable with user interaction (UI:R, CVSS 8.8 H/H/H). The primitive is a heap use-after-free: a raw-pointer attribute iterator reads a freed/reallocated PartitionAlloc buffer, and because the iterators are raw const Attribute* (not MiraclePtr-covered, per report), a reclaimed buffer of fake Attribute/StringImpl objects yields an arbitrary read/write primitive and a strong path to RCE in the renderer (reclamation/escalation inferred, not demonstrated by the diff). Precondition: the page can invoke an editing command (insertHTML/ReplaceNodeWithSpanCommand) and control popover/unload/beforetoggle timing — all script-reachable from a normal web page.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/dom/element.cc
modified
for
third_party/blink/renderer/core/dom/element.cc
modified
for
third_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc
modified

Files Changed

  • third_party/blink/renderer/core/dom/element.cc
  • third_party/blink/renderer/core/dom/element.h
  • third_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc

Audit Directions

  • Batch attribute setters on non-fresh elements
    Audit all callers of CloneAttributesFrom/ParserSetAttributes (and similar batch mutators) to confirm the target is newly-created; the new CHECKs will surface violators.
  • Raw-pointer iteration across reentrancy
    Review range-based for loops over WTF::Vector/collections where the loop body can dispatch events or run script (popover beforetoggle, unload, Mutation events), and convert to indexed access or snapshot copies.
  • Popover/toggle synchronous dispatch
    Trace UpdatePopoverAttribute/HidePopoverInternal/beforetoggle dispatch sites for other C++ callers mid-iteration that assume no reentrancy.
  • Editing commands moving nodes
    Review other editing commands that move children (firing synchronous unload) and then mutate attributes/structure using cached iterators or indices.
From 155b785d0e4fb8d3ae466edb8e0fd8be6b6d5743 Mon Sep 17 00:00:00 2001
From: David Baron <dbaron@chromium.org>
Date: Mon, 04 May 2026 18:23:42 -0700
Subject: [PATCH] Add stronger (D)CHECK()s around batch setting of attributes.

This adds additional CHECK()s and DCHECK()s around two Element functions
(ParserSetAttributes and CloneAttributesFrom) that set attributes in
batches and then do multiple AttributeChanged notifications.  This is
only safe for newly-created elements for which this won't run script.

This fixes one caller that would trigger these checks to not use
CloneAttributesFrom.

This also removes some code that is unnecessary given these checks.

Bug: 496280532
Change-Id: Ic8c44e3cfae20272f2ab480f5eb169c565298801
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7810037
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Commit-Queue: David Baron <dbaron@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1625093}
---

diff --git a/third_party/blink/renderer/core/dom/element.cc b/third_party/blink/renderer/core/dom/element.cc
index 0d223b3d..abe6c0f 100644
--- a/third_party/blink/renderer/core/dom/element.cc
+++ b/third_party/blink/renderer/core/dom/element.cc
@@ -1282,6 +1282,16 @@
   RemoveAttributeInternal(index, AttributeModificationReason::kDirectly);
 }
 
+void Element::RemoveAllAttributes() {
+  while (hasAttributes()) {
+    const AttributeCollection& attributes = GetElementData()->Attributes();
+    // Use a reason of kByCloning since our caller is going to use the cloning
+    // process to restore a different set of attributes.
+    RemoveAttributeInternal(attributes.size() - 1,
+                            AttributeModificationReason::kByCloning);
+  }
+}
+
 void Element::SetBooleanAttribute(const QualifiedName& name, bool value) {
   if (value) {
     setAttribute(name, g_empty_atom);
@@ -4095,9 +4105,14 @@
 
 void Element::ParserSetAttributes(
     const Vector<Attribute, kAttributePrealloc>& attribute_vector) {
+  // We must start with a newly-created element.  If we don't, it would not be
+  // safe to batch the AttributeChanged notifications the way we do, since on
+  // elements that are not newly-created, AttributeChanged might run script.
   DCHECK(!isConnected());
   DCHECK(!parentNode());
   DCHECK(!element_data_);
+  DCHECK(!HasChildren());
+  DCHECK_EQ(attribute_or_class_bloom_, 0u);
 
   if (!attribute_vector.empty()) {
     if (ElementDataCache* cache = GetDocument().GetElementDataCache()) {
@@ -4108,15 +4123,9 @@
           ShareableElementData::CreateWithAttributes(attribute_vector);
     }
 
-    DCHECK_EQ(nullptr, ElementTraversal::FirstChild(*this));
-
-    // NOTE: AttributeChanged() will add back the class names (if any),
-    // so it is safe to reset the filter here.
-    attribute_or_class_bloom_ = 0;
     for (const Attribute& attribute : attribute_vector) {
       attribute_or_class_bloom_ |= FilterForAttribute(attribute.GetName());
     }
-    UpdateSubtreeBloomFilterAfterInsert();
   }
 
   ParserDidSetAttributes();
@@ -11851,22 +11860,6 @@
   }
 }
 
-void Element::DetachAllAttrNodesFromElement() {
-  AttrNodeList* list = GetAttrNodeList();
-  if (!list) {
-    return;
-  }
-
-  AttributeCollection attributes = GetElementData()->Attributes();
-  for (const Attribute& attr : attributes) {
-    if (Attr* attr_node = AttrIfExists(attr.GetName())) {
-      attr_node->DetachFromElementWithValue(attr.Value());
-    }
-  }
-
-  RemoveAttrNodeList();
-}
-
 void Element::WillRecalcStyle(const StyleRecalcChange) {
   DCHECK(HasCustomStyleCallbacks());
 }
@@ -11886,9 +11879,16 @@
 }
 
 void Element::CloneAttributesFrom(const Element& other) {
-  if (RareData()) {
-    DetachAllAttrNodesFromElement();
-  }
+  // We must start with a newly-created element.  If we don't, it would not be
+  // safe to batch the AttributeChanged notifications the way we do, since on
+  // elements that are not newly-created, AttributeChanged might run script.
+  DCHECK(!isConnected());
+  DCHECK(!parentNode());
+  DCHECK(!element_data_);
+  DCHECK(!HasChildren());
+  CHECK_EQ(attribute_or_class_bloom_, 0u);
+  CHECK(!hasAttributes());
+  CHECK(!GetAttrNodeList());
 
   other.SynchronizeAllAttributes();
   if (!other.element_data_) {
@@ -11940,19 +11940,6 @@
     element_data_ = other.element_data_->MakeUniqueCopy();
   }
 
-  // Since we're going through the list of attributes now, we use the
-  // opportunity to recreate the Bloom filter; in particular, it may
-  // be different from the source's Bloom filter if it came from a document
-  // with different quirks mode setting.
-  Element* first_child = ElementTraversal::FirstChild(*this);
-  if (!first_child) {
-    attribute_or_class_bloom_ = 0;
-  } else if (!first_child->nextSibling()) {
-    attribute_or_class_bloom_ = first_child->attribute_or_class_bloom_;
-  } else {
-    // Two or more children left; we don't consider it worth it
-    // to try to reset the filter fully.
-  }
   for (wtf_size_t i = 0; i < element_data_->Attributes().size(); ++i) {
     const Attribute& attr = element_data_->Attributes().at(i);
     attribute_or_class_bloom_ |= FilterForAttribute(attr.GetName());
@@ -11960,7 +11947,6 @@
         AttributeModificationParams(attr.GetName(), g_null_atom, attr.Value(),
                                     AttributeModificationReason::kByCloning));
   }
-  UpdateSubtreeBloomFilterAfterInsert();
 
   if (other.nonce() != g_null_atom) {
     setNonce(other.nonce());
diff --git a/third_party/blink/renderer/core/dom/element.h b/third_party/blink/renderer/core/dom/element.h
index a407890..0adda714 100644
--- a/third_party/blink/renderer/core/dom/element.h
+++ b/third_party/blink/renderer/core/dom/element.h
@@ -649,6 +649,7 @@
   }
   void removeAttributeNS(const AtomicString& namespace_uri,
                          const AtomicString& local_name);
+  void RemoveAllAttributes();
 
   Attr* DetachAttribute(wtf_size_t index);
 
@@ -2583,7 +2584,6 @@
                                   ExceptionState& exception_state);
 
   void RemoveAttrNodeList();
-  void DetachAllAttrNodesFromElement();
   void DetachAttrNodeFromElementWithValue(Attr*, const AtomicString& value);
   void DetachAttrNodeAtIndex(Attr*, wtf_size_t index);
 
diff --git a/third_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc b/third_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc
index 23e9cd1..65ff217 100644
--- a/third_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc
+++ b/third_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc
@@ -56,9 +56,14 @@
   for (const auto& child : children)
     new_element->AppendChild(child);
 
-  // FIXME: Fix this to send the proper MutationRecords when MutationObservers
-  // are present.
-  new_element->CloneAttributesFrom(element_to_replace);
+  new_element->RemoveAllAttributes();
+  // Make a copy of element_to_replace's attributes since setting attributes
+  // on new_element could run script and thus modify attributes on either
+  // element.
+  AttributeVector attributes(element_to_replace.Attributes());
+  for (const Attribute& attr : attributes) {
+    new_element->SetAttributeWithoutValidation(attr.GetName(), attr.Value());
+  }
 
   parent_node->RemoveChild(&element_to_replace, ASSERT_NO_EXCEPTION);
 }
Loading diff…

Original Bug Report

reported by rj...@google.com

Heap-UAF in Element::CloneAttributesFrom via popover events

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential heap use-after-free vulnerability exists in Blink’s Element::CloneAttributesFrom due to raw pointer iterators being invalidated during attribute cloning. By triggering synchronous beforetoggle events during a popover attribute change, an attacker can reallocate the attribute vector, leaving the iteration loop with stale pointers. This can lead to memory corruption and potential Remote Code Execution in the renderer process.

Affected files:

  • third_party/blink/renderer/core/dom/element.cc
  • third_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc
  • third_party/blink/renderer/core/html/html_element.cc
  • third_party/blink/renderer/core/editing/commands/replace_selection_command.cc

Estimated timestamp from git blame: 2025-11-04

Summary

A potential heap use-after-free (UAF) vulnerability has been identified in Element::CloneAttributesFrom in Blink. The issue occurs because the function iterates over an element’s attributes using a range-based for loop, which desugars to raw pointers (const Attribute*) pointing into a WTF::Vector backing buffer on the PartitionAlloc heap.

Synchronous JavaScript execution triggered during this loop can reallocate the vector, freeing the backing buffer while the raw pointer iterators are still on the stack. Because these iterators are raw pointers, they are not protected by MiraclePtr (BRP).

Technical Details

In third_party/blink/renderer/core/dom/element.cc, the attribute cloning process loops over the source element’s attributes and applies them to the target element:

void Element::CloneAttributesFrom(const Element& other) {
  // ... 
  for (const Attribute& attr : element_data_->Attributes()) {
    attribute_or_class_bloom_ |= FilterForAttribute(attr.GetName());
    AttributeChanged(
        AttributeModificationParams(attr.GetName(), g_null_atom, attr.Value(),
                                    AttributeModificationReason::kByCloning));
  }
  // ...
}

The element_data_->Attributes() collection uses raw const Attribute* iterators. If AttributeChanged triggers synchronous JavaScript, the underlying AttributeVector (a WTF::Vector<Attribute, 4>) can be modified, causing it to exceed its capacity, reallocate a new buffer, and free the old one.

Potential Attacker Steps (Theoretical)

Note: These are suggested steps, as our setup does not execute code to verify a working proof-of-concept.

  1. Setup: The attacker creates a target element with popover="manual" and at least 5 attributes (to ensure the AttributeVector uses a heap-allocated PartitionAlloc buffer instead of inline capacity). An iframe with an unload event listener is appended as a child of this element.
  2. Trigger Command: The attacker executes an editing command like insertHTML which invokes ReplaceNodeWithSpanCommand.
  3. Pre-arming via Unload: ReplaceNodeWithSpanCommand inserts a new span and moves the children to it. Moving the iframe fires a synchronous unload event. In this handler, the attacker’s script sets the new span to popover="auto" and calls showPopover().
  4. Attribute Cloning: C++ execution resumes. CloneAttributesFrom begins iterating over the attributes to copy them to the new span.
  5. The Trap: When the loop reaches the popover attribute (copying the value "manual"), it calls AttributeChanged, which routes to HTMLElement::UpdatePopoverAttribute.
  6. Synchronous Event: UpdatePopoverAttribute detects a type mismatch (the element is currently an "auto" popover, but the attribute is changing to "manual"). It calls HidePopoverInternal, which synchronously dispatches a beforetoggle event via DispatchEvent.
  7. Reallocation (UAF Trigger): The attacker catches the beforetoggle event in JavaScript. The script repeatedly calls setAttribute on the new span. This appends new attributes to the span’s UniqueElementData attribute vector, forcing WTF::Vector to reallocate and free the old PartitionAlloc backing buffer.
  8. Exploitation: The attacker script reclaims the freed PartitionAlloc chunk using heap spraying (e.g., ArrayBuffers) filled with fake Attribute structures containing controlled StringImpl pointers. When C++ execution returns to the for loop, it increments the stale raw pointer iterator and reads the fake objects, leading to an arbitrary read/write primitive and potential RCE.

Suggested Fix

Do not use range-based for loops (which rely on raw pointer iterators) when iterating over collections that can be modified by synchronous JavaScript execution.

Instead, iterate over the attributes using a stable index (wtf_size_t i = 0; i < element_data_->Attributes().size(); ++i), or take an inline copy/snapshot of the attributes vector before iterating through them. Using an index ensures that even if the underlying WTF::Vector reallocates its backing buffer, the next element access (at(i)) will correctly read from the newly allocated buffer.

Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker