CVE-2026-7907
Overview
Files Changed
third_party/blink/renderer/core/html/html_script_element.cc
Patch
From 2e3a535512700ff4b1e6081ac7d06387951e1d4f Mon Sep 17 00:00:00 2001
From: Takashi Sakamoto <tasak@google.com>
Date: Mon, 30 Mar 2026 19:03:37 -0700
Subject: [PATCH] Block PrepareScript() while a document state is preserving-atomic-move-in-progress.
Bug: 496292089
Change-Id: I1b357a0dc34874e5e3795ea54d9dd1da2b584ac7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7701234
Reviewed-by: Dominic Farolino <dom@chromium.org>
Commit-Queue: Takashi Sakamoto <tasak@google.com>
Cr-Commit-Position: refs/heads/main@{#1607555}
---
diff --git a/third_party/blink/renderer/core/html/html_script_element.cc b/third_party/blink/renderer/core/html/html_script_element.cc
index 615dfdf..68bb2387 100644
--- a/third_party/blink/renderer/core/html/html_script_element.cc
+++ b/third_party/blink/renderer/core/html/html_script_element.cc
@@ -86,7 +86,10 @@
void HTMLScriptElement::ChildrenChanged(const ChildrenChange& change) {
HTMLElement::ChildrenChanged(change);
- loader_->ChildrenChanged(change);
+
+ if (!GetDocument().StatePreservingAtomicMoveInProgress()) {
+ loader_->ChildrenChanged(change);
+ }
// We'll record whether the script element children were ever changed by
// the API (as opposed to the parser).
Original Bug Report
Potential Use-After-Free via reentrancy in Node::moveBefore and HTMLObjectElement
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: Node::moveBefore incorrectly assumes no script can execute during its operation, but moving a Text node into a connected <script> element can trigger synchronous script execution. This reentrancy allows an attacker to manipulate the DOM while an internal atomic-move flag is set, leading to subframe-count desynchronization, frame tree corruption, and a highly probable Use-After-Free.
Affected files:
third_party/blink/renderer/core/dom/node.ccthird_party/blink/renderer/core/dom/container_node.ccthird_party/blink/renderer/core/html/html_plugin_element.ccthird_party/blink/renderer/core/html/html_frame_owner_element.ccthird_party/blink/renderer/core/dom/child_frame_disconnector.cc
Estimated timestamp from git blame: 2024-12-18
Summary
The Node::moveBefore() implementation in Blink aims to provide an atomic move operation for DOM nodes. To achieve this, it sets a per-document StatePreservingAtomicMoveInProgress flag, asserting that “No script can run synchronously during the move.” However, this assumption is incorrect.
If a Text node is moved into a connected, empty <script> element, the HTMLScriptElement::ChildrenChanged path triggers synchronous script execution via ScriptLoader::PrepareScript() before moveBefore completes. Running script while the atomic-move flag is active causes significant bookkeeping errors and bypasses critical security checks related to subframe management, leading to cross-document frame-tree confusion and a high probability of a Use-After-Free (UAF) vulnerability.
Root Cause: Script-Execution Gap
When moveBefore (third_party/blink/renderer/core/dom/node.cc:906) calls insertBefore, it eventually triggers DidInsertNodeVector, which calls ChildrenChanged(ForInsertion). If the target is a script element, it may execute synchronously:
ContainerNode::AppendChild->InsertNodeVector->DidInsertNodeVectorChildrenChanged(ForInsertion)->HTMLScriptElement::ChildrenChangedScriptLoader::ChildrenChanged->PrepareScript()-> Synchronous Script Execution.
Because the StatePreservingAtomicMoveInProgress flag is true during this execution, internal DOM operations behave as if they are part of the atomic move, even if they are arbitrary removals or insertions performed by the script.
Subframe-Count Bookkeeping and Disconnection Bypass
When the atomic-move flag is set, ContainerNode::WillRemoveChild skips its normal teardown path, including ChildFrameDisconnector::Disconnect(), intentionally keeping the ContentFrame() alive. The system instead relies on HTMLFrameOwnerElement::RemovedFrom for bookkeeping.
However, the bookkeeping in HTMLFrameOwnerElement::RemovedFrom decrements the ConnectedSubframeCount(). This leads to a state where an element’s ConnectedSubframeCount() becomes 0 while its ContentFrame() remains live and attached.
Security Check Bypass with <object>
While <iframe> and <frame> elements have a SECURITY_CHECK in InsertedInto to prevent re-insertion if a stale ContentFrame exists (unless an atomic move is genuinely in progress), HTMLPlugInElement (used by <object> and <embed>) lacks this check.
If the attacker re-inserts the <object> element into the DOM after moveBefore returns, it is re-inserted while retaining its stale ContentFrame(), and its ConnectedSubframeCount() remains 0.
Later, if the element is adopted into a new document, ChildFrameDisconnector early-returns because the count is 0, leaving a live LocalFrame attached to a DOM-disconnected owner element. The frame tree becomes corrupted, as a LocalFrame belonging to one document’s frame tree is owned by an element in a different document. When the original document is destroyed, the frame tree is freed, leaving the <object> element holding a dangling LocalFrame pointer.
Potential Exploit Steps
(Note: These are suggested steps; Fortify LLM agent has not run a live Proof of Concept)
- An attacker creates a webpage (Document A) containing an empty, connected
<script>element and an<object>element that has loaded a subframe. - The attacker creates a
Textnode. - The attacker calls
script_element.moveBefore(text_node, null). moveBeforesets theStatePreservingAtomicMoveInProgressflag and inserts the text node.- The insertion triggers
ScriptLoader::PrepareScript, which synchronously executes the inline script. - The reentrant script removes the
<object>element from the DOM (object_element.remove()). - Because the atomic move flag is set, the
<object>’sContentFrame()is kept alive, but itsConnectedSubframeCountdrops to 0. - The reentrant script finishes, and
moveBeforeclears the flag and returns. - The attacker re-inserts the
<object>into Document A (document.body.appendChild(object_element)), bypassing missing security checks inHTMLPlugInElement. - The attacker moves the
<object>to a second document (Document B) viaadoptNode(). Because the subframe count is 0, theLocalFrameis not disconnected. - The attacker closes Document A or triggers garbage collection. The
LocalFrameis freed. - Document B accesses the
<object>’s staleContentFrame(), triggering a Use-After-Free.
Suggested Fixes
- Enforce ScriptForbiddenScope: Wrap the
insertBeforecall withinNode::moveBeforein aScriptForbiddenScopeto guarantee that no script execution can occur during an atomic move, completely preventing reentrancy. - Add SECURITY_CHECK to HTMLPlugInElement: Implement the
SECURITY_CHECK(!ContentFrame() || GetDocument().StatePreservingAtomicMoveInProgress())inHTMLPlugInElement::InsertedInto(orHTMLObjectElement::InsertedInto), mirroring the behavior ofHTMLFrameElementBase::InsertedIntoto ensure a staleContentFramecannot be re-inserted outside of a valid atomic move.
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.