Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Blink
DescriptionUse after free in Blink
ComponentBlink
Bug ClassUAF
Tracker496281816
Fix commit848cf5567223 (chromium/src) +49/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-07

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/core/dom/container_node.cc
modified

Files Changed

  • third_party/blink/renderer/core/dom/container_node.cc
  • third_party/blink/renderer/core/dom/container_node.h
  • third_party/blink/renderer/platform/runtime_enabled_features.json5
  • third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html
  • third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload.https.html.headers
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=*
Loading diff…

Regression Test / PoC

shipped with the fix
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=*
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • third_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

  1. An attacker creates an HTML page and executes JavaScript to create an empty target element, an empty node1, and a node2 containing a same-origin <iframe>.
  2. The attacker registers an unload event listener on the iframe’s window.
  3. The attacker calls target.append(node1, node2).
  4. In Node::ConvertNodeUnionsIntoNodes, Blink iterates over the arguments:
    • node1 is detached (node1->remove()).
    • node2 is detached (node2->remove()).
  5. Detaching node2 tears down the iframe, which synchronously fires the unload event, pausing C++ execution and running the attacker’s JavaScript.
  6. The attacker’s unload script executes evilParent.appendChild(node1), reparenting node1 into a new, unrelated DOM element.
  7. C++ execution resumes. ConvertNodeUnionsIntoNodes returns [node1, node2]. Critically, node1 is no longer an orphan; its parentNode() is evilParent.
  8. Execution proceeds to ContainerNode::AppendChildren.
  9. AppendChildren has a check: if (new_children.size() == 1u) { ... RecheckNodeInsertionStructuralPrereq(...) }. Because there are 2 children, this block is skipped.
  10. node1 is passed to ContainerNode::AppendChildCommon, which updates node1’s parentNode, nextSibling, and previousSibling pointers to link it into target.
  11. However, node1 was never properly removed from evilParent. evilParent still holds raw pointers to node1 in its internal child linked list.
  12. 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.cc Node::ConvertNodeUnionsIntoNodes sequentially calls node->remove(exception_state) (line ~1151). This triggers ContainerNode::RemoveChild, which fires synchronous detachment events.
  • File: third_party/blink/renderer/core/dom/container_node.cc ContainerNode::AppendChildren (and similar methods like InsertBefore) only perform the post-removal sanity check RecheckNodeInsertionStructuralPrereq if new_children.size() == 1u (line ~1156). When multiple children are inserted, this crucial guard is bypassed.
  • Inside ContainerNode::InsertNodeVector, the debug assertion DCHECK(!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.

  1. Modify ContainerNode::AppendChildren, InsertBefore, ReplaceChild, and ReplaceChildren to unconditionally call RecheckNodeInsertionStructuralPrereq (or a variant of it designed for multi-node vectors) after the ConvertNodeUnionsIntoNodes step has completed and before InsertNodeVector is called.
  2. Alternatively, modify Node::ConvertNodeUnionsIntoNodes to verify that all nodes in the returned VectorOf<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.

View on issue tracker