Собственная документация Google по Android говорит, что certificate pinning «не рекомендуется для Android-приложений».

Apple говорит: «в большинстве случаев pinning не нужен, и его следует избегать».

А вот инженер Chromium отвечает на вопрос о том, как правильно настроить pinning. Начинает он так: «Хотя pinning в целом никогда не следует поощрять, поскольку он активно вредит безопасности и стабильности интернета в целом, если вы пиннитесь к приватному CA, Network Security Config в Android будет предпочтительным подходом». Человек буквально объясняет, как это сделать, и всё равно не может удержаться.

При этом pinning есть примерно в половине мобильных приложений, которые мы смотрим, включая те, что попадают к нам на аудит. Хорошая новость: если вы на Flutter, он, скорее всего, не работает. Плохая новость — та же самая фраза.

Коротко

  • Pinning — узкий механизм против угрозы, которая сильно сжалась, и при этом радиус поражения у него — вся ваша пользовательская база. Certificate Transparency, CAA-записи и изменение модели доверия в Android 7 забрали у него большую часть исходной работы.
  • Он действительно нужен финтеху, медицине и госуслугам — то есть приложениям, которые обязаны проходить проверку по MAS-L2, профилю OWASP для софта с чувствительными данными. Всем остальным Google, Apple, Cloudflare и половина OWASP говорят «не надо».
  • Сроки жизни сертификатов схлопываются: 200 дней с марта 2026-го, 100 — в 2027-м, 47 — в 2029-м. Если вы пиннитесь на лист и не можете ротировать пины без релиза, это восемь принудительных релизов в год.
  • Пиннить нужно ключ (SPKI), а не сертификат, и всегда с резервным пином на офлайновый ключ, который принадлежит вам.
  • Никогда не выкатывайте сразу в hard-fail — и телеметрию отказов придётся написать самим, потому что ни на одной платформе её нет.
  • На Flutter <pin-set> в network_security_config.xml ничего не делает с трафиком Dart. У Dart свой TLS-стек. Баг открыт с января 2022 года.
  • Тест на полчаса в конце статьи показывает, какой из этих вариантов — про ваше приложение. Наивная версия этого теста вам врёт.

От чего pinning защищает на самом деле

Коротко о базе, чтобы говорить на одном языке. Обычно приложение доверяет любому сертификату, подписанному любым удостоверяющим центром из системного хранилища, а корневых сертификатов там около полутора сотен. Pinning сужает этот список до одного: вот этот ключ и больше никакой.

Сначала про название. Все говорят «SSL pinning», мы в том числе — в заголовке этой статьи. SSL мёртв больше десяти лет, везде . Но ищут люди именно это, поэтому название остаётся. Просто держите в голове: если кто-то в 2026 году говорит «SSL» и имеет это в виду буквально, это кое-что говорит о свежести его источников.

Теперь честная модель угроз. От двух вещей pinning защищает по-настоящему:

Скомпрометированный или недобросовестный удостоверяющий центр. Это его настоящая работа. Учебный пример — DigiNotar, 2011 год: атакующие взломали нидерландский CA, выпустили поддельные сертификаты на домены Google и перехватывали иранских пользователей. Поймал это именно pinning — в Chrome был предзагруженный пин для google.com.

Корпоративный перехват. DLP-прокси; антивирусы, которые инспектируют TLS; MDM, который ставит корневой сертификат на уровне системы. Pinning ломает их все. В этом и смысл — но это же означает, что вы сломаете легальный прокси корпоративного заказчика вместе с прокси атакующего.

А теперь то, чего нет в статьях 2015 года, потому что тогда этого просто не существовало.

Угроза «плохой CA» сильно сжалась

Certificate Transparency публикует каждый публично доверенный сертификат в открытые логи, в которые можно только дописывать. Механизм появился в 2013 году, но обязательным на практике стал в 2018-м: Chrome начал требовать его в версии 68, а Apple проверяет CT на уровне ОС — то есть внутри приложений, а не только в Safari.

