CVE-2026-17681
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/webauth/authenticator_impl_unittest.cc |
modified |
Files Changed
components/webauthn/android/java/src/org/chromium/components/webauthn/AuthenticatorImpl.javacomponents/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplPasswordOnlyTest.javacomponents/webauthn/android/junit/src/org/chromium/components/webauthn/AuthenticatorImplTest.javacontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/webauth/authenticator_impl_unittest.cc
Patch
From 9c7e33bdd59f62e86d8a85aea04799bd53250adc Mon Sep 17 00:00:00 2001
From: Ken Buchanan <kenrb@chromium.org>
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 <nsatragno@chromium.org>
Commit-Queue: Ken Buchanan <kenrb@chromium.org>
Auto-Submit: Ken Buchanan <kenrb@chromium.org>
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
Regression Test / PoC
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
Original Bug Report
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.javacontent/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.
- BFCache Transition: A compromised renderer hosting
https://attacker.cominitiates a navigation tohttps://victim.com. Theattacker.compage enters the Back/Forward Cache, and itsRenderFrameHostlifecycle state transitions tokInBackForwardCachewhile the renderer process remains alive. - Bypassing Freeze: The compromised renderer bypasses process-freezing scheduler constraints and requests the
blink.mojom.Authenticatorinterface via the BFCache’d frame’sBrowserInterfaceBrokerchannel. - Interface Binding: Since BFCache does not apply Mojo capability control binders to this interface, the browser binds the
Authenticatorpipe and instantiates the JavaAuthenticatorImplwithmOriginashttps://attacker.comandmTopOriginset to the currently active main frame’s origin (https://victim.com). - Visibility Gate Bypass: The attacker sends WebAuthn requests (such as
GetCredentialorMakeCredential) over the bound pipe. Because the browser tab is in the foreground showingvictim.com, the internal visibility check (mWebContents.getVisibility() != Visibility.VISIBLE) passes, launching privileged system FIDO2 / Credential Manager UI prompts overlaid on top of the activevictim.compage. - Cryptographic Assertion Forgery: The forged
topOrigin(https://victim.com) is serialized into theclientDataJSONand cryptographically signed by the device’s authenticator, falsely asserting thatattacker.comwas embedded invictim.comduring 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.