Why NextWhy Next
К списку
💻 Разработка17 мин чтения

Я выследил девятерых, кто перехватывал TLS нашего приложения. Это оказался Google

Мы включили пиннинг сертификатов в режиме report-only, и в Sentry набралось 74 события о несовпадении SPKI. Выглядело это как MITM-атака, а ИИ решительно отбросил версию с проверкой в сторе: «Google не перехватывает ваш HTTPS». Реальные IP из логов продакшн-API опрокинули это предположение.

Главное

В первую же неделю после включения пиннинга сертификатов в режиме report-only в Sentry прилетело 74 события о несовпадении SPKI от 9 пользователей. Сертификат, который получало приложение, не совпадал с нашим серверным ни в одной позиции, а значит, кто-то перехватывал TLS. По отпечатку устройства все 74 события шли с «OnePlus 8 Pro», но с архитектурой x86 и экраном 288x448. Это была ферма эмуляторов, притворявшихся физическими устройствами. Пока мы сужали круг подозреваемых, ИИ-агент отбросил версию с автоматической проверкой в сторе, заявив, что «Google не делает MITM вашего HTTPS». Но реальные IP, приходившие в продакшн-API в моменты несовпадений, все до единого принадлежали Google. Вывод, построенный на общих принципах, рухнул за три минуты, столкнувшись с фактическим замером.

Содержание

Однажды наше приложение уже умирало из-за пиннинга сертификатов. 5 апреля 2026 года мы зашили в пины SPKI leaf-сертификата, ACM автоматически продлил сертификат, открытый ключ leaf поменялся, и в тот же миг API перестал отвечать всем пользователям. Удалённого killswitch тоже не было, поэтому пришлось выкладывать в стор хотфикс-сборку. После этого в репозитории осталось правило: «пиннинг сертификатов не вводить».

Через два месяца мы построили пиннинг заново. На этот раз в пины зашили не leaf, а SPKI четырёх корневых сертификатов Amazon Root CA. Корни не меняются, даже когда ACM продлевает сертификат, так что та же авария не повторится. Добавили и killswitch, который включается и выключается через удалённую конфигурацию. 29 июня мы удалённо включили режим report-only: он ничего не блокирует, только сообщает.

Через два дня в Sentry висел новый issue.

[CertPinning] SPKI mismatch (report-only)
74 events · 9 users · 2026-06-29

Два хеша, которых мы никогда не отдавали

Внутри события были записаны два SPKI сертификата, который приложение получило на самом деле.

mode: "reportOnly"
host: "web.example.com"
expected_pin_count: 4
served_spki: [
  "sha256/9hqPsoMiyQMwLCoRPk6FoCYmOsPiGqzQqUcpuZIfvgs=",
  "sha256/r9mjYco6rQO8YkTqr/XXGsQlDUuQqz2mGr67S0imt7M="
]

Через openssl s_client я вытащил реальную цепочку сервера и сравнил. Ни leaf, который отдаёт наш сервер, ни промежуточный сертификат, ни корень не совпадали ни с одним из этих двух хешей. Эти девять пользователей вообще не получили настоящий сертификат нашего сервера. Кто-то посередине разрывал TLS, переподписывал соединение своим сертификатом и отдавал его приложению.

Зацепкой было и то, что в served_spki всего два элемента. На нормальном пути приложение сообщает цепочку из трёх сертификатов, включая корень. Раз записано только два (leaf и промежуточный), значит, этот отчёт пришёл из другой ветки.

Здесь я сделал первый вывод. Точнее, вывод сделал ИИ-агент, а я решил, что звучит правдоподобно. «Это не баг, который надо чинить. Это пиннинг в режиме report-only работает ровно так, как задумано, и ловит перехват, который происходит в чужой сети. Корпоративные или школьные прокси, веб-защита антивируса, captive portal и тому подобное. Мы этим не управляем, так что давайте заглушим issue в Sentry и оставим его только как метрику».

Заодно поступило предложение добавить резервный пин. Я его принял.

Резервный пин не уберёт отсюда ни одного события

Сразу после того, как работа над добавлением пина началась, я почувствовал, что тут концы с концами не сходятся, и переспросил.

Так а разве несовпадение вообще должно происходить?

Одной этой строчки хватило, чтобы только что выбранное направление рассыпалось. Агент перечитал код андроидной пробы и поправился.

val verified = verifiedChainSpki(offered, host)
if (verified == null) {
    // Проверка по хранилищу доверия провалена = перехват/подделка -> отчет без сравнения пинов
    report(host, offered.mapNotNull(::spkiSha256))
    return
}
// Сравнение пинов происходит только для цепочки, прошедшей проверку
if (verified.none(pins::contains)) { report(host, verified) }

Проба состоит из двух шагов. Сначала цепочка проверяется по хранилищу доверия ОС, и только прошедшая проверку цепочка сверяется со списком пинов. Наши 74 события падали уже на первом шаге. Раз verified == null, сравнение с пинами не происходит вообще и отчёт уходит сразу. Резервный пин работает только на втором шаге, то есть в случае «проверку прошли, а пин не совпал».

