High patch-now ~ plausible 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 DOM
DescriptionUse after free in DOM
ComponentDOM
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — 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 AIPlausible
Reviewer notes: Diff only adds a StatePreservingAtomicMoveInProgress() guard before loader_->ChildrenChanged; the claimed chain (reentrant script execution freeing a node the move depends on) is stated as fact but not shown. ChildrenChanged fires when the script's children change, not when the script moves.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker496292089
Fix commit2e3a53551270 (chromium/src) +4/-1
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-31 (public git)
Disclosed2026-05-05
Patch gap35 days — fix public in source before the advisory

Background

State-preserving atomic move
the DOM moveBefore() operation that relocates a node within the tree without the usual remove-then-insert teardown, keeping state intact; Blink tracks that it is underway via Document::StatePreservingAtomicMoveInProgress().
`HTMLScriptElement::ChildrenChanged`
the callback Blink invokes when a <script> element’s child nodes change, which normally forwards the change to the element’s ScriptLoader (loader_).
`ScriptLoader::ChildrenChanged` / `PrepareScript()`
the loader logic that reacts to child mutations and can schedule or run script preparation, a step that can execute script and dispatch events that reenter and mutate the DOM.
Reentrancy during a move
running script-preparation side effects while an atomic move holds live references to nodes being relocated can free or detach those nodes out from under the in-progress operation.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Diff only adds a StatePreservingAtomicMoveInProgress() guard before loader_-&gt;ChildrenChanged; the claimed chain (reentrant script execution freeing a node the move depends on) is stated as fact but not shown. ChildrenChanged fires when the script's children change, not when the script moves.

HTMLScriptElement::ChildrenChanged unconditionally called loader_->ChildrenChanged(change) after delegating to HTMLElement::ChildrenChanged. During a state-preserving atomic move (moveBefore()), the move mutates the <script> element’s children, which fires this callback and, through the loader, can reach PrepareScript() and its script-execution / event-dispatch side effects while the atomic move is still in progress and holding references to nodes mid-relocation. That reentrancy can detach or free a node the atomic move still depends on, producing a use-after-free.

The fix wraps the loader notification in if (!GetDocument().StatePreservingAtomicMoveInProgress()), so the loader_->ChildrenChanged(change) path — and thus the script preparation it can trigger — is suppressed while a move is underway, as the commit subject states (“Block PrepareScript() while a document state is preserving-atomic-move-in-progress”). The guard tightens behavior by adding a precondition that previously did not exist.

Key insight
The core mistake was forwarding <script> child-change notifications to the ScriptLoader even during a state-preserving atomic move, letting script preparation reenter and mutate the DOM mid-move; the fix gates that forwarding on !StatePreservingAtomicMoveInProgress() so no script-preparation side effects run while the move is in progress.

Attack Path

  1. Prepare a script element in a movable position A page sets up a <script> element whose children will change as part of a moveBefore() atomic move.
  2. Invoke `moveBefore()` Script triggers the state-preserving atomic move, which mutates the <script> element’s children and fires HTMLScriptElement::ChildrenChanged while StatePreservingAtomicMoveInProgress() is true.
  3. Reenter via the loader Pre-patch, loader_->ChildrenChanged(change) runs during the move and can reach PrepareScript(), whose script execution / event dispatch reenters and mutates the DOM mid-move.
  4. Use a freed node The reentrant mutation detaches or frees a node the still-active atomic move references, yielding a use-after-free in the renderer (consistent with the advisory’s “use after free in DOM”; the specific freed object is not identified in the diff).

Impact Assessment

An attacker-controlled page can drive a moveBefore() atomic move over a <script> element to reenter script preparation during the move, causing a use-after-free in the renderer process; the advisory rates this high severity and does not report in-the-wild exploitation. The patch demonstrates the fix (suppressing the loader call during the move) but not a concrete controlled read/write primitive or any sandbox-level escalation, so exploitation beyond the UAF is inferred. The precondition is script access to the moveBefore() atomic-move API acting on a <script> element, available to ordinary web content.

Files Changed

  • third_party/blink/renderer/core/html/html_script_element.cc

Audit Directions

  • Mutation callbacks during atomic move
    audit other ChildrenChanged/insertion/removal callbacks that trigger script execution, event dispatch, or resource loading, and verify they consult StatePreservingAtomicMoveInProgress() before running reentrant side effects.
  • `moveBefore()` reentrancy surface
    review DOM operations reachable while a state-preserving atomic move is active for places that can detach or free nodes the move still references.
  • Loader/observer forwarding without state guards
    flag element callbacks that unconditionally forward mutations to loaders or observers capable of executing script, since those paths need the same in-progress-move suppression.
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).
Loading diff…

Original Bug Report

reported by rj...@google.com

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