CVE-2026-9126
Overview
Background
- `AttributeCollection` / `AttributeVector`
- Blink stores attributes in a
WTF::Vector<Attribute, 4>backing buffer on the PartitionAlloc heap; iterating with a range-basedforyields rawconst Attribute*iterators into that buffer (not MiraclePtr-protected; per report). - `AttributeChanged` → popover handling
- Changing the
popoverattribute can route intoHTMLElement::UpdatePopoverAttribute→HidePopoverInternal, which synchronously dispatches abeforetoggleevent (runs script). - `ParserSetAttributes`
- A sibling batch-attribute path, safe only for newly-created elements where
AttributeChangedcannot 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.
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
- Build the trap element
Create a target with
popover="manual"and ≥5 attributes (heap-allocated attribute vector), with an<iframe>child carrying anunloadlistener. (from report) - Invoke the editing command
Trigger
ReplaceNodeWithSpanCommand(e.g. viainsertHTML), which creates a<span>, appends the moved children, then (pre-patch) callsCloneAttributesFrom. (shown by the pre-patch caller) - Pre-arm via `unload`
Moving the iframe fires synchronous
unload; script sets the span topopover="auto"and shows it. (from report) - Hit the popover attribute
Cloning reaches the
popoverattribute;AttributeChanged→UpdatePopoverAttributesees a type mismatch and callsHidePopoverInternal, synchronously dispatchingbeforetoggle. (from report) - Reallocate the vector
The
beforetogglehandler callssetAttributerepeatedly, growing/reallocating theWTF::Vectorand freeing the old PartitionAlloc buffer while the loop’s raw iterator points into it. (root cause) - Reclaim + resume loop
Attacker sprays the freed chunk with fake
Attribute/StringImpldata; the C++ loop advances the stale iterator over attacker data. (from report) - Primitive Use-after-free yielding attacker-controlled reads/writes → potential renderer RCE (escalation inferred).
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/dom/element.cc |
modified | |
forthird_party/blink/renderer/core/dom/element.cc |
modified | |
forthird_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc |
modified |
Files Changed
third_party/blink/renderer/core/dom/element.ccthird_party/blink/renderer/core/dom/element.hthird_party/blink/renderer/core/editing/commands/replace_node_with_span_command.cc
Audit Directions
- Batch attribute setters on non-fresh elementsAudit 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 reentrancyReview range-based
forloops overWTF::Vector/collections where the loop body can dispatch events or run script (popoverbeforetoggle,unload, Mutation events), and convert to indexed access or snapshot copies. - Popover/toggle synchronous dispatchTrace
UpdatePopoverAttribute/HidePopoverInternal/beforetoggledispatch sites for other C++ callers mid-iteration that assume no reentrancy. - Editing commands moving nodesReview other editing commands that move children (firing synchronous
unload) and then mutate attributes/structure using cached iterators or indices.
Patch
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);
}
Original Bug Report
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.ccthird_party/blink/renderer/core/editing/commands/replace_node_with_span_command.ccthird_party/blink/renderer/core/html/html_element.ccthird_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.
- Setup: The attacker creates a target element with
popover="manual"and at least 5 attributes (to ensure theAttributeVectoruses a heap-allocated PartitionAlloc buffer instead of inline capacity). Aniframewith anunloadevent listener is appended as a child of this element. - Trigger Command: The attacker executes an editing command like
insertHTMLwhich invokesReplaceNodeWithSpanCommand. - Pre-arming via Unload:
ReplaceNodeWithSpanCommandinserts a new span and moves the children to it. Moving theiframefires a synchronousunloadevent. In this handler, the attacker’s script sets the new span topopover="auto"and callsshowPopover(). - Attribute Cloning: C++ execution resumes.
CloneAttributesFrombegins iterating over the attributes to copy them to the new span. - The Trap: When the loop reaches the
popoverattribute (copying the value"manual"), it callsAttributeChanged, which routes toHTMLElement::UpdatePopoverAttribute. - Synchronous Event:
UpdatePopoverAttributedetects a type mismatch (the element is currently an"auto"popover, but the attribute is changing to"manual"). It callsHidePopoverInternal, which synchronously dispatches abeforetoggleevent viaDispatchEvent. - Reallocation (UAF Trigger): The attacker catches the
beforetoggleevent in JavaScript. The script repeatedly callssetAttributeon the new span. This appends new attributes to the span’sUniqueElementDataattribute vector, forcingWTF::Vectorto reallocate and free the old PartitionAlloc backing buffer. - Exploitation: The attacker script reclaims the freed PartitionAlloc chunk using heap spraying (e.g., ArrayBuffers) filled with fake
Attributestructures containing controlledStringImplpointers. When C++ execution returns to theforloop, 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.