Вывод: даже если добавить Starfield G2 в пины, количество событий в этом issue не уменьшится ни на одно.

Тогда же выяснилось и то, что у нормального пользователя такой отчёт появиться не может. Живая цепочка точно совпадает с нашими пинами. Значит, эти девять по определению не нормальные пользователи. Никакого бага, который надо чинить, здесь не было. Нужно было выяснить, кто эти девятеро.

Если это проигнорировать, включить пиннинг не выйдет никогда

Рекомендацию «проигнорировать и просто мониторить» я тоже принять не мог.

Надо чинить по-настоящему, мы же собираемся это потом реально включить, а так включить и не получится

Конечная цель этого пиннинга - режим enforce. Условие, по которому решается, можно ли включать enforce, было записано в проектном документе ещё раньше. Доля несовпадений у реальных пользователей должна быть ниже 0,5% на протяжении двух недель. Если оставить природу несовпадений невыясненной, эти 74 события так и будут отравлять условие. Заглушить их значит убрать 74 события с дашборда, но само условие останется отравленным.

Поэтому я копнул данные глубже. Странность вылезла сразу, как только я вытащил распределение тегов.

Все 74 события с одного устройства, вот только такого устройства не существует

Тег device у всех 74 событий был один и тот же: OnePlus8Pro. Но отпечаток этого устройства выглядел странно.

ПолеЗначение в событииНастоящий OnePlus 8 Pro
archsвключая x86_64, x86только arm64 (Snapdragon 865)
Экран288 x 448 px, 106 dpi1440 x 3168, 513 dpi
CPU2 ядра, частота 08 ядер
simulatorfalse-

Потребительский ARM-телефон не заявляет о поддержке x86 ABI. Android-телефона с экраном 288x448 и плотностью 106 dpi не существует в природе. Это виртуальное x86-устройство, у которого под настоящее имя устройства загримирован только Build.MODEL. Оно ещё и притворялось физическим через simulator: false.

Девять пользовательских ID тоже не принадлежали людям. Все девять существовали ровно один день, 29 июня, и за этот день прогнали все три версии: 1.7.0, 1.8.0 и 1.8.1. Это не люди, это конвейер. Регион у всех 74 событий - США, но город везде null. Так это выглядит, когда запросы приходят из диапазонов дата-центров.

Тут я спросил: кто же тогда разбирает наше приложение? Агент поставил на первое место автоматические песочницы анализа APK от security-вендоров, следом шли компании, торгующие app intelligence, и сайты-зеркала APK. Автоматическая проверка в сторе была на четвёртом месте.

Гипотеза с четвёртого места стала первой из-за трёх IP

Меня зацепило именно четвёртое место. Поэтому я спросил.

А может быть, это Google Play Console или App Store?

Ответ был категоричным.

Google Play pre-launch report / Test Lab - почти наверняка нет. Решающая причина: Google не делает MITM вашего HTTPS. Play pre-launch получает ваш настоящий сертификат через стандартное хранилище доверия. Краулеры стора не перехватывают трафик и не переподписывают его. Так поступают песочницы для анализа.

Как рассуждение из общих принципов, это безупречно. И это неверно.

Не хватало доказательства в виде IP. Sentry вычищает IP как персональные данные, поэтому user.ip во всех событиях был null, а исходный IP уже случившихся событий восстановить нельзя. Чтобы установить личность, нужен был IP, а инструмента, у которого этот IP есть, не было.

Зато остались логи доступа продакшн-API. Они писали для каждого запроса X-Forwarded-For и User-Agent, а User-Agent нашего приложения был уникальным: Dart/3.12 (dart:io). И 29 июня ещё не вышло за срок хранения логов (7 дней).

Сначала я вытащил из Sentry время всех 74 событий и сгруппировал их в три сгустка. 02:12-02:20, 11:30-11:41, 17:32-17:40 (UTC). Затем в CloudWatch запросил IP тех запросов, что пришли в эти три окна с Dart/3.12.

02:12-02:20  ...  66.249.84.132, 66.249.84.141
11:30-11:41  ...  66.102.7.69
17:32-17:40  ...  66.249.84.141

66.249.x.x - это диапазон, которым пользуется Googlebot, 66.102.x.x тоже принадлежит Google. IP Google нашлись во всех трёх окнах. В двух из них google-овский IP был вообще единственным, с которого в это время приходили запросы приложения. В оставшемся окне были и IP, похожие на реальных пользователей, но google-овский IP присутствовал и там.

Через три минуты агент поправил сам себя.

Поправка. Выше я утверждал, что «Google не делает MITM, значит, это не он», но доказательство в виде IP опрокидывает это предположение. Это действительно Google. Измеренный IP - более сильное доказательство, чем рассуждение.