Это не теория. В сентябре 2015 года CT впервые поймал ошибочно выпущенный сертификат в реальном мире — неавторизованный сертификат на google.com, который Symantec выпустил при внутреннем тестировании. Без злого умысла, срок жизни — один день. Этот же случай стал первой трещиной в истории, которая закончилась в 2018 году полной потерей доверия к Symantec во всех браузерах.

Свежий случай всплыл в сентябре 2025 года: хорватский CA Fina выпустил двенадцать неавторизованных сертификатов на 1.1.1.1 Cloudflare — с февраля 2024-го по август 2025-го. Полтора года этого никто не замечал; показали их в итоге логи CT. И обратите внимание: Fina вообще нет в мобильных хранилищах доверия, так что на телефоне эти сертификаты и так бы не прошли проверку. Лог поймал их независимо от того, кто доверяет этому CA. Вот в этом и ценность.

К этому добавились CAA-записи, то есть DNS-записи, которые перечисляют, какие CA вправе выпускать сертификаты для вашего домена, и обязательная валидация домена из нескольких точек сети.

Часть работы платформа уже сделала за вас

Начиная с Android 7 — точнее, с targetSdk 24 — приложения по умолчанию не доверяют сертификатам, установленным пользователем. Так по умолчанию с 2016 года. То есть сценарий «жертва поставила себе вредоносный корневой сертификат» — уже не то, от чего вас спасает pinning. Операционная система сделала это раньше.

targetSdkСистемные CAПользовательские CAОткрытый HTTP
≤ 23довериедовериеразрешён
24–27довериенет доверияразрешён
≥ 28довериенет довериязапрещён по умолчанию

Таблица привязана к targetSdk, а не к версии Android на устройстве. Поэтому старое приложение на новом телефоне по-прежнему доверяет всему, что пользователь себе поставил.

От чего pinning не защищает вообще

Формулировка OWASP прямая: если атакующий контролирует устройство, он просто отключает вашу логику pinning. На рутованном Android или на iPhone с джейлбрейком это одна команда в objection — а для Flutter есть отдельный инструментарий, до которого мы дойдём, потому что он интересный.

Итого: pinning защищает реальных пользователей от перехвата во враждебной сети. Он не защищает ваш API от владельца устройства. Если вы используете pinning, чтобы спрятать ключи или помешать реверс-инжинирингу, вы уже проиграли. Для этого существует серверная аттестация — Play Integrity, App Attest, — а не клиентские фокусы.

OWASP сам себе противоречит, и это важно, когда вам приносят отчёт

MASVS действительно требует pinning — но только для приложения, которое обязано проходить проверку по MAS-L2, профилю OWASP «защита в глубину» для софта с чувствительными данными. Это не базовое требование. А руководство по тестированию говорит прямым текстом: если приложение не реализует pinning, это не следует сообщать как уязвимость. При этом OWASP Pinning Cheat Sheet пишет: «Первый вопрос должен быть — а нужно ли мне пиннить? Ответ на него — вероятно, никогда». Эта формулировка появилась в марте 2023 года, раньше её там не было. Так что когда в отчёте пентеста «отсутствие SSL pinning» стоит как находка, это обычно автоматический чек-лист, а не вывод о вашей модели угроз.

Нужен ли вам certificate pinning?

Короткий чек-лист. Если хотя бы один пункт — про вас, не пиннитесь.

  • Вы не контролируете оба конца: и сервер, и приложение
  • Вы не можете обновить набор пинов безопасно и быстро
  • Обновление пинов требует релиза приложения
  • Вы не можете узнать пару ключей до того, как она уйдёт в прод
  • Это не нативное мобильное приложение

Кому он правда нужен: финансы, медицина, госуслуги — всё, где данные чувствительные, а модель угроз предполагает враждебную сеть. Это и есть случай MAS-L2. Всем остальным — скорее нет.

Почему это стало срочным именно в 2026-м

