Skip to main content

Detecting Android RATs in Kotlin: A Scanner for Accessibility Abuse, Overlay Phishing, and C2 Indicators

Android Security Engineering

Detecting Android RATs: A Scanner for Accessibility Abuse, Overlay Phishing, and C2 Indicators

A complete defensive architecture in Kotlin using Room, Paging 3, Hilt, and Compose to identify Remote Access Trojans through permission analysis, runtime state inspection, side-load correlation, and static C2 signature scanning.

Scope
This article is a detection guide. Every code block builds the DangerousAppScanner — an app that identifies suspicious packages on your own device. No attack code is provided. The APIs discussed (Accessibility, NotificationListener, DevicePolicyManager) are explained at the level needed to recognize their abuse, not deploy it.

1. Why Detection, Not Construction

Every capability in a typical Android RAT is built from a legitimate, documented API. AccessibilityService exists so screen readers can work. NotificationListenerService Exists so smartwatches can mirror alerts. DevicePolicyManager Exists so enterprises can manage fleets. The RAT is not a broken implementation of these — it is a deliberate combination of them behind a non-transparent UI.

That means detection is tractable. You cannot remove the APIs (they are required for legitimate use), but you can identify the combinations. A flashlight app that requests CAMERA is normal. A flashlight app that requests CAMERA, READ_SMS, and BIND_ACCESSIBILITY_SERVICE, and was installed from a browser download, is not.

The scanner's role: observe which apps have declared dangerous capabilities, which ones have been granted them at runtime, and how those capabilities cluster together on this specific device.

2. The RAT Attack Surface & MITRE Mapping

Each capability maps to a MITRE ATT&CK for Mobile technique. The scanner reports findings in MITRE terms so analysts can pivot between this tool and other intelligence.

Capability Observable Signal MITRE Technique
Accessibility auto-granting BIND_ACCESSIBILITY_SERVICE + service enabled + non-assistive app T1453, T1516
Overlay phishing BIND_ACCESSIBILITY_SERVICE + SYSTEM_ALERT_WINDOW T1411, T1453
Keylogging Accessibility config declares typeViewTextChanged without assistive purpose T1417.001
OTP interception BIND_NOTIFICATION_LISTENER_SERVICE + SMS perms + bank targets T1517, T1636
Device lockout Non-MDM active admin T1629.002
Persistence RECEIVE_BOOT_COMPLETED + foreground service T1402
SMS interception READ_SMS + RECEIVE_SMS on non-messaging app T1636
Telegram C2 Bot token regex in APK strings T1437
Side-loaded delivery Installer not in trusted store set T1474

The key insight from real-world analysis is that no single row is diagnostic. A screen reader legitimately declares BIND_ACCESSIBILITY_SERVICE. A banking app legitimately requests READ_SMS. The scanner's value lies in identifying apps where multiple rows overlap, and the app's declared purpose doesn't match its capability set.

3. Malware Family Fingerprints

Public threat intelligence documents consistent behavioral fingerprints for major Android RAT families. The scanner encodes these as detection rules. Each row is descriptive — what the family does, so the scanner can recognize it — not instructional.

Family Behavioral Fingerprint Detection Signal
DogeRat Telegram C2, accessibility abuse, SMS/contact exfil, foreground service persistence Telegram token + BIND_ACCESSIBILITY_SERVICE + READ_SMS + RECEIVE_BOOT_COMPLETED
AhMyth Socket.IO C2, accessibility keylogger, screen recording Accessibility + RECORD_AUDIO + CAMERA + outbound Socket.IO strings
AndroRAT Python server on port 8000/42474, shell command exec, file exfil Hardcoded IP regex + shell exec strings + READ_EXTERNAL_STORAGE
SpyNote Accessibility + overlay, keylogger, screen streaming, camera/mic Accessibility + SYSTEM_ALERT_WINDOW + full sensor cluster
Anatsa / Cerberus Accessibility auto-grant, banking overlay, dropper install chain Accessibility + sideload + banking package target list
TermuRAT GitHub Gist as C2, Termux environment checks Gist URL regex + Termux package string check
RatHat Self-ADB pairing, AI-driven UI automation, accessibility auto-grant Accessibility + WRITE_SECURE_SETTINGS (unusual) + adb key strings
ClayRat Accessibility keylogger, PIN capture, device unlock Accessibility + typeViewTextChanged + DISABLE_KEYGUARD

These are detection signatures, not implementation instructions. The scanner stores them as a lookup table keyed on the combination of permission clusters and string indicators.

4. Scanner Architecture

The scanner is a data producer: it walks the installed-package list, gathers findings, and writes them into a Room database that a Paging 3-backed Compose UI renders. The architecture reuses the same repository + ViewModel + Flow pattern from any well-structured Android app.

PackageManager (all installed)
  ├── PermissionAnalyzer → finding list
  ├── AccessibilityInspector → enabled?
  ├── AdminInspector → active admin?
  ├── InstallSourceInspector → sideloaded?
  └── C2SignatureScanner → string indicators
      ↓
