Skip to content

The app that would not let go

9 min read
#android#security#adb#debugging

An older relative came to me with a phone that had stopped behaving. The battery was draining in hours. Google Play Protect had flagged something and claimed to have dealt with it. But the app was still there in Settings, and it would not come off.

Every time he opened the uninstall screen, the screen closed itself. He tried a factory reset. The reset screen closed itself too.

By the time it reached me, he had been living with it for six days. He had installed it after seeing an advertisement on TikTok for an app called Mox, which promised free IPTV, films and series. The link took him outside the Play Store to an APK he installed by hand.

This is what it took to find it, why the obvious approach failed, and why the phone kept closing its own settings screens.

The first thing I tried, and why it found nothing

The app was called Mox, so:

adb shell pm list packages | grep -i mox

Nothing.

That result is the whole lesson of this case, so it’s worth being precise about why. The name you see in Settings is the app’s label, a human-readable string stored in the APK’s resources, which the developer can set to anything. The package name is a separate identifier, and nothing requires the two to resemble each other.

Here, the label said Mox. The package name was com.nzr.truove.

Searching by name cannot find an app that does not want to be found. I widened the net anyway, in case something turned up in a component name or a file path:

adb shell pm list packages -d      # disabled packages
adb shell pm list packages -u      # includes uninstalled packages with retained data
adb shell pm list packages -f      # includes the APK path
adb shell dumpsys package | grep -i mox

Still nothing matching “mox”. The -u list did contain com.nzr.truove, but at that point it was one unremarkable string among three hundred. I had no reason to look at it twice.

I also checked for a hidden work profile, which is a common place for things to sit out of sight:

adb shell pm list users

One user. Nothing hiding there either.

Name-based search was exhausted.

The pivot: search by capability instead

If you cannot find an app by what it is called, find it by what it can do.

This turns out to be a much better approach in general, because the set of permissions that make an app genuinely dangerous is small, and Android will list every holder of them on request. Three commands cover it:

adb shell settings get secure enabled_accessibility_services
adb shell settings get secure enabled_notification_listeners
adb shell dumpsys device_policy | head -40

The first one ended the search:

com.openai.chatgpt/com.openai.feature.conversations.screencontext.ConversationScreenAccessibilityService
:com.nzr.truove/com.nzr.truove.muuozzscovxd

Two entries. The first belongs to a known app and has a descriptive class name. The second is an unrecognised package whose service class is muuozzscovxd, a random consonant string.

Real developers name a service something like ScreenReaderService, because they have to maintain it. Randomised class names exist to defeat signature-based scanning. An unknown package holding accessibility rights under an obfuscated name is not an ambiguous finding.

Why the phone kept closing its own screens

An enabled accessibility service is the most powerful thing a non-system app can hold on Android. It is designed for screen readers and switch access, so it grants two capabilities together:

  • read the entire contents of every screen, including text typed into password fields
  • generate synthetic taps, swipes and gestures

The second half is what my relative had been fighting for six days. The app could see which screen was open. When that screen was its own uninstall page, or the factory reset page, it pressed Back, a real input event, indistinguishable from a finger.

That distinction matters practically. ADB commands go to the package manager directly. There is no screen for the app to detect and no button for it to press, so it cannot interfere. If you are stuck using the phone’s own UI, the equivalent move is to revoke the accessibility permission first. That removes the app’s hands before you try to remove the app.

The same dump corroborated the finding independently:

accessibility services: 2 apps
  0: com.openai.chatgpt
  1: com.nzr.truove

Notification access, meanwhile, was clean. Truecaller, Samsung Routines, the launcher, nothing unexpected. Useful negative evidence: whatever this was, it was not reading notifications.

What it actually had

adb shell dumpsys package com.nzr.truove \
  | grep -iE "versionName|firstInstallTime|installerPackageName|requested permissions" -A 25

The record was unambiguous.

installerPackageName was absent and packageSource was 0, confirming a sideload rather than a Play Store install. The version string was 2.184.44 suupenavuvubgfpxk, with a random token appended to a plausible-looking version number. It declared around twenty-five custom permissions under a com.gbu.efgypmb namespace that corresponds to no real package. Anti-analysis noise, nothing more.