В апреле 2025 года CA/Browser Forum проголосовал за поэтапное сокращение срока жизни TLS-сертификатов. Бюллетень внесла Apple. График такой:

С какого моментаМаксимальный срокРотаций в год
до марта 2026398 дней~1
15 марта 2026200 дней~2
15 марта 2027100 дней~4
15 марта 202947 дней~8

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

И экосистема борется с pinning намеренно. Ещё в 2024 году Let's Encrypt начал выбирать выпускающий промежуточный сертификат случайно — специально, чтобы сломать привычку пиннить промежуточные. В ноябре 2025 года эта же политика перешла в новую иерархию из шести промежуточных, и в анонсе написано прямо: «как и раньше, при каждом выпуске промежуточный сертификат будет выбираться случайно, чтобы не поощрять pinning промежуточных ключей». Крупнейший публичный CA в интернете два года целенаправленно ломает такой pinning.

Это не злой умысел, а выстраданный опыт. Мир браузеров прошёл через это первым.

HPKP — pinning через HTTP-заголовок, спецификация 2015 года, написанная инженерами Google, — провалился полностью. Chrome объявил его устаревшим в версии 67 и убрал в версии 72, в январе 2019-го. Google назвал причины: очень низкое внедрение, риск отказа в обслуживании и враждебный pinning.

Знаменитая жертва — Smashing Magazine, октябрь 2016 года: четыре дня недоступности для большей части читателей. Они перешли на сертификат с новым ключом, отдали заголовок только с новым пином, и все, у кого в кеше лежал старый набор, просто перестали попадать на сайт. Откатиться было нельзя: срок старого сертификата уже истёк.

Из мобильных случаев — Barclays, ноябрь 2016 года. По сообщениям, приложение пиннило устаревший промежуточный сертификат, цепочка изменилась, и платежи встали — накануне «чёрной пятницы». Чтобы починить это в срок, Symantec выпустил сертификат под старым промежуточным и вынужден был нарушить требование CA/Browser Forum к серийным номерам.

Мораль в обоих случаях одна: pinning ломается не тогда, когда вас атакуют. Он ломается в обычный вторник, когда кто-то продлил сертификат.

Пять правил, если вы всё-таки пиннитесь

Правило 1. Пиннить ключ, а не сертификат

Технически это SPKI — SubjectPublicKeyInfo, структура внутри сертификата, в которой лежит открытый ключ. Пиннится его SHA-256-хеш в base64.

Почему это ключевой момент: сертификат меняется при каждом продлении, ключ — нет. Пиннится ключ — и продление с тем же ключом вообще не касается вашего пина. Пиннится отпечаток всего сертификата — и он ломается каждый раз: дважды в год при 200 днях и восемь раз при 47.

Однострочник для рабочего хоста:

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

И тот же хеш из приватного ключа, который вы уже сгенерировали, но ещё не сертифицировали, — так считается резервный пин:

openssl pkey -in backup.key -pubout -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

Хорошая новость: обе платформы нативно поддерживают только SPKI, и Android, и iOS. Так что если вы пиннитесь на весь сертификат, вы либо написали это руками, либо выбрали библиотеку, которая делает это за вас, — и почти наверняка без всяких оснований.

Правило 2. Резервные пины обязательны

Как минимум один ключ, полностью находящийся под вашим контролем. Это формулировка Google, и «полностью под вашим контролем» — несущая часть. Не «второй сертификат от того же CA». Пара ключей, которую вы сгенерировали заранее и держите офлайн; сертификат на неё можно получить в любой момент. Её пин едет в приложение рядом с рабочим и просто лежит там до нужного часа.

Именно отсутствие резервного пина превратило один плохой день Smashing Magazine в четыре дня простоя. Старый ключ у них на самом деле был — его сохранил хостер. В заголовке они отдали только отпечаток нового.

<!-- res/xml/network_security_config.xml -->
<network-security-config>
  <domain-config>
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2027-03-01">
      <!-- рабочий ключ -->
      <pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
      <!-- офлайновый резервный ключ, ещё ни разу не обслуживавший трафик -->
      <pin digest="SHA-256">sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

