Detecting Android RATs in Kotlin: A Scanner for Accessibility Abuse, Overlay Phishing, and C2 Indicators
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.
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.
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.
├── 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.
Comments
Post a Comment