Как устроено Android-приложение внутри системы — песочница, права и дыры через exported и ContentProvider

· 9 минут чтения

androidбезопасностьпермишены

Прежде чем ломать или защищать, полезно понять, из чего приложение вообще состоит и где оно живёт. Большинство уязвимостей в 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/                — подписи и манифест целостности

Ключевое, что часто упускают:

С 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) может делать только то, что описано в политике. Это второй забор на случай, если первый пробьют.

Отсюда несколько важных следствий:

  1. Внутреннее хранилище не нужно шифровать «руками» от других приложений. Другое приложение туда и так не залезет. (От физического доступа к разлоченному устройству или от root — другой разговор, там нужен EncryptedFile/Keystore.)
  2. MODE_WORLD_READABLE/MODE_WORLD_WRITABLE удалены не просто так. Раньше можно было выставить файлу «доступ всем» и пробить собственную песочницу. С API 24 это бросает SecurityException.
  3. Чтобы поделиться данными легально, песочницу надо пробивать через контролируемые «шлюзы» — а это как раз компоненты приложения и ContentProvider. И вот тут начинаются ошибки.

Пермишены: три уровня доверия

Разрешения (permissions) — это способ запросить доступ к тому, что за пределами вашей песочницы: камера, контакты, сеть, файлы другого приложения. Объявляются в манифесте:

<uses-permission android:name="android.permission.CAMERA" />

Но не все разрешения равны. По protectionLevel они делятся на:

Отдельная категория — 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>.

Ограничения песочницы, о которые спотыкаются

Модель со временем ужесточалась. То, что работало пять лет назад, сегодня падает:

Эти ограничения — не столько про взлом, сколько про то, что «легальные» способы обмена данными сузились, и разработчиков сильнее подталкивают к правильным шлюзам: ContentProvider, SAF, явные интенты с временными правами.

Дыра №1: exported-компоненты

У приложения четыре типа компонентов: Activity, Service, BroadcastReceiver, ContentProvider. Каждый в манифесте имеет флаг android:exported. Он определяет, может ли чужое приложение обратиться к компоненту.

Правило по умолчанию:

С 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»: доверенный компонент делает грязную работу за недоверенного.

Как закрывать

Дыра №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, если нужен доступ «семейству»).

Как проверить свой провайдер

Как это выглядит со стороны атакующего

Чтобы защита была осмысленной, полезно знать типичный маршрут аудита (он же — то, что делает злоумышленник с вашим публичным APK):

  1. apktool d app.apk — достать читаемый манифест.
  2. Выписать все компоненты с exported="true" (или с интент-фильтром) и все <provider> с их authorities.
  3. jadx app.apk — посмотреть логику этих компонентов: есть ли проверка вызывающего, как формируются SQL-запросы, как открываются файлы.
  4. Написать маленькое приложение-«атакующего», которое дёргает найденные компоненты и провайдеры с граничными данными (1 OR 1=1, ../, вложенные интенты).
  5. adb shell + content query --uri … — прощупать провайдеры даже без своего приложения.

Всё это делается на своём приложении и своём устройстве в рамках анализа собственной защищённости. Инструменты — публичные и штатные (apktool, jadx, adb); ценность не в них, а в понимании, какие из ваших «внутренних» дверей на самом деле открыты.

Короткий чек-лист

Модель безопасности Android надёжна ровно до той границы, где вы сами открываете дверь наружу. Песочница по UID практически непробиваема штатными средствами — но exported-компонент с интент-фильтром «на всякий случай» и ContentProvider со строковой конкатенацией сводят всю эту защиту на нет. Большинство дыр закрывается не хитрым кодом, а честным ответом на вопрос: «этот компонент правда должен быть доступен снаружи — и что будет, если его дёрнет чужое приложение с мусором на входе?»