Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Web Authentication
DescriptionInsufficient validation of untrusted input in Web Authentication
ComponentWeb Authentication
Bug ClassLogic Error
Tracker516813184
Fix commit9c7e33bdd59f (chromium/src) +80/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/webauth/authenticator_impl_unittest.cc
modified

Files Changed

  • components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorImpl.java
  • components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplPasswordOnlyTest.java
  • components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplTest.java
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/webauth/authenticator_impl_unittest.cc
From 9c7e33bdd59f62e86d8a85aea04799bd53250adc Mon Sep 17 00:00:00 2001
From: Ken Buchanan <[email protected]>
Date: Mon, 22 Jun 2026 07:32:16 -0700
Subject: [PATCH] [WebAuthn] Prevent WebAuthn requests from inactive frames

WebAuthn calls should not be possible from inactive frames, such as
those in the process of navigation or in BFCache. This is currently
enforced in the desktop WebAuthn implementation and treated as a
non-focused frame, but there is no check in the Android implementation.

This change adds an explicit check when binding the interface, for
both Android and desktop.

It also adds checks at the time of invocation of makeCredential
and getAssertion, in case an interface bound on a live frame
is used while the frame is later inactive.

Fixed: 516813184
Change-Id: I1ede1544bcdbb4b090b1feb777d9e6dc58436a73
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7969319
Reviewed-by: Nina Satragno <[email protected]>
Commit-Queue: Ken Buchanan <[email protected]>
Auto-Submit: Ken Buchanan <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1650300}
---

diff --git a/components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorImpl.java b/components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorImpl.java
index 2e934bcc..95f4fc8 100644
--- a/components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorImpl.java
+++ b/components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorImpl.java
@@ -32,6 +32,7 @@
 import org.chromium.build.annotations.Nullable;
 import org.chromium.components.password_manager.BrowserAssistedLoginType;
 import org.chromium.components.ukm.UkmRecorder;
+import org.chromium.content_public.browser.LifecycleState;
 import org.chromium.content_public.browser.RenderFrameHost;
 import org.chromium.content_public.browser.Visibility;
 import org.chromium.content_public.browser.WebContents;
@@ -165,6 +166,13 @@
                             new RequestMetrics.Builder().build()));
             return;
         }
+        if (mRenderFrameHost.getLifecycleState() != LifecycleState.ACTIVE) {
+            requestCallback.onComplete(
+                    WebauthnRequestResponse.forFailedMakeCredential(
+                            AuthenticatorStatus.NOT_ALLOWED_ERROR,
+                            new RequestMetrics.Builder().build()));
+            return;
+        }
         log(TAG, "makeCredential");
 
         mIsPaymentRequest = options.isPaymentCredentialCreation;
@@ -254,6 +262,13 @@
                             new RequestMetrics.Builder().build()));
             return;
         }
+        if (mRenderFrameHost.getLifecycleState() != LifecycleState.ACTIVE) {
+            requestCallback.onComplete(
+                    WebauthnRequestResponse.forFailedGetCredential(
+                            AuthenticatorStatus.NOT_ALLOWED_ERROR,
+                            new RequestMetrics.Builder().build()));
+            return;
+        }
         log(TAG, "getCredential");
 
         mRequestCallback = requestCallback;
diff --git a/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplPasswordOnlyTest.java b/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplPasswordOnlyTest.java
index 666befeb..352ea8a 100644
--- a/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplPasswordOnlyTest.java
+++ b/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplPasswordOnlyTest.java
@@ -30,6 +30,7 @@
 import org.chromium.blink.mojom.GetCredentialOptions;
 import org.chromium.blink.mojom.Mediation;
 import org.chromium.blink.mojom.PublicKeyCredentialRequestOptions;
+import org.chromium.content_public.browser.LifecycleState;
 import org.chromium.content_public.browser.RenderFrameHost;
 import org.chromium.content_public.browser.Visibility;
 import org.chromium.content_public.browser.WebContents;
@@ -63,6 +64,7 @@
         mTopOrigin = Origin.create(new GURL("https://example.com"));
 
         when(mRenderFrameHost.getLastCommittedOrigin()).thenReturn(mOrigin);
+        when(mRenderFrameHost.getLifecycleState()).thenReturn(LifecycleState.ACTIVE);
 
         WebauthnModeProvider.setInstanceForTesting(mModeProviderMock);
         when(mModeProviderMock.getWebauthnMode(any())).thenReturn(WebauthnMode.CHROME);
diff --git a/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplTest.java b/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplTest.java
index 1216f497..abc7d24 100644
--- a/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplTest.java
+++ b/components/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplTest.java
@@ -42,6 +42,7 @@
 import org.chromium.blink.mojom.WebAuthnClientCapability;
 import org.chromium.components.ukm.UkmRecorder;
 import org.chromium.components.ukm.UkmRecorderJni;
