Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Forms
DescriptionUse after free in Forms
ComponentForms
Bug ClassUAF
Tracker517345069
Fix commit64a1e167421c (chromium/src) +35/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • third_party/blink/renderer/core/html/html_frame_owner_element.cc
  • third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html
From 64a1e167421cf5654ed53ed88c91cc5c875baa42 Mon Sep 17 00:00:00 2001
From: Dominic Farolino <dom@chromium.org>
Date: Tue, 16 Jun 2026 15:03:09 -0700
Subject: [PATCH] DOM: Fix connected subframe count during moveBefore()

Before this CL: In `HTMLFrameOwnerElement::RemovedFrom()`, when an
element was removed during a state-preserving atomic move
(moveBefore()), we decremented the connected subframe count on the frame
owner element itself and on all ancestors starting from
`insertion_point` (the old, connected parent of the newly-disconnected
subtree).

Whe moving an ancestor of an iframe, this caused a bookkeeping problem:
  1. Intermediate nodes between the iframe and the insertion point never
     had their subframe counts decremented;
  2. When InsertedInto() later runs, the subframe counts on all nodes
     starting at the iframe's parent up to the root get re-incremented.

This means that intermediate nodes between the iframe and the insertion
point have their counts "re-incremented" after never being decremented.
This causes the page's subframe count to by desynchronized from reality,
causing a DCHECK() in `CheckFrameCountConsistency()` during the next
operation that invokes that path, such as the child frame disconnector.

After this CL: We updated the increment logic in `InsertedInto()` to
mirror that in `RemovedFrom()`. Specifically, we now increment the
connected subframe count on the frame owner element itself and on all
ancestors starting from `insertion_point` (the new parent of the
attached subtree). This leaves the subframe counts of any intermediate
nodes within the moved subtree completely untouched and accurate.

See https://crrev.com/c/7882216 for a previous attempt.

R=jarhar, nrosenthal

Bug: 517345069
Change-Id: I3d2f1d9641cfde9b57246fa4c7932da35b7c2588
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7950701
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Commit-Queue: Dominic Farolino <dom@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1647915}
---

