CVE-2026-95370
Overview
Background
- WebNFC
- Android web API exposing NFC tag read/write to a page, gated so only a visible, foreground origin may perform operations.
- Visibility suspension
- mechanism where
MaybeResumeOrSuspendOperationssuspends NFC reader/write when the page/tab loses visibility and resumes it when visible again. - `NfcProviderImpl`
- Mojo-bound provider that constructs a
NfcImplperGetNFCbind and forwardssuspend/resumesignals to it. - Mojo FIFO ordering
- in-order message delivery guarantee; a signal enqueued before its target receiver is bound is dropped, not queued.
Root Cause Analysis
NFCHost::GetNFC invoked MaybeResumeOrSuspendOperations before GetNFCForHost, so under Mojo FIFO ordering the suspend signal was dispatched before the NfcImpl receiver was bound and NfcProviderImpl dropped it because mNfcImpl was null. Suspension state was held only inside the transient NfcImpl instance, so it did not survive that drop. On subsequent GetNFC rebinds the visibility check was skipped, and a fresh NfcImpl was constructed with mOperationsSuspended hardcoded to false. As a result an invisible or backgrounded page was reattached with operations UNSUSPENDED and continued reading and writing physical NFC tags. The suspended state had no owner that outlived a single NfcImpl, so any dropped or missed suspend signal permanently un-gated the origin.NfcProviderImpl and re-seeded into every new NfcImpl. Because Mojo FIFO drops a signal sent before the receiver is bound, the resume/suspend truth must be persisted, not merely forwarded.Attack Path
- Grant NFC A page on an origin the user has granted WebNFC access begins a reader/write session while foregrounded.
- Background the page The user backgrounds the tab or app; visibility is lost and a suspend signal is issued.
- Race-drop the signal
Mojo FIFO delivers the suspend before the
NfcImplreceiver binds, soNfcProviderImpldrops it withmNfcImplnull. - Rebind unsuspended
A later
GetNFCrebind constructs a freshNfcImplwithmOperationsSuspended=false, skipping the visibility check. - Read tags while invisible The non-foreground origin keeps scanning and writing physical NFC tags without the user’s presence or awareness.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java |
modified | |
ifservices/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.java |
modified |
Files Changed
services/device/nfc/android/junit/src/org/chromium/device/nfc/NFCTest.javaservices/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.javaservices/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java
Audit Directions
- State scoped to a rebindable receiverflag security-relevant flags (visibility, suspension, permission gates) stored only in a per-bind Mojo impl; verify they live in a longer-lived owner and are re-seeded on every rebind.
- Signals sent before receiver bindaudit
GetNFC-style flows where a suspend/state signal is dispatched before its receiver is bound; under Mojo FIFO such messages are dropped, so the resulting state must be persisted and reconciled after bind. - Default-permissive constructorscheck that new instances default gated capabilities to the safe (suspended/closed) state rather than a hardcoded
false, so a missed signal fails closed.
Patch
From b9235023ae196f4a49570390b7cba0ede6438219 Mon Sep 17 00:00:00 2001 From: Rob Pitkin <[email protected]> Date: Fri, 28 Aug 2026 18:00:59 -0700 Subject: [PATCH] [WebNFC] Maintain visibility suspension state across rebinds on Android On initial bind, NFCHost::GetNFC invoked MaybeResumeOrSuspendOperations before GetNFCForHost. Due to Mojo FIFO message ordering, the suspension signal was dispatched before the receiver was bound, causing NfcProviderImpl to drop the signal because mNfcImpl was null. On subsequent GetNFC rebinds, the visibility check was skipped entirely, re-creating NfcImpl with operations unsuspended while invisible. This CL fixes the race and rebind gaps by re-applying visibility suspension after GetNFCForHost on every GetNFC call, and persisting mOperationsSuspended in NfcProviderImpl across NfcImpl instances. Bug: 517417437 Test: services_junit_tests --gtest_filter="NFCTest*" Change-Id: I5c28f3ece27b0b008a3ae9f9ad37bdeb455f8201 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8274769 Reviewed-by: Matt Reynolds <[email protected]> Commit-Queue: Rob Pitkin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1688467} --- diff --git a/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java b/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java index 01c155ac..104fdbe 100644 --- a/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java +++ b/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java @@ -116,10 +116,14 @@ /** Last time in milliseconds when a Tag was discovered. */ private long mTagDiscoveredLastTimeMs = -1; - public NfcImpl(int hostId, NfcDelegate delegate, InterfaceRequest<Nfc> request) { + public NfcImpl( + int hostId, + NfcDelegate delegate, + InterfaceRequest<Nfc> request, + boolean operationsSuspended) { mHostId = hostId; mDelegate = delegate; - mOperationsSuspended = false; + mOperationsSuspended = operationsSuspended; // |request| may be null in tests. if (request != null) { @@ -455,10 +459,16 @@ /** * Enables reader mode, allowing NFC device to read / write / make read-only NFC tags. + * * @see android.nfc.NfcAdapter#enableReaderMode */ private void enableReaderModeIfNeeded() { - if (mReaderCallbackHandler != null || mActivity == null || mNfcAdapter == null) return; + if (mOperationsSuspended + || mReaderCallbackHandler != null + || mActivity == null + || mNfcAdapter == null) { + return; + } if (!hasActiveOperations()) return; @@ -732,4 +742,8 @@ processPendingPushOperation(); processPendingMakeReadOnlyOperation(); } + + boolean isOperationsSuspendedForTesting() { + return mOperationsSuspended; + } } diff --git a/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.java b/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.java index fd05be8..c7e01c9d 100644 --- a/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.java +++ b/services/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.java @@ -18,6 +18,7 @@ private static final String TAG = "NfcProviderImpl"; private final NfcDelegate mDelegate; private @Nullable NfcImpl mNfcImpl; + private boolean mOperationsSuspended; public NfcProviderImpl(NfcDelegate delegate) { mDelegate = delegate; @@ -33,6 +34,7 @@ mNfcImpl.closeMojoConnection(); mNfcImpl = null; } + mOperationsSuspended = false; } @Override @@ -49,12 +51,13 @@ if (mNfcImpl != null) { mNfcImpl.closeMojoConnection(); } - mNfcImpl = new NfcImpl(hostId, mDelegate, request); + mNfcImpl = new NfcImpl(hostId, mDelegate, request, mOperationsSuspended); } /** Suspends the NFC usage. Should be called when web page visibility is lost. */ @Override public void suspendNfcOperations() { + mOperationsSuspended = true; if (mNfcImpl != null) { mNfcImpl.suspendNfcOperations(); } @@ -63,11 +66,20 @@ /** Resumes the NFC usage. Should be called when web page becomes visible. */ @Override public void resumeNfcOperations() { + mOperationsSuspended = false; if (mNfcImpl != null) { mNfcImpl.resumeNfcOperations(); } } + @Nullable NfcImpl getNfcImplForTesting() { + return mNfcImpl; + } + + boolean getOperationsSuspendedForTesting() { + return mOperationsSuspended; + } + /** A factory for implementations of the NfcProvider interface. */ public static class Factory implements InterfaceFactory<@Nullable NfcProvider> { private final NfcDelegate mDelegate; diff --git a/services/device/nfc/android/junit/src/org/chromium/device/nfc/NFCTest.java b/services/device/nfc/android/junit/src/org/chromium/device/nfc/NFCTest.java index 4c38088ee..165e35e 100644 --- a/services/device/nfc/android/junit/src/org/chromium/device/nfc/NFCTest.java +++ b/services/device/nfc/android/junit/src/org/chromium/device/nfc/NFCTest.java @@ -6,8 +6,10 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; @@ -99,7 +101,7 @@ /** Class that is used test NfcImpl implementation */ private static class TestNfcImpl extends NfcImpl { public TestNfcImpl(Context context, NfcDelegate delegate) { - super(0, delegate, null); + super(0, delegate, null, false); } public void processPendingOperationsForTesting(NfcTagHandler handler) { @@ -935,7 +937,7 @@ localMojoNdefRecord.data = ApiCompatibilityUtils.getBytesUtf8(TEST_TEXT); { // Must start with ':'. - localMojoNdefRecord.recordType = "dummyLocalTypeNotStartingwith:"; + localMojoNdefRecord.recordType = "localTypeNotStartingWith:"; localMojoNdefRecord.data = ApiCompatibilityUtils.getBytesUtf8(TEST_TEXT); NdefMessage localMojoNdefMessage = createMojoNdefMessage(localMojoNdefRecord); android.nfc.NdefMessage localNdefMessage = null; @@ -1172,6 +1174,14 @@ verify(mockCallback).call(mErrorCaptor.capture()); assertNull(mErrorCaptor.getValue()); + // Check that reader mode was NOT enabled while operations are suspended. + verify(mNfcAdapter, never()) + .enableReaderMode( + any(Activity.class), + any(ReaderCallback.class), + anyInt(), + (Bundle) isNull()); + // Check that watch is not triggered when NFC tag is in proximity. nfc.processPendingOperationsForTesting(mNfcTagHandler); verify(mNfcClient, times(0)) @@ -1978,4 +1988,83 @@ ApiCompatibilityUtils.getBytesUtf8(DUMMY_RECORD_ID), payloadMessage.toByteArray()); } + + /** + * Test that NfcProviderImpl.suspendNfcOperations() persists the suspended state when called + * before getNfcForHost(), and applies it when NfcImpl is instantiated. + */ + @Test + @Feature({"NFCTest"}) + public void testProviderSuspendsBeforeGetNfcForHost() { + NfcProviderImpl provider = new NfcProviderImpl(mDelegate); + provider.suspendNfcOperations(); + assertTrue(provider.getOperationsSuspendedForTesting()); + + provider.getNfcForHost(1, null); + NfcImpl nfc = provider.getNfcImplForTesting(); + assertNotNull(nfc); + assertTrue(nfc.isOperationsSuspendedForTesting()); + + mDelegate.invokeCallback(); + Push_Response mockCallback = mock(Push_Response.class); + nfc.push(createMojoNdefMessage(), createNdefWriteOptions(), mockCallback); + + // Check that push request was cancelled immediately due to operations being suspended. + verify(mockCallback).call(mErrorCaptor.capture()); + assertNotNull(mErrorCaptor.getValue()); + assertEquals(NdefErrorType.OPERATION_CANCELLED, mErrorCaptor.getValue().errorType); + } + + /** + * Test that NfcProviderImpl preserves the suspended state when a new NfcImpl is requested + * (rebind) while suspended. + */ + @Test + @Feature({"NFCTest"}) + public void testProviderRebindPreservesSuspendedState() { + NfcProviderImpl provider = new NfcProviderImpl(mDelegate); + provider.getNfcForHost(1, null); + NfcImpl nfc1 = provider.getNfcImplForTesting(); + assertNotNull(nfc1); + assertFalse(nfc1.isOperationsSuspendedForTesting()); + + provider.suspendNfcOperations(); + assertTrue(nfc1.isOperationsSuspendedForTesting()); + assertTrue(provider.getOperationsSuspendedForTesting()); + + // Rebind with a new host/request + provider.getNfcForHost(2, null); + NfcImpl nfc2 = provider.getNfcImplForTesting(); + assertNotNull(nfc2); + assertTrue(nfc2.isOperationsSuspendedForTesting()); + + mDelegate.invokeCallback(); + Push_Response mockCallback = mock(Push_Response.class); + nfc2.push(createMojoNdefMessage(), createNdefWriteOptions(), mockCallback); + + // Check that push request was cancelled immediately on the new instance. + verify(mockCallback).call(mErrorCaptor.capture()); + assertNotNull(mErrorCaptor.getValue()); + assertEquals(NdefErrorType.OPERATION_CANCELLED, mErrorCaptor.getValue().errorType); + } + + /** + * Test that NfcProviderImpl.resumeNfcOperations() clears the suspended state and resumes + * operations on the underlying NfcImpl. + */ + @Test + @Feature({"NFCTest"}) + public void testProviderResumeClearsSuspendedState() { + NfcProviderImpl provider = new NfcProviderImpl(mDelegate); + provider.suspendNfcOperations(); + assertTrue(provider.getOperationsSuspendedForTesting()); + + provider.resumeNfcOperations(); + assertFalse(provider.getOperationsSuspendedForTesting()); + + provider.getNfcForHost(1, null); + NfcImpl nfc = provider.getNfcImplForTesting(); + assertNotNull(nfc); + assertFalse(nfc.isOperationsSuspendedForTesting()); + } }
Original Bug Report
Potential bypass of WebNFC visibility suspension on Android via race and rebind in 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential logic vulnerability in WebNFC on Android allows a hidden or background tab to bypass visibility-suspend privacy and hardware-access controls. This enables a background page with prior NFC permissions to covertly read, write, or permanently lock physical NFC tags. The bypass is caused by incorrect Mojo message sequencing during initial bind and a lack of state tracking during subsequent rebind requests.
Affected files:
services/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.javacontent/browser/android/nfc_host.cc
Estimated timestamp from git blame: 2020-12-16
Root Cause Analysis
There is a logic flaw in how NFCHost::GetNFC manages the visibility-suspend state relative to NfcImpl construction, combined with the fact that NfcProviderImpl.java does not persist or track the suspended state. This results in two potential pathways that allow a hidden or background tab to run with fully unsuspended NFC operations.
Path A: Initial Bind Ordering Gap (Potential Web-reachable Route)
When a background tab requests the WebNFC interface for the first time while hidden:
NFCHost::GetNFCis called incontent/browser/android/nfc_host.cc(line 42). Becausenfc_provider_is not yet bound, the browser binds theNfcProviderpipe and immediately callsMaybeResumeOrSuspendOperations(web_contents()->GetVisibility()), which queues a Mojo call toSuspendNFCOperations()because the tab is hidden:if (!nfc_provider_) { content::GetDeviceService().BindNFCProvider( nfc_provider_.BindNewPipeAndPassReceiver()); MaybeResumeOrSuspendOperations(web_contents()->GetVisibility()); }- Immediately after, it queues the initialization call on the same FIFO pipe:
nfc_provider_->GetNFCForHost(id, std::move(receiver)); - Because both calls run on the same FIFO pipe,
SuspendNFCOperations()arrives first at the Java implementation (NfcProviderImpl.java). However, becausegetNfcForHosthas not yet run,mNfcImplisnull:As a result, the suspension signal is silently ignored, and the suspended state is not saved.public void suspendNfcOperations() { if (mNfcImpl != null) { mNfcImpl.suspendNfcOperations(); } } - Next,
getNfcForHost()is processed, which instantiates a newNfcImplobject. This new object defaults tomOperationsSuspended = false(inNfcImpl.javaconstructor at line 122). - Since the tab is already hidden, no subsequent
OnVisibilityChangedevents fire, and the background tab’sNfcImplremains active and unsuspended indefinitely.
Path B: Rebind Wipes Suspended State (Potential Compromised Renderer Route)
- When a tab is visible, the connection is established and
mNfcImplis active. - When the tab becomes hidden,
OnVisibilityChangedfires, callingSuspendNFCOperations()and successfully settingmOperationsSuspended = trueinNfcImpl. - A compromised renderer can send a second
BrowserInterfaceBroker.GetInterface(device.mojom.NFC)request. - In
NFCHost::GetNFC, sincenfc_provider_is already initialized, the blockif (!nfc_provider_)is bypassed. Thus,MaybeResumeOrSuspendOperationsis NOT called. The browser proceeds to callnfc_provider_->GetNFCForHostwith a new receiver. - On the Java side,
NfcProviderImpl.getNfcForHost()destroys the existing correctly-suspendedmNfcImpland constructs a fresh one withmOperationsSuspended = false(without checking the current visibility of the host tab).
Potential Steps to Reproduce
Note: These are suggested/potential steps based on manual source code analysis; our tooling agent does not have the ability to run live code.
Path A:
- On Chrome for Android, visit an attacker-controlled site and grant the NFC permission prompt.
- Register a listener for the
visibilitychangeevent that initiates WebNFC scan/write requests after the document is hidden:document.addEventListener('visibilitychange', () => { if (document.hidden && !started) { started = true; const r = new NDEFReader(); r.scan().then(() => r.onreading = e => { // Read NDEF payload and serial number }); r.write("pwned"); } }); - Switch to a different tab or minimize the browser.
- The background tab requests the WebNFC interface. Due to the ordering gap,
NfcProviderImplignores the suspension call, leaving NFC operations fully functional. - When the user taps an NFC tag, the hidden background tab is able to read, write, or permanently lock the tag.
Suggested Fix
To resolve this issue, the suspension state should be persistent and tracked by the provider regardless of NfcImpl instantiation:
- Update
NfcProviderImpl.javato track the current suspension state via a boolean member variable (e.g.,private boolean mOperationsSuspended;). - Update this tracked state when
suspendNfcOperations()andresumeNfcOperations()are called, regardless of whethermNfcImplis currently null. - In
getNfcForHost(), initialize the newNfcImplinstance with the tracked state ofmOperationsSuspendedrather than hardcoding it tofalsein theNfcImplconstructor.
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.