Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Forms
DescriptionUse after free in Forms
ComponentForms
Sub-componentBlink>Forms
Bug ClassUAF
Tracker562230410
Fix commit0f9206889033 (chromium/src) +35/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/html/forms/html_select_element_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/forms/html_select_element_test.cc
  • third_party/blink/renderer/core/html/forms/html_select_element.cc
From 0f9206889033aa9a57462f583709be93c466ce49 Mon Sep 17 00:00:00 2001
From: Joey Arhar <jarhar@chromium.org>
Date: Fri, 18 Sep 2026 10:35:51 -0700
Subject: [PATCH] Remove suggested option when any option is removed

Previously, the autofill suggestion was only removed if the option being
removed was the one being suggested, which made it possible to figure
out which option is being suggested.

Fixed: 562230410
Change-Id: Ia31663673e2ed3566eb1832aaa12818e7ad5f404
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8423261
Reviewed-by: David Baron <dbaron@chromium.org>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Commit-Queue: Joey Arhar <jarhar@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1700926}
---
diff --git a/third_party/blink/renderer/core/html/forms/html_select_element.cc b/third_party/blink/renderer/core/html/forms/html_select_element.cc
index a7211e9..a8c8c231 100644
--- a/third_party/blink/renderer/core/html/forms/html_select_element.cc
+++ b/third_party/blink/renderer/core/html/forms/html_select_element.cc
@@ -1009,7 +1009,11 @@
   if (last_on_change_option_ == &option)
     last_on_change_option_.Clear();
   select_type_->OptionRemoved(option);
-  if (suggested_option_ == &option)
+  // The autofill preview should be closed/ended and we should stop matching
+  // :autofill when any option is removed because only doing so when a specific
+  // option is removed could reveal to the page which option is being suggested
+  // to the user.
+  if (suggested_option_)
     SetSuggestedOption(nullptr);
   if (option.Selected())
     SetAutofillState(WebAutofillState::kNotFilled);
diff --git a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
index 91e5284..b57ddbc 100644
--- a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
+++ b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
@@ -252,6 +252,36 @@
   EXPECT_EQ(initial_client_width, select->clientWidth());
 }
 
