Разговор, который у меня повторяется примерно раз в квартал. Интеграция ходит к партнёрскому API, партнёр её режет. Разработчик ставит в запрос User-Agent от Chrome. Не помогает. Дальше идут версии про адрес, про частоту, про капчу — и все мимо.
Сервер понял, что это не браузер, ещё до того, как увидел хоть один HTTP-заголовок. User-Agent он в тот момент не читал, потому что читать было нечего: HTTP начинается после установления TLS, а решение принимается по первому же пакету рукопожатия.
Одинаковый User-Agent, разные клиенты
Проверяется это на публичном сервисе, который возвращает отпечаток пришедшего TLS-клиента. Сначала curl, честно представляющийся собой:
$ curl -s https://tls.browserleaks.com/json "user_agent": "curl/8.18.0", "ja4": "t13d9013h2_c6771aded2ed_57a60bdf03d1"
Теперь тот же curl, но с User-Agent от Chrome:
$ curl -s -A "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ... Chrome/145.0.0.0 Safari/537.36" \ https://tls.browserleaks.com/json "user_agent": "Mozilla/5.0 (X11; Linux x86_64) ... Chrome/145.0.0.0 Safari/537.36", "ja4": "t13d9013h2_c6771aded2ed_57a60bdf03d1"
Отпечаток не изменился ни на символ. А вот что отдаёт настоящий браузер, открывший ту же страницу:
"ja4": "t13d1516h2_8daaf6152771_d8a2da3f94cd"
Совпадает только рамка: TCP, TLS 1.3, SNI на месте, ALPN h2. Всё, что описывает сам стек, разное. Заголовок подменился, клиент — нет.
Что именно отпечатывается
TLS-соединение начинается с сообщения ClientHello. Клиент в нём перечисляет, что умеет: версии протокола, список поддерживаемых шифров, набор расширений, эллиптические кривые, форматы точек, алгоритмы подписи, список протоколов в ALPN.
Ничего из этого приложение обычно не выбирает. Список формирует библиотека TLS — BoringSSL в Chrome, NSS в Firefox, Schannel в системных компонентах Windows, OpenSSL или GnuTLS в утилитах командной строки. Состав и порядок в нём определяются кодом библиотеки и её версией, а не тем, что программа напишет в заголовке потом.
JA4 — способ записать увиденное короткой строкой. Читается она по частям, и первая часть читается прямо глазами (пробелы ниже только для наглядности, в самой строке их нет):
t 13 d 15 16 h2 _ 8daaf6152771 _ d8a2da3f94cd │ │ │ │ │ │ │ └─ хеш отсортированных расширений (без SNI и ALPN — │ │ │ │ │ │ │ они уже учтены в первой части) плюс алгоритмы │ │ │ │ │ │ │ подписи в исходном порядке │ │ │ │ │ │ └─ хеш отсортированного списка шифров │ │ │ │ │ └─ первый и последний символ первого ALPN: h2 → «h2», http/1.1 → «h1», │ │ │ │ │ ALPN нет → «00» │ │ │ │ └─ количество расширений │ │ │ └─ количество шифров │ │ └─ имя хоста передано (d) или соединение по адресу (i) │ └─ версия TLS └─ транспорт: TCP (t), QUIC (q), DTLS (d)
Теперь сравнение становится наглядным. Браузер: t13d1516h2 — пятнадцать шифров, шестнадцать расширений. curl: t13d9013h2 — девяносто шифров, тринадцать расширений. Оба счётчика — ровно два знака и обрезаются на 99: клиент со ста двадцатью шифрами запишется как «99». GREASE-значения в подсчёт не идут.
Девяносто против пятнадцати — самое наглядное различие, и оно не случайно. Утилита общего назначения собрана с расчётом «договориться с чем угодно», включая старые серверы, поэтому предлагает всё, что у неё есть. Браузер годами вычищал из своего списка слабые наборы и оставил необходимый минимум. Отличить одно от другого можно, даже не считая хеши.
Предшественник JA4 — JA3 — строил хеш по списку в том порядке, в каком его прислал клиент. GREASE он отбрасывал, а вот порядок его и подвёл: начиная с Chrome 110 браузер перемешивает расширения при каждом соединении, и JA3-хеш перестал быть постоянным даже у одного и того же браузера. В JA4 шифры и расширения перед хешированием сортируются, поэтому отпечаток стабилен между запусками. Алгоритмы подписи, наоборот, остаются в исходном порядке — у библиотек он устойчив и сам по себе их различает.
Что это даёт защите
Признак работает до запроса. Решение принимается на первом пакете, до разбора HTTP и до расхода ресурсов на приложение. Для отсечения потока автоматики на границе это дёшево.
Несоответствие сильнее самого отпечатка. Ценность не в том, что клиент опознан как curl, а в противоречии: заголовок заявляет Chrome под Windows, стек TLS — OpenSSL с набором параметров, типичным для requests под Python. Сам по себе браузер такого расхождения не даёт; а если даёт — ищите на пути расшифровывающий прокси, и это тоже полезный вывод. Правило «сопоставить заявленный User-Agent с отпечатком и оценить, бывает ли такая пара» ловит больше, чем список запрещённых отпечатков.
Признак не чистится пользователем. В отличие от cookie и хранилищ, отпечаток нечего удалять: браузер его не хранит и не отправляет — принимающая сторона выводит его из ClientHello, который браузер обязан прислать, чтобы соединение вообще состоялось.
Где признак ломается
Здесь начинается часть, которую в рекламных материалах не пишут.
Отпечаток не уникален. У всех пользователей одной версии Chrome он один и тот же — это миллионы человек. Для опознания конкретного посетителя он бесполезен, для разделения классов клиентов — годится.
Он меняется при обновлении. Chrome правит список шифров и расширений между версиями. Жёсткий список разрешённых отпечатков начинает резать легальных пользователей на следующий день после выхода новой версии браузера. Списки нужно обновлять, а значит, кто-то должен за этим следить.
Инспекция TLS стирает признак. Если на пути стоит прокси, расшифровывающий трафик, то сервер видит отпечаток прокси, а не клиента. Все сотрудники компании приезжают с одним и тем же отпечатком, и признак теряет смысл именно там, где корпоративная сеть, — то есть в самом интересном сегменте.
Мобильные приложения выглядят как автоматика. Приложение на своём HTTP-клиенте даёт отпечаток, не похожий на браузерный, — потому что браузером и не является. Правило «не браузер — значит бот» отрезает вам собственных мобильных клиентов.
Отпечаток воспроизводим. Публичные библиотеки, повторяющие рукопожатие популярных браузеров, существуют и открыто развиваются. Против осознанного противника признак не работает; он работает против массового потока, который пишут на том, что было под рукой. Это нормально: большая часть нежелательного трафика — как раз массовый поток.
Отсюда практический вывод для тех, кто строит детектирование: отпечаток TLS хорош как признак в наборе и плох как единственное основание для блокировки. Я видел обе крайности — и когда его игнорировали, теряя дешёвый сигнал, и когда на нём одном строили блокировку и потом неделю разбирали заявки от живых пользователей.
Что делать, если режут вас
Обратная ситуация встречается не реже: у вас легальная интеграция, а партнёр её фильтрует.
Начинать стоит с инвентаря: снять отпечатки своих исходящих клиентов и понять, чем именно они ходят наружу. Обычно выясняется, что четыре сервиса используют три разные библиотеки и одна из них десятилетней давности — та самая, чей отпечаток и попал в чей-то список.
Дальше — разговаривать с партнёром, а не подстраивать заголовки. Решение здесь простое: выдать интеграции клиентский сертификат или ключ и внести её в список известных, чтобы она опознавалась явно, а не угадывалась по косвенным признакам. Подстройка под браузер — это ровно то поведение, которое фильтр и ищет, и приводит она к более жёсткой реакции.
Что посмотреть у себя
Снять отпечатки клиентов, которыми ваши сервисы ходят наружу, и сравнить с тем, что вы про них думали. Расхождение находится почти всегда.
Со стороны приёма — посмотреть, доезжает ли отпечаток TLS до системы сбора логов. Nginx отдаёт в переменных часть параметров рукопожатия: $ssl_protocol, $ssl_cipher, а также списки из ClientHello в $ssl_ciphers и $ssl_curves. Готового JA4 в базовой сборке нет — за ним придётся идти к модулю или к балансировщику, и там это обычно отдельная настройка, по умолчанию выключенная. Если признака в логах нет, то и при разборе следующего инцидента его не будет: восстановить его задним числом не из чего.

