CVE-2026-6316
Overview
Background
- Form state restoration
- When navigating back/forward, Blink restores saved form-control values (
FormController), iterating over the form’s listed elements and reapplying their persisted state. - `ListedElement::List` / `DocumentState::ControlList`
- The live collections of form-associated controls iterated during restoration; they reflect the current DOM membership.
- `<selectedcontent>` / customizable `<select>`
- A customizable-select feature where a
<selectedcontent>element mirrors the selected<option>’s content; restoring the<select>re-renders that content, which can mutate the DOM. - Iterator invalidation
- Modifying a container (adding/removing listed elements) while iterating a reference to it can invalidate the iteration and leave code operating on a removed/freed element.
Root Cause Analysis
The restoration loops iterated over references to the live control collections: const ListedElement::List& elements = form.ListedElements() and for (auto& control : document_state_->GetControlList()). Per the commit and the added test, restoring a <select> that contains a <selectedcontent> element can modify the DOM mid-iteration — specifically, applying the <select>’s restored value re-renders <selectedcontent>, and if its subtree contains a form-associated control such as an <input>, the set of listed/form controls changes while the loop is still walking it. The regression comment is explicit: the 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,” i.e. a use-after-free / stale-element access caused by iterator invalidation.
The fix makes a copy of each collection before iterating — ListedElement::List elements_copy(form.ListedElements()) and DocumentState::ControlList control_list_copy(document_state_->GetControlList()) — so the loop walks a stable snapshot and DOM mutations during restoration no longer corrupt the iteration. (ListedElement::List/ControlList are heap-traced collections, so the copy keeps the members alive for the duration of the loop.)
<select>/<selectedcontent> could add or remove controls, invalidating the iteration; the fix iterates over a copy of each list so mid-loop DOM mutations cannot free or reorder the elements being visited.Attack Path
- Build a customizable select with a nested control
A page uses
appearance: base-selectwith a<select>whose<selectedcontent>contains (or is scripted to contain) an<input>. Ground: the addedselectedcontent-input.htmlresource constructs exactly this and injects an<input name=input>into<selectedcontent>. - Set a value and submit
The page sets the
<select>value, fills the input, and submits the form so state is persisted for the session history. Ground: the test’screateInput(), value assignment, andform.submit(). - Navigate back to trigger restoration
history.back()causes Blink to restore form-control state, entering the restoration loop over the live collection. Ground: the test’shistory.back()and the patched loops inform_controller.cc. - Mutate the list during the loop
Restoring the
<select>re-renders<selectedcontent>, changing the set of listed controls (removing/disconnecting the<input>) while the loop holds a reference to the live list, so the loop then dereferences a disconnected/freed element. Ground: the regression comment describing iterating, removing the input, and trying to restore the disconnected input.
Impact Assessment
<selectedcontent> so that restoring the <select> mutates the control list mid-iteration, which the advisory rates High severity (“Use after free in Forms”). The precondition is purely page-controlled HTML/JS plus a user back-navigation; the advisory does not report in-the-wild exploitation. The patch and test demonstrate the crash/UaF; any escalation to controlled memory corruption or code execution is (inferred).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
Audit Directions
- Iterating live DOM collectionsFlag loops that hold a reference to a live
ListedElement::List/ControlList/node collection while the loop body can run script or layout that mutates the DOM; snapshot into a copy first. - Customizable-select side effectsReview other code paths that touch
<select>/<selectedcontent>where applying a value re-renders content and can add/remove form-associated elements mid-operation. - State restoration re-entrancyAudit form, autofill, and session-restore routines for assumptions that the control set is stable across a single pass, since restoration can trigger event dispatch and DOM mutation.
Patch
From 847b11ad2fa3f99154fb2ceb117b622ced7fa96c Mon Sep 17 00:00:00 2001 From: Joey Arhar <[email protected]> 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 <[email protected]> Commit-Queue: Joey Arhar <[email protected]> Reviewed-by: Dominic Farolino <[email protected]> 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:[email protected]"> -<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:[email protected]"> +<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:[email protected]">
-<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:[email protected]">
+<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.