+import org.chromium.content_public.browser.LifecycleState;
 import org.chromium.content_public.browser.RenderFrameHost;
 import org.chromium.content_public.browser.Visibility;
 import org.chromium.content_public.browser.WebContents;
@@ -93,6 +94,7 @@
         mTopOrigin = Origin.create(new GURL("https://example.com"));
 
         when(mRenderFrameHost.getLastCommittedOrigin()).thenReturn(mOrigin);
+        when(mRenderFrameHost.getLifecycleState()).thenReturn(LifecycleState.ACTIVE);
 
         WebauthnModeProvider.setInstanceForTesting(mModeProviderMock);
         when(mModeProviderMock.getWebauthnMode(any())).thenReturn(WebauthnMode.CHROME);
@@ -426,4 +428,39 @@
         verify(callback, never()).call(any());
         verify(mFido2CredentialRequestMock).handleGetCredentialRequest(any(), any(), any(), any());
     }
+
+    @Test
+    public void testMakeCredential_inactiveFrame() {
+        when(mRenderFrameHost.getLifecycleState()).thenReturn(LifecycleState.IN_BACK_FORWARD_CACHE);
+
+        Authenticator.MakeCredential_Response callback =
+                mock(Authenticator.MakeCredential_Response.class);
+        PublicKeyCredentialCreationOptions options = new PublicKeyCredentialCreationOptions();
+        mAuthenticator.makeCredential(options, callback);
+
+        verify(callback).call(eq(AuthenticatorStatus.NOT_ALLOWED_ERROR), any(), any());
+        verify(mFido2CredentialRequestMock, never())
+                .handleMakeCredentialRequest(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    public void testGetCredential_inactiveFrame() {
+        when(mRenderFrameHost.getLifecycleState()).thenReturn(LifecycleState.IN_BACK_FORWARD_CACHE);
+
+        Authenticator.GetCredential_Response callback =
+                mock(Authenticator.GetCredential_Response.class);
+        GetCredentialOptions options = new GetCredentialOptions();
+        options.publicKey = new PublicKeyCredentialRequestOptions();
+        mAuthenticator.getCredential(options, callback);
+
+        ArgumentCaptor<GetCredentialResponse> captor =
+                ArgumentCaptor.forClass(GetCredentialResponse.class);
+        verify(callback).call(captor.capture());
+        assertEquals(
+                AuthenticatorStatus.NOT_ALLOWED_ERROR,
+                captor.getValue().getGetAssertionResponse().status);
+
+        verify(mFido2CredentialRequestMock, never())
+                .handleGetCredentialRequest(any(), any(), any(), any());
+    }
 }
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 72734c4..69e1ebd 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -15095,6 +15095,10 @@
 
 void RenderFrameHostImpl::GetWebAuthenticationService(
     mojo::PendingReceiver<blink::mojom::Authenticator> receiver) {
+  if (!IsActive()) {
+    return;
+  }
+
 #if !BUILDFLAG(IS_ANDROID)
   AuthenticatorImpl::Create(this, std::move(receiver));
 #else
diff --git a/content/browser/webauth/authenticator_impl_unittest.cc b/content/browser/webauth/authenticator_impl_unittest.cc
index ec91600..541e613e 100644
--- a/content/browser/webauth/authenticator_impl_unittest.cc
+++ b/content/browser/webauth/authenticator_impl_unittest.cc
@@ -10711,4 +10711,26 @@
   EXPECT_EQ(result.status, AuthenticatorStatus::CROSS_DEVICE_FALLBACK);
 }
 
+TEST_F(AuthenticatorImplTest, InactiveRenderFrameHost) {
+  NavigateAndCommit(GURL(kTestOrigin1));
+
+  // Set the lifecycle state to `kInBackForwardCache` so the RenderFrameHost is
+  // inactive.
+  static_cast<RenderFrameHostImpl*>(main_rfh())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  ASSERT_FALSE(main_rfh()->IsActive());
+
+  // Try to connect to the authenticator service.
+  mojo::Remote<blink::mojom::Authenticator> authenticator =
+      ConnectToAuthenticator();
+
+  // The receiver should be dropped immediately, causing the remote to be
+  // disconnected.
+  base::RunLoop run_loop;
+  authenticator.set_disconnect_handler(run_loop.QuitClosure());
+  run_loop.Run();
+  EXPECT_FALSE(authenticator.is_connected());
+}
+
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/webauth/authenticator_impl_unittest.cc b/content/browser/webauth/authenticator_impl_unittest.cc
index ec91600..541e613e 100644
--- a/content/browser/webauth/authenticator_impl_unittest.cc
+++ b/content/browser/webauth/authenticator_impl_unittest.cc
@@ -10711,4 +10711,26 @@
   EXPECT_EQ(result.status, AuthenticatorStatus::CROSS_DEVICE_FALLBACK);
 }
 
