Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient data validation in DOM
DescriptionInsufficient data validation in DOM
ComponentDOM
Bug ClassLogic Error
Tracker523729553
Fix commitcb26b6a1eb79 (chromium/src) +57/-16
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-08

Changed Functions

FunctionChangeNotes
if
third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html
modified

Files Changed

  • third_party/blink/renderer/core/dom/container_node.cc
  • third_party/blink/renderer/platform/runtime_enabled_features.json5
  • third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html
  • third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html.headers
From cb26b6a1eb7999001e771a07944d4cb0e377341c Mon Sep 17 00:00:00 2001
From: David Baron <dbaron@chromium.org>
Date: Mon, 22 Jun 2026 11:07:35 -0700
Subject: [PATCH] Recheck prerequisites when inserting multiple nodes into a Document.

This moves the check of prerequisites earlier so that it also applies
when the parent node is a Document.  This is needed so that we check
that the nodes' parents are still all null.

The added test is a simplified version of the AI-generated test provided
in the bug.  Without the fix it fails a DCHECK in DCHECK-enabled builds,
at third_party/blink/renderer/core/dom/container_node.cc:400:
DCHECK failed: !target_node->parentNode().

Fixed: 523729553
Change-Id: I409e096d54150f97083a281e0b3dee5a203015ac
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7963938
Commit-Queue: David Baron <dbaron@chromium.org>
Reviewed-by: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1650442}
---

diff --git a/third_party/blink/renderer/core/dom/container_node.cc b/third_party/blink/renderer/core/dom/container_node.cc
index b463af6..d33adc43 100644
--- a/third_party/blink/renderer/core/dom/container_node.cc
+++ b/third_party/blink/renderer/core/dom/container_node.cc
@@ -274,6 +274,20 @@
     }
   }
 
+  // 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 && new_children->size() != 1u &&
+      RuntimeEnabledFeatures::RecheckParentDuringNodeVectorInsertionEnabled() &&
+      !RecheckNodeInsertionStructuralPrereq(*new_children, next,
+                                            exception_state)) {
+    return false;
+  }
+
   if (auto* document = DynamicTo<Document>(this)) {
     // Step 2 is unnecessary. No one can have a Document child.
     // Step 3:
@@ -324,21 +338,6 @@
         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()) {
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index b463f88..11503dc2 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -4885,7 +4885,7 @@
       status: "stable",
     },
     {
-      // Shipping in M148, so should be removed around M150.
+      // Shipping in M148, revised in M151, so should be removed around M153.
       name: "RecheckParentDuringNodeVectorInsertion",
       status: "stable",
     },
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html
new file mode 100644
index 0000000..a961114
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html>
+<head><title>Document.append reentrancy tree corruption</title></head>
+<body>
+<script>
+
+// Create a container whose removal fires a synchronous JS callback.
+function makeTrigger(callback) {
+  const container = document.createElement('div');
+  const iframe = document.createElement('iframe');
+  container.appendChild(iframe);
+  document.body.appendChild(container);
+  let fired = false;
+  function listener() {
+    if (fired) {
+      return;
+    }
+    fired = true;
+    callback();
+  }
+  iframe.contentWindow.addEventListener('unload', listener);
+  iframe.contentWindow.addEventListener('pagehide', listener);
+  return container;
+}
+
+function bug() {
+  const doc = document.implementation.createDocument(null, null, null);
+  const div = document.createElement('div');
+  const node1 = document.createComment('victim');
+  const trig = makeTrigger(() => {
+    div.appendChild(node1);
+  });
+  try { doc.append(node1, trig); } catch (e) { }
+}
+
+window.onload = () => {
+  bug();
+};
+</script>
+</body>
+</html>
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html.headers b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.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-document.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-document.https.html b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html
new file mode 100644
index 0000000..a961114
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html>
+<head><title>Document.append reentrancy tree corruption</title></head>
+<body>
+<script>
+
+// Create a container whose removal fires a synchronous JS callback.
+function makeTrigger(callback) {
+  const container = document.createElement('div');
+  const iframe = document.createElement('iframe');
+  container.appendChild(iframe);
+  document.body.appendChild(container);
+  let fired = false;
+  function listener() {
+    if (fired) {
+      return;
+    }
+    fired = true;
+    callback();
+  }
+  iframe.contentWindow.addEventListener('unload', listener);
+  iframe.contentWindow.addEventListener('pagehide', listener);
+  return container;
+}
+
+function bug() {
+  const doc = document.implementation.createDocument(null, null, null);
+  const div = document.createElement('div');
+  const node1 = document.createComment('victim');
+  const trig = makeTrigger(() => {
+    div.appendChild(node1);
+  });
+  try { doc.append(node1, trig); } catch (e) { }
+}
+
+window.onload = () => {
+  bug();
+};
+</script>
+</body>
+</html>
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html.headers b/third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.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-document.https.html.headers
@@ -0,0 +1 @@
+Permissions-Policy: unload=*
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential DOM tree corruption in Document::append via multi-node insertion reentrancy

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in ContainerNode::EnsurePreInsertionValidity allows JavaScript to bypass structural safety checks during multi-node insertions on Document targets. Malicious reparenting during synchronous events can lead to a node existing in two child lists, creating a memory corruption primitive.

