CVE-2026-113002
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc |
modified | |
ifcomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc |
modified | |
TEST_Pcomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc |
modified | |
TEST_Fcomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc |
modified |
Files Changed
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cccomponents/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
Patch
From 06f3959dec63232bfe4382333c5fc841c15fe591 Mon Sep 17 00:00:00 2001
From: Matthias Körber <koerber@google.com>
Date: Fri, 18 Sep 2026 03:02:46 -0700
Subject: [PATCH] [Autofill] Ignore OTP fields in embedded frame trees
Refuse OTP field detection and suggestions in embedded frame trees.
TAG=agy
CONV=65c2d9be-4ee2-46c8-bf62-f243fdd2ae87
BUG=562225226
Change-Id: Iaed046eb4beef98d3c9e428f41fb33c2e540c68a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8408556
Reviewed-by: Christoph Schwering <schwering@google.com>
Commit-Queue: Matthias Körber <koerber@google.com>
Cr-Commit-Position: refs/heads/main@{#1700611}
---
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
index cde3f493..8f64689c 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
@@ -77,7 +77,8 @@
FieldTypeSource source,
bool small_forms_were_parsed) {
const FormStructure* form_structure = manager.FindCachedFormById(form);
- if (form_structure && IsOtpForm(*form_structure)) {
+ if (!manager.driver().IsEmbedded() && form_structure &&
+ IsOtpForm(*form_structure)) {
AddFormAndNotifyIfNecessary(form);
} else {
RemoveFormAndNotifyIfNecessary(form);
@@ -97,7 +98,8 @@
AutofillManager& manager,
AutofillDriver::LifecycleState old_state,
AutofillDriver::LifecycleState new_state) {
- if (new_state != AutofillDriver::LifecycleState::kActive) {
+ if (new_state != AutofillDriver::LifecycleState::kActive ||
+ manager.driver().IsEmbedded()) {
manager.ForEachCachedForm([&](const FormStructure& form) {
RemoveFormAndNotifyIfNecessary(form.global_id());
});
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
index dfa7ec6..2874ff3 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
@@ -89,7 +89,8 @@
const FormStructure& form,
const FormFieldData& field,
OtpManagerImpl::GetOtpSuggestionsCallback callback) {
- if (field.origin().opaque() || !OtpFieldDetector::IsOtpForm(form)) {
+ if (owner_->driver().IsEmbedded() || field.origin().opaque() ||
+ !OtpFieldDetector::IsOtpForm(form)) {
std::move(callback).Run({});
return;
}
@@ -150,7 +151,9 @@
AutofillManager::Observer::FieldTypeSource source,
bool small_forms_were_parsed) {
// On non-android platforms and in tests the backend may be not initialized.
- if (!one_time_token_service_) {
+ // Furthermore, do not retrieve or subscribe to OTPs for embedded frame trees
+ // (such as fenced frames or GuestViews).
+ if (!one_time_token_service_ || manager.driver().IsEmbedded()) {
return;
}
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
index 26cea4c2c..2bda30d 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
@@ -236,6 +236,19 @@
EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
}
+// Verify that OTP fields in embedded frame trees (e.g. fenced frames) do not
+// register as present.
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest,
+ IsOtpFieldPresent_EmbeddedFrameTreeIgnored) {
+ autofill_driver().SetIsEmbedded(true);
+ EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
+
+ FormData form = CreateSimpleOtp();
+ AddOtpToThePage(form);
+
+ EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
+}
+
// Verify that the OtpFieldsDetectedCallback is triggered when an OTP form is
// detected.
TEST_P(OtpFieldDetectorAutofillManagerObserverTest, DiscoverOTPs) {
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
index bf48538..f951efc 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
@@ -914,6 +914,42 @@
EXPECT_TRUE(future.Get().empty());
}
+// Tests that when the driver is for an embedded frame tree (e.g. fenced frame),
+// no query is issued to the SMS backend even if field and main frame origins
+// match.
+TEST_F(OtpManagerImplTest, EmbeddedFrameTreeNoQueryIssued) {
+ autofill_driver().SetIsEmbedded(true);
+
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp).Times(0);
+ AddFormWithOtpField(
+ /*field_origin=*/url::Origin::Create(GURL("https://example.test")),
+ /*main_frame_origin=*/url::Origin::Create(GURL("https://example.test")));
+}
+
+// Tests that `GetOtpSuggestions` immediately returns empty suggestions when the
+// driver is for an embedded frame tree (e.g. fenced frame).
+TEST_F(OtpManagerImplTest, EmbeddedFrameTreeGetOtpSuggestionsReturnsEmpty) {
+ autofill_driver().SetIsEmbedded(true);
+
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp).Times(0);
+ EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck).Times(0);
+
+ const FormStructure* form = AddFormWithOtpField(
+ /*field_origin=*/url::Origin::Create(GURL("https://example.test")),
+ /*main_frame_origin=*/url::Origin::Create(GURL("https://example.test")));
+ ASSERT_TRUE(form);
+
+ base::test::TestFuture<const std::vector<std::string>> future;
+ otp_manager.GetOtpSuggestions(*form, *form->field(0), future.GetCallback());
+
+ EXPECT_TRUE(future.IsReady());
+ EXPECT_TRUE(future.Get().empty());
+}
+
// Tests that `GetOtpSuggestions` returns OTP suggestions when the form is on a
// same-TLD+1 origin and `kAutofillRestrictOtpToSameTldPlusOne` is enabled.
TEST_F(OtpManagerImplTest,
Regression Test / PoC
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
index 26cea4c2c..2bda30d 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
@@ -236,6 +236,19 @@
EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
}
+// Verify that OTP fields in embedded frame trees (e.g. fenced frames) do not
+// register as present.
+TEST_P(OtpFieldDetectorAutofillManagerObserverTest,
+ IsOtpFieldPresent_EmbeddedFrameTreeIgnored) {
+ autofill_driver().SetIsEmbedded(true);
+ EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
+
+ FormData form = CreateSimpleOtp();
+ AddOtpToThePage(form);
+
+ EXPECT_FALSE(otp_field_detector().IsOtpFieldPresent());
+}
+
// Verify that the OtpFieldsDetectedCallback is triggered when an OTP form is
// detected.
TEST_P(OtpFieldDetectorAutofillManagerObserverTest, DiscoverOTPs) {
diff --git a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
index bf48538..f951efc 100644
--- a/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
+++ b/components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
@@ -914,6 +914,42 @@
EXPECT_TRUE(future.Get().empty());
}
+// Tests that when the driver is for an embedded frame tree (e.g. fenced frame),
+// no query is issued to the SMS backend even if field and main frame origins
+// match.
+TEST_F(OtpManagerImplTest, EmbeddedFrameTreeNoQueryIssued) {
+ autofill_driver().SetIsEmbedded(true);
+
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp).Times(0);
+ AddFormWithOtpField(
+ /*field_origin=*/url::Origin::Create(GURL("https://example.test")),
+ /*main_frame_origin=*/url::Origin::Create(GURL("https://example.test")));
+}
+
+// Tests that `GetOtpSuggestions` immediately returns empty suggestions when the
+// driver is for an embedded frame tree (e.g. fenced frame).
+TEST_F(OtpManagerImplTest, EmbeddedFrameTreeGetOtpSuggestionsReturnsEmpty) {
+ autofill_driver().SetIsEmbedded(true);
+
+ OtpManagerImpl otp_manager(autofill_manager(), &one_time_token_service_);
+
+ EXPECT_CALL(sms_otp_backend_, RetrieveSmsOtp).Times(0);
+ EXPECT_CALL(otp_phish_guard_delegate(), StartOtpPhishGuardCheck).Times(0);
+
+ const FormStructure* form = AddFormWithOtpField(
+ /*field_origin=*/url::Origin::Create(GURL("https://example.test")),
+ /*main_frame_origin=*/url::Origin::Create(GURL("https://example.test")));
+ ASSERT_TRUE(form);
+
+ base::test::TestFuture<const std::vector<std::string>> future;
+ otp_manager.GetOtpSuggestions(*form, *form->field(0), future.GetCallback());
+
+ EXPECT_TRUE(future.IsReady());
+ EXPECT_TRUE(future.Get().empty());
+}
+
// Tests that `GetOtpSuggestions` returns OTP suggestions when the form is on a
// same-TLD+1 origin and `kAutofillRestrictOtpToSameTldPlusOne` is enabled.
TEST_F(OtpManagerImplTest,
Original Bug Report
SMS OTP same-TLD gate bypass in fenced frames via GetMainFrame origin confusion
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 same-TLD+1 restriction for SMS OTP autofill (kAutofillRestrictOtpToSameTldPlusOne, enabled by default) compares an OTP field’s origin against FormData::main_frame_origin, which is populated from RenderFrameHost::GetMainFrame(). GetMainFrame() never crosses a fenced-frame boundary, so for a form inside a <fencedframe> both sides of the comparison are the embedded document’s own origin and the gate passes trivially. A third-party ad rendered via Protected Audience / Shared Storage can therefore subscribe to the profile-global, origin-blind OneTimeTokenService and — after two ordinary taps by the user — receive and exfiltrate any SMS 2FA code that arrived on the device in the last 3 minutes. PhishGuard does not compensate: its server-side reputation check evaluates only the outermost (benign publisher) URL.
Affected files:
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc:39-46— same-TLD gate compares field origin againstmain_frame_origin(self-satisfying in fenced frames)components/autofill/content/browser/content_autofill_driver.cc:100—Lift()populatesmain_frame_originfromGetMainFrame(), the wrong principal for MPArch nested frame treescomponents/autofill/content/browser/content_autofill_driver_factory.cc:109-114— fenced-frame autofill bail-out is dead code (kFencedFramesAPIChangesis stable/enabled)chrome/browser/ui/autofill/chrome_otp_phish_guard_delegate.cc:86-139— server-side PhishGuard request bound to the outermost main frame URL onlycomponents/one_time_tokens/core/browser/one_time_token_service_impl.cc:45-64— profile-global cache returns every recent token to any subscriber, no origin attribution
Estimated timestamp from git blame: 2026-04-30
Summary
With kAutofillRestrictOtpToSameTldPlusOne enabled by default (components/autofill/core/common/autofill_features.cc:992-993), OtpFieldDetector::IsOtpForm is the only origin restriction between a web frame and the device’s recent SMS OTPs. The restriction is void inside fenced frames: main_frame_origin resolves to the fenced frame’s own root, not the outermost page, so field.origin() == form.main_frame_origin() for any fenced-frame form. components/autofill/core/browser/form_structure.h:303-306 even documents that main_frame_origin_ is “not necessarily primary main frame … as described by MPArch nested frame trees”.
The unlocked data source is profile-scoped and origin-blind:
OneTimeTokenServiceis a profile-keyed service; on Android the SMS backend is created unconditionally, with no feature flag (chrome/browser/autofill/one_time_token_service_factory.cc:52-55).GetRecentOneTimeTokensreturns every token cached in the last 3 minutes to any subscriber in any tab; live SMS arrivals are broadcast to all subscribers with GMSCore re-polling every 5 s (one_time_token_service_impl.cc:45-64,193-206).OneTimeTokenhas no origin field for SMS (components/one_time_tokens/core/browser/one_time_token.h:53-60); the backend notes the Android API “is not able to differentiate between senders and websites origins” (components/one_time_tokens/android/backend/sms/android_sms_otp_backend.cc:60-64).
An executed proof of concept (Linux desktop build, real MPArch fenced frame, stock gate logic, flag at its enabled default) confirmed the chain with a three-run differential: the fenced-frame ad logs field_origin=ads.evil form_main_frame_origin=ads.evil passed=1, the autofill UI shows the raw OTP digits over the ad, and accepting the suggestion fills the code into the ad’s field, whose script exfiltrates it. The identical ad in a cross-origin <iframe> logs passed=0 and gets nothing, isolating the fenced boundary as the bypass. The device SMS source was simulated with the unmodified in-tree --mock-otp-value switch (GMSCore is Android-only), which is consulted only after the vulnerable gate; the closed-source GMSCore fetch itself is the one link not demonstrated end-to-end.
Chrome deliberately disables the origin-bound WebOTP API in all fenced frames via the kOTPCredentials permissions-policy carveout (content/browser/fenced_frame/PERMISSIONS_POLICIES.md), yet this origin-unbound OTP path works there — the asymmetry suggests the fenced case was simply never considered for autofill OTP.
Root Cause Analysis
- Wrong principal captured at form lift:
components/autofill/content/browser/content_autofill_driver.cc:100,119— the browser-sideFormDatagets its “main frame origin” fromGetMainFrame(), which walks onlyGetParent()and stops at a fenced-frame root (parent_is null there). OnlyGetOutermostMainFrame()crosses “outer documents (e.g. fenced frame owners)” (content/public/browser/render_frame_host.h:393-403).// content_autofill_driver.cc:100,119 form.set_main_frame_origin(rfh.GetMainFrame()->GetLastCommittedOrigin()); // <-- fenced root, not publisher ... field.set_origin(rfh.GetLastCommittedOrigin()); // <-- same RFH → same origin - Gate self-satisfies:
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc:39-46— for a fenced-frame form both operands are the ad’s origin, soSameDomainOrHosttrivially returns true and the form is treated as an eligible OTP form.// otp_field_detector.cc:39-46 if (!net::registry_controlled_domains::SameDomainOrHost( f->origin(), form.main_frame_origin(), // <-- ads.evil vs ads.evil inside a fenced frame net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)) { return false; } - Fenced frames get a full autofill driver:
components/autofill/content/browser/content_autofill_driver_factory.cc:109-114bails out of driver creation only whenblink::features::kFencedFramesAPIChangesis disabled; that feature isbase_feature_status: "enabled", status: "stable"(third_party/blink/renderer/platform/runtime_enabled_features.json5:2877-2882), so the guard is dead code. No other fenced-frame check exists anywhere in the OTP suggestion/fill path. - Origin-blind profile-global token source: once
IsOtpFormpasses,OtpManagerImpl::OnFieldTypesDetermined(components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc:144-179) silently subscribes the profile-wide service and starts SMS polling. Every device OTP from the last 3 minutes — bank, 3DS, publisher login, any sender — is deliverable to the ad’s form. The opaque-origin early-out (otp_manager_impl.cc:89) does not help because fenced-frame documents have real, non-opaque origins (kOrigin/kForms/kScriptsare mandatory-unsandboxed,third_party/blink/public/common/frame/fenced_frame_sandbox_flags.h). - PhishGuard vets the wrong frame:
chrome/browser/ui/autofill/chrome_otp_phish_guard_delegate.cc:86-103checks local SB blocklists for three frame URLs (a fresh attacker domain is unlisted), then sends the server-side zero-day phishing request only for the outermost main frame URL — the benign publisher (:133-139). If the publisher is on the high-confidence allowlist, the request short-circuits to “not phishing” with no server ping at all (components/safe_browsing/content/browser/password_protection/password_protection_service.cc:150-169).
Reproduction
- On Android Chrome with default flags (Privacy Sandbox active), an attacker ad-tech wins a Protected Audience auction (or uses
sharedStorage.selectURL()) on a benign HTTPS publisher page and renders its creative (fresh, non-blocklisted domain) in a<fencedframe>. - The creative shows a pretext (“enter the code we sent you”) with
<input autocomplete="one-time-code">; classification fires andIsOtpFormpasses because both compared origins are the ad’s own. Chrome silently begins polling GMSCore for SMS OTPs. - A genuine 2FA SMS arrives on the device — from a parallel tab’s bank/3DS flow, the publisher’s login, or a login the attacker provoked at a target service — and is cached profile-wide for 3 minutes.
- The user taps the ad’s input, then taps the suggestion chip showing the raw OTP digits.
- Expected: no OTP suggestion inside a cross-site fenced frame — the same-TLD restriction exists precisely to keep embedded third-party content away from device OTPs (and the ad in a plain cross-origin
<iframe>is correctly refused).
Observed (executed POC, desktop Linux, stock gate logic and default flags): the fenced-frame ad passes the gate with its own origin on both sides, the autofill UI displays the real OTP digits over the ad, accepting fills the code into the ad’s field, and the ad’s script exfiltrates it to its server; the identical ad in a cross-origin <iframe> is blocked (passed=0, no suggestion). The Android-specific remainder (keyboard-accessory rendering and the closed-source GMSCore SMS fetch, which the POC replaced with the in-tree mock OTP switch) was not device-demonstrated, but no code excludes fenced frames from the accessory UI and the Chromium-side polling/consent plumbing is fully present.
Severity
medium — cross-origin disclosure of high-value user secrets (live SMS 2FA codes from any sender, enabling account takeover) to arbitrary web content in the default configuration, defeating both deployed mitigations (same-TLD gate self-satisfies; PhishGuard evaluates the benign publisher). It requires only ordinary user interaction (two taps on a dark-pattern ad) and no improbable preconditions, which precludes low; it involves no memory corruption and no compromised renderer, so it does not reach high. Prior fenced-frame restriction bypasses (CVE-2022-4182, CVE-2025-0441) were rated Medium and are class precedent, not duplicates of this OTP stack.
Suggested Fix Direction
Compare against GetOutermostMainFrame()->GetLastCommittedOrigin() when populating or checking the OTP gate’s principal, or refuse OTP retrieval/suggestions entirely for frames where IsNestedWithinFencedFrame() is true — consistent with the existing WebOTP kOTPCredentials fenced-frame carveout. Independently, the PhishGuard reputation request should evaluate the frame that will actually receive the OTP, not only the outermost URL.
POC: SMS OTP autofill same-TLD gate self-satisfies in fenced frames — cross-origin ad receives and exfiltrates the device OTP
Strategy: code-injection+web-content Bug Type: CWE-668 — cross-origin exposure of one-time passwords (logic flaw, no memory corruption)
The same-TLD restriction for OTP autofill
(kAutofillRestrictOtpToSameTldPlusOne, enabled by default) compares the
field origin against FormData::main_frame_origin
(components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc:39-46).
main_frame_origin is populated from RenderFrameHost::GetMainFrame()
(components/autofill/content/browser/content_autofill_driver.cc, Lift()),
which never crosses a fenced-frame boundary. For a form inside a
<fencedframe>, both sides of the comparison are the ad’s own origin, so the
gate passes trivially and Chrome offers the device’s OTP to arbitrary ad
content embedded on any publisher page. The same ad in a normal cross-origin
<iframe> is correctly blocked — demonstrated below as a control.
What the POC shows (three runs, one variable)
| Run | Top-level page | Ad embedding | Gate log (stock logic) | Result |
|---|---|---|---|---|
| fenced (attack) | https://publisher.test:8443 |
<fencedframe> → https://ads.evil:8443/otp-ad.html |
field_origin=ads.evil form_main_frame_origin=ads.evil passed=1 |
Autofill popup shows raw OTP 834271; accepted → filled into the ad’s field → ad JS exfiltrates it (exfil.log) |
| iframe (control) | same publisher | <iframe> → same ad |
field_origin=ads.evil form_main_frame_origin=publisher.test passed=0 |
No suggestion, no exfiltration |
| toplevel (sanity) | https://ads.evil:8443/otp-ad.html itself |
— | passed=1 (legitimately same-origin) |
OTP offered (intended behavior) |
The only variable between attack and control is fenced frame vs iframe;
the differential isolates GetMainFrame() (vs GetOutermostMainFrame())
as the defect.
Environment shims (see chromium-changes.patch, 4 files)
This build environment lacks two platform capabilities that exist in
production; both stand-ins are outside the code under test — every
security-relevant check (the same-TLD gate, IsContextSecure, suggestion
generation, the fill path) runs stock:
FencedFrameConfig(url)web constructor restored (blink IDL + 6 lines). In production, ad-techs obtainFencedFrameConfigs via Protected AudiencerunAdAuction()/sharedStorage.selectURL()(both GA); this checkout has the browser-side Privacy Sandbox services stripped (content/browser/interest_group,content/browser/shared_storageabsent, mojom not bound), so no web path can mint a config. The patch re-exposes the already-implemented script-constructed-config path (FencedFrameConfig::Create,HTMLFencedFrameElement::NavigateToConfig()documents “config_ was constructed from script”; browser-sideFencedFrame::Navigateaccepts such raw https URLs unmodified). The resulting fenced frame is a real MPArch fenced frame tree — the structure the bug is about.--mock-otp-value=834271— an unmodified, in-tree switch (components/one_time_tokens/.../one_time_token_switches.cc) that simulates the device’s SMS OTP source (Android GMSCore backend does not exist on Linux). Crucially,OtpManagerImpl::GetOtpSuggestionsevaluates the vulnerable gate before consulting the mock value, so the gate alone decides whether the “device OTP” is handed out — exactly the differential the three runs measure.- Observation logging only: two
LOG(ERROR)lines inotp_field_detector.ccprint the gate’s operands and verdict; no behavior change.
Launch flags besides the shim: standard local-testing affordances only
(--host-resolver-rules to reach the local server,
--ignore-certificate-errors-spki-list for the self-signed cert — keeps the
security level SECURE like a production cert, --user-data-dir,
--no-first-run, --no-proxy-server, --ignore-gpu-blocklist,
--enable-logging=stderr, window geometry). No security feature is disabled;
kAutofillRestrictOtpToSameTldPlusOne stays at its enabled default — it is
the mitigation being bypassed.
Prerequisites
- Linux ASAN (or any desktop) Chromium build from this tree with
chromium-changes.patchapplied:autoninja -C out/asan chrome - X display;
xdotool,scrot,openssl, python3 (all stdlib)
Reproduction
cd /chromium_src/src && git apply /poc-artifacts/chromium-changes.patch
autoninja -C out/asan chrome
cd /poc-artifacts
python3 server.py & # serves publisher.test + ads.evil on :8443 (uses cert.pem/key.pem/spki.txt)
python3 drive.py fenced # ATTACK — expect exfiltration of 834271
python3 drive.py iframe # CONTROL — expect gate blocks, no popup, no exfil
python3 drive.py toplevel # SANITY — legitimate flow still works
drive.py performs only the two user interactions the attack needs: a click
into the ad’s code field and accepting the shown suggestion (ArrowDown,
Enter). Certificates can be regenerated with the openssl command in
server.py’s docstring counterpart:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30 -nodes -subj "/CN=otp-poc" -addext "subjectAltName=DNS:publisher.test,DNS:ads.evil" (then refresh spki.txt).
Expected Result
- fenced: browser log shows the gate passing with the ad’s own origin on
both sides; the autofill dropdown renders the raw OTP digits over the ad
(
fenced_2_after_click.png); after accept, the value is filled (fenced_3_after_accept.png) and the ad server logsEXFILTRATED OTP: code=834271. - iframe: gate logs
passed=0(ads.evilvspublisher.test); no popup (iframe_2_after_click.png),exfil.logunchanged.
Output (from poc-run.log, exfil.log, chrome_*.log)
=== RUN 1: fenced (ATTACK), top-level = https://publisher.test:8443 ===
[OTP-GATE] field_origin=https://ads.evil:8443 form_main_frame_origin=https://ads.evil:8443 same_tld_gate_passed=1
[drive] VERDICT(fenced): OTP 834271 DELIVERED TO AD AND EXFILTRATED - same-TLD gate bypassed
exfil.log: EXFILTRATED OTP: code=834271 (query=code=834271&src=fencedframe-ad)
=== RUN 2: iframe (CONTROL), same publisher, same ad ===
[OTP-GATE] field_origin=https://ads.evil:8443 form_main_frame_origin=https://publisher.test:8443 same_tld_gate_passed=0
[drive] VERDICT(iframe control): no OTP delivered - gate correctly blocked cross-origin iframe
=== RUN 3: toplevel (SANITY), ad page visited directly ===
[OTP-GATE] field_origin=https://ads.evil:8443 form_main_frame_origin=https://ads.evil:8443 same_tld_gate_passed=1
[drive] VERDICT(toplevel sanity): OTP offered/filled on the site itself - legitimate flow works
Screenshots: fenced_2_after_click.png (popup showing 834271 over the
fenced ad on the publisher page — the disclosure moment),
fenced_3_after_accept.png (filled + “EXFILTRATED: 834271” rendered by the
ad), iframe_2_after_click.png (control: no popup).
Bandaid fix (bandaid-fix.patch, verified)
OtpManagerImpl additionally requires the form’s frame-tree root
(main_frame_origin) to be same-site with the primary main frame
(AutofillClient::GetLastCommittedPrimaryMainFrameOrigin()), both before
starting OTP retrieval (OnFieldTypesDetermined) and before delivering
suggestions (GetOtpSuggestions). For any form inside a cross-site fenced
frame the two differ and OTP autofill is refused; normal pages and iframes
are unaffected (their frame-tree root is the primary main frame).
Verified by rebuild + rerun:
- fenced attack: inner gate still logs
passed=1, but no popup and no exfiltration (fixed_fenced_no_popup.png,fixed_chrome_fenced.log); - toplevel sanity: OTP still delivered (no regression).
Notes / scope
- The bug is in cross-platform
components/autofillcode and is demonstrated here on desktop Linux. The fully-armed production configuration is Android, where the SMS backend is created unconditionally (chrome/browser/autofill/one_time_token_service_factory.cc,IS_ANDROIDblock), the OneTimeTokenService caches every device SMS OTP for 3 minutes profile-wide, and the suggestion surfaces in the keyboard accessory. This POC substitutes that origin-blind token source with the in-tree mock switch; everything from the gate to the fill is the shipped logic. - PhishGuard (
kOtpPhishGuard, Android-only) is not exercised here; per the code, its server-side reputation check is bound to the outermost main frame URL (the benign publisher) and local blocklists, so it does not vet the fenced ad’s origin either. - No Privacy Sandbox security gate is bypassed by the constructor shim: fenced
frames themselves are default-enabled (
blink::features::kFencedFrames), the fenced frame document opts in via the standardSupports-Loading-Mode: fenced-frameresponse header (sent byserver.pyexactly as real ad servers do), and config minting via Protected Audience/Shared Storage is generally available to any enrolled ad-tech in production.
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://ads.evil:8443
- https://ads.evil:8443/otp-ad.html
- https://publisher.test:8443
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/autofill/one_time_token_service_factory.cc
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/autofill/one_time_token_service_factory.cc;l=52
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/autofill/chrome_otp_phish_guard_delegate.cc;l=86
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/content/browser/content_autofill_driver.cc
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/content/browser/content_autofill_driver.cc;l=100
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/content/browser/content_autofill_driver_factory.cc;l=109
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/form_structure.h;l=303
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc;l=39
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc;l=144
- https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/common/autofill_features.cc;l=992
- https://source.chromium.org/chromium/chromium/src/+/main:components/one_time_tokens/.../one_time_token_switches.cc
- https://source.chromium.org/chromium/chromium/src/+/main:components/one_time_tokens/android/backend/sms/android_sms_otp_backend.cc;l=60
- https://source.chromium.org/chromium/chromium/src/+/main:components/one_time_tokens/core/browser/one_time_token.h;l=53
- https://source.chromium.org/chromium/chromium/src/+/main:components/one_time_tokens/core/browser/one_time_token_service_impl.cc;l=45
- https://source.chromium.org/chromium/chromium/src/+/main:components/safe_browsing/content/browser/password_protection/password_protection_service.cc;l=150
- https://source.chromium.org/chromium/chromium/src/+/main:content/public/browser/render_frame_host.h;l=393
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/common/frame/fenced_frame_sandbox_flags.h