CVE-2026-6316
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/html/forms/form_controller.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/forms/form_controller.ccthird_party/blink/web_tests/VirtualTestSuitesthird_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.htmlthird_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.htmlthird_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html
Patch
From 847b11ad2fa3f99154fb2ceb117b622ced7fa96c Mon Sep 17 00:00:00 2001
From: Joey Arhar <jarhar@chromium.org>
Date: Fri, 10 Apr 2026 12:19:11 -0700
Subject: [PATCH] Fix crashes when restoring <selectedcontent> with <input>
When restoring form control state, the DOM could be modified to add or
remove more listed elements to the form if a select element is being
restored which has a selectedcontent element.
Fixed: 499384399
Change-Id: I18f69c30ae25396c53625f7a3172626b79de3ae3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7732030
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Commit-Queue: Joey Arhar <jarhar@chromium.org>
Reviewed-by: Dominic Farolino <dom@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1613032}
---
diff --git a/third_party/blink/renderer/core/html/forms/form_controller.cc b/third_party/blink/renderer/core/html/forms/form_controller.cc
index abacf37..c4cb596 100644
--- a/third_party/blink/renderer/core/html/forms/form_controller.cc
+++ b/third_party/blink/renderer/core/html/forms/form_controller.cc
@@ -492,8 +492,10 @@
if (!document_->HasFinishedParsing())
return;
EventQueueScope scope;
- const ListedElement::List& elements = form.ListedElements();
- for (const auto& control : elements) {
+ // Make a copy of the list because the DOM could be modified during
+ // restoration of a <select> with a <selectedcontent> element.
+ ListedElement::List elements_copy(form.ListedElements());
+ for (const auto& control : elements_copy) {
if (!control->ClassSupportsStateRestore())
continue;
if (OwnerFormForState(*control) != &form)
@@ -550,7 +552,11 @@
return;
HeapHashSet<Member<HTMLFormElement>> finished_forms;
EventQueueScope scope;
- for (auto& control : document_state_->GetControlList()) {
+ // Make a copy of the list because the DOM could be modified during
+ // restoration of a <select> with a <selectedcontent> element.
+ DocumentState::ControlList control_list_copy(
+ document_state_->GetControlList());
+ for (auto& control : control_list_copy) {
auto* owner = OwnerFormForState(*control);
if (!owner)
RestoreControlStateFor(*control);
diff --git a/third_party/blink/web_tests/VirtualTestSuites b/third_party/blink/web_tests/VirtualTestSuites
index d301e85a..2ed156543 100644
--- a/third_party/blink/web_tests/VirtualTestSuites
+++ b/third_party/blink/web_tests/VirtualTestSuites
@@ -3029,7 +3029,7 @@
],
"bases": [
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-in-option-crash.html",
- "external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html",
+ "external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html",
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-color.html",
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-nested.html",
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontentelement-attr.tentative.html"
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.html
new file mode 100644
index 0000000..847f42a
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<style>
+select,::picker(select) {
+ appearance: base-select;
+}
+</style>
+<form action="blank.html">
+ <select>
+ <button>
+ <selectedcontent></selectedcontent>
+ </button>
+ <option id=one>one</option>
+ <option id=two>two</option>
+ </select>
+</form>
+
+<script>
+window.createInput = () => {
+ const selectedcontent = document.querySelector('selectedcontent');
+ const input = document.createElement('input');
+ window.input = input;
+ input.name = 'input';
+ selectedcontent.innerHTML = '';
+ selectedcontent.appendChild(input);
+};
+window.createInput();
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html
deleted file mode 100644
index da5fe450..0000000
--- a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html
+++ /dev/null
@@ -1,35 +0,0 @@
-<!DOCTYPE html>
-<link rel=author href="mailto:jarhar@chromium.org">
-<link rel=help href="https://github.com/whatwg/html/issues/9799">
-<script src="/resources/testharness.js"></script>
-<script src="/resources/testharnessreport.js"></script>
-<script src="/resources/testdriver.js"></script>
-<script src="/resources/testdriver-vendor.js"></script>
-
-<iframe src="resources/selectedcontent-restore-iframe.html"></iframe>
-
-<script>
-const iframe = document.querySelector('iframe');
-promise_test(async () => {
- await new Promise(resolve => iframe.onload = resolve);
- await test_driver.bless();
-
- iframe.contentDocument.querySelector('select').value = 'two';
- assert_equals(iframe.contentDocument.querySelector('select').value, 'two',
- 'Assining two to select.value should work.');
- iframe.contentDocument.querySelector('form').submit();
- await new Promise(resolve => iframe.onload = resolve);
-
- await test_driver.bless();
- iframe.contentWindow.history.back();
- await new Promise(resolve => iframe.onload = resolve);
- await new Promise(requestAnimationFrame);
- await new Promise(requestAnimationFrame);
-
- assert_equals(iframe.contentDocument.querySelector('select').value, 'two',
- 'The selects value should be restored after navigating back.');
- assert_equals(iframe.contentDocument.querySelector('selectedcontent').innerHTML,
- iframe.contentDocument.querySelector('option[value=two]').innerHTML,
- 'selectedcontent.innerHTML should match the selected <option>');
-}, '<selectedcontent> should be up to date after form restoration.');
-</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html
new file mode 100644
index 0000000..1d0064659c
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://github.com/whatwg/html/issues/9799">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+<!-- This test is marked optional because form control restoration is not explicitly specified. -->
+
+<iframe id=iframe1 src="resources/selectedcontent-restore-iframe.html"></iframe>
+<iframe id=iframe2 src="resources/selectedcontent-input.html"></iframe>
+
+<script>
+const iframe1 = document.getElementById('iframe1');
+const iframe2 = document.getElementById('iframe2');
+const iframe1load = new Promise(resolve => iframe1.onload = resolve);
+const iframe2load = new Promise(resolve => iframe2.onload = resolve);
+
+promise_test(async () => {
+ await iframe1load;
+ await test_driver.bless();
+
+ iframe1.contentDocument.querySelector('select').value = 'two';
+ assert_equals(iframe1.contentDocument.querySelector('select').value, 'two',
+ 'Assigning two to select.value should work.');
+ iframe1.contentDocument.querySelector('form').submit();
+ await new Promise(resolve => iframe1.onload = resolve);
+
+ await test_driver.bless();
+ iframe1.contentWindow.history.back();
+ // Form controls are restored immediately after the load event is fired, so
+ // one rAF is added after awaiting the load event. See
+ // LocalDOMWindow::DispatchLoadAndPageshowEvents.
+ await new Promise(resolve => iframe1.onload = resolve);
+ await new Promise(requestAnimationFrame);
+
+ assert_equals(iframe1.contentDocument.querySelector('select').value, 'two',
+ 'The selects value should be restored after navigating back.');
+ assert_equals(iframe1.contentDocument.querySelector('selectedcontent').innerHTML,
+ iframe1.contentDocument.querySelector('option[value=two]').innerHTML,
+ 'selectedcontent.innerHTML should match the selected <option>');
+}, '<selectedcontent> should be up to date after form restoration.');
+
+promise_test(async () => {
+ await iframe2load;
+ await test_driver.bless();
+
+ iframe2.contentDocument.querySelector('select').value = 'two';
+ iframe2.contentWindow.createInput();
+ iframe2.contentDocument.querySelector('input').value = 'value';
+ iframe2.contentDocument.querySelector('form').submit();
+ await new Promise(resolve => iframe2.onload = resolve);
+
+ await test_driver.bless();
+ iframe2.contentWindow.history.back();
+ // Form controls are restored immediately after the load event is fired, so
+ // one rAF is added after awaiting the load event. See
+ // LocalDOMWindow::DispatchLoadAndPageshowEvents.
Regression Test / PoC
diff --git a/third_party/blink/web_tests/VirtualTestSuites b/third_party/blink/web_tests/VirtualTestSuites
index d301e85a..2ed156543 100644
--- a/third_party/blink/web_tests/VirtualTestSuites
+++ b/third_party/blink/web_tests/VirtualTestSuites
@@ -3029,7 +3029,7 @@
],
"bases": [
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-in-option-crash.html",
- "external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html",
+ "external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html",
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-color.html",
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-nested.html",
"external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontentelement-attr.tentative.html"
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.html
new file mode 100644
index 0000000..847f42a
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<style>
+select,::picker(select) {
+ appearance: base-select;
+}
+</style>
+<form action="blank.html">
+ <select>
+ <button>
+ <selectedcontent></selectedcontent>
+ </button>
+ <option id=one>one</option>
+ <option id=two>two</option>
+ </select>
+</form>
+
+<script>
+window.createInput = () => {
+ const selectedcontent = document.querySelector('selectedcontent');
+ const input = document.createElement('input');
+ window.input = input;
+ input.name = 'input';
+ selectedcontent.innerHTML = '';
+ selectedcontent.appendChild(input);
+};
+window.createInput();
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html
deleted file mode 100644
index da5fe450..0000000
--- a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html
+++ /dev/null
@@ -1,35 +0,0 @@
-<!DOCTYPE html>
-<link rel=author href="mailto:jarhar@chromium.org">
-<link rel=help href="https://github.com/whatwg/html/issues/9799">
-<script src="/resources/testharness.js"></script>
-<script src="/resources/testharnessreport.js"></script>
-<script src="/resources/testdriver.js"></script>
-<script src="/resources/testdriver-vendor.js"></script>
-
-<iframe src="resources/selectedcontent-restore-iframe.html"></iframe>
-
-<script>
-const iframe = document.querySelector('iframe');
-promise_test(async () => {
- await new Promise(resolve => iframe.onload = resolve);
- await test_driver.bless();
-
- iframe.contentDocument.querySelector('select').value = 'two';
- assert_equals(iframe.contentDocument.querySelector('select').value, 'two',
- 'Assining two to select.value should work.');
- iframe.contentDocument.querySelector('form').submit();
- await new Promise(resolve => iframe.onload = resolve);
-
- await test_driver.bless();
- iframe.contentWindow.history.back();
- await new Promise(resolve => iframe.onload = resolve);
- await new Promise(requestAnimationFrame);
- await new Promise(requestAnimationFrame);
-
- assert_equals(iframe.contentDocument.querySelector('select').value, 'two',
- 'The selects value should be restored after navigating back.');
- assert_equals(iframe.contentDocument.querySelector('selectedcontent').innerHTML,
- iframe.contentDocument.querySelector('option[value=two]').innerHTML,
- 'selectedcontent.innerHTML should match the selected <option>');
-}, '<selectedcontent> should be up to date after form restoration.');
-</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html
new file mode 100644
index 0000000..1d0064659c
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://github.com/whatwg/html/issues/9799">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+
+<!-- This test is marked optional because form control restoration is not explicitly specified. -->
+
+<iframe id=iframe1 src="resources/selectedcontent-restore-iframe.html"></iframe>
+<iframe id=iframe2 src="resources/selectedcontent-input.html"></iframe>
+
+<script>
+const iframe1 = document.getElementById('iframe1');
+const iframe2 = document.getElementById('iframe2');
+const iframe1load = new Promise(resolve => iframe1.onload = resolve);
+const iframe2load = new Promise(resolve => iframe2.onload = resolve);
+
+promise_test(async () => {
+ await iframe1load;
+ await test_driver.bless();
+
+ iframe1.contentDocument.querySelector('select').value = 'two';
+ assert_equals(iframe1.contentDocument.querySelector('select').value, 'two',
+ 'Assigning two to select.value should work.');
+ iframe1.contentDocument.querySelector('form').submit();
+ await new Promise(resolve => iframe1.onload = resolve);
+
+ await test_driver.bless();
+ iframe1.contentWindow.history.back();
+ // Form controls are restored immediately after the load event is fired, so
+ // one rAF is added after awaiting the load event. See
+ // LocalDOMWindow::DispatchLoadAndPageshowEvents.
+ await new Promise(resolve => iframe1.onload = resolve);
+ await new Promise(requestAnimationFrame);
+
+ assert_equals(iframe1.contentDocument.querySelector('select').value, 'two',
+ 'The selects value should be restored after navigating back.');
+ assert_equals(iframe1.contentDocument.querySelector('selectedcontent').innerHTML,
+ iframe1.contentDocument.querySelector('option[value=two]').innerHTML,
+ 'selectedcontent.innerHTML should match the selected <option>');
+}, '<selectedcontent> should be up to date after form restoration.');
+
+promise_test(async () => {
+ await iframe2load;
+ await test_driver.bless();
+
+ iframe2.contentDocument.querySelector('select').value = 'two';
+ iframe2.contentWindow.createInput();
+ iframe2.contentDocument.querySelector('input').value = 'value';
+ iframe2.contentDocument.querySelector('form').submit();
+ await new Promise(resolve => iframe2.onload = resolve);
+
+ await test_driver.bless();
+ iframe2.contentWindow.history.back();
+ // Form controls are restored immediately after the load event is fired, so
+ // one rAF is added after awaiting the load event. See
+ // LocalDOMWindow::DispatchLoadAndPageshowEvents.
+ await new Promise(resolve => iframe2.onload = resolve);
+ await new Promise(requestAnimationFrame);
+
+ // A crash would happen here because the form restoration code would iterate
+ // over all of the form controls and remove an input element to restore during
+ // restoration of the selectedcontent element, then try to restore the
+ // disconnected input.
+
+ assert_equals(iframe2.contentDocument.querySelector('select').value, 'two',
+ 'The selects value should be restored after navigating back.');
+ assert_equals(iframe2.contentDocument.querySelector('selectedcontent').innerHTML,
+ iframe2.contentDocument.getElementById('two').innerHTML,
+ 'selectedcontent.innerHTML should match the selected <option>');
+ assert_equals(iframe2.contentWindow.input.value, '',
+ 'The text inputs value should not be restored because it was removed before restoring.');
+}, '<input> inside <selectedcontent> should be restored after form submission.');
+</script>
Original Bug Report
Renderer UAF via Iterator Invalidation in FormController::RestoreControlStateIn
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 without the security team.
Overview: A potential Use-After-Free (UAF) vulnerability exists in the Blink renderer due to iterator invalidation during form state restoration. FormController::RestoreControlStateIn iterates over a cached list of form elements that can be synchronously cleared mid-iteration by DOM mutations triggered when restoring <select> elements. This allows an attacker to free and reclaim the iterator’s backing buffer, leading to type confusion and arbitrary code execution in the renderer.
Affected files:
third_party/blink/renderer/core/html/forms/form_controller.ccthird_party/blink/renderer/core/html/forms/html_form_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: 2019-09-29
Summary
A potential Use-After-Free (UAF) vulnerability exists in the Blink renderer’s form restoration logic. Specifically, FormController::RestoreControlStateIn iterates over a HeapVector of listed elements using a C++ range-based for loop. During this iteration, the restoration of a <select> element containing a <selectedcontent> element can trigger a synchronous DOM mutation. If this mutation removes an existing form-associated element, it clears the form’s internal element cache, freeing the backing buffer while the loop’s iterators are still actively pointing to it.
Technical Details
In third_party/blink/renderer/core/html/forms/form_controller.cc, RestoreControlStateIn processes elements during history navigation:
void FormController::RestoreControlStateIn(HTMLFormElement& form) {
// ...
const ListedElement::List& elements = form.ListedElements();
for (const auto& control : elements) { // Caches raw __begin and __end pointers
if (!control->ClassSupportsStateRestore())
continue;
// ...
RestoreControlStateInternal(*control);
}
}
The range-based for loop caches raw pointers to the HeapVector’s backing buffer. Inside the loop, RestoreControlStateInternal invokes HTMLSelectElement::RestoreFormControlState. If the select element has a <selectedcontent> descendant, it calls UpdateAllSelectedcontents, which clones the new option’s contents and replaces the old contents via ReplaceChildren.
If the <selectedcontent> previously contained a form-associated element (e.g., an <input>), ReplaceChildren detaches it. This removal synchronously triggers HTMLFormControlElement::RemovedFrom -> ListedElement::ResetFormOwner -> HTMLFormElement::Disassociate.
HTMLFormElement::Disassociate calls listed_elements_.clear(). For a HeapVector, this translates to ShrinkCapacity(0), which synchronously invokes cppgc::subtle::FreeUnreferencedObject. If no GC pause is active, the memory is immediately returned to the cppgc Linear Allocation Buffer (LAB) or free list.
The range-based for loop resumes, increments its dangling iterator, and dereferences the reclaimed memory. The subsequent virtual call (control->ClassSupportsStateRestore()) results in a type confusion and control-flow hijack. MiraclePtr (BRP) does not prevent this because the freed memory is a cppgc backing buffer.
Potential Attacker Steps
Please note: These are suggested steps based on static analysis. Our tooling agent does not have the ability to execute code to produce a live proof-of-concept.
- Construct a web page with an
<form>. - Inside the form, place a
<select>element associated with a<selectedcontent>element. - Inside the
<selectedcontent>element, nest a form-associated control like an<input>. This places the<input>into the form’s cachedListedElements(). - Add extra dummy form controls to the page to pad the
ListedElements()vector to a target size, allowing for precisecppgcheap grooming. - Use JavaScript to change the
<select>element’s selected option, saving its state. - Navigate the page away, then trigger a
history.back()navigation. - Upon restoration, the
<select>triggers the removal of the<input>, freeing theHeapVectorbuffer synchronously. - The subsequent allocations from the
CloneContentsFromOptionElementoperation reclaim the freed buffer with attacker-controlled data. - The loop dereferences the attacker-controlled fake object, hijacking the renderer process via a fake vtable.
Suggested Fix
Do not iterate over the live reference returned by form.ListedElements(), as it is highly susceptible to mutation during event dispatch or DOM modifications. Instead, make a local copy of the HeapVector before iterating. This safely anchors the elements and protects the backing buffer from being destroyed by cache invalidation:
void FormController::RestoreControlStateIn(HTMLFormElement& form) {
// ...
ListedElement::List elements_copy(form.ListedElements());
for (const auto& control : elements_copy) {
// ...
}
}
Evaluated with Chrome root at commit: 09ec9e7cc4d24823d20b6d37cf3d282734f6bf0f
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. And please feel free to reach out to me directly if you have concerns or feedback on the project.