diff --git a/third_party/blink/renderer/core/html/html_frame_owner_element.cc b/third_party/blink/renderer/core/html/html_frame_owner_element.cc
index bd22073..f48b78e95 100644
--- a/third_party/blink/renderer/core/html/html_frame_owner_element.cc
+++ b/third_party/blink/renderer/core/html/html_frame_owner_element.cc
@@ -222,12 +222,15 @@
   // move flow.
   if (GetDocument().StatePreservingAtomicMoveInProgress() && ContentFrame()) {
     // During a state-preserving atomic move, we must specifically inform all of
-    // `this`'s ancestor nodes of the new connected frame they are adopting.
+    // `this`'s new ancestor nodes, starting from `insertion_point`, of the new
+    // connected frame they are adopting. We also re-increment `this` to match
+    // the decrement performed in `RemovedFrom()` below.
     //
     // For the non-state-preserving atomic move case (i.e., when we're setting
     // up a full frame due to real insertion), this is done in
     // `HTMLFrameOwnerElement::SetContentFrame()` below.
-    for (ContainerNode* node = this; node;
+    IncrementConnectedSubframeCount();
+    for (ContainerNode* node = &insertion_point; node;
          node = node->ParentOrShadowHostNode()) {
       node->IncrementConnectedSubframeCount();
     }
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html
new file mode 100644
index 0000000..1bbe468
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html class=test-wait>
+<link rel=author href="mailto:dom@chromium.org">
+<link rel=help href="https://crbug.com/517345069">
+<title>Node.moveBefore: iframe ancestor subframe count consistency</title>
+<body>
+<script>
+// This is a regression test for a Chromium bug where the Blink internal
+// subframe count consistency checker gets out-of-sync due to `moveBefore()`.
+// See https://crbug.com/517345069.
+const oldParent = document.createElement('div');
+document.body.append(oldParent);
+
+const moveTarget = document.createElement('div');
+oldParent.append(moveTarget);
+
+const iframe = document.createElement('iframe');
+moveTarget.append(iframe);
+
+// Perform a state-preserving atomic move of `moveTarget` to `document.body`.
+// In Blink, previously this would result in `moveTarget`'s subframe count
+// getting over-incremented (becoming 2 instead of 1).
+document.body.moveBefore(moveTarget, null);
+
+// In Blink, this triggers the subframe count consistency checker, making the
+// above desynchronization cause a crash.
+moveTarget.remove();
+document.documentElement.classList.remove('test-wait');
+</script>
+</body>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html
new file mode 100644
index 0000000..1bbe468
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html class=test-wait>
+<link rel=author href="mailto:dom@chromium.org">
+<link rel=help href="https://crbug.com/517345069">
+<title>Node.moveBefore: iframe ancestor subframe count consistency</title>
+<body>
+<script>
+// This is a regression test for a Chromium bug where the Blink internal
+// subframe count consistency checker gets out-of-sync due to `moveBefore()`.
+// See https://crbug.com/517345069.
+const oldParent = document.createElement('div');
+document.body.append(oldParent);
+
+const moveTarget = document.createElement('div');
+oldParent.append(moveTarget);
+
+const iframe = document.createElement('iframe');
+moveTarget.append(iframe);
+
+// Perform a state-preserving atomic move of `moveTarget` to `document.body`.
+// In Blink, previously this would result in `moveTarget`'s subframe count
+// getting over-incremented (becoming 2 instead of 1).
+document.body.moveBefore(moveTarget, null);
+
+// In Blink, this triggers the subframe count consistency checker, making the
+// above desynchronization cause a crash.
+moveTarget.remove();
+document.documentElement.classList.remove('test-wait');
+</script>
+</body>
Loading diff…

Original Bug Report

reported by vm...@google.com

UAF via HTMLOptionElement::RemovedFrom and moveBefore DOM Mutation

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: HTMLOptionElement::RemovedFrom lacks a StatePreservingAtomicMoveInProgress check, allowing synchronous DOM mutations during moveBefore. This can lead to detached subtrees with live frames and layout/DOM tree desynchronization, potentially enabling a use-after-free.

Affected files:

  • third_party/blink/renderer/core/html/forms/html_option_element.cc
  • third_party/blink/renderer/core/html/forms/html_select_element.cc
  • third_party/blink/renderer/core/html/forms/html_selected_content_element.cc

Estimated timestamp from git blame: 2024-10-10

Potential Use-After-Free via HTMLOptionElement::RemovedFrom and moveBefore DOM Mutation

Overview

A potential security vulnerability exists in Blink’s handling of the state-preserving atomic move API (moveBefore). Specifically, HTMLOptionElement::RemovedFrom does not perform a StatePreservingAtomicMoveInProgress() check before calling into HTMLSelectElement::OptionRemoved. In default production configurations (where the experimental <selectedcontent> specification is disabled), this allows OptionRemoved to synchronously trigger a DOM mutation via ReplaceChildren on a connected <selectedcontent> element during the removal phase of moveBefore. This synchronous mutation can bypass the atomic move invariants, leading to a detached DOM subtree with live frames and layout/DOM tree desynchronization, which can result in a Use-After-Free (UAF) or an invariant security check crash.

Note: Since our tooling agent currently lacks the capability to run code dynamically, these findings and steps are based on static analysis of the source code and represent potential behavior.

Root Cause Analysis

During a state-preserving atomic move (such as when invoking moveBefore() on a node containing a <selectedcontent> element and an iframe), the document-wide flag StatePreservingAtomicMoveInProgress is set to true to ensure that frame disconnects and layout tree detachment are safely skipped during the removal phase (see container_node.cc).

However, during this phase:

  1. HTMLOptionElement::RemovedFrom() is invoked on a removed option element (see html_option_element.cc:636). It fails to verify if StatePreservingAtomicMoveInProgress() is active.
  2. It calls old_ancestor_select->OptionRemoved(*this, old_ancestor_select_child) (line 654).
  3. Under the default configuration, this invokes ResetToDefaultSelection, which executes SelectOption and triggers a synchronous call to UpdateAllSelectedcontentsSingle(element) (see html_select_element.cc).
  4. This method updates the descendant <selectedcontent> element by calling CloneContentsFromOptionElement (see html_selected_content_element.cc), which executes a synchronous DOM mutation via ReplaceChildren during node removal.
  5. The synchronous mutation increments the DOM tree version (IncDOMTreeVersion()), causing DOMTreeMutationDetector::NeedsRecheck() to return false (see container_node.cc).
  6. Consequently, RecheckNodeInsertionStructuralPrereq() fails (since the reference node’s parent has been mutated), causing moveBefore to abort early with a NotFoundError exception.
  7. Because the move aborted after the removal phase (which ran while StatePreservingAtomicMoveInProgress was true), the skipped detachment operations are never performed. The removed element subtree is left detached but with live layout objects (LayoutObject) and its iframe’s ContentFrame() active.

Potential Impact

  1. Use-After-Free (UAF): The detached subtree retains active LayoutObject pointers referencing the original parent’s layout tree. If the parent layout tree is subsequently destroyed (for example, by setting display: none or removing the parent element from the DOM), these layout objects are freed, leaving the detached DOM elements with dangling pointers. Accessing or modifying these detached elements later can trigger a UAF.
  2. Security Invariant Crash: Re-inserting the detached element back into the document triggers HTMLFrameOwnerElement::InsertedInto (see html_frame_owner_element.cc:209). Since the iframe still holds a live ContentFrame() but StatePreservingAtomicMoveInProgress() is now false, this triggers a release-mode security crash (SECURITY_CHECK).

Suggested Reproduction Steps (Potential)

  1. Construct a DOM structure where an option is a descendant of a moving subtree, containing an iframe and a <selectedcontent> element sibling: select > button > [selectedcontent > span#ref] + [div#moving > (span > option[selected]) + iframe]
  2. Wait for the iframe to load.
  3. Call selectedcontent.moveBefore(moving, ref).
  4. Observe that the call throws a NotFoundError and leaves moving detached but with its iframe contentWindow non-null and layout objects active.
  5. Trigger a style/layout recalc on the button/select or set display: none to destroy the layout tree.
  6. Access/manipulate the children of moving to trigger the potential UAF, or append moving back to the document to observe the security invariant crash.

Suggested Fix

To mitigate this issue, HTMLOptionElement::RemovedFrom should perform a check for StatePreservingAtomicMoveInProgress() and skip calling OptionRemoved if a state-preserving atomic move is in progress.

Specifically, in third_party/blink/renderer/core/html/forms/html_option_element.cc:

void HTMLOptionElement::RemovedFrom(ContainerNode& insertion_point) {
  HTMLElement::RemovedFrom(insertion_point);

  if (GetDocument().StatePreservingAtomicMoveInProgress()) {
    return;
  }

  HTMLSelectElement* old_ancestor_select = nearest_ancestor_select_;
  ...

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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