High patch-now ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Forms
DescriptionUse after free in Forms
ComponentForms
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker499384399
Fix commit847b11ad2fa3 (chromium/src) +113/-39
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-03 (per vendor advisory)
Fix landed2026-04-10 (public git)
Disclosed2026-04-15
Patch gap5 days — fix public in source before the advisory

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.)

Key insight
The restoration loops iterated the live control collections while restoring a <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

  1. Build a customizable select with a nested control A page uses appearance: base-select with a <select> whose <selectedcontent> contains (or is scripted to contain) an <input>. Ground: the added selectedcontent-input.html resource constructs exactly this and injects an <input name=input> into <selectedcontent>.
  2. 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’s createInput(), value assignment, and form.submit().
  3. 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’s history.back() and the patched loops in form_controller.cc.
  4. 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

A crafted page can cause a use-after-free (observed as a renderer crash) during back/forward form-state restoration by nesting a form control inside a <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

FunctionChangeNotes
for
third_party/blink/renderer/core/html/forms/form_controller.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/forms/form_controller.cc
  • third_party/blink/web_tests/VirtualTestSuites
  • third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/resources/selectedcontent-input.html
  • third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.html
  • third_party/blink/web_tests/external/wpt/html/semantics/forms/the-select-element/customizable-select/selectedcontent-restore.optional.html

Audit Directions

  • Iterating live DOM collections
    Flag 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 effects
    Review 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-entrancy
    Audit 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.
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.
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • third_party/blink/renderer/core/html/forms/html_form_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: 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.

  1. Construct a web page with an <form>.
  2. Inside the form, place a <select> element associated with a <selectedcontent> element.
  3. Inside the <selectedcontent> element, nest a form-associated control like an <input>. This places the <input> into the form’s cached ListedElements().
  4. Add extra dummy form controls to the page to pad the ListedElements() vector to a target size, allowing for precise cppgc heap grooming.
  5. Use JavaScript to change the <select> element’s selected option, saving its state.
  6. Navigate the page away, then trigger a history.back() navigation.
  7. Upon restoration, the <select> triggers the removal of the <input>, freeing the HeapVector buffer synchronously.
  8. The subsequent allocations from the CloneContentsFromOptionElement operation reclaim the freed buffer with attacker-controlled data.
  9. 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.

View on issue tracker