Medium watch CVSS 5.4 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in NFC
DescriptionInappropriate implementation in NFC
ComponentNFC
Sub-componentWebNFC
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517417437
Fix commitb9235023ae19 (chromium/src) +121/-6
CWECWE-841
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-28 (per vendor advisory)
Fix landed2026-08-29 (public git)
Disclosed2026-09-22
Patch gap24 days — fix public in source before the advisory

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 MaybeResumeOrSuspendOperations suspends NFC reader/write when the page/tab loses visibility and resumes it when visible again.
`NfcProviderImpl`
Mojo-bound provider that constructs a NfcImpl per GetNFC bind and forwards suspend/resume signals 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

On the initial bind, 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.
Key insight
Per-instance suspension state cannot survive a rebind or a race-dropped signal; the visibility gate must be anchored in the longer-lived 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

  1. Grant NFC A page on an origin the user has granted WebNFC access begins a reader/write session while foregrounded.
  2. Background the page The user backgrounds the tab or app; visibility is lost and a suspend signal is issued.
  3. Race-drop the signal Mojo FIFO delivers the suspend before the NfcImpl receiver binds, so NfcProviderImpl drops it with mNfcImpl null.
  4. Rebind unsuspended A later GetNFC rebind constructs a fresh NfcImpl with mOperationsSuspended=false, skipping the visibility check.
  5. Read tags while invisible The non-foreground origin keeps scanning and writing physical NFC tags without the user’s presence or awareness.

Impact Assessment

A backgrounded, non-foreground origin bypasses the WebNFC visibility gate and can read and write physical NFC tags while the user is not looking at the page. This defeats the foreground-presence guarantee that makes WebNFC’s tag interactions user-attributable, enabling covert tag reads (tracking, credential/token skimming from nearby tags) and unauthorized writes. Exploitation requires a previously granted origin and a physical tag in range, but needs no further user interaction once backgrounded.

Changed Functions

FunctionChangeNotes
if
services/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java
modified
if
services/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.java
  • services/device/nfc/android/java/src/org/chromium/device/nfc/NfcProviderImpl.java
  • services/device/nfc/android/java/src/org/chromium/device/nfc/NfcImpl.java

Audit Directions

  • State scoped to a rebindable receiver
    flag 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 bind
    audit 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 constructors
    check that new instances default gated capabilities to the safe (suspended/closed) state rather than a hardcoded false, so a missed signal fails closed.
From b9235023ae196f4a49570390b7cba0ede6438219 Mon Sep 17 00:00:00 2001
From: Rob Pitkin <robpitkin@google.com>
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 <mattreynolds@chromium.org>
Commit-Queue: Rob Pitkin <robpitkin@chromium.org>
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());
+    }
 }
Loading diff…

Original Bug Report

reported by vm...@google.com

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.java
  • content/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:

  1. NFCHost::GetNFC is called in content/browser/android/nfc_host.cc (line 42). Because nfc_provider_ is not yet bound, the browser binds the NfcProvider pipe and immediately calls MaybeResumeOrSuspendOperations(web_contents()->GetVisibility()), which queues a Mojo call to SuspendNFCOperations() because the tab is hidden:
    if (!nfc_provider_) {
      content::GetDeviceService().BindNFCProvider(
          nfc_provider_.BindNewPipeAndPassReceiver());
      MaybeResumeOrSuspendOperations(web_contents()->GetVisibility());
    }
    
  2. Immediately after, it queues the initialization call on the same FIFO pipe:
    nfc_provider_->GetNFCForHost(id, std::move(receiver));
    
  3. Because both calls run on the same FIFO pipe, SuspendNFCOperations() arrives first at the Java implementation (NfcProviderImpl.java). However, because getNfcForHost has not yet run, mNfcImpl is null:
    public void suspendNfcOperations() {
        if (mNfcImpl != null) { 
            mNfcImpl.suspendNfcOperations();
        }
    }
    
    As a result, the suspension signal is silently ignored, and the suspended state is not saved.
  4. Next, getNfcForHost() is processed, which instantiates a new NfcImpl object. This new object defaults to mOperationsSuspended = false (in NfcImpl.java constructor at line 122).
  5. Since the tab is already hidden, no subsequent OnVisibilityChanged events fire, and the background tab’s NfcImpl remains active and unsuspended indefinitely.

Path B: Rebind Wipes Suspended State (Potential Compromised Renderer Route)

  1. When a tab is visible, the connection is established and mNfcImpl is active.
  2. When the tab becomes hidden, OnVisibilityChanged fires, calling SuspendNFCOperations() and successfully setting mOperationsSuspended = true in NfcImpl.
  3. A compromised renderer can send a second BrowserInterfaceBroker.GetInterface(device.mojom.NFC) request.
  4. In NFCHost::GetNFC, since nfc_provider_ is already initialized, the block if (!nfc_provider_) is bypassed. Thus, MaybeResumeOrSuspendOperations is NOT called. The browser proceeds to call nfc_provider_->GetNFCForHost with a new receiver.
  5. On the Java side, NfcProviderImpl.getNfcForHost() destroys the existing correctly-suspended mNfcImpl and constructs a fresh one with mOperationsSuspended = 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:

  1. On Chrome for Android, visit an attacker-controlled site and grant the NFC permission prompt.
  2. Register a listener for the visibilitychange event 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");
      }
    });
    
  3. Switch to a different tab or minimize the browser.
  4. The background tab requests the WebNFC interface. Due to the ordering gap, NfcProviderImpl ignores the suspension call, leaving NFC operations fully functional.
  5. 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:

  1. Update NfcProviderImpl.java to track the current suspension state via a boolean member variable (e.g., private boolean mOperationsSuspended;).
  2. Update this tracked state when suspendNfcOperations() and resumeNfcOperations() are called, regardless of whether mNfcImpl is currently null.
  3. In getNfcForHost(), initialize the new NfcImpl instance with the tracked state of mOperationsSuspended rather than hardcoding it to false in the NfcImpl constructor.

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