Прежде чем ломать или защищать, полезно понять, из чего приложение вообще состоит и где оно живёт. Большинство уязвимостей в Android — это не хитрый эксплойт ядра, а недопонимание того, как система хранит данные и раздаёт к ним доступ. Ниже — путь от .apk-файла до чужого ContentProvider, из которого утекают токены.
Что такое APK и что внутри
APK (Android Package) — это обычный ZIP-архив с конкретной структурой. Распакуйте любой .apk через unzip и увидите:
AndroidManifest.xml — манифест (в бинарном виде, AXML)
classes.dex — байткод Dalvik/ART (может быть classes2.dex, classes3.dex…)
resources.arsc — скомпилированные ресурсы (строки, размеры, цвета)
res/ — ресурсы (картинки, layout'ы, XML)
assets/ — сырые файлы как есть
lib/ — нативные .so под каждую ABI (arm64-v8a, x86_64…)
META-INF/ — подписи и манифест целостности
Ключевое, что часто упускают:
classes.dex— это не машинный код, а байткод. Его легко декомпилировать обратно в почти-читаемый Java/Smali (jadx,apktool). Всё, что зашито в код — URL, ключи, логика проверки лицензии — потенциально доступно любому, у кого есть APK. Обфускация (R8/ProGuard) усложняет чтение, но не прячет строки и не шифрует логику.- Манифест — публичный.
apktool d app.apkвытащит читаемыйAndroidManifest.xml. Именно отсюда атакующий узнаёт, какие компоненты у вас открыты наружу. - Подпись гарантирует целостность, а не секретность. APK подписан ключом разработчика. Система проверяет, что архив не меняли после подписи, и что обновление пришло от того же автора. Но прочитать содержимое может кто угодно.
С Android App Bundle (.aab) в Play Store загружается «конструктор», а Google собирает и подписывает финальные APK под конкретное устройство. На саму модель безопасности это не влияет — на устройстве всё равно оказывается APK.
Куда приложение устанавливается
После установки система раскладывает приложение по нескольким местам:
/data/app/<пакет>-<хэш>/base.apk — сам APK (только чтение)
/data/app/<пакет>-<хэш>/lib/ — распакованные нативные библиотеки
/data/data/<пакет>/ — приватные данные приложения
/data/misc/profiles/… — профили ART для AOT-компиляции
Самое интересное — /data/data/<пакет>/ (на устройствах с несколькими пользователями — /data/user/<id>/<пакет>/). Это домашняя папка приложения:
/data/data/com.example.app/
├── files/ — getFilesDir(), ваши файлы
├── cache/ — getCacheDir(), система может почистить под нехватку места
├── databases/ — SQLite базы
├── shared_prefs/ — SharedPreferences (XML)
├── code_cache/ — кеш скомпилированного кода
└── no_backup/ — то, что не попадает в авто-бэкап
Всё, что вы сохраняете через Context.getFilesDir(), openFileOutput(), getSharedPreferences(), лежит здесь.
Песочница: изоляция по UID
Вот главная идея модели безопасности Android, и она удивительно простая: каждое приложение — это отдельный пользователь Linux.
При установке система выдаёт приложению уникальный UID (обычно в диапазоне от 10000). Папка /data/data/<пакет>/ принадлежит этому UID, и права на неё выставлены так, что читать/писать может только владелец:
$ ls -l /data/data/
drwx------ u0_a142 u0_a142 com.example.app
drwx------ u0_a143 u0_a143 com.other.app
drwx------ означает: полный доступ владельцу, ничего — всем остальным. Приложение com.other.app физически не может открыть файл из com.example.app — не потому что Android «не разрешает» на уровне API, а потому что ядро Linux вернёт EACCES. Это не проверка в рантайме, которую можно обойти рефлексией — это стандартный механизм прав файловой системы.
Поверх UID-изоляции работает SELinux (с Android 5.0 в enforcing-режиме): даже root-процессы ограничены политиками, и каждый домен приложения (untrusted_app) может делать только то, что описано в политике. Это второй забор на случай, если первый пробьют.
Отсюда несколько важных следствий:
- Внутреннее хранилище не нужно шифровать «руками» от других приложений. Другое приложение туда и так не залезет. (От физического доступа к разлоченному устройству или от root — другой разговор, там нужен
EncryptedFile/Keystore.) MODE_WORLD_READABLE/MODE_WORLD_WRITABLEудалены не просто так. Раньше можно было выставить файлу «доступ всем» и пробить собственную песочницу. С API 24 это бросаетSecurityException.- Чтобы поделиться данными легально, песочницу надо пробивать через контролируемые «шлюзы» — а это как раз компоненты приложения и
ContentProvider. И вот тут начинаются ошибки.
Пермишены: три уровня доверия
Разрешения (permissions) — это способ запросить доступ к тому, что за пределами вашей песочницы: камера, контакты, сеть, файлы другого приложения. Объявляются в манифесте:
<uses-permission android:name="android.permission.CAMERA" />
Но не все разрешения равны. По protectionLevel они делятся на:
-
normal— низкий риск, выдаются автоматически при установке.INTERNET,VIBRATE,ACCESS_NETWORK_STATE. Пользователь их даже не видит в момент выдачи. -
dangerous— доступ к приватным данным пользователя.CAMERA,READ_CONTACTS,ACCESS_FINE_LOCATION. С Android 6.0 (API 23) их надо запрашивать в рантайме, и пользователь может отказать или отозвать позже:if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { requestPermissions(arrayOf(Manifest.permission.CAMERA), REQ_CAMERA) }Объявить в манифесте — недостаточно. Не проверив разрешение перед вызовом, вы получите
SecurityException. -
signature— выдаются только приложению, подписанному тем же ключом, что и объявившее разрешение. Так два ваших приложения могут обмениваться данными, а чужие — нет. Именно на этом строится безопасное межпроцессное взаимодействие внутри одного «семейства» приложений.
Отдельная категория — special permissions (MANAGE_EXTERNAL_STORAGE, SYSTEM_ALERT_WINDOW, доступ к использованию/уведомлениям). Их не выдают ни при установке, ни обычным диалогом — пользователь идёт в системные настройки и включает вручную. Play Store дополнительно требует обоснования для самых чувствительных.
Свои разрешения
Вы можете защитить свои компоненты собственным разрешением:
<permission
android:name="com.example.app.permission.READ_DATA"
android:protectionLevel="signature" />
<provider
android:name=".SecretProvider"
android:authorities="com.example.app.secret"
android:permission="com.example.app.permission.READ_DATA"
android:exported="true" />
С signature до провайдера дотянется только приложение с вашим ключом. Частая ошибка — поставить protectionLevel="normal": тогда «защиту» получит кто угодно, просто объявив <uses-permission>.
Ограничения песочницы, о которые спотыкаются
Модель со временем ужесточалась. То, что работало пять лет назад, сегодня падает:
- Scoped Storage (Android 10–11). Приложение больше не видит всю карту памяти. Доступ — только к своим папкам (
getExternalFilesDir()) и к общим коллекциям черезMediaStore/Storage Access Framework. Прямые пути вроде/sdcard/DCIMв общем случае не работают. - Package visibility (Android 11).
getInstalledPackages()больше не отдаёт список всех приложений. Чтобы «увидеть» чужое приложение (например, проверить, установлен ли конкретный мессенджер), надо объявить его в<queries>в манифесте. - Background restrictions. Запуск сервисов и активити из фона, доступ к местоположению в фоне, будильники — всё обросло ограничениями. Песочница ограничивает не только что вы читаете, но и когда вы можете действовать.
Эти ограничения — не столько про взлом, сколько про то, что «легальные» способы обмена данными сузились, и разработчиков сильнее подталкивают к правильным шлюзам: ContentProvider, SAF, явные интенты с временными правами.
Дыра №1: exported-компоненты
У приложения четыре типа компонентов: Activity, Service, BroadcastReceiver, ContentProvider. Каждый в манифесте имеет флаг android:exported. Он определяет, может ли чужое приложение обратиться к компоненту.
Правило по умолчанию:
- Если есть
<intent-filter>— компонент по умолчаниюexported="true"(система считает, что вы хотите принимать интенты извне). - Если нет —
exported="false".
С Android 12 (API 31) для компонентов с интент-фильтром exported обязателен явно — иначе приложение не соберётся/не установится. Это как раз реакция на массу приложений, случайно открывших свои внутренности.
Почему это опасно, на примере. Экран смены пароля, который «внутренний»:
<activity android:name=".ResetPasswordActivity">
<intent-filter>
<action android:name="com.example.RESET" />
</intent-filter>
</activity>
Разработчик думал, что запускает его только сам. Но интент-фильтр сделал активити открытым. Любое приложение может дёрнуть:
val i = Intent("com.example.RESET").apply {
setPackage("com.example.app")
putExtra("new_password", "hacked123")
}
startActivity(i)
Если внутри ResetPasswordActivity нет проверки, кто её вызвал, — пароль сменён из чужого приложения без ведома пользователя. То же касается открытого Service (запуск фоновой работы, слив данных) и BroadcastReceiver (подделка системных событий).
Отдельный класс — Intent Redirection. Компонент принимает вложенный Intent в extras и слепо его запускает:
// уязвимо: пробрасываем чужой интент своими правами
val forward = intent.getParcelableExtra<Intent>("next")
startActivity(forward)
Атакующий кладёт сюда интент на ваш же приватный (exported=false) компонент — и запускает его вашими руками, обходя изоляцию. Это называют «confused deputy»: доверенный компонент делает грязную работу за недоверенного.
Как закрывать
- Всё, что не предназначено для внешнего мира —
android:exported="false". Явно, а не по умолчанию. - Для межпроцессного вызова «своих» — защищать
signature-разрешением. - Проверять источник вызова там, где это важно:
getCallingPackage()для активити, запущенныхstartActivityForResult, проверка UID/пакета в сервисах. - Не пробрасывать полученный извне
Intent/PendingIntentбез валидации компонента и флагов.
Дыра №2: криво написанный ContentProvider
ContentProvider — легальный шлюз наружу из песочницы. Именно поэтому ошибки в нём особенно болезненны: он для того и создан, чтобы отдавать данные другим. Три классических провала.
2.1. SQL-инъекция в query()
Провайдер часто ходит в SQLite. Если параметры запроса подставлять конкатенацией строк — получаем инъекцию, только теперь по IPC-границе:
// уязвимо
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor {
val id = uri.lastPathSegment
return db.rawQuery("SELECT * FROM notes WHERE id = $id", null)
}
Атакующий из другого приложения вызывает:
contentResolver.query(
Uri.parse("content://com.example.app.notes/notes/1 OR 1=1"),
null, null, null, null
)
1 OR 1=1 возвращает все заметки, а не одну. А selection, пришедший от вызывающего, ещё опаснее — через него подставляют произвольные условия и подзапросы. Правильно — параметризованные запросы и белый список колонок:
val qb = SQLiteQueryBuilder().apply {
tables = "notes"
setProjectionMap(ALLOWED_COLUMNS) // только разрешённые колонки
appendWhere("id = ?")
}
return qb.query(db, projection, selection, selectionArgs, null, null, sortOrder)
// значения — всегда через selectionArgs, никогда конкатенацией
2.2. Path traversal в openFile()
Если провайдер отдаёт файлы по имени из URI и не нормализует путь — открывается дорога наружу песочницы своими же правами:
// уязвимо
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
val name = uri.lastPathSegment
val file = File(baseDir, name) // name = "../../databases/creds.db"
return ParcelFileDescriptor.open(file, MODE_READ_ONLY)
}
URI вида content://…/files/..%2F..%2Fdatabases%2Fcreds.db уводит из разрешённой папки в вашу БД с секретами. Провайдер читает её вашими правами и отдаёт атакующему. Лечится канонизацией пути и проверкой, что он не вышел за пределы разрешённой директории:
val target = File(baseDir, name).canonicalFile
require(target.path.startsWith(baseDir.canonicalFile.path + File.separator)) {
"path traversal blocked"
}
Именно от этого класса ошибок спасает штатный FileProvider с описанием разрешённых путей в XML — он сам не пускает за пределы объявленных папок. Свой велосипед на openFile() почти всегда хуже.
2.3. grantUriPermissions и утечка временных прав
Флаг android:grantUriPermissions="true" позволяет выдавать временный доступ к URI через Intent.FLAG_GRANT_READ_URI_PERMISSION. Полезно (так и работает шаринг файлов), но опасно в паре с широкими <grant-uri-permission android:pathPattern=".*"/>: атакующий может уговорить приложение выдать грант на URI, который вы не собирались открывать. Плюс уже упомянутая связка с Intent Redirection — прокинуть content://-URI с флагом гранта через доверенный компонент.
Правило: гранты — на конкретные, узкие пути; не пробрасывать чужие URI с флагами прав вслепую; exported="false" для провайдеров, которые нужны только внутри приложения (android:exported="false" + signature-permission, если нужен доступ «семейству»).
Как проверить свой провайдер
- По умолчанию
exported="false", открывать наружу — осознанно. - Только параметризованные запросы, белый список колонок и таблиц, никакого доверия к
selection/sortOrderот вызывающего. openFile()— только через канонизацию пути или, лучше, черезFileProvider.- Гранты — узкие; никакого
pathPattern=".*". - Валидация вызывающего там, где данные чувствительные:
callingUid = Binder.getCallingUid()и проверка пакета/подписи.
Как это выглядит со стороны атакующего
Чтобы защита была осмысленной, полезно знать типичный маршрут аудита (он же — то, что делает злоумышленник с вашим публичным APK):
apktool d app.apk— достать читаемый манифест.- Выписать все компоненты с
exported="true"(или с интент-фильтром) и все<provider>с ихauthorities. jadx app.apk— посмотреть логику этих компонентов: есть ли проверка вызывающего, как формируются SQL-запросы, как открываются файлы.- Написать маленькое приложение-«атакующего», которое дёргает найденные компоненты и провайдеры с граничными данными (
1 OR 1=1,../, вложенные интенты). adb shell+content query --uri …— прощупать провайдеры даже без своего приложения.
Всё это делается на своём приложении и своём устройстве в рамках анализа собственной защищённости. Инструменты — публичные и штатные (apktool, jadx, adb); ценность не в них, а в понимании, какие из ваших «внутренних» дверей на самом деле открыты.
Короткий чек-лист
- Внутреннее хранилище закрыто ядром по UID — не городите поверх лишнего, но и не выносите секреты в общие папки.
exported="false"— состояние по умолчанию для всего, что не обязано быть публичным. С Android 12 объявляйте флаг явно.- Чувствительные компоненты — за
signature-разрешением; проверяйте вызывающего. ContentProvider: параметризованные запросы, белые списки, канонизация путей, узкие гранты.- Не пробрасывайте пришедшие извне
Intent/Uriбез валидации — это прямая дорога к «confused deputy». - Помните, что код и манифест в APK читаемы. Секрет в
classes.dex— это не секрет, а отложенная утечка.
Модель безопасности Android надёжна ровно до той границы, где вы сами открываете дверь наружу. Песочница по UID практически непробиваема штатными средствами — но exported-компонент с интент-фильтром «на всякий случай» и ContentProvider со строковой конкатенацией сводят всю эту защиту на нет. Большинство дыр закрывается не хитрым кодом, а честным ответом на вопрос: «этот компонент правда должен быть доступен снаружи — и что будет, если его дёрнет чужое приложение с мусором на входе?»