Overview

Medium
Severity
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
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-19

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
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