Эквивалент для iOS — декларативный и проверяемый самой ОС:

<!-- Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSPinnedDomains</key>
  <dict>
    <key>api.example.com</key>
    <dict>
      <key>NSIncludesSubdomains</key><true/>
      <key>NSPinnedLeafIdentities</key>
      <array>
        <dict><key>SPKI-SHA256-BASE64</key><string>YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</string></dict>
        <dict><key>SPKI-SHA256-BASE64</key><string>sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis=</string></dict>
      </array>
    </dict>
  </dict>
</dict>

В этом примере закреплены ключи листа. В документации Apple используется NSPinnedCAIdentities — та же структура на уровень выше по цепочке, и как раз об этом правило 3.

Правило 3. Выберите уровень в цепочке — и знайте, чем за него платите

Консенсуса здесь нет, поэтому вот обе позиции. Apple рекомендует пиннить CA, а не сервер — тогда серверные сертификаты можно ротировать без релиза приложения. OWASP рекомендует обратное: пиннить лист, но всегда с резервом.

УровеньЦена ротацииЧему вы доверяетеВердикт в 2026-м
ЛистБесплатно при продлении с тем же ключом, иначе релизРовно одному ключуРабочий вариант только с резервными пинами
ПромежуточныйНепредсказуемаяВсему, что выпустит этот промежуточныйСнят с рассмотрения на публичных CA
Публичный кореньРедкаяВсему, что этот CA когда-либо выпуститНастолько широко, что почти бессмысленно
Свой приватный кореньКак решите самиСвоей собственной PKIЕдинственный однозначно хороший случай

Pinning промежуточного для публично доверенных сертификатов, по сути, снят с рассмотрения с 2024 года: у крупнейшего публичного CA теперь шесть промежуточных, и предсказать, какой достанется вашему сертификату, нельзя. Если вы на публичном CA, остаётся лист с резервными пинами или корень.

Конфигурация, в которой pinning по-прежнему однозначно хорошая идея, — свой приватный CA, который вы контролируете от начала до конца. Пиннить свой корень разумно, предсказуемо, и он не сломается потому, что кто-то другой передумал. Собственно, ровно об этом и говорил инженер Chromium в цитате из начала статьи.

Правило 4. Никогда не выкатывайте сразу в hard-fail

Порядок такой: сначала режим только отчётов — пин проверяется, соединение не блокируется, промахи уходят в телеметрию. Неделю смотрим. Потом hard-fail для одного процента пользователей. Потом на всех.

И вот неприятная часть: встроенной отчётности об отказах нет ни на одной платформе. Ни в Network Security Config на Android, ни в App Transport Security на iOS. Единственный механизм, который это когда-либо умел, — report-uri из HPKP, и он мёртв. Так что телеметрию вы пишете сами: ловите исключение, собираете событие — домен, какой ключ получили, какие ожидали, версия приложения — и отправляете по каналу, который не пиннится, иначе отчёт о вашей же аварии наружу не уйдёт.

И ещё одно: научитесь отличать аварию от атаки. Если падают все сразу — вы сломали свою собственную ротацию. Разрозненные отказы — это корпоративный прокси, антивирус или настоящая попытка перехвата.

Правило 5. Сделайте выключатель до того, как он понадобится

На Android он встроенный — атрибут expiration у набора пинов. После этой даты pinning просто прекращается: пины не проверяются, трафик идёт как будто ничего и не было. Выключатель по таймеру.

Но у него есть цена, и Google сам об этом пишет: «установка срока истечения для пинов может позволить атакующим обойти ваши закреплённые сертификаты». То есть это одновременно и ваша страховка от «окирпичивания» приложения, и способ обойти ваш pinning, просто подождав. А на другой странице той же документации Google рекомендует «достаточно короткий срок истечения». Оба утверждения официальные и оба актуальные.

