CVE-2026-13887
Overview
Background
- WebNFC
- Web platform API exposing NFC read/write to pages, gated to top-level, foreground, focused contexts by the spec.
- `content::NFCHost`
- Browser-process object that brokers
blink.mojom.NFCinterface requests from a renderer’sRenderFrameHostto the platform NFC adapter. - Primary main frame
- The current top-level
RenderFrameHostof aWebContents; excludes BFCached, prerendered, subframe, and detached frames. - `mojo::ReportBadMessage`
- Mojo-layer primitive that flags a renderer IPC as invalid and terminates the offending renderer process.
Root Cause Analysis
NFCHost::GetNFC bound the blink.mojom.NFC interface for whatever RenderFrameHost issued the request, verifying only origin/permission state and never confirming the frame was the current primary main frame. Because a back/forward-cached or backgrounded frame retains a live RenderFrameHost and can still dispatch Mojo IPCs, such a frame could invoke GetNFC and be handed an NFC binding despite no longer being top-level or foreground. The browser then serviced NFC operations against the foreground Activity’s single NFC adapter on behalf of a non-foreground, potentially cross-origin context, violating the WebNFC “primary main frame only” invariant. The defect is a missing lifecycle/state check on a security-relevant capability grant — a logic error, not memory unsafety.
The fix adds web_contents()->GetPrimaryMainFrame() != render_frame_host as a hard gate and treats a mismatch as a malformed message worthy of renderer termination.
RenderFrameHost is not proof of foreground primacy: BFCached and backgrounded frames keep valid Mojo endpoints and can request capabilities long after they cease to be the primary main frame. Capability brokers must re-validate frame lifecycle state at bind time, never trusting that only the current top frame can reach the endpoint.Attack Path
- Acquire a stale frame
Attacker page navigates such that its
RenderFrameHostenters the back/forward cache or is backgrounded while remaining alive. - Replay the request
The stale renderer sends a
GetNFCMojo message from the non-primaryRenderFrameHost. - Bind without check
Pre-fix
NFCHost::GetNFCbinds the NFC interface, passing only the origin/permission check. - Operate on foreground adapter The cross-frame/cross-origin context drives NFC reads/writes against the current foreground Activity’s adapter, unauthorized.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/android/nfc_host_unittest.cc |
modified |
Files Changed
content/browser/android/nfc_host.cccontent/browser/android/nfc_host_unittest.cc
Audit Directions
- Bind-time lifecycle validationAudit other browser-process capability brokers (
Get*/interface binders) that trust aRenderFrameHostwithout comparing againstGetPrimaryMainFrame()or checkingIsInPrimaryMainFrame()/GetLifecycleState(). - BFCache-reachable Mojo endpointsEnumerate
blink.mojominterfaces bound per-frame and confirm each rejects or defers requests from cached, prerendered, or backgrounded frames. - Cross-WebContents capability leakageReview platform singletons (NFC, sensors, media) shared with the foreground Activity to ensure the requesting frame belongs to the foreground
WebContents, not merely to a live renderer.
Patch
From e2f73644a9273ac70be414b6db262973a9aa4b67 Mon Sep 17 00:00:00 2001
From: Hongchan Choi <hongchan@google.com>
Date: Wed, 27 May 2026 11:22:23 -0700
Subject: [PATCH] [NFC] Verify requesting frame is primary main frame in Android NFCHost
This CL adds a lifecycle check to NFCHost::GetNFC on Android to
ensure the requesting RenderFrameHost matches
web_contents()->GetPrimaryMainFrame(). This prevents BFCached or
backgrounded frames from performing unauthorized cross-origin NFC
operations using the foreground Activity. If the check fails, the
renderer is terminated using a Mojo bad message.
A new unit test is added to verify this security check.
Bug: 500508524
Test: content_unittests --gtest_filter=NFCHostTest.*
Change-Id: Iba6bc8ed668f1f842d7fd1be53e683bf8e862b42
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7870447
Reviewed-by: Nasko Oskov <nasko@chromium.org>
Commit-Queue: Hongchan Choi <hongchan@chromium.org>
Reviewed-by: Alvin Ji <alvinji@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1637108}
---
diff --git a/content/browser/android/nfc_host.cc b/content/browser/android/nfc_host.cc
index 22a3142..54a5dc3 100644
--- a/content/browser/android/nfc_host.cc
+++ b/content/browser/android/nfc_host.cc
@@ -60,6 +60,11 @@
return;
}
+ if (web_contents()->GetPrimaryMainFrame() != render_frame_host) {
+ mojo::ReportBadMessage("WebNFC not on primary main frame.");
+ return;
+ }
+
if (render_frame_host->GetBrowserContext()
->GetPermissionController()
->GetPermissionStatusForCurrentDocument(
diff --git a/content/browser/android/nfc_host_unittest.cc b/content/browser/android/nfc_host_unittest.cc
index 3b7ae85..c549218 100644
--- a/content/browser/android/nfc_host_unittest.cc
+++ b/content/browser/android/nfc_host_unittest.cc
@@ -14,6 +14,8 @@
#include "content/test/test_render_view_host.h"
#include "content/test/test_web_contents.h"
#include "services/device/public/mojom/nfc.mojom.h"
+#include "mojo/public/cpp/test_support/fake_message_dispatch_context.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/permissions/permission_utils.h"
@@ -97,4 +99,24 @@
DeleteContents();
}
+TEST_F(NFCHostTest, GetNFCFromDifferentWebContentsMainFrame) {
+ NavigateAndCommit(GURL(kTestUrl));
+
+ // Create a second WebContents.
+ std::unique_ptr<TestWebContents> second_web_contents =
+ TestWebContents::Create(browser_context(), nullptr);
+
+ mojo::Remote<device::mojom::NFC> nfc;
+ mojo::FakeMessageDispatchContext fake_dispatch_context;
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ // Request NFC from the first WebContents, but passing the main frame of the
+ // second WebContents.
+ contents()->GetNFC(second_web_contents->GetPrimaryMainFrame(),
+ nfc.BindNewPipeAndPassReceiver());
+
+ EXPECT_EQ("WebNFC not on primary main frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
} // namespace content
Regression Test / PoC
diff --git a/content/browser/android/nfc_host_unittest.cc b/content/browser/android/nfc_host_unittest.cc
index 3b7ae85..c549218 100644
--- a/content/browser/android/nfc_host_unittest.cc
+++ b/content/browser/android/nfc_host_unittest.cc
@@ -14,6 +14,8 @@
#include "content/test/test_render_view_host.h"
#include "content/test/test_web_contents.h"
#include "services/device/public/mojom/nfc.mojom.h"
+#include "mojo/public/cpp/test_support/fake_message_dispatch_context.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/permissions/permission_utils.h"
@@ -97,4 +99,24 @@
DeleteContents();
}
+TEST_F(NFCHostTest, GetNFCFromDifferentWebContentsMainFrame) {
+ NavigateAndCommit(GURL(kTestUrl));
+
+ // Create a second WebContents.
+ std::unique_ptr<TestWebContents> second_web_contents =
+ TestWebContents::Create(browser_context(), nullptr);
+
+ mojo::Remote<device::mojom::NFC> nfc;
+ mojo::FakeMessageDispatchContext fake_dispatch_context;
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ // Request NFC from the first WebContents, but passing the main frame of the
+ // second WebContents.
+ contents()->GetNFC(second_web_contents->GetPrimaryMainFrame(),
+ nfc.BindNewPipeAndPassReceiver());
+
+ EXPECT_EQ("WebNFC not on primary main frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
} // namespace content
Original Bug Report
Potential Cross-Origin NFC Access from BFCache via Android NFCHost
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 without the security team.
Overview: A missing lifecycle check in the Android NFCHost implementation allows a compromised renderer in the Back/Forward Cache to re-bind the NFC interface. By leveraging the visibility of the active tab, the background page can enable NFC reader mode using the foreground Activity and silently intercept NFC tag data intended for a different origin.
Affected files:
content/browser/android/nfc_host.ccservices/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.javacontent/browser/permissions/permission_controller_impl.cc
Estimated timestamp from git blame: 2021-12-09
Description
A logic error in the Android NFCHost implementation allows a RenderFrameHost in the Back/Forward Cache (BFCache) to access the device.mojom.NFC service.
When a frame requests the NFC interface, NFCHost::GetNFC in content/browser/android/nfc_host.cc performs several lifecycle checks. It verifies that the frame has no parent, is not prerendering, and is not a fenced frame. However, it lacks a check to ensure that the requesting frame is the active primary main frame. This safeguard is correctly implemented in the equivalent iOS code (content/browser/ios/nfc_host.mm), which checks web_contents()->GetPrimaryMainFrame() != render_frame_host.
Because of this missing check on Android, a compromised renderer that has entered the BFCache can bypass renderer-side suspension and send a new Mojo request for device.mojom.NFC via its BrowserInterfaceBroker. NFCHost::GetNFC will accept the request and bind a new NFC provider pipe.
Subsequently, NFCHost::GetNFC calls MaybeResumeOrSuspendOperations(web_contents()->GetVisibility()). Because it checks the visibility of the entire WebContents (the tab), and the tab is actively displaying a different page, it returns Visibility::VISIBLE. This prevents the Java implementation from suspending operations.
On the Java side, when the BFCached page initiates a Watch operation, NfcHost.java retrieves the Android Activity associated with the active WebContents. This foreground Activity is then used to enable Android’s NFC Reader Mode. The Android OS, seeing a foreground Activity requesting NFC, allows the operation. When the user taps an NFC tag, the data is delivered to the backgrounded, BFCached page’s origin, resulting in a silent cross-origin data leak.
Impact
An attacker with a compromised renderer can perform cross-origin NFC operations while the user is interacting with a different, potentially sensitive site. A BFCached malicious page can:
- Read the contents of NFC tags (such as payment cards, access badges, or transit passes) when the user taps them while viewing a different site.
- Write attacker-controlled NDEF records to a tag.
- Permanently and irreversibly lock a tag using the
MakeReadOnlyoperation.
There is no system-level UI attribution indicating which origin is accessing the NFC hardware, meaning the user assumes the visible site is interacting with the tag.
Potential Reproduction Steps
Note: These are suggested steps based on code analysis.
- On an Android device, a user navigates to
https://evil.comand grants the site NFC permissions. - The user navigates to another site (e.g.,
https://bank.com) in the same tab.evil.comenters the BFCache. - A compromised renderer for
evil.combypasses freezing and sends a Mojo request fordevice.mojom.NFCvia itsBrowserInterfaceBrokerpipe. - The browser process binds the NFC interface because it passes the incomplete lifecycle checks in
content/browser/android/nfc_host.cc. - The
evil.compage starts an NFCwatchoperation. Because the tab is visible,NfcImpl.javasuccessfully uses the foreground Activity to enable NFC Reader Mode. - The user taps an NFC tag while viewing
bank.com. The tag data is silently delivered to the BFCachedevil.comrenderer.
Suggested Fix
Update NFCHost::GetNFC in content/browser/android/nfc_host.cc to verify that the requesting frame is the primary main frame, bringing it inline with the iOS implementation:
if (web_contents()->GetPrimaryMainFrame() != render_frame_host) {
mojo::ReportBadMessage("WebNFC not on primary main frame.");
return;
}
Alternatively, add an explicit check to reject requests if the frame’s lifecycle state is RenderFrameHost::LifecycleState::kInBackForwardCache.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.