CVE-2026-113235
Overview
Files Changed
chrome/browser/password_manager/chrome_password_manager_client.cc
Patch
From 082660183b56bc28611e16953bd39523d42c620a Mon Sep 17 00:00:00 2001 From: Rafal Godlewski <[email protected]> Date: Mon, 28 Sep 2026 03:33:38 -0700 Subject: [PATCH] Enforce focused frame check before showing password generation popups Same for editing popup. Fixed: 563352925 Change-Id: I116e751a7a06a6f366eed6a091d52c8d2d5b1ed2 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8421746 Reviewed-by: Maria Kazinova <[email protected]> Commit-Queue: Rafał Godlewski <[email protected]> Cr-Commit-Position: refs/heads/main@{#1705962} --- diff --git a/chrome/browser/password_manager/chrome_password_manager_client.cc b/chrome/browser/password_manager/chrome_password_manager_client.cc index 970dbd06..f7819e3 100644 --- a/chrome/browser/password_manager/chrome_password_manager_client.cc +++ b/chrome/browser/password_manager/chrome_password_manager_client.cc @@ -1638,6 +1638,10 @@ driver->GetPasswordAutofillManager()->MaybeShowPasswordSuggestions( field_id, element_bounds_in_screen_space, ui_data.text_direction); #else + if (web_contents()->GetFocusedFrame() != &rfh) { + return; + } + // Attempt to show the autofill dropdown UI first. gfx::RectF element_bounds_in_top_frame_space = TransformToRootCoordinates(rfh, ui_data.bounds); @@ -1745,6 +1749,10 @@ password_value)) { return; } + if (web_contents()->GetFocusedFrame() != &rfh) { + return; + } + auto* driver = password_manager::ContentPasswordManagerDriver::GetForRenderFrameHost( &rfh);
Original Bug Report
Password generation popup spoofing via missing desktop focus check
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: The desktop branch of ChromePasswordManagerClient::AutomaticGenerationAvailable — a renderer→browser Mojo handler (autofill::mojom::PasswordGenerationDriver) bound for every non-credentialless frame in a tab — shows the Google Password Manager password-generation popup without verifying that the sending frame is the focused frame. The Android branch of the same function enforces exactly this check (ShouldAcceptFocusEvent) and discards the message. A compromised, unfocused, cross-origin iframe can therefore open (or atomically replace) the topmost (kSecuritySurface), origin-unattributed generation popup at renderer-chosen coordinates over a sibling origin’s password field while the user is typing there. One click on “Use strong password” fills the attacker frame’s hidden form and silently writes a credential for the attacker’s origin into the user’s password store — no save prompt. Demonstrated end-to-end on a Linux desktop build.
Affected files:
chrome/browser/password_manager/chrome_password_manager_client.cc:1581-1661— asymmetric handler: Android gate:1612-1616, desktop branch:1635-1660has no focus checkchrome/browser/password_manager/chrome_password_manager_client.cc:1729-1765—ShowPasswordEditingPopup, second ungated show-path on the same interfacechrome/browser/password_manager/android/password_manager_ui_util_android.cc:12-34— the missing gate; its logic is platform-independent but exists only underandroid/chrome/browser/ui/passwords/password_generation_popup_controller_impl.cc:98-122—GetOrCreatehides any previous controller (mid-interaction replacement primitive); no focus check anywhere in the controllerchrome/browser/ui/views/autofill/popup/popup_base_view.cc:117-132— widget shown atui::ZOrderLevel::kSecuritySurface, non-activatable, no focus check
Estimated timestamp from git blame: 2019-04-12
Summary
PasswordGenerationDriver messages are accepted from any non-credentialless frame in the tab (RenderFrameHostReceiverSet, chrome_password_manager_client.cc:274-292). On desktop, no layer between message receipt and widget show verifies that the sender is the focused frame or that the popup anchors inside the sender’s viewport. The popup carries no origin attribution (it shows only the generated password plus GPM branding/account email), so the user cannot tell which frame it belongs to.
The attack works in default configuration for a signed-in user; no feature flags are involved. Preconditions:
- A page embedding an attacker-origin (https) OOPIF alongside content the user interacts with (sibling cross-origin frame or main frame).
- The attacker iframe’s renderer is compromised (standard threat-model premise: arbitrary Mojo from a compromised renderer).
- The attacker origin has no stored credentials, so
MaybeShowPasswordSuggestionsWithGenerationreturns false and control falls through toShowPasswordGenerationPopup(:1656-1658).
The sibling autofill sink already treats this as a browser-side invariant: AutofillPopupControllerImpl::Show (chrome/browser/ui/autofill/autofill_popup_controller_impl.cc:297-352) layers tab-focus, frame-liveness, and a per-frame rfh != GetFocusedFrame() check behind kAutofillRequireFocusInFrameForSuggestions. That flag is referenced only by the autofill popup and keyboard-accessory controllers — it cannot cover this sink.
Root Cause Analysis
- Desktop branch omits the focus gate its own Android branch enforces:
chrome_password_manager_client.cc:1612-1660—// chrome_password_manager_client.cc:1612-1616, 1635, 1656-1660 #if BUILDFLAG(IS_ANDROID) if (!ShouldAcceptFocusEvent(web_contents(), driver, FocusedFieldType::kFillablePasswordField)) { return; // <-- Android discards messages from non-focused frames } ... #else ... // <-- desktop: no focus check of any kind if (!ui_data.generation_rejected) { // renderer-supplied bool ShowPasswordGenerationPopup(PasswordGenerationType::kAutomatic, driver, ui_data); } #endifShouldAcceptFocusEvent(password_manager_ui_util_android.cc:21-24) accepts onlydriver->render_frame_host() == web_contents->GetFocusedFrame()— in the attack scenario Android drops the message, desktop shows the popup. - Remaining gates on the desktop path are non-focus and pass: the CPSP URL check (
:1585-1590) is inert because theFormDatamojom carries no url field, so it validates an empty GURL (separately tracked);CheckFrameActiveAndNotPrerenderingisIsActive()only;IsGenerationEnabledis profile/origin-level (default-true for signed-in users). - Renderer-supplied bounds are transformed, never clipped:
TransformToRootCoordinates(:243-257) is a pure quad transform to root coordinates — the attacker anchors the popup over any sibling frame’s field. Geometric constraints inPopupBaseViewonly require intersection with the visible content area. - Controller adds a replacement primitive and no gate:
PasswordGenerationPopupControllerImpl::GetOrCreate(:107-116) callsprevious->HideImpl()when (bounds, driver, element_id) differ — the attacker atomically replaces a sibling frame’s legitimate generation popup in place.Show(:262-306) performs no focus or visibility check. - All hide signals are renderer honesty:
GenerationElementLostFocus/FrameWasScrolledare Mojo messages the honest renderer sends on blur/scroll (password_generation_agent.cc:612-626); a compromised renderer withholds them. DesktopFocusedInputChanged(:487-534) has its entire body inside#if BUILDFLAG(IS_ANDROID), so the user focusing/typing in the victim frame produces no browser-side reaction. - Keyboard dismissal routes to the wrong frame: the popup’s key handler is registered on the sending frame’s RenderWidgetHost (
KeyPressRegistrator,password_generation_popup_controller_impl.cc:57-96,274-279); the user’s ESC goes to the victim frame’s widget and never reaches the popup. - Accept → fill + promptless persistent write:
PasswordAccepted(:222-240) fills the generated password into the attacker frame’s form; the renderer’s follow-upPresaveGeneratedPasswordflows throughPasswordManager::OnPresaveGeneratedPasswordtoPasswordGenerationManager::PresaveGeneratedPassword(components/password_manager/core/browser/password_generation_manager.cc:391-424), which callsform_saver->Save(...)(:419-420) — an immediate password-store write for the attacker’s origin with no prompt anywhere in the chain.
Reproduction
- Victim: desktop Chrome, signed in (password generation on by default), on a page embedding an attacker-origin OOPIF A alongside cross-origin content B (e.g. B’s signup form).
- Compromise A’s renderer. While A is unfocused and the user clicks into and types in B’s “choose a password” field, send
PasswordGenerationDriver::AutomaticGenerationAvailablefrom A withboundsmapping onto B’s field rect,generation_rejected=false,is_generation_element_password_type=true. Never sendGenerationElementLostFocus/FrameWasScrolled. - User presses ESC, then clicks “Use strong password” on the popup anchored to B’s field.
- Expected: the browser discards generation messages from a non-focused frame (as the Android branch of the same function does), or at minimum constrains the popup to the sender’s frame with attribution.
Observed (demonstrated by executed proof of concept on a Linux desktop release build, stock browser-side code): the GPM-branded, topmost, origin-unattributed popup appeared anchored exactly over B’s password field mid-typing, pixel-identical to B’s own honest popup; ESC did not dismiss it; one click filled A’s hidden form and wrote a credential for the attacker origin into the profile’s Login Data with no save prompt, while the victim origin got no entry. If B’s own generation popup is open, the forged message replaces it in place. Adding the sender-frame==focused-frame check to the desktop branch blocked every forged send while leaving the honest focused-frame flow intact.
Severity
Medium — a show-time authorization gap on a browser-process security surface (kSecuritySurface is explicitly an anti-attack measure per popup_base_view.cc:117-121), reachable in default configuration from a compromised renderer. Consequence is misattributed trusted browser UI plus silent, promptless persistent modification of security state — a Google Password Manager credential for the attacker’s origin (“modify limited amounts of information”, severity-guidelines S2). Not higher: no memory corruption or cross-origin data read, and it requires a compromised renderer plus one plausible user click. chrome/browser/ui/SECURITY.md (the only component security doc on the affected paths) scopes nested-message-loop re-entrancy claims and is inapplicable — this finding makes no re-entrancy or lifetime claim.
Suggested Fix Direction
Hoist the platform-independent sender-frame==GetFocusedFrame() comparison (the ShouldAcceptFocusEvent logic) out of the #if BUILDFLAG(IS_ANDROID) block in AutomaticGenerationAvailable, and apply it in ShowPasswordEditingPopup as well; alternatively (or additionally) mirror the per-frame focus check the autofill popup is rolling out (autofill_popup_controller_impl.cc:340-347) in PasswordGenerationPopupControllerImpl::Show, and clip renderer-supplied bounds to the sending frame’s rect.
POC: Desktop AutomaticGenerationAvailable lacks the sender-frame==GetFocusedFrame gate its own Android branch enforces — unfocused compromised cross-origin OOPIF opens/replaces the kSecuritySurface password-generation popup at renderer-chosen bounds
Strategy: code-injection (compromised-renderer simulation) Bug Type: missing show-time authorization check on a browser-process security surface (CWE-1021); security-UI misattribution + silent, promptless persistent write of an attacker-origin credential into the user’s password store. Logic bug — no memory corruption / no ASAN report expected.
ChromePasswordManagerClient::AutomaticGenerationAvailable
(chrome/browser/password_manager/chrome_password_manager_client.cc) is a
renderer→browser Mojo handler (autofill::mojom::PasswordGenerationDriver,
bound per-frame for every non-credentialless frame). The Android branch of
the function discards the message unless the sending frame is the
browser-side focused frame (ShouldAcceptFocusEvent,
chrome/browser/password_manager/android/password_manager_ui_util_android.cc).
The desktop branch has no focus check of any kind, and neither does any
layer below it (ShowPasswordGenerationPopup →
PasswordGenerationPopupControllerImpl → PopupBaseView). The popup bounds
are renderer-supplied and only coordinate-transformed (never clipped to the
sending frame), and all popup-hide signals on this path are renderer-honesty
messages that a compromised renderer simply withholds.
What the POC demonstrates (all verified end-to-end)
- A compromised, unfocused, invisible cross-origin OOPIF
(
evil.test) sendsAutomaticGenerationAvailablewith spoofed bounds equal to the rect of a sibling origin’s (victim.test) “Choose a password” signup field — while the user is focused and typing there. - The browser accepts the message and shows the GPM-branded, topmost
(
kSecuritySurface), origin-unattributed “Google Password Manager created a strong password for this website” popup anchored to victim.test’s field. It is pixel-identical to the popup victim.test’s own honest flow produces. - ESC does not dismiss it (the popup’s key handler is registered on the attacker frame’s RenderWidgetHost; the user’s keyboard routes to the victim frame).
- One click on “Use strong password” fills the generated password into the
attacker frame’s invisible form, and the browser immediately writes a
credential for
http://evil.test:8080/into the user’s password store with no save prompt (presave pipeline). The user, mid-signup on victim.test, believes they just set a strong password for victim.test. - On Android the identical message is discarded by the
ShouldAcceptFocusEventgate in the same function — the desktop-only nature of the bug is the asymmetry this POC exercises. - Bandaid fix (
bandaid-fix.patch): hoisting the same sender-frame==focused-frame comparison into the desktop branch blocks the forged path (popup never appears, store stays empty, compromised renderer keeps re-sending to no effect) while the honest focused-frame flow keeps working — verified by a full re-run (proof-run-fixed/).
Why Strategy B (code injection)
PasswordGenerationDriver is a channel-associated interface — the
renderer acquires it via GetRemoteAssociatedInterfaces()->GetInterface()
(components/autofill/content/renderer/password_generation_agent.cc,
unsafe_driver()). MojoJS (Mojo.bindInterface) can only reach
BrowserInterfaceBroker-brokered interfaces, not frame-associated ones, so a
MojoJS POC is infeasible; simulating the compromised renderer in the
renderer-side agent is the highest-fidelity reachable strategy. The forged
message is composed by the identical code path an honest renderer uses
(PasswordGenerationUIData built from the frame’s real form) with exactly
one attacker-controlled deviation: the bounds field.
chromium-changes.patch adds a POC-only hook to
PasswordGenerationAgent that arms any frame whose URL fragment is
#pwn:x,y,w,h,delay_ms,period_ms and then periodically sends the forged
message. No browser-process code is modified; every browser-side check on
the path is stock.
Preconditions and how the run substitutes them
Production precondition: password generation enabled — true by default for
any signed-in Chrome user (PasswordGenerationFrameHelper:: IsGenerationEnabled → sync_util::GetPasswordSyncState only requires the
sync engine to have the PASSWORDS type active). This sandbox cannot sign
into a Google account, so the run uses Chrome’s shipped local-sync
backend (--enable-local-sync-backend --local-sync-backend-dir=..., the
enterprise roaming-profile configuration backed by the in-binary
LoopbackServer) which reaches the same generation-enabled state with stock
browser code — see chrome/browser/sync/test/integration/local_sync_test.cc
(PASSWORDS is expected-active under local sync). The vulnerable code path
itself is default-enabled desktop behavior; no feature flags are flipped.
Environment-only flags: --no-proxy-server (sandbox CLI proxy),
--password-store=basic (no OS keyring daemon), --host-resolver-rules= "MAP *.test 127.0.0.1" (three distinct cross-origin sites → real OOPIFs,
one local server), --sync-deferred-startup-timeout-seconds=0 (start the
sync engine immediately; timing only), window position/scale pinning for
deterministic input coordinates, --enable-logging=stderr.
Prerequisites
- Linux desktop Chromium checkout; build with
chromium-changes.patchapplied:cd /chromium_src/src git apply chromium-changes.patch autoninja -C out/Default chrome - X display with a WM;
xdotool,xwininfo,scrot,python3+ PIL,openssl(for decrypting the v10-obfuscated store values in the dump).
Reproduction
cd <this directory>
./run_poc.sh /chromium_src/src/out/Default/chrome
The script serves the three origins, launches Chrome, clicks into
victim.test’s password field, types a partial password (the honest popup
appears on focus and honestly hides on typing), waits for the unfocused
evil.test frame to fire, verifies the forged popup window is on screen,
presses ESC (popup persists), clicks “Use strong password”, then shuts down
and dumps the profile’s Login Data DB (decrypting the password value).
To verify the fix instead:
cd /chromium_src/src && git apply bandaid-fix.patch && autoninja -C out/Default chrome
POC_OUT=<this directory>/proof-run-fixed ./run_poc.sh
Expected Result
Vulnerable build: script ends with a Login Data dump containing exactly one
row — signon_realm http://evil.test:8080/ with a freshly generated
password — created while the user only ever interacted with victim.test, and
proof-run-vulnerable/04-forged-popup-over-victim.png shows the GPM popup
anchored to victim.test’s field while the victim page’s own status line
shows its field is the focused one.
Fixed build: [!] forged popup did not appear, empty store, honest popup
still visible in proof-run-fixed/02-*.png.
Output
Vulnerable run (proof-run-vulnerable/, produced by this exact script):
[*] chrome launched
[*] attacker frame armed
[*] content origin: (4,146)
[*] clicked victim password field at (204,414) - focused frame is now B
[*] waiting for forged AutomaticGenerationAvailable from UNFOCUSED evil.test frame...
[*] forged popup window on screen at: 139 430 488 198 (see wininfo-during-popup.txt)
[*] ESC did not dismiss the popup (still at: 139 430 488 198)
[*] clicking 'Use strong password' at (443,590)
=== rows in /tmp/poc-profile/Default/Login Data: 1 ===
origin_url : http://evil.test:8080/attacker.html
signon_realm : http://evil.test:8080/
username_value : user@evil
password (decr.): kLppegFC4HCFuQy
date_created UTC: 2026-09-18 03:13:03
---
Renderer log (proof-run-vulnerable/poc-log-excerpt.txt; PID 19458 is the
evil.test OOPIF renderer — a separate process from victim.test’s):
[19458:...:031248...] [POC] compromised-renderer sim armed in http://evil.test:8080/attacker.html#pwn:40,250,320,36,12000,4000 ...
[19458:...:031300...] [POC] sending forged AutomaticGenerationAvailable from UNFOCUSED frame, spoofed bounds=40,250 320x36
[19458:...:031304...] [POC] generated password was accepted; stopping re-fire
Fixed run (proof-run-fixed/): six forged sends logged, zero popups, and
=== rows in /tmp/poc-profile/Default/Login Data: 0 ===
(no rows)
Evidence index
proof-run-vulnerable/
01-loaded.png— page loaded; victim signup form (victim.test OOPIF) under the site header; attacker OOPIF invisible beneath the opaque header.02-victim-focused-honest-popup.png— user clicks victim’s password field: the HONEST generation popup from the focused frame (baseline; note the password preview inside victim’s field).03-user-typing-honest-popup-hid.png— user typed 6 chars; honest popup correctly hid (renderer-reported rejected-by-typing); victim status line: “[B] FOCUSED, user typed 6 chars”.04-forged-popup-over-victim.png— the bug: popup re-appears over the victim field while the user is focused/typing there — this one was sent by the unfocused evil.test frame with spoofed bounds; no preview appears in victim’s field (the preview went to the attacker’s invisible field).05-esc-does-not-dismiss.png— ESC pressed (routed to victim frame); popup persists.06-after-accept.png— after clicking “Use strong password”: no visible change for the user, no save prompt; the evil.test credential is already in the store.wininfo-during-popup.txt— the popup as a native X window (488x198+139+430) while shown.login-data-dump.txt,poc-log-excerpt.txt,chrome.log.
proof-run-fixed/ — same choreography with bandaid-fix.patch applied:
honest popup works (02), forged popup absent (04-no-forged-popup.png),
six ignored forged sends (poc-log-excerpt.txt), empty store
(login-data-dump.txt).
Files
index.html/victim.html/attacker.html— the three-origin site.server.py— single local server behind the three mapped hosts.run_poc.sh— full choreography + evidence collection.dump_login_data.py— password-store dump/decrypt.chromium-changes.patch— renderer-side compromised-renderer simulation (the only source change needed to reproduce).bandaid-fix.patch— minimal browser-side fix: enforceweb_contents()->GetFocusedFrame() == sender framein the desktop branch ofAutomaticGenerationAvailableand inShowPasswordEditingPopup(mirrors the Android-onlyShouldAcceptFocusEventgate); verified to stop the attack without breaking the honest flow.
Attached file hierarchy:
The following files were flattened for attachment. Double-underscores (__) in filenames represent directory separators.
proof-run-fixed__01-loaded.png→proof-run-fixed/01-loaded.pngproof-run-fixed__02-victim-focused-honest-popup.png→proof-run-fixed/02-victim-focused-honest-popup.pngproof-run-fixed__03-user-typing-honest-popup-hid.png→proof-run-fixed/03-user-typing-honest-popup-hid.pngproof-run-fixed__04-no-forged-popup.png→proof-run-fixed/04-no-forged-popup.pngproof-run-fixed__chrome.log→proof-run-fixed/chrome.logproof-run-fixed__login-data-dump.txt→proof-run-fixed/login-data-dump.txtproof-run-fixed__poc-log-excerpt.txt→proof-run-fixed/poc-log-excerpt.txtproof-run-vulnerable__01-loaded.png→proof-run-vulnerable/01-loaded.pngproof-run-vulnerable__02-victim-focused-honest-popup.png→proof-run-vulnerable/02-victim-focused-honest-popup.pngproof-run-vulnerable__03-user-typing-honest-popup-hid.png→proof-run-vulnerable/03-user-typing-honest-popup-hid.pngproof-run-vulnerable__04-forged-popup-over-victim.png→proof-run-vulnerable/04-forged-popup-over-victim.pngproof-run-vulnerable__05-esc-does-not-dismiss.png→proof-run-vulnerable/05-esc-does-not-dismiss.pngproof-run-vulnerable__06-after-accept.png→proof-run-vulnerable/06-after-accept.pngproof-run-vulnerable__chrome.log→proof-run-vulnerable/chrome.logproof-run-vulnerable__login-data-dump.txt→proof-run-vulnerable/login-data-dump.txtproof-run-vulnerable__poc-log-excerpt.txt→proof-run-vulnerable/poc-log-excerpt.txtproof-run-vulnerable__wininfo-during-popup.txt→proof-run-vulnerable/wininfo-during-popup.txt
The following directories were attached as zip archives:
evidence.zip→evidence/evidence-with-fix.zip→evidence-with-fix/
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.
- http://evil.test:8080/
- http://evil.test:8080/attacker.html
- http://evil.test:8080/attacker.html#pwn:40,250,320,36,12000,4000
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/android/password_manager_ui_util_android.cc
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/android/password_manager_ui_util_android.cc;l=12
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/chrome_password_manager_client.cc
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/chrome_password_manager_client.cc;l=1581
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/password_manager/chrome_password_manager_client.cc;l=1729
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/sync/test/integration/local_sync_test.cc
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/autofill/autofill_popup_controller_impl.cc;l=297
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/passwords/password_generation_popup_controller_impl.cc;l=98
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/views/autofill/popup/popup_base_view.cc;l=117
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/content/renderer/password_generation_agent.cc
- https://source.chromium.org/chromium/chromium/src/+/main:components/password_manager/core/browser/password_generation_manager.cc;l=391