RatScanner.calculateScore() → ScanResultDomain
      ↓
ScanRepository → Room DB → Pager → Compose UI

The manifest declares only four permissions — none of which are dangerous runtime permissions:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
    android:name="android.permission.QUERY_ALL_PACKAGES"
    tools:ignore="QueryAllPackagesPermission" />

QUERY_ALL_PACKAGES is required to enumerate all installed apps on Android 11+. Google Play restricts this to specific categories — security/antivirus is an approved category, so document the use case in your Play listing if publishing.

5. Static Permission Analysis

The first layer inspects each app's declared permissions — what the manifest requests, regardless of runtime grants.

data class PermissionFinding(
    val id: String,
    val permission: String,
    val weight: Int,
    val mitre: String,
    val rationale: String
)

class PermissionAnalyzer @Inject constructor() {

    private val catalog: Map<String, PermissionFinding> = mapOf(
        Manifest.permission.BIND_ACCESSIBILITY_SERVICE to PermissionFinding(
            id = "accessibility_declared",
            permission = Manifest.permission.BIND_ACCESSIBILITY_SERVICE,
            weight = 40,
            mitre = "T1453",
            rationale = "Declares accessibility binding; can observe all UI text and inject input across apps"
        ),
        Manifest.permission.BIND_DEVICE_ADMIN to PermissionFinding(
            id = "device_admin_declared",
            permission = Manifest.permission.BIND_DEVICE_ADMIN,
            weight = 35,
            mitre = "T1629",
            rationale = "Declares device admin; can lock, wipe, or alter password policy"
        ),
        Manifest.permission.SYSTEM_ALERT_WINDOW to PermissionFinding(
            id = "overlay_declared",
            permission = Manifest.permission.SYSTEM_ALERT_WINDOW,
            weight = 25,
            mitre = "T1411",
            rationale = "Can draw over other apps; combined with accessibility, enables overlay phishing"
        ),
        Manifest.permission.READ_SMS to PermissionFinding(
            id = "sms_read",
            permission = Manifest.permission.READ_SMS,
            weight = 20,
            mitre = "T1636",
            rationale = "Can read SMS database; enables OTP and message theft"
        ),
        Manifest.permission.RECEIVE_SMS to PermissionFinding(
            id = "sms_receive",
            permission = Manifest.permission.RECEIVE_SMS,
            weight = 20,
            mitre = "T1636",
            rationale = "Real-time SMS interception"
        ),
        Manifest.permission.SEND_SMS to PermissionFinding(
            id = "sms_send",
            permission = Manifest.permission.SEND_SMS,
            weight = 20,
            mitre = "T1444",
            rationale = "Can send SMS; enables premium-rate fraud and contact spam"
        ),
        Manifest.permission.CAMERA to PermissionFinding(
            id = "camera",
            permission = Manifest.permission.CAMERA,
            weight = 15,
            mitre = "T1512",
            rationale = "Camera access; combined with background services, enables surveillance"
        ),
        Manifest.permission.RECORD_AUDIO to PermissionFinding(
            id = "mic",
            permission = Manifest.permission.RECORD_AUDIO,
            weight = 15,
            mitre = "T1429",
            rationale = "Microphone access; ambient recording capability"
        ),
        Manifest.permission.ACCESS_FINE_LOCATION to PermissionFinding(
            id = "location_fine",
            permission = Manifest.permission.ACCESS_FINE_LOCATION,
            weight = 12,
            mitre = "T1430",
            rationale = "Precise location tracking"
        ),
        Manifest.permission.READ_CONTACTS to PermissionFinding(
            id = "contacts",
            permission = Manifest.permission.READ_CONTACTS,
            weight = 10,
            mitre = "T1636",
            rationale = "Contact database access; enables social-graph exfiltration"
        ),
        Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE to PermissionFinding(
            id = "notif_listener",
            permission = Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE,
            weight = 30,
            mitre = "T1517",
            rationale = "Notification access; enables OTP interception from banking and messaging apps"
        ),
        Manifest.permission.RECEIVE_BOOT_COMPLETED to PermissionFinding(
            id = "boot",
            permission = Manifest.permission.RECEIVE_BOOT_COMPLETED,
            weight = 8,
            mitre = "T1402",
            rationale = "Auto-start on device boot; persistence enabler"
        ),
        Manifest.permission.READ_CALL_LOG to PermissionFinding(
            id = "call_log",
            permission = Manifest.permission.READ_CALL_LOG,
            weight = 12,
            mitre = "T1430",
            rationale = "Call-history access; combined with SMS, indicates surveillance"
        )
    )

    fun analyze(permissions: List<String>): List<PermissionFinding> =
        permissions.mapNotNull { catalog[it] }
}

The catalog weights are calibrated so that a single medium-risk permission alone never triggers a critical finding. The scorer (Section 10) applies additional weight when specific permissions co-occur.

6. Runtime State Inspection

A package can declare a dangerous capability without it being active. The scanner therefore queries the platform's actual runtime state — which accessibility services are enabled, which device admins are active.

6.1 Accessibility Enabled State