Affected files:

  • third_party/blink/renderer/core/dom/container_node.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Root Cause

When a multi-node DOM insertion API like document.append(node1, node2) is called, the C++ implementation (Node::append) sequentially processes the nodes via ConvertNodeUnionsIntoNodes. This function removes each node from its current location using node->remove(). If removing one of the nodes triggers a synchronous event (e.g., removing an iframe dispatches an unload event), JavaScript execution is re-entered.

During this reentrancy, an attacker can safely reparent one of the previously processed nodes into a new intermediate container (e.g., a detached div). Once the event handler completes, the append operation resumes and calls ContainerNode::AppendChildren on the target Document.

To prevent this exact type of reentrancy attack, a structural validation check (RecheckNodeInsertionStructuralPrereq) was previously introduced. However, ContainerNode::EnsurePreInsertionValidity contains an early-return path specifically for Document targets:

  if (auto* document = DynamicTo<Document>(this)) {
    // Step 2 is unnecessary. No one can have a Document child.
    // Step 3:
    if (!CheckReferenceChildParent(*this, next, old_child, exception_state))
      return false;
    // Step 4-6.
    return document->CanAcceptChild(new_child, new_children, next, old_child,
                                    exception_state);
  }

This early return completely bypasses the RecheckNodeInsertionStructuralPrereq call located further down in the function. As a result, the insertion proceeds blindly.

Inside InsertNodeVector, a DCHECK(!target_node->parentNode()) validates that the node is detached. However, in release builds, this DCHECK is compiled out. The operation then calls AppendChildCommon, which overwrites the node’s parent and sibling pointers to point to the Document. Crucially, it does not unlink the node from the intermediate div. The div’s internal child list still references the node, while the node points to the Document and its siblings.

This DOM tree corruption (where a node effectively belongs to two different parents) can confuse the Blink layout engine or Garbage Collector, reliably providing a primitive for Use-After-Free (UAF) or Out-of-Bounds (OOB) memory corruption in the renderer process.

Potential Trigger Steps

These steps outline how an attacker could potentially trigger this vulnerability (note: this is a theoretical sequence derived from code analysis):

  1. Create a target Document (const doc = document.implementation.createDocument(null, null, null);).
  2. Create an intermediate container (const div = document.createElement('div');).
  3. Create a detached comment node (const node1 = document.createComment('');).
  4. Create an iframe (node2), append it to the active DOM, and attach a synchronous unload handler to its contentWindow.
  5. Call the multi-node append API on the target Document: doc.append(node1, node2).
  6. During the extraction phase, removing node2 triggers the unload handler.
  7. Inside the unload handler, reparent the comment node: div.append(node1).
  8. The insertion resumes and bypasses the structural safety check due to the Document-specific early return, corrupting the tree.

Suggested Fix

Ensure that the structural validation check is also applied when inserting into a Document. For instance, in ContainerNode::EnsurePreInsertionValidity, add the re-check logic before the Document early return completes:

  if (auto* document = DynamicTo<Document>(this)) {
    if (!CheckReferenceChildParent(*this, next, old_child, exception_state))
      return false;
    
    if (new_children && new_children->size() != 1u &&
        RuntimeEnabledFeatures::RecheckParentDuringNodeVectorInsertionEnabled() &&
        !RecheckNodeInsertionStructuralPrereq(*new_children, next, exception_state)) {
      return false;
    }

    return document->CanAcceptChild(new_child, new_children, next, old_child,
                                    exception_state);
  }

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker