CVE-2026-5872
Overview
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
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.