class AccessibilityInspector @Inject constructor(
    @ApplicationContext private val context: Context
) {

    fun enabledPackages(): Set<String> {
        val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
            as? AccessibilityManager ?: return emptySet()

        return am.getEnabledAccessibilityServiceList(
            AccessibilityServiceInfo.FEEDBACK_ALL_MASK
        ).mapNotNull { info ->
            info.resolveInfo?.serviceInfo?.packageName
        }.toSet()
    }

    fun isEnabled(packageName: String): Boolean =
        packageName in enabledPackages()
}

The delta between declared and enabled is analytically useful. An app that declares accessibility but isn't enabled is a lower-priority finding than one that actively observes the UI.

6.2 Device Admin State

class AdminInspector @Inject constructor(
    @ApplicationContext private val context: Context
) {

    // Known enterprise device-policy controllers (extend as needed)
    private val knownMdmPackages = setOf(
        "com.google.android.apps.work.clouddpc",
        "com.microsoft.windowsintune.companyportal",
        "com.airwatch.androidagent",
        "com.vmware.workspaceone.hub",
        "com.blackberry.workspaces",
        "com.soti.mobicontrol"
    )

    fun activeAdmins(): List<String> {
        val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE)
            as? DevicePolicyManager ?: return emptyList()

        return dpm.activeAdmins?.map { it.packageName } ?: emptyList()
    }

    fun isActiveAdmin(packageName: String): Boolean =
        packageName in activeAdmins()

    fun isNonMdmAdmin(packageName: String): Boolean =
        isActiveAdmin(packageName) && packageName !in knownMdmPackages
}

Flag any active admin that isn't a recognized MDM. On a personal device, the set should typically be empty or contain only system components. A third-party app with device-admin privileges on a consumer phone is either malware or a misconfigured enterprise app.

7. Side-Load Correlation

Security research on Android banking trojans consistently finds that side-loaded apps with enabled accessibility services are the highest-confidence RAT indicator available without root. The installer identity is cheap to check.

class InstallSourceInspector @Inject constructor(
    @ApplicationContext private val context: Context
) {

    private val trustedInstallers = setOf(
        "com.android.vending",              // Google Play
        "com.google.android.packageinstaller",
        "com.samsung.android.galaxyapps",   // Galaxy Store
        "com.amazon.venezia",               // Amazon Appstore
        "com.huawei.appmarket",
        "com.sec.android.app.samsungapps"
    )

    @Suppress("DEPRECATION")
    fun isSideloaded(packageName: String): Boolean {
        val pm = context.packageManager
        val installer = try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                pm.getInstallSourceInfo(packageName).installingPackageName
            } else {
                pm.getInstallerPackageName(packageName)
            }
        } catch (e: Exception) {
            null
        }
        return installer == null || installer !in trustedInstallers
    }
}

Note that null is treated as side-loaded. On older Android versions, apps installed via ADB report null as installer — which is exactly the delivery path for dropper-based RATs. The scanner flags them; the analyst interprets whether the context justifies the install.

8. Behavioral Chain Detection

MITRE's detection guidance frames RATs as chains, not individual capabilities. The scanner encodes each chain as a set of prerequisites, scored as a unit. This is what differentiates it from a naive "list dangerous permissions" tool.

8.1 The Overlay Phishing Chain (T1453 + T1411)

The observable prerequisites are: accessibility binding and enabled, overlay window capability, and non-assistive purpose (heuristic: the app is not installed from a store OR lacks accessibility-related strings).

class OverlayPhishingDetector @Inject constructor(
    private val accessibilityInspector: AccessibilityInspector,
    private val installSourceInspector: InstallSourceInspector
) {

    fun matches(packageName: String, declaredPermissions: List<String>): Boolean {
        val accessibility = Manifest.permission.BIND_ACCESSIBILITY_SERVICE
        val overlay = Manifest.permission.SYSTEM_ALERT_WINDOW

        val hasChain = accessibility in declaredPermissions
            && overlay in declaredPermissions
            && accessibilityInspector.isEnabled(packageName)

        if (!hasChain) return false

        // Assistive apps installed from a store are lower priority
        return installSourceInspector.isSideloaded(packageName)
    }
}

8.2 The OTP Interception Chain (T1517 + T1636)

Notification listener + SMS read + a target package that indicates banking or messaging.

class OtpInterceptionDetector @Inject constructor(
    @ApplicationContext private val context: Context
) {

    private val bankingPackageHints = setOf(
        "com.chase", "com.bankofamerica", "com.wellsfargo",
        "com.paypal", "com.venmo", "com.squareup.cash",
        "com.coinbase", "com.binance", "com.revolut"
    )

    fun matches(packageName: String, declaredPermissions: List<String>): Boolean {
        val hasNotificationAccess = Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE
            in declaredPermissions
        val hasSmsRead = Manifest.permission.READ_SMS in declaredPermissions
        if (!hasNotificationAccess && !hasSmsRead) return false

        // Check whether the app's own package or strings reference banking targets
        return referencesBankingTargets(packageName)
    }

    private fun referencesBankingTargets(packageName: String): Boolean {
        // Read the app's own metadata (label, activities) for banking hints
        return try {
            val pm = context.packageManager
            val info = pm.getPackageInfo(packageName, 0)
            val label = pm.getApplicationLabel(info.applicationInfo).toString().lowercase()
            bankingPackageHints.any { hint ->
                label.contains(hint.substringAfterLast('.')) || packageName.contains(hint)
            }
        } catch (e: Exception) {
            false
        }
    }
}