И вот наша любимая деталь во всей этой истории. В каноническом примере документации Google до сих пор стоит expiration="2018-01-01". Если вы его скопировали — а его копируют все, — ваш pinning полностью выключен уже восемь лет. Страницу обновляли в июне 2026-го. Дата осталась.

На iOS аналогичного механизма нет вообще. Выключить pinning там — значит выпустить релиз.

Если вы делаете выключатель через remote config или , помните про проблему курицы и яйца: канал доставки нельзя защищать теми же пинами, иначе вы никогда не восстановитесь. Правильный ответ — подписывать набор пинов ключом, публичная половина которого зашита в приложение. Тогда неважно, по какому каналу он пришёл. И добавьте монотонный счётчик версий, чтобы никто не подсунул повторно старый, но корректно подписанный набор.

Flutter: где certificate pinning рассыпается

Всё сказанное выше касается всех. Дальше — то, ради чего статья и написана: мы делаем приложения на Flutter, и всё это мы проверяли руками.

Главное, и это противоречит интуиции большинства: Dart не использует сетевой стек платформы.

HttpClient из dart:io не ходит ни через OkHttp и HttpURLConnection на Android, ни через NSURLSession на iOS. Он открывает сокет и сам проводит TLS-хендшейк — через копию BoringSSL, встроенную в движок Flutter.

Практическое следствие: ваш <pin-set> в network_security_config.xml ничего не делает с трафиком Dart. Молча. Ни ошибки, ни предупреждения — запрос просто уходит.

Это не наше открытие. Это issue #96722 во Flutter, заведённый в январе 2022 года и до сих пор открытый. Участник команды Flutter воспроизвёл проблему и зафиксировал вывод: на iOS запрос корректно отменяется, на Android проходит, а в контрольном нативном приложении для Android с тем же конфигом падает, как и должен.

На iOS поведение другое: по сообщениям, там работает, потому что Dart на платформах Apple передаёт решение о доверии системному SecTrust. Нигде это не задокументировано. И, честно говоря, это худший из исходов. Pinning, который работает на одной платформе и молча не делает ничего на другой, опаснее pinning, который не работает нигде — потому что вы его проверили. На айфоне.

Дальше три ловушки. Мы наступили на все три в продакшене.

Dart не даёт доступа к SPKI. Класс X509Certificate отдаёт der, pem, sha1, subject, issuer и даты действия. Открытого ключа среди них нет. Поэтому практически каждый туториал пиннит SHA-256 всего сертификата — что, по правилу 1, ломается при каждом продлении. Сделать правильно — значит разбирать ASN.1 руками.

В README самого dio показаны два способа сделать это неправильно. Вот первый, в сокращении:

// Пример из README dio. Три проблемы в девяти строках.
dio.httpClientAdapter = IOHttpClientAdapter(
  createHttpClient: () {
    final client = HttpClient(context: SecurityContext(withTrustedRoots: false));
    client.badCertificateCallback = (cert, host, port) => true;
    return client;
  },
  validateCertificate: (cert, host, port) =>
      cert != null && fingerprint == sha256.convert(cert.der).toString(),
);

withTrustedRoots: false вместе с безусловным badCertificateCallback отключает построение цепочки, проверку срока действия и проверку имени хоста. Сравнивается хеш от cert.der — то есть от всего сертификата, а это, по правилу 1, ломается при каждом продлении. И validateCertificate вызывается позже, чем кажется, — об этом ниже.

Второй вариант из README ещё хуже: там пин проверяется прямо внутри badCertificateCallback, сравнением cert.pem с сохранённым PEM. А этот колбэк по определению срабатывает только если валидация уже провалилась. Если атакующий предъявит сертификат, корректно подписанный любым доверенным CA, колбэк не вызовется вообще и сравнивать будет нечего. Как механизм pinning он не работает в принципе.

