High patch-now ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Blink
DescriptionUse after free in Blink
ComponentBlink
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: none; diff adds RecheckNodeInsertionStructuralPrereq to the multi-node vector path (feature stable/default) plus an unload-reentrancy crashtest, matching the claimed re-entrant reparent UAF.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker496281816
Fix commit848cf5567223 (chromium/src) +49/-2
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-25 (per vendor advisory)
Fix landed2026-03-26 (public git)
Disclosed2026-04-07
Patch gap12 days — fix public in source before the advisory

Background

`ConvertNodeUnionsIntoNodes`
Node-level helper that flattens the (Node or string)... arguments of DOM mutation APIs into a NodeVector, and for the multi-node case simulates insertion into a DocumentFragment, which detaches each node from its current parent before the real insertion.
`RecheckNodeInsertionStructuralPrereq`
ContainerNode method that re-validates, after script may have run, that none of the to-be-inserted children have acquired a parentNode() 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.

Key insight
The optimized multi-node insertion path trusted that no re-entrant DOM mutation could occur between validation and insertion, but 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

  1. Set up a multi-node insertion Page script calls a mutation API such as target.append(node1, node2) where node2 contains a frame permitted to run an unload handler.
  2. Trigger re-entrant mutation during flattening ConvertNodeUnionsIntoNodes detaches the nodes for the multi-node (DocumentFragment-simulated) case, and the resulting unload handler re-parents node1 (evilParent.appendChild(node1) in the shipped crashtest).
  3. Insert with stale assumptions Because the pre-patch vector path skipped RecheckNodeInsertionStructuralPrereq, insertion continues using the now-invalid node state.
  4. 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

An attacker who can run ordinary JavaScript and HTML in a renderer (no special flag, extension, or policy; the crashtest uses only 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

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

Audit Directions

  • Validate-then-mutate across script-running calls
    Audit DOM insertion/removal paths that validate preconditions before a helper (like ConvertNodeUnionsIntoNodes, RemoveChild, or dispatch of mutation/unload events) that can synchronously run author script, and ensure prerequisites are re-checked afterward.
  • Divergent fast paths
    Where 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 events
    Look 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.
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