8.3 The Device Lockout Chain (T1629.002)

class DeviceLockoutDetector @Inject constructor(
    private val adminInspector: AdminInspector,
    private val installSourceInspector: InstallSourceInspector
) {

    fun matches(packageName: String, declaredPermissions: List<String>): Boolean {
        if (Manifest.permission.BIND_DEVICE_ADMIN !in declaredPermissions) return false
        if (!adminInspector.isActiveAdmin(packageName)) return false
        // Non-MDM admin on a side-loaded app is the strongest signal
        return adminInspector.isNonMdmAdmin(packageName)
            && installSourceInspector.isSideloaded(packageName)
    }
}

9. Static C2 Indicator Scanning

Telegram bot tokens and Gist URLs are commonly embedded in RAT APKs. The scanner parses the target package's resources.arsc and DEX strings, applying regex patterns documented in public threat research. These findings are not conclusive alone — legitimate apps use Telegram — but raise the score when combined with the behavioral chains.

class C2SignatureScanner @Inject constructor(
    @ApplicationContext private val context: Context
) {

    private val telegramBotToken = Regex(
        "\\b\\d{8,10}:[A-Za-z0-9_-]{35}\\b"
    )
    private val gistUrl = Regex(
        "https?://gist\\.github(usercontent)?\\.com/[^\\s\"']+"
    )
    private val genericIpPattern = Regex(
        "\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{2,5})?\\b"
    )

    fun scan(packageName: String): List<C2Indicator> {
        val sourceDir = try {
            context.packageManager.getApplicationInfo(packageName, 0).sourceDir
        } catch (e: Exception) {
            return emptyList()
        }

        val apkFile = File(sourceDir)
        if (!apkFile.exists()) return emptyList()

        val indicators = mutableListOf<C2Indicator>()

        // Extract printable strings from the APK zip entries
        val strings = extractStrings(apkFile)

        telegramBotToken.findAll(strings).forEach { match ->
            indicators += C2Indicator(
                type = "telegram_bot_token",
                evidence = "${match.value.take(12)}...",
                weight = 40
            )
        }

        gistUrl.findAll(strings).forEach { match ->
            indicators += C2Indicator(
                type = "github_gist_c2",
                evidence = match.value,
                weight = 30
            )
        }

        // Only flag IPs in combination with other indicators to avoid noise
        if (indicators.isNotEmpty()) {
            genericIpPattern.findAll(strings).take(5).forEach { match ->
                indicators += C2Indicator(
                    type = "hardcoded_ip",
                    evidence = match.value,
                    weight = 10
                )
            }
        }

        return indicators
    }

    private fun extractStrings(apkFile: File): String {
        val sb = StringBuilder()
        try {
            ZipInputStream(apkFile.inputStream()).use { zip ->
                var entry = zip.nextEntry
                while (entry != null) {
                    val name = entry.name
                    if (name.endsWith(".dex") || name == "resources.arsc") {
                        // Limit per-entry to avoid OOM on huge DEX
                        val buffer = ByteArray(64 * 1024)
                        var total = 0
                        while (total < 8 * 1024 * 1024) {
                            val read = zip.read(buffer)
                            if (read <= 0) break
                            sb.append(String(buffer, 0, read, Charsets.ISO_8859_1))
                            total += read
                        }
                    }
                    zip.closeEntry()
                    entry = zip.nextEntry
                }
            }
        } catch (e: Exception) {
            // Malformed or encrypted APK — signal below
        }
        return sb.toString()
    }
}

data class C2Indicator(
    val type: String,
    val evidence: String,
    val weight: Int
)

The ISO-8859-1 decode is intentional: DEX files contain raw bytes that would corrupt with UTF-8 multi-byte interpretation. For production, use a proper DEX parser (e.g., dexlib2 from smali) which handles string tables precisely. The regex-on-raw-bytes approach here is a fast, dependency-free approximation.

10. The Scoring Model

The scorer combines individual findings, runtime state, and behavioral chains into a 0–100 score with a categorical risk level. The design principle is that combinations dominate singles: an app with one 40-weight permission scores 40; an app with the same permission and it is enabled, and the app is side-loaded scores 90.

enum class RiskLevel(val minScore: Int) {
    LOW(0), MEDIUM(30), HIGH(55), CRITICAL(80);

    companion object {
        fun fromScore(score: Int): RiskLevel =
            entries.last { score >= it.minScore }
    }
}

