Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DOM
DescriptionUse after free in DOM
ComponentDOM
Bug ClassUAF
Tracker496292089
Fix commit2e3a53551270 (chromium/src) +4/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Files Changed

  • third_party/blink/renderer/core/html/html_script_element.cc
From 2e3a535512700ff4b1e6081ac7d06387951e1d4f Mon Sep 17 00:00:00 2001
From: Takashi Sakamoto <[email protected]>
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 <[email protected]>
Commit-Queue: Takashi Sakamoto <[email protected]>
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).
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • third_party/blink/renderer/core/dom/container_node.cc
  • third_party/blink/renderer/core/html/html_plugin_element.cc
  • third_party/blink/renderer/core/html/html_frame_owner_element.cc
  • third_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:

  1. ContainerNode::AppendChild -> InsertNodeVector -> DidInsertNodeVector
  2. ChildrenChanged(ForInsertion) -> HTMLScriptElement::ChildrenChanged
  3. ScriptLoader::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)

  1. An attacker creates a webpage (Document A) containing an empty, connected <script> element and an <object> element that has loaded a subframe.
  2. The attacker creates a Text node.
  3. The attacker calls script_element.moveBefore(text_node, null).
  4. moveBefore sets the StatePreservingAtomicMoveInProgress flag and inserts the text node.
  5. The insertion triggers ScriptLoader::PrepareScript, which synchronously executes the inline script.
  6. The reentrant script removes the <object> element from the DOM (object_element.remove()).
  7. Because the atomic move flag is set, the <object>’s ContentFrame() is kept alive, but its ConnectedSubframeCount drops to 0.
  8. The reentrant script finishes, and moveBefore clears the flag and returns.
  9. The attacker re-inserts the <object> into Document A (document.body.appendChild(object_element)), bypassing missing security checks in HTMLPlugInElement.
  10. The attacker moves the <object> to a second document (Document B) via adoptNode(). Because the subframe count is 0, the LocalFrame is not disconnected.
  11. The attacker closes Document A or triggers garbage collection. The LocalFrame is freed.
  12. Document B accesses the <object>’s stale ContentFrame(), triggering a Use-After-Free.

Suggested Fixes

  1. Enforce ScriptForbiddenScope: Wrap the insertBefore call within Node::moveBefore in a ScriptForbiddenScope to guarantee that no script execution can occur during an atomic move, completely preventing reentrancy.
  2. Add SECURITY_CHECK to HTMLPlugInElement: Implement the SECURITY_CHECK(!ContentFrame() || GetDocument().StatePreservingAtomicMoveInProgress()) in HTMLPlugInElement::InsertedInto (or HTMLObjectElement::InsertedInto), mirroring the behavior of HTMLFrameElementBase::InsertedInto to ensure a stale ContentFrame cannot 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.

View on issue tracker