CVE-2026-113197
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_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.ccthird_party/blink/renderer/core/html/forms/html_select_element.cc
Patch
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'>"
Regression Test / PoC
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'>"
Original Bug Report
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:
third_party/blink/renderer/core/html/forms/html_select_element.cc:1012-1013— secret-conditioned preview teardown inOptionRemoved(root cause)third_party/blink/renderer/core/html/forms/html_select_element.cc:383-401—SetSuggestedOption(nullptr)flips autofill state and hides the preview popoverthird_party/blink/renderer/core/css/selector_checker.cc:3272-3276— author-facing:autofill/:-webkit-autofillmatch preview state (probe channel A)third_party/blink/renderer/core/html/forms/select_type.cc:2079-2093— synchronous popover show/hide on suggested-option change (probe channel B)components/autofill/content/renderer/autofill_agent.cc:2189-2195— option-mutation notifications dropped during preview, so the browser never observes the probing
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
-
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-2666previews non-focused fields, andform_autofill_util.cc:1398-1410handleskSelectOneby callingHTMLSelectElement::SetSuggestedOptionwith the option matching the user’s stored value. A<select size="4">is stillkSelectOne(html_select_element.cc:109-112) and renders as an in-page listbox. -
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); -
The teardown is synchronously observable to page JS:
SetSuggestedOption(nullptr)(html_select_element.cc:396-400) setsWebAutofillState::kNotFilledand callsDidSetSuggestedOption(nullptr). Channel A:selector_checker.cc:3272-3276makes:autofill/:-webkit-autofillmatchIsAutofilled() || IsPreviewed(), soselect.matches(':-webkit-autofill')flips false inside the sameoption.remove()call. Channel B:select_type.cc:2088-2092synchronously hides the top-layer preview popover, flippingdocument.elementFromPointover the select from SELECT to OPTION. -
Removing any non-previewed option is preview-silent in the attack configuration (
size=4, no option selected):ResetToDefaultSelectionnever reaches its state-changingSelectOptioncall because that is gated onsize_ <= 1(html_select_element.cc:802);ListBoxSelectType::OptionRemoved(select_type.cc:1604-1611) only clears internal selection anchors; and theSetAutofillState(kNotFilled)athtml_select_element.cc:1014-1015requiresoption.Selected(), false for every option. The state flip at step 3 is therefore the only observable transition in the probe loop. -
The browser process cannot intervene: the per-removal
SelectFieldOptionsChangednotification (html_select_element.cc:1026-1030) is dropped during a pure preview becauseAutofillAgent::SelectFieldOptionsChangedearly-returns whenwas_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
- Chrome release build, default flags, one saved Autofill address profile (e.g. country “France”).
- Attack page:
<form>with<input autocomplete="name">and<select size="4" autocomplete="country">containing ~250 country options, none markedselected(250 < Autofill’skMaxListSizeof 512). - Page JS polls
select.matches(':-webkit-autofill'); when it flips true (preview onset), it synchronously loops: removeoptions[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. - User clicks the name input and hovers — never clicks — the Autofill suggestion in the browser-drawn dropdown.
- 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:
- 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 currentaddresses/address_type_tokensWeb Data schema. - Serves
poc.html(viaserver.pyonlocalhost:8080): a checkout form with<input autocomplete=name>,<input autocomplete=street-address>and<select size=4 autocomplete=country>holding 249 country options. - 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 → DidSelectSuggestionpreview path if the blind hover misses). - 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 viasendBeacon.
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:elementFromPointover 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 leakseed_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 screenshotsbandaid-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.
- https://crbug.com/1261689
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/content/renderer/autofill_agent.cc;l=2189
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/content/renderer/form_autofill_util.cc;l=2661
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/ui/autofill_external_delegate.cc;l=746
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/css/selector_checker.cc;l=3272
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/forms/html_select_element.cc;l=1012
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/forms/html_select_element.cc;l=383
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/forms/select_type.cc;l=2079