class RatScanner @Inject constructor(
    @ApplicationContext private val context: Context,
    private val permissionAnalyzer: PermissionAnalyzer,
    private val accessibilityInspector: AccessibilityInspector,
    private val adminInspector: AdminInspector,
    private val installSourceInspector: InstallSourceInspector,
    private val c2Scanner: C2SignatureScanner,
    private val overlayChain: OverlayPhishingDetector,
    private val otpChain: OtpInterceptionDetector,
    private val lockoutChain: DeviceLockoutDetector,
    private val clock: Clock
) {

    suspend fun scanAll(): List<ScanResultDomain> = withContext(Dispatchers.IO) {
        val pm = context.packageManager
        val packages = pm.getInstalledPackages(
            PackageManager.PackageInfoFlags.of(
                PackageManager.GET_PERMISSIONS.toLong()
            )
        )

        packages.mapNotNull { pkg ->
            if (pkg.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) return@mapNotNull null
            if (pkg.packageName == context.packageName) return@mapNotNull null

            val perms = pkg.requestedPermissions?.toList() ?: emptyList()
            buildResult(pkg, perms)
        }.filter { it.riskScore >= 15 }
         .sortedByDescending { it.riskScore }
    }

    private fun buildResult(
        pkg: PackageInfo,
        permissions: List<String>
    ): ScanResultDomain {
        val pm = context.packageManager
        val findings = permissionAnalyzer.analyze(permissions)
        val c2 = c2Scanner.scan(pkg.packageName)

        val accessibilityEnabled = accessibilityInspector.isEnabled(pkg.packageName)
        val isAdmin = adminInspector.isActiveAdmin(pkg.packageName)
        val sideloaded = installSourceInspector.isSideloaded(pkg.packageName)

        var score = findings.sumOf { it.weight } + c2.sumOf { it.weight }

        // Behavioral chain bonuses
        if (overlayChain.matches(pkg.packageName, permissions)) score += 35
        if (otpChain.matches(pkg.packageName, permissions)) score += 25
        if (lockoutChain.matches(pkg.packageName, permissions)) score += 30

        // Runtime state bonuses
        if (accessibilityEnabled) score += 15
        if (isAdmin) score += 15
        if (sideloaded) score += 10

        // Combination bonuses
        val hasAccessibility = Manifest.permission.BIND_ACCESSIBILITY_SERVICE in permissions
        val hasSms = Manifest.permission.READ_SMS in permissions
        if (hasAccessibility && hasSms && accessibilityEnabled && sideloaded) {
            score += 20
        }

        score = score.coerceIn(0, 100)

        return ScanResultDomain(
            id = UUID.randomUUID().toString(),
            packageName = pkg.packageName,
            appLabel = pm.getApplicationLabel(pkg.applicationInfo).toString(),
            versionName = pkg.versionName,
            riskScore = score,
            riskLevel = RiskLevel.fromScore(score),
            findingIds = findings.map { it.id },
            mitreTechniques = (findings.map { it.mitre } + c2.map { "T1437" }).distinct(),
            isSideloaded = sideloaded,
            hasAccessibilityEnabled = accessibilityEnabled,
            isDeviceAdmin = isAdmin,
            requestedPermissions = permissions,
            c2Indicators = c2,
            scannedAt = clock.now(),
            deviceModel = Build.MODEL,
            androidVersion = Build.VERSION.RELEASE ?: "unknown"
        )
    }
}
Score Level Interpretation
0–29 LOW Declares a sensitive permission; typical of the app's category
30–54 MEDIUM Multiple sensitive permissions; verify declared purpose
55–79 HIGH Behavioral chain detected; recommend review
80–100 CRITICAL Full RAT chain; recommend uninstall after verification

11. Data Pipeline: Room, Paging, Hilt

11.1 Domain Model and Entity

data class ScanResultDomain(
    val id: String,
    val packageName: String,
    val appLabel: String,
    val versionName: String?,
    val riskScore: Int,
    val riskLevel: RiskLevel,
    val findingIds: List<String>,
    val mitreTechniques: List<String>,
    val isSideloaded: Boolean,
    val hasAccessibilityEnabled: Boolean,
    val isDeviceAdmin: Boolean,
    val requestedPermissions: List<String>,
    val c2Indicators: List<C2Indicator>,
    val scannedAt: Long,
    val deviceModel: String,
    val androidVersion: String
)

@Entity(
    tableName = "scan_results",
    indices = [
        Index("packageName"),
        Index("riskScore"),
        Index("scannedAt")
    ]
)
data class ScanResultEntity(
    @PrimaryKey val id: String,
    val packageName: String,
    val appLabel: String,
    val versionName: String?,
    val riskScore: Int,
    val riskLevel: String,
    val findingIdsJson: String,
    val mitreJson: String,
    val isSideloaded: Boolean,
    val hasAccessibilityEnabled: Boolean,
    val isDeviceAdmin: Boolean,
    val permissionsJson: String,
    val c2Json: String,
    val scannedAt: Long,
    val deviceModel: String,
    val androidVersion: String
)

11.2 DAO with Filtered Paging

@Dao
interface ScanResultDao {

