CVE-2026-13848
Overview
Files Changed
third_party/blink/renderer/core/html/html_frame_owner_element.ccthird_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-subframe-count-crash.html
Patch
From 64a1e167421cf5654ed53ed88c91cc5c875baa42 Mon Sep 17 00:00:00 2001 From: Dominic Farolino <[email protected]> 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 <[email protected]> Commit-Queue: Dominic Farolino <[email protected]> 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:[email protected]"> +<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>
Regression Test / PoC
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:[email protected]"> +<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>
Original Bug Report
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.ccthird_party/blink/renderer/core/html/forms/html_select_element.ccthird_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:
HTMLOptionElement::RemovedFrom()is invoked on a removed option element (seehtml_option_element.cc:636). It fails to verify ifStatePreservingAtomicMoveInProgress()is active.- It calls
old_ancestor_select->OptionRemoved(*this, old_ancestor_select_child)(line 654). - Under the default configuration, this invokes
ResetToDefaultSelection, which executesSelectOptionand triggers a synchronous call toUpdateAllSelectedcontentsSingle(element)(seehtml_select_element.cc). - This method updates the descendant
<selectedcontent>element by callingCloneContentsFromOptionElement(seehtml_selected_content_element.cc), which executes a synchronous DOM mutation viaReplaceChildrenduring node removal. - The synchronous mutation increments the DOM tree version (
IncDOMTreeVersion()), causingDOMTreeMutationDetector::NeedsRecheck()to returnfalse(seecontainer_node.cc). - Consequently,
RecheckNodeInsertionStructuralPrereq()fails (since the reference node’s parent has been mutated), causingmoveBeforeto abort early with aNotFoundErrorexception. - Because the move aborted after the removal phase (which ran while
StatePreservingAtomicMoveInProgresswas true), the skipped detachment operations are never performed. The removed element subtree is left detached but with live layout objects (LayoutObject) and its iframe’sContentFrame()active.
Potential Impact
- Use-After-Free (UAF): The detached subtree retains active
LayoutObjectpointers referencing the original parent’s layout tree. If the parent layout tree is subsequently destroyed (for example, by settingdisplay: noneor 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. - Security Invariant Crash: Re-inserting the detached element back into the document triggers
HTMLFrameOwnerElement::InsertedInto(seehtml_frame_owner_element.cc:209). Since the iframe still holds a liveContentFrame()butStatePreservingAtomicMoveInProgress()is nowfalse, this triggers a release-mode security crash (SECURITY_CHECK).
Suggested Reproduction Steps (Potential)
- 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] - Wait for the iframe to load.
- Call
selectedcontent.moveBefore(moving, ref). - Observe that the call throws a
NotFoundErrorand leavesmovingdetached but with its iframecontentWindownon-null and layout objects active. - Trigger a style/layout recalc on the button/select or set
display: noneto destroy the layout tree. - Access/manipulate the children of
movingto trigger the potential UAF, or appendmovingback 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.