Then the runtime grants:

android.permission.READ_SMS:     granted=true
android.permission.CAMERA:       granted=true
android.permission.RECORD_AUDIO: granted=true

READ_SMS is the line that changed the nature of the problem.

Screen reading gives you what someone types into a login form. READ_SMS gives you the one-time passcode sent to confirm it. Held simultaneously, they collapse two-factor authentication into one factor, because both factors arrive at the same compromised device. Every banking app on that phone had been operating inside that window.

Both authentication factors arriving at one compromised deviceThe first factor, a password typed into a login form, is captured by the accessibility service reading the screen. The second factor, a one-time passcode sent by SMS, is captured by the READ_SMS permission. Both are held by com.nzr.truove on the same handset, so the two factors converge on a single app.Something you knowtyped into the login formSomething you haveone-time passcode, by SMSaccessibility servicereads the screenREAD_SMSreads the passcodecom.nzr.truoveone app, one handset
Two factors only count as two while they travel separately.

firstInstallTime gave the window’s start: 23 August, 13:24. That single field turned out to be the most valuable thing in the whole dump, because it is what a bank’s fraud team needs in order to know which transactions to review.

Checking whether it came alone

Malware often installs companions, so before removing anything I checked whether the timeline had other entries:

adb shell pm path com.nzr.truove
adb shell pm list packages | grep -i "gbu\|efgypmb"

The APK sat under /data/app/, which is the ordinary third-party area. That was good news: a package under /system/ or /product/ would have been a privileged preinstall and a much worse problem. The phantom permission namespace matched no installed package.

I then checked install dates on every other unexplained package on the device. One was timestamped 2008, the factory image date, so a vendor preinstall. The rest were from ten months earlier. Nothing else appeared on 23 August. Single package, single incident.

Removal, and confirming it

adb pull /data/app/~~.../base.apk ~/evidence.apk     # keep a copy first
adb shell pm disable-user --user 0 com.nzr.truove    # contain, reversible
adb shell pm uninstall --user 0 com.nzr.truove       # remove app and data

disable-user switches an app off immediately without deleting it, which is useful when you want the threat neutralised now but the evidence intact.

Verification is where it is easy to fool yourself:

adb shell pm list packages -u | grep nzr
adb shell settings get secure enabled_accessibility_services
adb shell find /sdcard -iname "*.apk"

The -u flag is essential. A plain pm list packages will report clean even when the package was uninstalled with its data directory deliberately retained. Without -u you cannot tell “removed” from “removed, but it kept everything”.

All three came back clean. The accessibility list was down to the one legitimate entry, and the original APK was no longer sitting in Downloads waiting to be run again.

What I would do first next time

Knowing the answer, two commands would have found this in under a minute.

Audit the privileged grants before enumerating anything. The three settings and dumpsys reads above produce a list short enough to check by eye, and anything hostile enough to matter is almost certainly in it. I spent most of my time enumerating packages, which was the wrong end of the problem.

List everything that did not come from the Play Store:

adb shell pm list packages -i -3 | grep -v "installer=com.android.vending"

On an ordinary consumer phone this should be close to empty. A sideloaded app nobody remembers installing is worth a hard look.

The part that isn’t technical

Removing the app does not undo the exposure. For six days, credentials and SMS passcodes had both been readable on that device, and no amount of correct ADB work changes that. The actual remediation happened elsewhere: credentials reset from a different, clean device, the bank notified with the exposure window so they could review activity against it, and passcodes rotated on every account that had been opened on the phone during those six days.

The triage tells you what happened and when. It is the beginning of the response, not the end of it.

There is also a design observation buried in this, which I keep turning over. Accessibility services exist for a genuinely good reason. They are how blind users operate a phone at all. The capability cannot be removed without removing that. But the same grant that lets a screen reader describe an interface lets hostile software operate it, and a person tapping through a consent dialog on a phone they got from a TikTok advertisement is not equipped to tell those apart. The permission model is technically sound and, for the person actually holding the phone, close to useless.

Read next