Правильный хук вызывается слишком поздно. У dio он есть — validateCertificate, который срабатывает при успешной валидации. Но выполняется он после того, как запрос уже ушёл, а ответ уже пришёл. В сети оказались не только тело запроса и заголовок Authorization: вы уже приняли статус и заголовки ответа. Единственное, что спасает эта проверка, — тело ответа. Запрос уже утёк.

И теперь про пакеты

Самый популярный пакет для pinning на pub.dev — около ста шестидесяти лайков и почти пятьдесят тысяч загрузок в месяц на момент проверки в августе 2026-го — через делает отдельный нативный запрос к вашему хосту, проверяет отпечаток на нём и, если совпало, отправляет настоящий запрос по другому соединению, где pinning не проверяется вообще. Одно соединение проверено, данные идут по другому. Плюс каждый запрос теперь — два запроса. Мы его не называем, потому что дело не в одном мейнтейнере: прочитайте исходники того пакета для pinning, который используете вы, и выясните, какое соединение он на самом деле проверяет.

Что делать на самом деле. На платформах Apple есть чистый ответ: перевести HTTP-слой на cupertino_http. Это настоящий NSURLSession, а значит, pinning становится декларативным — NSPinnedDomains в Info.plist, ровно как в правиле 2. Проверяется операционной системой, работает по SPKI, сторонний код не нужен.

На Android эквивалента нет. У Cronet есть собственный API для pinning, но пакет cronet_http его не экспортирует. JNI-биндинг лежит прямо в пакете. Зато пакет экспортирует enablePublicKeyPinningBypassForLocalTrustAnchors — переключатель, который pinning обходит. Не сам pinning.

И есть один открытый вопрос, на который в интернете никто не отвечает внятно, — мы его так и обозначаем как открытый: применяется ли Network Security Config из Android к трафику, идущему через Cronet. Источники противоречат друг другу, официальной документации нет ни в одну сторону. Проверьте сами, прежде чем опираться на любой из ответов.

Два предупреждения напоследок, специфичных для Flutter. Сторонние SDK — аналитика, крашлитика, платежи — делают сетевые вызовы через свои нативные клиенты, и ваш pinning их не касается. Из-за этого же разделения отложенный диплинкинг на Flutter приходится делать отдельно под каждую платформу, а не одной реализацией. И если вы перейдёте на native_dio_adapter, чтобы получить нативный сетевой стек, pinning на dio, скопированный из статьи в блоге, молча выключится, потому что SecurityContext и оба колбэка перестанут вызываться.

Как проверить свой pinning за полчаса

Сделайте это правильно, потому что наивная версия теста вам врёт.

Наивная версия — поднять прокси, поставить его сертификат на телефон, посмотреть, сломается ли приложение. Сломается. И это не значит ничего: как разобрано выше, начиная с Android 7 приложения и так не доверяют сертификатам, установленным пользователем. Вы сделаете вывод, что вас защищает pinning, тогда как всё это время вас защищала операционная система.

Проверка pinning за 30 минут

Прогоняется по одному разу на каждую платформу. Всё, что осталось неотмеченным, — находка.

Подготовка

  • Release-сборка, а не debug — блок debug-overrides обесценивает весь тест
  • Сертификат прокси установлен в СИСТЕМНОЕ хранилище доверия, а не в пользовательское
  • Flutter: подтверждено, что трафик доходит до прокси через VPN-перехват или iptables, потому что Dart игнорирует системные настройки прокси
  • Пройден реальный сценарий с авторизацией, а не только первый экран

На что смотреть

  • Трафик вашего API не читается — если читается, работающего pinning нет
  • Трафик сторонних SDK и ваш ведут себя одинаково, либо вы знаете, почему нет
  • То, что вы видите, — отказ pinning, а не отказ хранилища доверия
  • Одинаковый результат на Android и на iOS

Затем проверьте сам конфиг

  • Пины — это хеши SPKI, а не отпечатки сертификатов
  • Есть хотя бы один резервный пин на офлайновый ключ, принадлежащий вам
  • Android: дата expiration у набора пинов в будущем и выбрана осознанно
  • Есть выключатель, который не требует релиза в сторе
  • Телеметрия отказов уходит по каналу, который сам не пиннится