+TEST_F(HTMLSelectElementTest,
+       OptionRemovalClearsSuggestedOptionUnconditionally) {
+  SetHtmlInnerHTML(R"HTML(
+    <!DOCTYPE HTML>
+    <select id='sel' size='4'>
+      <option id='o0' value='v0'>o0</option>
+      <option id='o1' value='v1'>o1</option>
+      <option id='o2' value='v2'>o2</option>
+    </select>
+  )HTML");
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+
+  auto* select = To<HTMLSelectElement>(GetElementById("sel"));
+  auto* opt0 = To<HTMLOptionElement>(GetElementById("o0"));
+
+  select->SetSuggestedValue("v2");
+  ASSERT_TRUE(select->IsPreviewed());
+  EXPECT_EQ("v2", select->SuggestedValue());
+  EXPECT_TRUE(select->matches(AtomicString(":autofill"), ASSERT_NO_EXCEPTION));
+
+  // Removing a non-previewed option must still tear down the autofill preview
+  // so that page scripts cannot probe which option is previewed by removing
+  // options one by one and observing when :autofill stops matching.
+  opt0->remove();
+  EXPECT_FALSE(select->IsPreviewed());
+  EXPECT_EQ("", select->SuggestedValue());
+  EXPECT_FALSE(select->matches(AtomicString(":autofill"), ASSERT_NO_EXCEPTION));
+}
+
 TEST_F(HTMLSelectElementTest, SaveRestoreSelectSingleFormControlState) {
   SetHtmlInnerHTML(
       "<!DOCTYPE HTML><select id='sel'>"
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
index 91e5284..b57ddbc 100644
--- a/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
+++ b/third_party/blink/renderer/core/html/forms/html_select_element_test.cc
@@ -252,6 +252,36 @@
   EXPECT_EQ(initial_client_width, select->clientWidth());
 }
 
+TEST_F(HTMLSelectElementTest,
+       OptionRemovalClearsSuggestedOptionUnconditionally) {
+  SetHtmlInnerHTML(R"HTML(
+    <!DOCTYPE HTML>
+    <select id='sel' size='4'>
+      <option id='o0' value='v0'>o0</option>
+      <option id='o1' value='v1'>o1</option>
+      <option id='o2' value='v2'>o2</option>
+    </select>
+  )HTML");
+  test::RunPendingTasks();
+  UpdateAllLifecyclePhasesForTest();
+
+  auto* select = To<HTMLSelectElement>(GetElementById("sel"));
+  auto* opt0 = To<HTMLOptionElement>(GetElementById("o0"));
+
+  select->SetSuggestedValue("v2");
+  ASSERT_TRUE(select->IsPreviewed());
+  EXPECT_EQ("v2", select->SuggestedValue());
+  EXPECT_TRUE(select->matches(AtomicString(":autofill"), ASSERT_NO_EXCEPTION));
+
+  // Removing a non-previewed option must still tear down the autofill preview
+  // so that page scripts cannot probe which option is previewed by removing
+  // options one by one and observing when :autofill stops matching.
+  opt0->remove();
+  EXPECT_FALSE(select->IsPreviewed());
+  EXPECT_EQ("", select->SuggestedValue());
+  EXPECT_FALSE(select->matches(AtomicString(":autofill"), ASSERT_NO_EXCEPTION));
+}
+
 TEST_F(HTMLSelectElementTest, SaveRestoreSelectSingleFormControlState) {
   SetHtmlInnerHTML(
       "<!DOCTYPE HTML><select id='sel'>"
Loading diff…

Original Bug Report

reported by vm...@google.com

Autofill preview leak via conditioned teardown in HTMLSelectElement::OptionRemoved

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: When Chrome Autofill previews a hovered suggestion into a listbox <select> (pre-consent — the user has only moused over the suggestion in the browser dropdown), Blink stores the matched option in suggested_option_, and HTMLSelectElement::OptionRemoved tears the preview down only when the removed option is the previewed one. That teardown is synchronously observable to page JS (select.matches(':-webkit-autofill') flips false; the UA preview popover hides), while removing any other option is preview-silent. A page can therefore remove options one at a time during the hover and learn exactly which option was previewed — the user’s stored country/state/card-expiry value — before any consent. Demonstrated end-to-end on a release build with default flags: the stored value was recovered in ~3 ms from a pure hover, three out of three runs.

Affected files:

Estimated timestamp from git blame: 2016-07-06 (conditioned teardown, cf0c390214da2e; the popover probe channel is much more recent)

Summary

Hovering an address/card suggestion previews the whole form section, including unfocused fields. For a <select>, the renderer stores the matched option in suggested_option_ and sets WebAutofillState::kPreviewed. Blink’s own code documents that this preview must be invisible to page JS (html_select_element.cc:388-392 hides suggested values under canvas “to prevent leaking this information to javascript”), and the CVE-2022-0109 fix family enforces the same invariant against passive readback.

This report is an active probing oracle that bypasses those masks. In the attack configuration (<select size="4">, nothing selected), removing a non-previewed option has zero observable effect on preview state, but removing the previewed option synchronously clears it — a per-option equality test against the secret. The page removes options one by one and the removal index at which the observable state flips identifies the previewed option, i.e. the stored Autofill value.

The root cause (conditioned teardown) is distinct from the sibling reports that leak a 1-bit “a preview exists” signal (popover hit-test occlusion, :autofill-matches-preview): this bug converts any presence channel into exact-value extraction. Fixing a presence channel alone leaves this oracle intact via the other channel; the teardown itself must stop being conditioned on the secret.


Root Cause Analysis

  1. Hover previews the whole section, including an unfocused listbox select: components/autofill/core/browser/ui/autofill_external_delegate.cc:746-761 — selecting (hovering) a suggestion triggers a section-wide preview; components/autofill/content/renderer/form_autofill_util.cc:2661-2666 previews non-focused fields, and form_autofill_util.cc:1398-1410 handles kSelectOne by calling HTMLSelectElement::SetSuggestedOption with the option matching the user’s stored value. A <select size="4"> is still kSelectOne (html_select_element.cc:109-112) and renders as an in-page listbox.

  2. Teardown on option removal is conditioned on the secret:

    // html_select_element.cc:1011-1015
    select_type_->OptionRemoved(option);
    if (suggested_option_ == &option)   // <-- equality test against the secret
      SetSuggestedOption(nullptr);      // <-- observable teardown
    if (option.Selected())
      SetAutofillState(WebAutofillState::kNotFilled);
    
  3. The teardown is synchronously observable to page JS: SetSuggestedOption(nullptr) (html_select_element.cc:396-400) sets WebAutofillState::kNotFilled and calls DidSetSuggestedOption(nullptr). Channel A: selector_checker.cc:3272-3276 makes :autofill / :-webkit-autofill match IsAutofilled() || IsPreviewed(), so select.matches(':-webkit-autofill') flips false inside the same option.remove() call. Channel B: select_type.cc:2088-2092 synchronously hides the top-layer preview popover, flipping document.elementFromPoint over the select from SELECT to OPTION.

  4. Removing any non-previewed option is preview-silent in the attack configuration (size=4, no option selected): ResetToDefaultSelection never reaches its state-changing SelectOption call because that is gated on size_ <= 1 (html_select_element.cc:802); ListBoxSelectType::OptionRemoved (select_type.cc:1604-1611) only clears internal selection anchors; and the SetAutofillState(kNotFilled) at html_select_element.cc:1014-1015 requires option.Selected(), false for every option. The state flip at step 3 is therefore the only observable transition in the probe loop.

  5. The browser process cannot intervene: the per-removal SelectFieldOptionsChanged notification (html_select_element.cc:1026-1030) is dropped during a pure preview because AutofillAgent::SelectFieldOptionsChanged early-returns when was_last_action_fill_ is false (autofill_agent.cc:2189-2195), and the preview path returns before setting that flag (autofill_agent.cc:1117-1124). The entire probe loop runs in one synchronous JS task, so mouse-out preview clearing cannot interleave.

Reproduction

  1. Chrome release build, default flags, one saved Autofill address profile (e.g. country “France”).
  2. Attack page: <form> with <input autocomplete="name"> and <select size="4" autocomplete="country"> containing ~250 country options, none marked selected (250 < Autofill’s kMaxListSize of 512).
  3. Page JS polls select.matches(':-webkit-autofill'); when it flips true (preview onset), it synchronously loops: remove options[0], re-test the selector; the number of removals at the true→false flip is the previewed option’s index. It then restores the option list in the same task.
  4. User clicks the name input and hovers — never clicks — the Autofill suggestion in the browser-drawn dropdown.
  5. Expected: preview state and its teardown are unobservable to the page; no signal from option removal correlates with the previewed value.

Observed (executed, 3/3 runs): while the suggestion was still merely hovered, the probe loop recovered index 77 = “FR”/“France” — the stored profile value — in 2.5–3.3 ms and exfiltrated it via sendBeacon before any consent. Channel B was corroborated in the same runs: elementFromPoint over the select flipped SELECT→OPTION exactly at the secret-conditioned teardown. The browser process received no option-change notification during the probe, and the fill still completes normally if the user later clicks the suggestion.

Severity

medium — Pre-consent disclosure of browser-held user PII (whatever the select encodes: country, state, card expiry month/year) to web content, from a renderer-process logic flaw. It is a read-only leak of a limited amount of information with no memory corruption, so the high/critical tiers do not apply; it exceeds low because every code path ships enabled in stable, the only interaction is the ordinary focus-then-hover en route to accepting any fill, and it extracts the exact stored value rather than a presence bit. This matches Chrome’s historical rating for this capability class (CVE-2022-0109 / crbug.com/1261689, Autofill preview info leak, Medium) and severity-guidelines S2 (“read limited amounts of information”).

Suggested Fix Direction

Make the teardown independent of the secret: in OptionRemoved (and symmetrically OptionInserted), clear or cancel the preview whenever any option is mutated while suggested_option_ is set, or cancel the whole preview via the embedder on DOM mutation of a previewed select. This direction was verified experimentally: with an unconditional teardown, the probe flips on the first removal regardless of the stored value, destroying the equality oracle and leaving only the separately reported 1-bit presence signal.


POC: Autofill-preview option-index extraction via secret-conditioned teardown in HTMLSelectElement::OptionRemoved

Strategy: web-content Bug Type: information disclosure (CWE-359) — pre-consent leak of the user’s stored Autofill data to page JS

When Chrome Autofill previews a suggestion (user merely hovers it in the browser-drawn dropdown — before any consent), the matched <option> of a listbox <select> is stored in suggested_option_. HTMLSelectElement::OptionRemoved (third_party/blink/renderer/core/html/forms/html_select_element.cc:1012-1013) tears the preview down only if the removed option is the previewed one:

if (suggested_option_ == &option)
  SetSuggestedOption(nullptr);   // :autofill stops matching + preview popover hidden

The teardown is synchronously observable to page JS (select.matches(':-webkit-autofill') flips false; document.elementFromPoint over the select flips SELECT→OPTION when the popover hides), while removing any other option is preview-silent for a <select size=4> with nothing selected. That is a per-option equality oracle: the page removes options one at a time and the index at which the probe flips identifies the previewed option — i.e. the user’s stored country — from a single hover.

Prerequisites

  • Linux desktop build of Chrome: /chromium_src/src/out/Default/chrome (any recent build; no sanitizer needed — this is a logic info-leak).
  • X display with xdotool + scrot (used only to play the user: click the form, hover the Autofill suggestion), python3.
  • The environment flags used (--no-proxy-server, --ignore-gpu-blocklist, --disable-search-engine-choice-screen, --no-first-run, --force-device-scale-factor=1) only stabilize the sandbox run; none touch Autofill or the bug path.

Reproduction

./run_poc.sh          # uses CHROME=/chromium_src/src/out/Default/chrome by default

The script:

  1. Creates a fresh Chrome profile, then seeds one saved Autofill address via seed_profile.py (name “Marie Dupont”, street, country FR/France — the secret). This is only the ordinary “user has a saved address” precondition, written in the current addresses/address_type_tokens Web Data schema.
  2. Serves poc.html (via server.py on localhost:8080): a checkout form with <input autocomplete=name>, <input autocomplete=street-address> and <select size=4 autocomplete=country> holding 249 country options.
  3. Plays the user with xdotool: click the Name field, then move the mouse over the Autofill suggestion in the browser dropdown. The suggestion is never clicked and Enter is never pressed — pure hover (an ArrowDown fallback drives the identical SelectSuggestion → DidSelectSuggestion preview path if the blind hover misses).
  4. The page polls select.matches(':-webkit-autofill'); at preview onset it synchronously removes options one by one, re-probing after each removal, restores the full list in the same task, and exfiltrates the flip index via sendBeacon.

Expected Result

leak_log.txt / server output contain a /leak POST whose value/text equal the seeded secret (FR / France, index 77 of the alphabetized list), recovered in ~3 ms, while the suggestion is still only hovered. Screenshots:

  • step1_popup_open.png — dropdown open, nothing previewed yet.
  • step2_after_probe.png — tab title “LEAKED: France”; the dropdown is still open with the suggestion merely highlighted (no consent), name/street show the grey preview, and the page log shows the recovered index/value. Corroborating channel (b) is also logged: elementFromPoint over the select is SELECT while previewed (popover occludes) and OPTION right after the secret-conditioned teardown.

Output

Vulnerable build (out/Default, unmodified tree) — three consecutive runs, all pure hover:

[03:05:20.797] POST /leak {"leak":"LEAK","leakedIndex":77,"value":"FR","text":"France",
  "totalOptions":249,"probeMs":2.7,"efpPreviewed":"SELECT","efpAfterFlip":"OPTION",
  "matchesAfterRestore":false}
[03:05:20.797] *** EXFILTRATED AUTOFILL PREVIEW: index=77 value=FR (France) in 2.7 ms ***

(previous runs: identical leak at probeMs=2.5 and 3.3)

The page’s on-screen log (see step2_after_probe.png):

[!] PREVIEW DETECTED -> ran remove-and-probe oracle
[!] RECOVERED AUTOFILL PREVIEW: index=77 value=FR ("France") in 2.5 ms
[!] elementFromPoint over select: previewed=SELECT afterFlip=OPTION
[*] option list restored; page never saw a click/consent

Bandaid fix (verified)

bandaid-fix.patch makes the teardown unconditional — clear the preview whenever any option is removed while one is previewed (if (suggested_option_)), so the observable flip no longer depends on which option was removed. Rebuilt out/Default with the patch and re-ran the identical scenario: the probe now flips on the very first removal and recovers only index=0 value=AF (Afghanistan) — no correlation with the stored secret (bandaid_leak_log.txt, bandaid_step2_after_probe.png):

[03:04:16.444] POST /leak {"leak":"LEAK","leakedIndex":0,"value":"AF","text":"Afghanistan",...}

Only the 1-bit “a preview exists” signal remains (that presence channel is a separate, already-reported issue). The patch was reverted from the tree after verification.

Files

  • poc.html — attack page (form + remove-and-probe oracle)
  • server.py — serves the page, logs the exfiltrated leak
  • seed_profile.py — seeds the saved Autofill address (user-secret precondition)
  • run_poc.sh — end-to-end runner (profile setup, server, simulated user, evidence)
  • leak_log.txt, server_stdout.txt — exfiltration evidence (vulnerable build)
  • step1_popup_open.png, step2_after_probe.png — before/after screenshots
  • bandaid-fix.patch, bandaid_leak_log.txt, bandaid_step2_after_probe.png — fix + verification

Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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