Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Transactions Platform
DescriptionMemory corruption in Transactions Platform
ComponentTransactions Platform
Sub-componentAutofill
Bug ClassMemory Corruption
Tracker562225226
Fix commit06f3959dec63 (chromium/src) +58/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector.cc
modified
if
components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
modified
TEST_P
components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
modified
TEST_F
components/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.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_manager_impl_unittest.cc
  • components/autofill/core/browser/integrators/one_time_tokens/otp_field_detector_unittest.cc
From 06f3959dec63232bfe4382333c5fc841c15fe591 Mon Sep 17 00:00:00 2001
From: Matthias Körber <[email protected]>
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 <[email protected]>
Commit-Queue: Matthias Körber <[email protected]>
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,
Loading diff…

Regression Test / PoC

shipped with the fix
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,
Loading diff…

Original Bug Report

reported by [email protected]

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:

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:

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

  1. Wrong principal captured at form lift: components/autofill/content/browser/content_autofill_driver.cc:100,119 — the browser-side FormData gets its “main frame origin” from GetMainFrame(), which walks only GetParent() and stops at a fenced-frame root (parent_ is null there). Only GetOutermostMainFrame() 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
    
  2. 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, so SameDomainOrHost trivially 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;
    }
    
  3. Fenced frames get a full autofill driver: components/autofill/content/browser/content_autofill_driver_factory.cc:109-114 bails out of driver creation only when blink::features::kFencedFramesAPIChanges is disabled; that feature is base_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.
  4. Origin-blind profile-global token source: once IsOtpForm passes, 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/kScripts are mandatory-unsandboxed, third_party/blink/public/common/frame/fenced_frame_sandbox_flags.h).
  5. PhishGuard vets the wrong frame: chrome/browser/ui/autofill/chrome_otp_phish_guard_delegate.cc:86-103 checks 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

  1. 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>.
  2. The creative shows a pretext (“enter the code we sent you”) with <input autocomplete="one-time-code">; classification fires and IsOtpForm passes because both compared origins are the ad’s own. Chrome silently begins polling GMSCore for SMS OTPs.
  3. 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.
  4. The user taps the ad’s input, then taps the suggestion chip showing the raw OTP digits.
  5. 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:

  1. FencedFrameConfig(url) web constructor restored (blink IDL + 6 lines). In production, ad-techs obtain FencedFrameConfigs via Protected Audience runAdAuction() / sharedStorage.selectURL() (both GA); this checkout has the browser-side Privacy Sandbox services stripped (content/browser/interest_group, content/browser/shared_storage absent, 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-side FencedFrame::Navigate accepts such raw https URLs unmodified). The resulting fenced frame is a real MPArch fenced frame tree — the structure the bug is about.
  2. --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::GetOtpSuggestions evaluates 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.
  3. Observation logging only: two LOG(ERROR) lines in otp_field_detector.cc print 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.patch applied: 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 logs EXFILTRATED OTP: code=834271.
  • iframe: gate logs passed=0 (ads.evil vs publisher.test); no popup (iframe_2_after_click.png), exfil.log unchanged.

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/autofill code 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_ANDROID block), 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 standard Supports-Loading-Mode: fenced-frame response header (sent by server.py exactly 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.

View on issue tracker
Links in the report