Mikey Strauss

A Zero-Day Screen Hijack in Android’s Tap-to-Pay: The Grant That Never Expires

September 28, 2026 cybersecuritynfcvulnerabilityandroid-securityandroid

Security research · Oats Solutions

Any app that was ever your tap-to-pay handler keeps a permanent, background screen-hijack power — even after a clean logout.

By Mikey Strauss, Dani Levenfeld & Tal Dim — Oats Solutions

TL;DR: One public Android API lets any app briefly register itself as your phone’s preferred tap-to-pay handler — no user picking it as a default in Settings, just the app announcing itself. While registered, it gets a real exception to Android’s background-screen-hijack protection. Calling the matching public API to properly log back out is supposed to revoke that exception too. It doesn’t. We proved this isn’t a leftover flag sitting unused: a real app used the leaked power to pop an unprompted, full-screen takeover window from the background, seconds after doing everything right to log out. The same leftover power survives a completely different app taking over as the new preferred handler, and — as far as we measured — was still live at least 5 minutes later with no sign of decaying.

We found this reading AOSP’s NFC stack source, then proved it end-to-end with a live PoC on a real device — validated against a true negative control so we weren’t just watching a test-harness artifact.

The exception that doesn’t expire

Ordinarily, an app sitting in the background can’t just launch a full-screen Activity over you — Android calls this a background-activity-launch (BAL) restriction, and it’s a real, deliberate anti-phishing/anti-scareware protection. Apps that briefly need to pop UI from the background get a scoped, temporary exception instead of a blanket permission. A tap-to-pay app is a legitimate case: while it’s registered as a preferred payment handler, it may need to show something the instant a tap happens, even from the background.

The exception is supposed to disappear the moment that status is properly revoked, through the exact same public API a well-behaved app uses to register in the first place. It doesn’t:

// HostEmulationManager.java
private void unbindInactiveServicesLocked() {
    ...
    mComponentNameToConnectionsMap.keySet().forEach((key) -> {
        if (!preferredNameAndUser.equals(key)) {
            HostEmulationConnection connection = mComponentNameToConnectionsMap.get(key);
            if (connection.mMessenger != null) {      // <-- guards the unbind...
                mContext.unbindService(connection.mServiceConnection);
            }
            // ...but the tracking entry is dropped from the map either way
        }
    });
    mComponentNameToConnectionsMap = retainedConnections;
}

If this runs while a connection is still mid-handshake (its messenger hasn’t arrived yet), the real system binding — the one actually carrying the background-launch exception — never gets torn down. But the bookkeeping entry that would have tracked it gets removed anyway, unconditionally. The live exception is now orphaned: still active, with nothing left anywhere in NFC’s own code that would ever think to revoke it.

A second, narrower defect shares the same dangerous flag, in a sibling connection class elsewhere in the same file: its own early-return branch skips a required unbind call too. It’s reachable only by an app that’s already the system’s actual, Settings-selected default payment app — a higher bar than the primary bug above, which needs nothing but the public registration API. Both share the same fix shape; more on that at the end.

Proving it, not just reading it

We confirmed the primary defect on a real Pixel 10: a genuinely backgrounded app successfully launching an Activity, at 3 seconds, ~40 seconds, and 120 seconds after doing the documented, correct revocation — no decay across any of those checkpoints:

ActivityTaskManager: START u0 {... cmp=com.runzee.hooktest/.BalMarkerActivity ...}
with LAUNCH_SINGLE_INSTANCE from uid 10351 (com.runzee.hooktest) (BAL_ALLOW_TOKEN) result code=0

BAL_ALLOW_TOKEN is Android’s own internal reason code for holding a valid background-launch exception. As a negative control — proving the harness itself isn’t just granting this for free — the identical launch attempt from a process with no real exception is correctly blocked:

ActivityTaskManager: Background activity launch blocked! goo.gle/android-bal
ActivityTaskManager: START ... (BAL_BLOCK) result code=102

We also inspected the raw binding state directly, independent of any launch attempt: two simultaneous, live, non-dead service connections from the NFC system service, each independently flagged mIsAllowedBgActivityStartsByBinding=true, persisting 60+ seconds after revocation. A payment-category binding is only ever supposed to have one live connection at a time — observing two is a second, completely independent confirmation of the same leak.

We didn’t stop at “a flag stays set” — we weaponized it