    @Query("""
        SELECT * FROM scan_results
        WHERE (:query IS NULL
               OR packageName LIKE '%' || :query || '%'
               OR appLabel LIKE '%' || :query || '%')
          AND (:minScore IS NULL OR riskScore >= :minScore)
          AND (:onlySideloaded = 0 OR isSideloaded = 1)
        ORDER BY
          CASE WHEN :sortBy = 'score' THEN riskScore END DESC,
          CASE WHEN :sortBy = 'date'  THEN scannedAt END DESC,
          CASE WHEN :sortBy = 'name'  THEN appLabel END ASC
    """)
    fun paged(
        query: String?,
        minScore: Int?,
        onlySideloaded: Int,
        sortBy: String
    ): PagingSource<Int, ScanResultEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertAll(items: List<ScanResultEntity>)

    @Query("SELECT * FROM scan_results")
    suspend fun all(): List<ScanResultEntity>

    @Query("DELETE FROM scan_results WHERE scannedAt < :cutoff")
    suspend fun deleteOlderThan(cutoff: Long)
}

11.3 Repository

data class ScanFilter(
    val query: String? = null,
    val minRisk: RiskLevel? = null,
    val onlySideloaded: Boolean = false,
    val sort: SortMode = SortMode.Score
)

enum class SortMode { Score, Date, Name }

interface ScanRepository {
    fun observe(filter: Flow<ScanFilter>): Flow<PagingData<ScanResultDomain>>
    suspend fun saveAll(results: List<ScanResultDomain>): Result<Unit>
    suspend fun exportAsJson(): Result<String>
    suspend fun clear()
}

class ScanRepositoryImpl @Inject constructor(
    private val dao: ScanResultDao,
    private val json: Json,
    @IoDispatcher private val io: CoroutineDispatcher
) : ScanRepository {

    override fun observe(filter: Flow<ScanFilter>): Flow<PagingData<ScanResultDomain>> =
        filter.distinctUntilChanged().flatMapLatest { f ->
            Pager(
                config = PagingConfig(pageSize = 20, enablePlaceholders = false),
                pagingSourceFactory = {
                    dao.paged(
                        query = f.query,
                        minScore = f.minRisk?.minScore,
                        onlySideloaded = if (f.onlySideloaded) 1 else 0,
                        sortBy = f.sort.name.lowercase()
                    )
                }
            ).flow.map { paging ->
                paging.map(ScanResultEntity::toDomain)
            }
        }

    override suspend fun saveAll(results: List<ScanResultDomain>): Result<Unit>
        = withContext(io) {
            runCatching { dao.upsertAll(results.map(ScanResultDomain::toEntity)) }
        }

    override suspend fun exportAsJson(): Result<String> = withContext(io) {
        runCatching {
            json.encodeToString(dao.all().map(ScanResultEntity::toDomain))
        }
    }

    override suspend fun clear() = withContext(io) {
        dao.deleteOlderThan(Long.MAX_VALUE)
    }
}

11.4 ViewModel

@HiltViewModel
class ScannerViewModel @Inject constructor(
    private val repository: ScanRepository,
    private val scanner: RatScanner
) : ViewModel() {

    private val _filter = MutableStateFlow(ScanFilter())
    val filter: StateFlow<ScanFilter> = _filter.asStateFlow()

    private val _scanning = MutableStateFlow(false)
    val scanning: StateFlow<Boolean> = _scanning.asStateFlow()

    val results: Flow<PagingData<ScanResultDomain>> =
        repository.observe(_filter).cachedIn(viewModelScope)

    fun runScan() = viewModelScope.launch {
        _scanning.value = true
        try {
            val findings = scanner.scanAll()
            repository.clear()
            repository.saveAll(findings)
        } finally {
            _scanning.value = false
        }
    }

    fun setQuery(q: String?) =
        _filter.update { it.copy(query = q?.takeIf(String::isNotBlank)) }
    fun setMinRisk(level: RiskLevel?) =
        _filter.update { it.copy(minRisk = level) }
    fun setOnlySideloaded(enabled: Boolean) =
        _filter.update { it.copy(onlySideloaded = enabled) }
    fun setSort(sort: SortMode) =
        _filter.update { it.copy(sort = sort) }
}

11.5 Compose UI

@Composable
fun ScannerScreen(vm: ScannerViewModel = hiltViewModel()) {
    val items = vm.results.collectAsLazyPagingItems()
    val filter by vm.filter.collectAsStateWithLifecycle()
    val scanning by vm.scanning.collectAsStateWithLifecycle()

    Scaffold(
        floatingActionButton = {
            ExtendedFloatingActionButton(
                onClick = vm::runScan,
                icon = { Icon(Icons.Default.Search, contentDescription = null) },
                text = { Text(if (scanning) "Scanning…" else "Scan") }
            )
        }
    ) { padding ->
        Column(Modifier.padding(padding)) {
            FilterBar(
                filter = filter,
                onQuery = vm::setQuery,
                onRisk = vm::setMinRisk,
                onSideload = vm::setOnlySideloaded,
                onSort = vm::setSort
            )
            if (items.itemCount == 0 && !scanning) {
                EmptyState()
            } else {
                LazyColumn(Modifier.fillMaxSize()) {
                    items(items.itemCount) { idx ->
                        items[idx]?.let { ScanResultCard(it) }
                    }
                }
            }
        }
    }
}