Если совсем коротко

Pinning — узкий механизм против угрозы, которая стала гораздо менее вероятной, и радиус поражения у него — вся ваша пользовательская база. Он действительно нужен финансам, медицине и госуслугам. Всем остальным Google, Apple, Cloudflare и половина OWASP говорят «не надо», и у них есть причины — те же самые, что убили HPKP и что сейчас режут срок жизни сертификатов до 47 дней.

Если делаете: ключ, а не сертификат. Резервные пины на офлайновый ключ. Наблюдение до hard-fail. Свой выключатель. И ясное понимание, чем вы платите за свой уровень в цепочке.

А если вы на Flutter — сначала выясните, работает ли он вообще. Три самых вероятных ответа: pinning выключен датой 2018 года, скопированной из документации Google; pinning объявлен в конфиге, который Dart никогда не читает; или pinning проверяет соединение, по которому ваши данные не идут.

Версия из статьи в блоге говорит: «включите pinning, защитите пользователей». Инженерная реальность — выяснить, к чему вы на самом деле привязаны, проверить, что это срабатывает на обеих платформах, и положить запасной ключ в сейф. Про это никто не снимает виральных видео.

Источники

Всё выше проверяемо, поэтому вот откуда что взято.

  • Google, «Security with HTTPS and SSL» — pinning «не рекомендуется для Android-приложений»; резервные пины и «как минимум один ключ, полностью находящийся под вашим контролем»: developer.android.com/privacy-and-security/security-ssl
  • Google, «Network security configuration» — атрибут expiration, предупреждение про обход, «поддерживается только SHA-256» и пример с 2018-01-01: developer.android.com/privacy-and-security/security-config
  • Apple, «Identity Pinning: How to configure server certificates for your app» — «в большинстве случаев pinning не нужен, и его следует избегать», плюс рекомендация пиннить CA, а не лист: developer.apple.com/news
  • Райан Сливи в net-dev@chromium.org, март 2021 года — цитата про приватный CA целиком: groups.google.com
  • OWASP Pinning Cheat Sheet — «ответ на него — вероятно, никогда» и список причин не пиннить: cheatsheetseries.owasp.org
  • OWASP MASTG, раздел про сетевое взаимодействие — «если приложение не реализует pinning, это не следует сообщать как уязвимость; но если приложение обязано проходить проверку по MAS-L2, pinning должен быть реализован»: github.com/OWASP/mastg
  • Бюллетень CA/Browser Forum SC-081v3 — график 398 → 200 → 100 → 47, внесён Apple, принят 11 апреля 2025 года: cabforum.org
  • Let's Encrypt, «Generation Y» — шесть промежуточных, выбираются случайно, «чтобы не поощрять pinning промежуточных ключей»: letsencrypt.org
  • Cloudflare — двенадцать неавторизованных сертификатов на 1.1.1.1 и отсутствие Fina в мобильных корневых хранилищах: blog.cloudflare.com
  • Flutter issue #96722 — открыт с января 2022 года: github.com/flutter/flutter
  • dio, IOHttpClientAdapter — где на самом деле вызывается validateCertificate, прямо в исходниках: github.com/cfug/dio
  • Dart X509Certificate — полный список того, что класс отдаёт, и открытого ключа там нет: api.dart.dev
  • cronet_http, CronetEngine.build — список параметров, где обход есть, а pinning отсутствует: pub.dev
