Долгое время я советовал всем разработчикам включать SSL Pinning в своих приложениях, чтобы защитить пользователей от MITM атак. И многие до сих пор делают это по инерции, даже не задумываясь о том так ли это необходимо, и от чего это вообще защищает. Пришло время разобраться и расставить все точки в вопросе про необходимость реализации этого подхода в ваших приложениях.
– Стой и не шевелись! Я тебя породил, я тебя и убью!
“Что, пиннинг больше не нужен? Что случилось то?!”. Случился Android 7, в котором поменялась политика установки сертификатов.
By default, apps that target Android 7.0 only trust system-provided certificates and no longer trust user-added Certificate Authorities (CA). Apps targeting Android 7.0 (API level 24) that wish to trust user-added CAs should use the Network security configuration to specify how user CAs should be trusted.
Т.е. теперь, если приложение имеет targetSdk
равным 24
или выше, то оно по умолчанию не будет доверять никаким сертификатам кроме системных. Что в общем-то умножает на ноль всю дивную схему с обманом пользователя и попыткой подкинуть ему свой серт в публичном Wi-Fi чтобы перехватить трафик. Казалось бы, на этом можно и закончить. Ставь targetSdk=24
и живи в безопасности. Но мне, как исследователю безопасности, было интересно убедиться, что все работает именно так как заявлено. А еще поискать сценарии в которых все идет не так как ожидалось. Спойлер: такие сценарии конечно же есть.
Подготовка лаборатории
Чтобы тесты были честными, я взял самый всратый девайс, который нашел у себя: INOI 2 Lite 2021. Поскольку он никогда не рутовался (а значит имеет заблокированный загрузчик), не обновлялся, и даже не вводился в режим разработчика (!), то оказался идеальным кандидатом для тестов. А еще я сбросил его к заводским настройкам. Уж если на этой гадости удаться защитить пользователя, то на всем остальном и подавно. На самом аппарате установлен Android 10 Go (INOI_2_Lite_2021_v5.0_03052021), а значит он должен успешно сопротивляться MITM-у, если Google нам не наврал.
Приложение для тестов я буду использовать самописное чтобы не усложнять себе жизнь и контролировать все нужные мне аспекты тестирования. Приложение представляет из себя одну activity с кнопкой и, в зависимости от сценария, компонентом TextView
или WebView
.
В качестве прокси будет выступать BurpSuite, потому что у меня бесконечная оперативная память и я могу его кормить сколько угодно 🙃
Самый базовый тест
Для начала я настроил прокси в настройках сети и попробовал зайти на https://google.com
через обычный Chrome:
Ну уже неплохо, базовая защита от MITM у нас тут есть. Далее ставлю сертификат burp-а через браузер и пробую зайти на сайт еще раз и ввести какую-нибудь секретную информацию в поле запроса:
Ага, тут то защита и рухнула. Никогда не ставьте левые сертификаты себе на устройство, а то может быть вот так 😉
Тесты с собственным приложением
Конфигурация по умолчанию
В первом варианте я буду полностью полагаться на настройки системы по умолчанию и не буду ничего конфигурировать. Посмотрим что из этого выйдет. Сертификат естественно остается на девайсе, а прокси включенным.
Код манифеста:
<?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="true"
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.SSLPinningMustDie"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Код MainActivity
:
class MainActivity : AppCompatActivity() {
private val client = OkHttpClient()
private val request = Request.Builder()
.url("https://jsonplaceholder.typicode.com/users")
.build()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val responseView = findViewById<TextView>(R.id.response_text_view).apply {
movementMethod = ScrollingMovementMethod()
}
findViewById<Button>(R.id.request_button).setOnClickListener {
lifecycleScope.launch {
try {
val response = withContext(Dispatchers.IO) {
client.newCall(request).execute()
}
responseView.text = response.body?.string() ?: "No reponse"
} catch (e: Exception) {
responseView.text = e.stackTraceToString()
}
}
}
}
}
Ошибка SSLHandshakeException
показывает, что защита устояла. Трафика в burp-е естественно при этом не видно. Уже неплохо. И пиннинг не пригодился ;)
Включаем доверие пользовательским сертификатам
Для этого понадобится добавить в приложение network security configuration файл следующего содержания:
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
Студия при этом ругнется на небезопасность такой конфигурации:
The Network Security Configuration allows the use of user certificates in the release version of your app
А значит надо быть очень невнимательным разработчиком, чтобы это проигнорировать. И я буду моделировать именно такого разработчика. Собираю, устанавливаю, запускаю:
Никаких сюрпризов. Что было настроено, то и было получено. И тут стоит сделать небольшое лирическое отступление. Кажется, что сделать такую тупость в реальной жизни невозможно. Но все, как обычно, несколько сложнее. NSC конфиг предполагает не только базовые значения, там есть и другие секции.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="..."/>
...
</trust-anchors>
</base-config>
<domain-config>
<domain>android.com</domain>
...
<trust-anchors>
<certificates src="..."/>
...
</trust-anchors>
<pin-set>
<pin digest="...">...</pin>
...
</pin-set>
</domain-config>
...
<debug-overrides>
<trust-anchors>
<certificates src="..."/>
...
</trust-anchors>
</debug-overrides>
</network-security-config>
И в каждой из них можно счастливо накосячить с <trust-anchors>
. Добавляем сюда разные build variants и получаем довольно неприятную комбинаторику, которая когда-нибудь может и стрельнуть. Поэтому будьте бдительны!
Оставляем доверие только системным сертификатам
Удаление пагубной строчки <certificates src="user" />
опять вызывает ошибку хендшейка, и делает MITM атаку невозможной. А значит таким базовым конфигом можно вполне себе пользоваться:
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
А что там с WebView?
Действительно, а что если разработчики не успели запилить важную фичу нативно, и пришлось схитрить и затянуть ее в приложение через WebView? Как будет работать доверие сертификатам в этом случае?
Хотя WebView и является отдельным компонентом, оно все равно следует настройкам указанным в network security config. Для верификации этого поведения, я написал следующий код:
...
<WebView
android:id="@+id/response_web_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/request_button" />
...
val responseWebView = findViewById<WebView>(R.id.response_web_view)
findViewById<Button>(R.id.request_button).setOnClickListener {
responseWebView.loadUrl("https://jsonplaceholder.typicode.com/posts")
}
При включенном доверии пользовательским сертификатам, трафик успешно поймался в bupr-е, а WebView отобразила содержимое ответа:
Отключение этого доверия, ожидаемо приводит к пустой WebView и отсутствию трафика в прокси. И это отличная новость, потому что единый источник конфигурации разных компонентов сильно снижает шанс где-то ошибиться.
А потом пришли Chrome Custom Tabs
Еще одна благодатная технология от Google, которая позволяет использовать всю мощь Chrome-а в контексте приложения. Т.е разработчик получает все плюсы и минусы Chrome-а попутно избавляясь от необходимости что-то верстать и настраивать, как в случае с WebView.
Внимательный читатель уже догадался в чем тут подвох. Для менее внимательных я напомню самый первый тест, где после установки сертификата в недоверенное пользовательское хранилище Хром радостно показал весь трафик. Собственно CCT работают точно также. При этом проблема, в некоторых случаях, даже глубже. Потому что разрабочтик может написать вот такой код:
val url = "https://jsonplaceholder.typicode.com/albums"
val customHeaders = Bundle().apply {
putString(
"Authorization",
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJodHRwczovL3QubWUvYW5kcm9pZF9ndWFyZHNfdG9kYXkiLCJuYW1lIjoiT2cgZHUgZXIgbnlzZ2plcnJpZyA7KSJ9.R4xFhVuxUk3SZdriG7SM6wpsvGmAIXOP63j5QVZOs94"
)
}
val intent = CustomTabsIntent.Builder()
.build().apply {
intent.putExtra(Browser.EXTRA_HEADERS, customHeaders)
}
intent.launchUrl(this@MainActivity, Uri.parse(url))
И будет вполне прав в своих желаниях, потому что ему нужно обеспечить пресловутый “бесшовный пользовательский опыт”, и перекидывание в вебчик не должно мешать пользователю потреблять контент. Финал немного предсказуем:
Трафик перехвачен, а аккаунт пользователя уходит в зрительный зал злоумышленнику.
Итоги
Отключение доверия пользовательским сертификатам в новых версиях Android действительно отстрелило некоторые сценарии, в которых требовалось закрепление сертификата. При этом, все еще остаются мисконфиги приложения и технологии, используя которые нужно думать о дополнительных мерах защиты.
Фактически, реализуя сегодня пиннинг во всех приложениях подряд, вы получаете только головную боль и никакого профита. Реверсер все равно установит сертификат в доверенное хранилище, а базовую реализацию пиннинга открутит скриптом или руками. Поэтому нужно пару-тройку раз подумать, прежде чем затягивать эту технологию в свое приложение и взвесить все “за” и “против”. Если у вас дярывое API с “секретными” ручками или параметрами dev=1
, то вам не поможет никакой пиннинг. Тут, как и в криптографии, отлично работает Принцип Керкгоффса - злоумышленник может знать все методы вашего API, но без знания конкретных параметров вроде ключа для подписи JWT токена он ничего не сможет с ним сделать. Однако, все это справедливо только если targetSdk
вашего приложения равно 24
или выше. В противном случае, люди на старых устройствах будут подвержены MITM атакам.
Так что, пиннинг совсем можно хоронить? Мой ответ: нет. Видя сколько проблем у людей вызывают кастомные, хорошо реализованные подходы к этой технологии, я могу сказать, что делать его нужно. Но только если вы собираетесь воевать с реверсерами-ботоводами. Тут можно применять все мыслимые и немыслимые способы, они правда работают и отпугивают большинство среднестатистических специалистов (не говоря уже о неспециалистах). Что конечно же не должно отменять применение принципов эшелонированной обороны во всех частях системы, а не только в мобильных приложениях.