@Composable
fun ScanResultCard(result: ScanResultDomain) {
    Card(
        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
        colors = CardDefaults.cardColors(
            containerColor = when (result.riskLevel) {
                RiskLevel.CRITICAL -> Color(0xFFFFEBEE)
                RiskLevel.HIGH -> Color(0xFFFFF3E0)
                RiskLevel.MEDIUM -> Color(0xFFFFFDE7)
                RiskLevel.LOW -> Color(0xFFE8F5E9)
            }
        )
    ) {
        Column(Modifier.padding(16.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(result.appLabel, style = MaterialTheme.typography.titleMedium,
                     modifier = Modifier.weight(1f))
                RiskBadge(result.riskLevel, result.riskScore)
            }
            Text(result.packageName, style = MaterialTheme.typography.bodySmall,
                 color = MaterialTheme.colorScheme.onSurfaceVariant)
            Spacer(Modifier.height(8.dp))
            result.mitreTechniques.take(4).forEach { technique ->
                AssistChip(onClick = {}, label = { Text(technique) })
            }
        }
    }
}

12. Export and Backup

Scan reports can be exported to Google Drive as JSON — the same repository abstraction used in any data-heavy Android app. The backup targets drive.file scope so the user can see and control exactly which files the app has written.

interface BackupDataSource {
    suspend fun isAuthenticated(): Boolean
    suspend fun upload(fileName: String, json: String): Result<String>
    suspend fun list(): Result<List<String>>
}

class DriveBackupDataSource @Inject constructor(
    @ApplicationContext private val context: Context,
    private val driveClient: DriveClient,
    @IoDispatcher private val io: CoroutineDispatcher
) : BackupDataSource {

    override suspend fun isAuthenticated(): Boolean = withContext(io) {
        driveClient.hasValidCredential()
    }

    override suspend fun upload(fileName: String, json: String): Result<String>
        = withContext(io) {
            runCatching {
                driveClient.uploadMultipart(fileName, json.toByteArray())
            }
        }

    override suspend fun list(): Result<List<String>> = withContext(io) {
        runCatching { driveClient.listAppFiles() }
    }
}

@HiltWorker
class ScanReportBackupWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val repository: ScanRepository,
    private val backup: BackupDataSource
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = runCatching {
        val json = repository.exportAsJson().getOrThrow()
        val name = "scan-report-${System.currentTimeMillis()}.json"
        backup.upload(name, json).getOrThrow()
        Result.success()
    }.getOrElse { err ->
        if (runAttemptCount < 3) Result.retry() else Result.failure()
    }

    companion object {
        fun schedule(ctx: Context) {
            val constraints = Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .setRequiresBatteryNotLow(true)
                .build()
            val request = PeriodicWorkRequestBuilder<ScanReportBackupWorker>(
                1, TimeUnit.DAYS
            ).setConstraints(constraints).build()
            WorkManager.getInstance(ctx)
                .enqueueUniquePeriodicWork(
                    "scan-backup",
                    ExistingPeriodicWorkPolicy.KEEP,
                    request
                )
        }
    }
}

13. Testing and Verification

13.1 DAO Filter Test

@Test
fun paged_filterByRisk_returnsOnlyAboveThreshold() = runTest {
    val db = Room.inMemoryDatabaseBuilder(
        ApplicationProvider.getApplicationContext(),
        AppDatabase::class.java
    ).build()

    db.scanDao().upsertAll(listOf(
        entity(packageName = "com.a", riskScore = 85, level = "CRITICAL"),
        entity(packageName = "com.b", riskScore = 40, level = "MEDIUM"),
        entity(packageName = "com.c", riskScore = 10, level = "LOW")
    ))

    val source = db.scanDao().paged(
        query = null, minScore = RiskLevel.HIGH.minScore,
        onlySideloaded = 0, sortBy = "score"
    )
    val page = source.load(LoadParams.Refresh(null, 20, false)) as LoadResult.Page

    assertEquals(1, page.data.size)
    assertEquals("com.a", page.data.first().packageName)
}

13.2 Analyzer Unit Test

@Test
fun accessibilityPlusSms_plusEnabled_plusSideload_scoresCritical() {
    val analyzer = PermissionAnalyzer()
    val findings = analyzer.analyze(listOf(
        Manifest.permission.BIND_ACCESSIBILITY_SERVICE,
        Manifest.permission.READ_SMS
    ))

    var score = findings.sumOf { it.weight }   // 40 + 20 = 60
    if (true) score += 15                       // accessibility enabled
    if (true) score += 10                       // sideloaded
    score += 20                                 // combination bonus

    assertEquals(105.coerceIn(0, 100), 100)
    assertEquals(RiskLevel.CRITICAL, RiskLevel.fromScore(score))
}

13.3 Manual Verification with ADB

# Confirm the scanner's enabled-accessibility set matches the platform's
adb shell dumpsys accessibility | grep "Service\\[" -A2

