CVE-2026-5872
Overview
Background
- `ConvertNodeUnionsIntoNodes`
Node-level helper that flattens the(Node or string)...arguments of DOM mutation APIs into aNodeVector, and for the multi-node case simulates insertion into aDocumentFragment, which detaches each node from its current parent before the real insertion.- `RecheckNodeInsertionStructuralPrereq`
ContainerNodemethod that re-validates, after script may have run, that none of the to-be-inserted children have acquired aparentNode()or otherwise violate the insertion preconditions.- Structural pre-insertion validity
- the DOM invariant that a node being inserted must be detached and must not create a cycle with its new parent at the moment of insertion.
- `RecheckParentDuringNodeVectorInsertion`
- the runtime feature flag added to gate (and, at
status: "stable", enable) the new re-check in the optimized vector path.
Root Cause Analysis
When a mutation API such as append() is called with more than one node, the optimized vector-insertion path in container_node.cc calls ConvertNodeUnionsIntoNodes, which for the multi-node case removes each child from its old parent up front. Those removals can synchronously run author script (for example a frame unload handler), and that script can re-parent one of the pending nodes into a different element, invalidating the structural prerequisites that were validated earlier. The pre-patch code only performed the full RecheckNodeInsertionStructuralPrereq re-validation in the DocumentFragment branch, not in this optimized vector path, so the vector path proceeded to insert nodes whose parentage had changed under it, leading to a use-after-free (inferred: of a Node detached/freed by the re-entrant mutation).
The fix adds, in the vector path, a re-check that runs RecheckNodeInsertionStructuralPrereq(*new_children, next, exception_state) when new_children->size() != 1u and the feature is enabled, returning false (aborting the insertion) if any child now has a parent. The accompanying const qualifier on RecheckNodeInsertionStructuralPrereq is a non-functional signature change enabling the call from this context.
ConvertNodeUnionsIntoNodes detaches nodes (which can run script) before insertion; the fix re-runs the same structural prerequisite check the DocumentFragment path already used, so a node re-parented by that script is detected and the insertion is aborted.Attack Path
- Set up a multi-node insertion
Page script calls a mutation API such as
target.append(node1, node2)wherenode2contains a frame permitted to run anunloadhandler. - Trigger re-entrant mutation during flattening
ConvertNodeUnionsIntoNodesdetaches the nodes for the multi-node (DocumentFragment-simulated) case, and the resultingunloadhandler re-parentsnode1(evilParent.appendChild(node1)in the shipped crashtest). - Insert with stale assumptions
Because the pre-patch vector path skipped
RecheckNodeInsertionStructuralPrereq, insertion continues using the now-invalid node state. - Use-after-free The insertion operates on a node whose backing object was freed/moved by the re-entrant mutation, producing a use-after-free in Blink (inferred: exact freed object not shown in the diff).
Impact Assessment
append() and an unload-permitted srcdoc iframe) can trigger a use-after-free in the Blink DOM of the renderer process. The vendor advisory rates this high. Any escalation beyond the memory-corruption primitive (controlled read/write, code execution, sandbox escape) is not demonstrated by the diff and is (inferred) at most.Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/dom/container_node.cc |
modified |
Files Changed
third_party/blink/renderer/core/dom/container_node.ccthird_party/blink/renderer/core/dom/container_node.hthird_party/blink/renderer/platform/runtime_enabled_features.json5third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.htmlthird_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers
Audit Directions
- Validate-then-mutate across script-running callsAudit DOM insertion/removal paths that validate preconditions before a helper (like
ConvertNodeUnionsIntoNodes,RemoveChild, or dispatch of mutation/unloadevents) that can synchronously run author script, and ensure prerequisites are re-checked afterward. - Divergent fast pathsWhere an optimized/single-item fast path mirrors a general (
DocumentFragment) path, compare them check-for-check; security re-validation present in one branch is frequently missing in its optimized twin. - Re-entrancy via lifecycle eventsLook for
unload,blur,pagehide, and mutation-observer/mutation-event handlers that can re-parent or free nodes mid-operation, especially in multi-argument mutation APIs.
Patch
From 848cf5567223d4db449b151c0274b7a8e552fdc5 Mon Sep 17 00:00:00 2001
From: David Baron <dbaron@chromium.org>
Date: Thu, 26 Mar 2026 15:17:31 -0700
Subject: [PATCH] Check parent nodes when handling vector node insertions.
In the optimized vector node insertion case, check the parent node just
as in the regular case.
Fixed: 496281816
Change-Id: I0fc6956d1c09fcb7ea54d94819fdf1cb06fbd9e5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7705373
Commit-Queue: David Baron <dbaron@chromium.org>
Reviewed-by: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1605818}
---
diff --git a/third_party/blink/renderer/core/dom/container_node.cc b/third_party/blink/renderer/core/dom/container_node.cc
index 72cc7f4..fbe796d 100644
--- a/third_party/blink/renderer/core/dom/container_node.cc
+++ b/third_party/blink/renderer/core/dom/container_node.cc
@@ -324,6 +324,21 @@
return false;
}
}
+
+ // Node::ConvertNodeUnionsIntoNodes behaves differently depending on
+ // whether there is one node or more than one, since it simulates
+ // insertion into a DocumentFragment for the latter case. If it handled
+ // more than one node, then it already removed the children from their old
+ // parent. Check here that those removals didn't do anything bad. (We
+ // could potentially make this faster by using a DOMMutationDetector in
+ // ConvertNodeUnionsIntoNodes and storing whether we need to do this.)
+ if (new_children->size() != 1u &&
+ RuntimeEnabledFeatures::
+ RecheckParentDuringNodeVectorInsertionEnabled() &&
+ !RecheckNodeInsertionStructuralPrereq(*new_children, next,
+ exception_state)) {
+ return false;
+ }
} else if (auto* child_fragment = DynamicTo<DocumentFragment>(new_child)) {
for (Node* node = child_fragment->firstChild(); node;
node = node->nextSibling()) {
@@ -346,7 +361,7 @@
bool ContainerNode::RecheckNodeInsertionStructuralPrereq(
const NodeVector& new_children,
const Node* next,
- ExceptionState& exception_state) {
+ ExceptionState& exception_state) const {
for (const auto& child : new_children) {
if (child->parentNode()) {
// A new child was added to another parent before adding to this
diff --git a/third_party/blink/renderer/core/dom/container_node.h b/third_party/blink/renderer/core/dom/container_node.h
index aac4d43c..e1656498 100644
--- a/third_party/blink/renderer/core/dom/container_node.h
+++ b/third_party/blink/renderer/core/dom/container_node.h
@@ -549,7 +549,7 @@
bool RecheckNodeInsertionStructuralPrereq(const NodeVector&,
const Node* next,
- ExceptionState&);
+ ExceptionState&) const;
inline bool CheckParserAcceptChild(const Node& new_child) const;
inline bool IsHostIncludingInclusiveAncestorOfThis(const Node&,
ExceptionState&) const;
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 117f0547..53e86ee 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -4550,6 +4550,11 @@
status: "stable",
},
{
+ // Shipping in M148, so should be removed around M150.
+ name: "RecheckParentDuringNodeVectorInsertion",
+ status: "stable",
+ },
+ {
name: "RecordSameDocumentPresentationTimeOnce",
status: "stable",
},
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html
new file mode 100644
index 0000000..e754cd6
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html
@@ -0,0 +1,26 @@
+<!DOCTYPE HTML>
+<div id="target"></div>
+<div id="evilParent"></div>
+<div id="node1">node1</div>
+<script>
+ window.node1 = document.getElementById("node1");
+ function runTest() {
+ document.getElementById("target").append(node1, document.getElementById("node2"));
+ }
+</script>
+<div id="node2">
+ <iframe allow="unload" srcdoc="
+ <!DOCTYPE HTML>
+ <script>
+ window.addEventListener('load', function() {
+ window.parent.runTest();
+ });
+ window.addEventListener('unload', function() {
+ let p = window.parent;
+ let doc = p.document;
+ doc.getElementById('evilParent').appendChild(p.node1);
+ });
+ </script>
+ ">
+ </iframe>
+</div>
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers
new file mode 100644
index 0000000..f1e8ace
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers
@@ -0,0 +1 @@
+Permissions-Policy: unload=*
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html
new file mode 100644
index 0000000..e754cd6
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html
@@ -0,0 +1,26 @@
+<!DOCTYPE HTML>
+<div id="target"></div>
+<div id="evilParent"></div>
+<div id="node1">node1</div>
+<script>
+ window.node1 = document.getElementById("node1");
+ function runTest() {
+ document.getElementById("target").append(node1, document.getElementById("node2"));
+ }
+</script>
+<div id="node2">
+ <iframe allow="unload" srcdoc="
+ <!DOCTYPE HTML>
+ <script>
+ window.addEventListener('load', function() {
+ window.parent.runTest();
+ });
+ window.addEventListener('unload', function() {
+ let p = window.parent;
+ let doc = p.document;
+ doc.getElementById('evilParent').appendChild(p.node1);
+ });
+ </script>
+ ">
+ </iframe>
+</div>
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers
new file mode 100644
index 0000000..f1e8ace
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers
@@ -0,0 +1 @@
+Permissions-Policy: unload=*
Original Bug Report
Potential DOM tree corruption via iframe unload in variadic DOM mutations
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential reentrancy vulnerability exists in Blink’s multi-node insertion paths (e.g., append(), prepend()). Synchronous iframe unload events triggered during argument preparation can allow attacker script to reparent a node, bypassing structural safety checks. This blindly splices the node into a new tree while leaving stale pointers in the old parent, corrupting the DOM and enabling potential Use-After-Free (UAF) or type confusion.
Affected files:
third_party/blink/renderer/core/dom/node.ccthird_party/blink/renderer/core/dom/container_node.cc
Estimated timestamp from git blame: 2024-10-29
Summary
There is a potential DOM tree linked-list corruption vulnerability in Blink’s variadic DOM mutation methods (append, prepend, before, after, replaceWith, replaceChildren). When multiple nodes are passed to these methods, they are processed by Node::ConvertNodeUnionsIntoNodes, which iteratively removes each node from its current parent. If an argument contains an <iframe>, its removal fires a synchronous unload event.
Malicious JavaScript in the unload handler can reparent a node that was removed in an earlier iteration of the loop. Because the multi-child insertion paths (e.g., ContainerNode::AppendChildren) explicitly skip the RecheckNodeInsertionStructuralPrereq check when the number of new children is greater than 1, the reparented node is blindly inserted into the target container. This corrupts the DOM tree by leaving dangling child pointers in the old parent.
Note: The following are suggested/potential steps, as our setup does not have the ability to execute code to create a working proof of concept.
Potential Steps to Reproduce
- An attacker creates an HTML page and executes JavaScript to create an empty
targetelement, an emptynode1, and anode2containing a same-origin<iframe>. - The attacker registers an
unloadevent listener on the iframe’swindow. - The attacker calls
target.append(node1, node2). - In
Node::ConvertNodeUnionsIntoNodes, Blink iterates over the arguments:node1is detached (node1->remove()).node2is detached (node2->remove()).
- Detaching
node2tears down the iframe, which synchronously fires theunloadevent, pausing C++ execution and running the attacker’s JavaScript. - The attacker’s
unloadscript executesevilParent.appendChild(node1), reparentingnode1into a new, unrelated DOM element. - C++ execution resumes.
ConvertNodeUnionsIntoNodesreturns[node1, node2]. Critically,node1is no longer an orphan; itsparentNode()isevilParent. - Execution proceeds to
ContainerNode::AppendChildren. AppendChildrenhas a check:if (new_children.size() == 1u) { ... RecheckNodeInsertionStructuralPrereq(...) }. Because there are 2 children, this block is skipped.node1is passed toContainerNode::AppendChildCommon, which updatesnode1’sparentNode,nextSibling, andpreviousSiblingpointers to link it intotarget.- However,
node1was never properly removed fromevilParent.evilParentstill holds raw pointers tonode1in its internal child linked list. - The DOM tree is now corrupted with overlapping sibling and parent pointers, which will likely confuse Blink’s Layout and Style engines and lead to a Use-After-Free.
Technical Details
- File:
third_party/blink/renderer/core/dom/node.ccNode::ConvertNodeUnionsIntoNodessequentially callsnode->remove(exception_state)(line ~1151). This triggersContainerNode::RemoveChild, which fires synchronous detachment events. - File:
third_party/blink/renderer/core/dom/container_node.ccContainerNode::AppendChildren(and similar methods likeInsertBefore) only perform the post-removal sanity checkRecheckNodeInsertionStructuralPrereqifnew_children.size() == 1u(line ~1156). When multiple children are inserted, this crucial guard is bypassed. - Inside
ContainerNode::InsertNodeVector, the debug assertionDCHECK(!target_node->parentNode())catches this in debug builds, but it compiles to a no-op in release builds, allowing the DOM corruption to proceed.
Impact
DOM tree corruption is a highly reliable primitive for memory corruption. Blink’s layout objects, layers, and style recalculation engines rely heavily on the integrity of the DOM tree. Crossing sibling pointers between unrelated subtrees can deterministically trigger Use-After-Free or Type Confusion vulnerabilities, leading to Remote Code Execution (RCE) in the sandboxed renderer process.
Suggested Fix
Ensure that the structural integrity check is performed regardless of the number of nodes being inserted.
- Modify
ContainerNode::AppendChildren,InsertBefore,ReplaceChild, andReplaceChildrento unconditionally callRecheckNodeInsertionStructuralPrereq(or a variant of it designed for multi-node vectors) after theConvertNodeUnionsIntoNodesstep has completed and beforeInsertNodeVectoris called. - Alternatively, modify
Node::ConvertNodeUnionsIntoNodesto verify that all nodes in the returnedVectorOf<Node>are orphans (!node->parentNode()) immediately before returning, and throw an exception if any node has acquired a parent during the removal loop.
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.