Разговор, который у меня повторяется примерно раз в квартал. Интеграция ходит к партнёрскому 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 в базовой сборке нет — за ним придётся идти к модулю или к балансировщику, и там это обычно отдельная настройка, по умолчанию выключенная. Если признака в логах нет, то и при разборе следующего инцидента его не будет: восстановить его задним числом не из чего.