# Confirm active admins match the platform's view
adb shell dumpsys device_policy | grep "Admin"

# Confirm install sources for a suspicious package
adb shell pm list packages -i | grep com.suspicious

# Trigger a scan and tail logcat
adb logcat -s RatScanner:D

For a reproducible test, install a benign assistive app (e.g., a screen reader) and confirm it scores ≤ MEDIUM. Install an app that declares BIND_ACCESSIBILITY_SERVICE + READ_SMS + SYSTEM_ALERT_WINDOW in a test signing key, enable its service, and confirm the scanner flags it as CRITICAL. No malicious behavior needs to be implemented — the permission declarations alone are sufficient to exercise the scorer.

14. What Android Hardening Means for Detection

Android has progressively added defenses against the attack patterns described above. For detection, this means the scanner's baseline changes over time: a side-loaded app that scores CRITICAL today may score HIGH in a year if the runtime capabilities are gated behind additional user actions.

Defense Version Detection implication
Restricted Settings Android 13+ Sideloaded apps cannot enable accessibility without manual user action; scoring can weight enabled accessibility on a sideloaded app more heavily than declaration alone
accessibilityDataSensitive Android 16 Banking apps return empty nodes to non-tool services; a healthy banking app should not be readable via a third-party accessibility service
FLAG_IS_ACCESSIBILITY_TOOL Android 12+ Synthetic clicks are flagged at the MotionEvent level; banking apps can detect and refuse them
Play Protect All versions Adds a complementary signal; the scanner should not assume Play Protect will have flagged a suspicious app
Notification listener gating Android 11+ Requires the user to manually grant access in Settings; the scanner's BIND_NOTIFICATION_LISTENER_SERVICE declaration finding remains valid regardless

The practical takeaway: the scanner should be calibrated against the runtime capabilities actually available on the device it runs on. A CRITICAL score on Android 12 might be HIGH on Android 15, where the user could not enable the dangerous service even if they wanted to. The scoring model in this article is conservative — it reports the full chain capability rather than the achieved state — so it naturally errs toward over-reporting, which is appropriate for a security tool.

End of Article
The DangerousAppScanner described here is a defensive tool. It reads package metadata, runtime service state, and APK strings — it does not execute, inject, or intercept anything on other devices. The technical detail is provided so defenders can build and reason about such tools; it is not a construction guide for the attack patterns described. If you find this useful and want a follow-up on the behavioral-chain visualizer (mapping each finding to its MITRE technique and the specific permission combination that triggered it), that's the natural next step.

Comments

Popular posts from this blog

How To Reset The Trial Period Of Software To Use It Lifetime? 🖥

1. First, uninstall the software and follow the steps. 2. After uninstalling software, OpenRUN (Windows key + R) and type regedit and hit Enter. 3. Now Registry Editor Window will appear and here navigate to HKEY_LOCAL_MA CHINE \ SOFTWARE \ Your Software name from left a pane of this window. If you find any Key name with your software name then just Delete that Key. 4. Similarly, navigate to HKEY_CURRENT_ USER \ Software \ Your Software name and if you find any key with your software name then just Delete it and close the Registry Editor window. 5. Again go to RUN and type %temp% and hit Enter. 6. Now Delete all the things in that folder. Don’t worry these are the temporary files and not necessary for your operating system. 7. Now go to C:\Users\your username here\AppData. Under AppData open all three folders "Local", "Local Low" and "Roaming” and check. Under these three folders if you find any folder or anything with your Software's name, j...

Permanently delete a file from its hard drive

Do you know that when you  delete a file from the recycle bin,  it is not  permanently deleted?   Yes, it is still present on your  hard drive!   If you try to  delete sensitive data  from your hard drive, a (very) malicious person can recover your files using data recovery software or through the  Windows File History.   What to do then to  permanently erase a file from his hard drive? How are you going to  delete this file? You put the  file in the trash  from Windows Explorer, you open the trash then you  delete the file.   Faster, you can also select the file from the Windows Explorer and press the  Ctrl + Delete keys  to  delete it without going through the trash. Using this method, you have the impression that your file is  permanently deleted.   Still, it still exists physically on the hard drive.               ...

How to Install Multiple Bootable Operating Systems on a USB Stick

1.  WinSetupFromUSB Using WinSetupFromUSB is easy. Open the software, and select your USB disk from the dropdown menu. Next, check the button next to your preferred operating systems. Then you’ll need to browse to the volume containing the operating system you wish to install on your multiboot USB. When you’re ready, click the  Go  button. If you’re interested, check the option by  Show Log  to view detailed information on what’s going on. Finally, when the installation is complete, you’ll see a  Job Done  message. Overall, WinSetupFromUSB is pretty simple to use and features a quick write time. It’s also got a QEMU mode, and it’s a small, portable app. Download:   WinSetupFromUSB  (Free) 2.  MultiBootUSB Adding multiple bootable OS distros is easy, browse under  Select image  and choose your ISO. Note that while MutiBootUSB runs on Windows and Linux, you can only create Linux Live USB syst...