Скорее нет, если вы не в финансах, медицине или госуслугах и ваша модель угроз не предполагает прямо враждебную сеть и недоверенное устройство. Google называет pinning не рекомендованным для Android-приложений, Apple пишет, что в большинстве случаев он не нужен и его следует избегать, а OWASP Pinning Cheat Sheet на вопрос нужно ли пиннить отвечает: вероятно, никогда. Не пиннитесь, если не можете обновить набор пинов без релиза, не знаете пару ключей до прода или не контролируете и сервер, и приложение.
Само по себе нет. Руководство OWASP по тестированию мобильных приложений прямо говорит, что отсутствие pinning не следует сообщать как уязвимость, а MASVS требует его только от приложений, которые обязаны проходить проверку по MAS-L2 — профилю для софта с чувствительными данными. Если в отчёте пентеста отсутствие SSL pinning стоит как находка, это обычно результат автоматического чек-листа, а не суждение о вашей модели угроз.
Открытый ключ, в виде SHA-256-хеша SPKI. Сертификат меняется при каждом продлении, ключ — нет, поэтому продление с тем же ключом вообще не затрагивает пин. И Android, и iOS нативно поддерживают только pinning по SPKI. Про уровень в цепочке: промежуточный сертификат для публичных CA снят с рассмотрения с 2024 года, когда крупнейший публичный CA начал выбирать промежуточные случайно; публичный корень настолько широк, что почти бессмысленен; лист работает только с резервными пинами. Свой приватный корень — единственный однозначно хороший случай.
Не так, как его настраивает большинство. Dart не использует сетевой стек платформы: dart:io проводит TLS через BoringSSL, встроенный в движок Flutter, поэтому pin-set из network_security_config.xml для трафика Dart на Android не читается. Это issue 96722 во Flutter, заведённый в январе 2022 года и до сих пор открытый. На iOS, по сообщениям, работает, потому что Dart передаёт решение о доверии в SecTrust, — то есть типичный исход это pinning, который держит на одной платформе и молча ничего не делает на другой.
Потому что он вызывается только после того, как валидация уже провалилась. Если атакующий предъявит сертификат, корректно подписанный любым доверенным CA, колбэк не сработает и отпечаток не будет сравнён вообще. Часто копируемый пример, где он идёт вместе с SecurityContext и withTrustedRoots false, дополнительно отключает построение цепочки, проверку срока действия и проверку имени хоста. Хук validateCertificate в dio ближе к правильному, но выполняется после отправки запроса и получения ответа, поэтому защищает только тело ответа.
Он делает pinning листа с обновлением через релиз практически неподъёмным. Бюллетень CA/Browser Forum, принятый в апреле 2025 года, снижает максимальный срок жизни TLS-сертификатов с 398 дней до 200 в марте 2026-го, 100 в марте 2027-го и 47 в марте 2029-го. Сорок семь дней — это около восьми ротаций в год. Если набор пинов меняется только вместе с релизом в сторе, это восемь принудительных релизов в год, каждый с ревью и с пользователями, которые не обновляются. Pinning ключа вместо сертификата убирает большую часть этой цены, потому что продление с тем же ключом пин не меняет.
Поставьте сертификат прокси в системное хранилище доверия, а не в пользовательское, — через эмулятор с записываемым системным разделом или Magisk, — и прочитайте перехваченный трафик release-сборки. Наивный тест с пользовательским сертификатом не доказывает ничего, потому что приложения Android не доверяют пользовательским сертификатам начиная с targetSdk 24. На Flutter отдельно убедитесь, что трафик вообще доходит до прокси: Dart игнорирует системные настройки прокси. Затем прогоните тот же тест на второй платформе и сравните.

Если нужен фон про то, за что вообще отвечает удостоверяющий центр, мы разбирали это в статье HTTPS без боли, а криптографию под ним — в как работает шифрование. Эта статья — компаньон к нашему видеовыпуску на ту же тему; предыдущий был все ошиблись со сроками.

А если при прогоне теста вы нашли что-то неожиданное — особенно отказ ровно на одной платформе, — нам правда интересно об этом услышать. Наша ставка: у половины Flutter-приложений pinning не работает как минимум на одной платформе.


Илья Никсан — основатель и ведущий разработчик Nerdy Production, Flutter-first агентства, которое создаёт и поддерживает приложения в финтехе, медицине и ритейле. Мы также занимаемся разработкой приложений на Flutter для команд, которые предпочли бы не выяснять всё это в продакшене.