A leftover internal flag is one thing. We wanted to know whether it’s an actual, usable attack primitive — so we built the simplest possible demonstration: use the exact leaked exception to pop a real, unprompted, full-screen window from a backgrounded process, with no tap, no notification, nothing. Run separately on a second device, a Pixel 9 (“tokay”, Android 15), to confirm this isn’t a Pixel 10-only artifact:

08:48:54.412 HOOKTEST_HCE_RACE: ON_PAUSE t=1784526534412
08:48:57.491 HOOKTEST_HCE_RACE: BAL_WEAPONIZED_ATTEMPT label=appeal pid=21043
08:48:57.505 ActivityTaskManager: START u0 {cmp=.../.BalWeaponizedDemoActivity}
  with LAUNCH_SINGLE_INSTANCE from uid 10330 (BAL_ALLOW_TOKEN) result code=0
08:48:57.519 HOOKTEST_HCE_RACE: BAL_WEAPONIZED_LAUNCHED label=appeal pid=21043

ON_PAUSE fires a full 3 seconds before the trigger — direct confirmation the app was genuinely backgrounded when it happened, not a foreground-window artifact dressed up as one. This is what actually rendered on screen, timestamp 08:48:57.517, matching the log to the second:

That’s a real screen, on a real device, taken over by a process the user had already stopped interacting with. This is the concrete version of the abstract “phishing/scareware enabler” every background-launch restriction exists to prevent — an unprompted fake dialog, a fake login screen, or a fake system warning could just as easily fill that same window.

It gets worse: the leak isn’t self-limiting

Two more things worth checking, because they change how bad this actually is: does the leak clear itself if someone else becomes the new preferred payment app, and how long does it actually last?

Neither works the way you’d hope. A second, genuinely separate app — different package, different UID — took over as the phone’s system-wide preferred payment handler, which should have no bearing on the first app’s status at all. The first app’s leaked exception fired successfully anyway, in two independent runs two minutes apart:

-- run 1, 08:54:57 --
HOOKTEST_HCE_RACE: BAL_LAUNCH_ATTEMPT label=crossapp pid=24133
ActivityTaskManager: START u0 {cmp=.../.BalMarkerActivity}
  with LAUNCH_SINGLE_INSTANCE from uid 10330 (BAL_ALLOW_TOKEN) result code=0
HOOKTEST_HCE_RACE: BAL_MARKER_ACTIVITY_LAUNCHED label=crossapp pid=24133
-- run 2, 08:56:56 (same pid, freshly re-triggered) --
HOOKTEST_HCE_RACE: BAL_LAUNCH_ATTEMPT label=crossapp pid=24133
ActivityTaskManager: START u0 {cmp=.../.BalMarkerActivity}
  with LAUNCH_SINGLE_INSTANCE from uid 10330 (BAL_ALLOW_TOKEN) result code=0
HOOKTEST_HCE_RACE: BAL_MARKER_ACTIVITY_LAUNCHED label=crossapp pid=24133

And rather than being capped at the roughly two minutes we’d originally measured, a fresh run confirmed the exception was still live and usable a full 5 minutes after revocation:

HOOKTEST_HCE_RACE: BAL_LAUNCH_ATTEMPT label=duration_5min pid=25809
ActivityTaskManager: START u0 {cmp=.../.BalMarkerActivity}
  with LAUNCH_SINGLE_INSTANCE from uid 10330 (BAL_ALLOW_TOKEN) result code=0
HOOKTEST_HCE_RACE: BAL_MARKER_ACTIVITY_LAUNCHED label=duration_5min pid=25809

Put together: any app that’s ever briefly declared itself a tap-to-pay handler keeps a real, demonstrated screen-hijack primitive that doesn’t depend on staying “preferred,” doesn’t depend on how long ago it logged out, and — as far as we measured — shows no sign of expiring on its own at all.

The fix

Two separate changes are needed, because two separate code paths share the same dangerous flag. Primary: drop the guard around the unbind call in unbindInactiveServicesLocked() — calling unbindService() on a connection that hasn’t finished connecting yet is well-defined and safe, so the guard serves no purpose except creating this exact leak. Secondary: add the missing unbindService(this) call to the sibling connection class’s own early-return branch, matching the pattern Google’s own team already applied correctly elsewhere in the same file for a related, previously-patched bug.

Reported to Google through the Android and Google Devices Security Reward Program, following standard coordinated disclosure.

Found using Runzee, Oats Solutions’ dynamic-analysis platform for Android — built to show you what a device is actually doing at the Binder, JNI, filesystem, and network layers, independent of anything the app under test claims about itself.

Originally published on Medium.