Наше приложение разбирало обычное автоматическое сканирование Google, через которое проходит любое приложение, выложенное в Play. Ни атакующий, ни конкурент, ни security-вендор. Эта среда сканирования инструментирует трафик. Поэтому нашей пробе цепочка и виделась непроверяемой.

Что в итоге исправили

Сам пиннинг мы оставили как есть, а изменили то, где применяется проба. Решили, что в среде сканера пиннинг просто не включается.

Оставалось понять, по какому признаку опознавать сканер. Что simulator: false доверять нельзя, уже выяснилось, так что нужен был сигнал, который не подделаешь. Архитектуру процессора не спрячешь.

// Решающий признак: потребительский ARM-телефон никогда не заявляет x86 ABI.
// Сканер Google может подделать Build.MODEL под реальное имя устройства (наблюдалось: "OnePlus8Pro" на x86),
// но факт x86_64 скрыть не может.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
  return true;
}

К этому добавилась проверка на имена железа семейства Cuttlefish/GCE и на отпечаток test-keys. Детектор намеренно смещён в сторону вердикта «это эмулятор». Ложное срабатывание всего лишь означает, что в одной такой среде пиннинг будет пропущен, а атакующий, который гоняет приложение в эмуляторе, всё равно может пропатчить его и обезвредить клиентский пиннинг, так что мы ничего не теряем. Пропуск же цели гораздо хуже: он запирает сканер на экране блокировки.

Почему это важно? Потому что, включи мы enforce просто так, произошло бы вот что. Pre-launch краулер Google упирается в экран блокировки «небезопасное соединение» в нашем приложении. Это попадает в pre-launch report в Play Console как провал. С кодом всё в порядке, но на экране проверки в сторе появляется красная строка.

Что осталось

Sentry вычищает IP как персональные данные. Это правильное значение по умолчанию. Но именно из-за него из событий пропало единственное поле, способное раскрыть суть происшествия. В итоге я скрестил две вещи, User-Agent приложения и время событий, и вытащил из логов доступа информацию, которую выбросил Sentry. То, чего нет в одном инструменте, не обязательно отсутствует везде.

Фраза «Google не перехватывает HTTPS» как рассуждение звучала гладко. Она отодвинула гипотезу на четвёртое место, разобрав по пути и цель краулера стора, и работу стандартного хранилища доверия, и разницу в поведении с песочницей. А потом трёх IP хватило, чтобы за три минуты вывод перевернулся. Спросишь у ИИ про принципы - получишь принципы. Совпадают ли эти принципы с реальностью, видно только из логов.

Заглуши мы эти 74 события в Sentry, дашборд стал бы чистым, а в день включения enforce мы бы получили красную строку на проверке в сторе, не понимая почему.

Enforce мы пока так и не включили. Условие требует, чтобы доля несовпадений у реальных пользователей две недели держалась ниже 0,5%. Сканер мы отсеяли, а значит, эти две недели надо отсчитывать заново.

Частые вопросы

Включил пиннинг сертификатов в режиме report-only, и посыпались отчёты о несовпадении. Если добавить резервные пины, их станет меньше?

Сначала нужно понять, из какой ветки кода приходят эти отчёты. Проба пиннинга обычно состоит из двух шагов. Сперва цепочка сертификатов проверяется по хранилищу доверия ОС, и только после успешной проверки цепочка сверяется со списком пинов. Если сама проверка цепочки провалилась (перехват, подделка), сравнение с пинами не выполняется вовсе и отчёт отправляется сразу. Все наши отчёты были из второго случая, поэтому сколько резервных пинов ни добавляй, число отчётов не уменьшится ни на один. Резервные пины помогают только тогда, когда цепочка проверку прошла, а пин не совпал.

В событиях Sentry нет IP. Можно ли всё-таки узнать, откуда шло подключение?

Sentry вычищает IP как персональные данные, поэтому в событиях его часто просто нет, а исходный IP уже случившихся событий восстановить нельзя. Зато можно сопоставить другие логи по времени. У нас логи доступа сервера писали для каждого запроса X-Forwarded-For и User-Agent, а User-Agent приложения был уникальным: «Dart/3.12 (dart:io)». Мы взяли из Sentry точные временные окна, в которых происходили несовпадения, запросили IP тех запросов, что пришли в эти окна с таким User-Agent, и источник стал виден.

Помешает ли пиннинг сертификатов в режиме enforce пройти проверку в Google Play?

Может помешать. Среда автоматического сканирования Play, судя по всему, инструментирует трафик, и если в ней приложение показывает экран блокировки от пиннинга, это может попасть в pre-launch report как провал. Безопаснее сразу сделать так, чтобы в среде сканера или эмулятора проба пиннинга вообще пропускалась. Атакующий, который гоняет приложение в эмуляторе, всё равно может пропатчить его и обезвредить клиентский пиннинг, так что исключение такой среды не сужает круг того, что мы защищаем.

Похожие статьи

Комментариев: 0