+TEST_F(AuthenticatorImplTest, InactiveRenderFrameHost) {
+  NavigateAndCommit(GURL(kTestOrigin1));
+
+  // Set the lifecycle state to `kInBackForwardCache` so the RenderFrameHost is
+  // inactive.
+  static_cast<RenderFrameHostImpl*>(main_rfh())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  ASSERT_FALSE(main_rfh()->IsActive());
+
+  // Try to connect to the authenticator service.
+  mojo::Remote<blink::mojom::Authenticator> authenticator =
+      ConnectToAuthenticator();
+
+  // The receiver should be dropped immediately, causing the remote to be
+  // disconnected.
+  base::RunLoop run_loop;
+  authenticator.set_disconnect_handler(run_loop.QuitClosure());
+  run_loop.Run();
+  EXPECT_FALSE(authenticator.is_connected());
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by [email protected]

Potential WebAuthn AuthenticatorFactory lacks RenderFrameHost LifecycleState check on Android

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: On Android, the WebAuthn AuthenticatorFactory does not verify the active lifecycle state of the calling RenderFrameHost, potentially allowing an inactive Back/Forward cached (BFCache’d) renderer to bind the Authenticator interface. This enables a compromised renderer to bypass WebContents visibility checks, overlaying privileged system credential dialogs over unrelated active foreground pages. It also allows forging the ’topOrigin’ parameter in cryptographically signed WebAuthn assertions.

Affected files:

  • components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorFactory.java
  • content/browser/renderer_host/render_frame_host_impl.cc

Estimated timestamp from git blame: 2018-03-29

Root Cause

On Android, RenderFrameHostImpl::GetWebAuthenticationService (in content/browser/renderer_host/render_frame_host_impl.cc around line 15041) binds requests for the blink.mojom.Authenticator interface. Unlike desktop platforms where AuthenticatorImpl::Create performs an explicit validation check (if (!render_frame_host->IsActive()) { return; }), the Android branch directly forwards the interface receiver to Java without checking the lifecycle state of the frame:

void RenderFrameHostImpl::GetWebAuthenticationService(
    mojo::PendingReceiver<blink::mojom::Authenticator> receiver) {
#if !BUILDFLAG(IS_ANDROID)
  AuthenticatorImpl::Create(this, std::move(receiver));
#else
  GetJavaInterfaces()->GetInterface(std::move(receiver));
#endif
}

On the Java side, AuthenticatorFactory.createImpl() (in components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorFactory.java around line 34) instantiates the Authenticator implementation. It performs no validation to verify that mRenderFrameHost is in an active lifecycle state, and derives topOrigin from the primary main frame of the live WebContents:

Origin topOrigin = webContents.getMainFrame().getLastCommittedOrigin();

If the calling RenderFrameHost is inactive and cached in the Back/Forward Cache (BFCache), the delegate WebContents primary main frame represents the newly navigated, active foreground page. Consequently, topOrigin is incorrectly resolved to the active page’s origin (e.g., https://victim.com) rather than the cached page’s origin (https://attacker.com).


Potential Trigger Path

Note: These are suggested and potential steps; our security tooling does not currently have the capability to run or execute code to produce a live proof of concept.

  1. BFCache Transition: A compromised renderer hosting https://attacker.com initiates a navigation to https://victim.com. The attacker.com page enters the Back/Forward Cache, and its RenderFrameHost lifecycle state transitions to kInBackForwardCache while the renderer process remains alive.
  2. Bypassing Freeze: The compromised renderer bypasses process-freezing scheduler constraints and requests the blink.mojom.Authenticator interface via the BFCache’d frame’s BrowserInterfaceBroker channel.
  3. Interface Binding: Since BFCache does not apply Mojo capability control binders to this interface, the browser binds the Authenticator pipe and instantiates the Java AuthenticatorImpl with mOrigin as https://attacker.com and mTopOrigin set to the currently active main frame’s origin (https://victim.com).
  4. Visibility Gate Bypass: The attacker sends WebAuthn requests (such as GetCredential or MakeCredential) over the bound pipe. Because the browser tab is in the foreground showing victim.com, the internal visibility check (mWebContents.getVisibility() != Visibility.VISIBLE) passes, launching privileged system FIDO2 / Credential Manager UI prompts overlaid on top of the active victim.com page.
  5. Cryptographic Assertion Forgery: The forged topOrigin (https://victim.com) is serialized into the clientDataJSON and cryptographically signed by the device’s authenticator, falsely asserting that attacker.com was embedded in victim.com during the signature generation.

Fix Recommendation

Add an active check before invoking GetJavaInterfaces() in RenderFrameHostImpl::GetWebAuthenticationService inside content/browser/renderer_host/render_frame_host_impl.cc:

void RenderFrameHostImpl::GetWebAuthenticationService(
    mojo::PendingReceiver<blink::mojom::Authenticator> receiver) {
  if (!IsActive()) {
    return;
  }
#if !BUILDFLAG(IS_ANDROID)
  AuthenticatorImpl::Create(this, std::move(receiver));
#else
  GetJavaInterfaces()->GetInterface(std::move(receiver));
#endif
}

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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