The app that would not let go
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.
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
- Twelve containers on a laptop I'd stopped usingWritingTwelve containers on a 2017 Dell Inspiron that had sat unopened for three years. What the path layout has to look like, why the auto-updater is commented out, and why the certificate setup I built first got thrown away.
- Alias the host, not the keyWritingManaging several SSH keys on one machine gets much simpler once you stop thinking about keys and start inventing hostnames.