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

JoshMil
31.08.2026 07:34Очень, очень полезная статья. Замучили уже эти,… кхм… партнерские API. Тоже пришел к выводу что пора кое что пересобрать.

ebye
31.08.2026 07:34$ curl -shttps://tls.browserleaks.com/json"user_agent": "curl/8.18.0","ja4": "t13d9013h2_c6771aded2ed_57a60bdf03d1"такой заход в access.log как выглядит, можно полный (без айпи)

RigelGL
31.08.2026 07:34Вычитайте вы статью перед публикацией, проверьте что вам выдала ллм. Поправьте форматирование в таблице с разбором ja4. Поправьте фразы, чтобы они не звучали как бред.
Текст приходится читать с усилием, чтобы понять что имела в виду ллм, позабыв терминологию и введя бессвязные понятия "для объяснения простым языком".
Тема для статьи хорошая, но ллм я могу и сам спросить. Ваша работа здесь в чём?

JBFW
31.08.2026 07:34Какая удобная штука для блокировок...

Volcano
31.08.2026 07:34Так эту штуку и используют в некоторых случаях. Самый банальный пример - блокировка тележного mtproxy

Skyuzi
31.08.2026 07:34Встречал сайт, который на основе TLS Fingerprint, определял что запрос не от реального пользователя и возвращал фейковые характеристики, довольно интересный подход, так как гораздо сложнее определить. Если интересно https://habr.com/ru/articles/596411/

darwin_usb
31.08.2026 07:34Уже все про это все знают. И без проблем в своих ботах пишут все как нужно. Так для ботостроителей и парсерологов из первого класса это все рассчитано.

lexore
31.08.2026 07:34Я вот не знал. Очень интересная и познавательная статья, как по мне. Побольше бы таких.

Caefah
31.08.2026 07:34Просто положу тут это решение: https://github.com/lwthiker/curl-impersonate
dab1818
достаточно пересобрать curl с нужной ssl-либой, либо подключать нужную на ходу
LD_LIBRARY_PATH=.../curl_nss curl -A 'я файрфокс' ...в curl_nss - libcurl.so… слинкованный в данном примере с NSS, версию которой нужно выбирать соответствующую версии файрфокс в юзерагенте. с BoringSSL тоже собирается… на линуксе хром/хромиум тоже NSS используют - хромоножкой можно прикинуться с нужным юзерагентом.jbenderov Автор
Большое спасибо за уточнение!
ebye
полностью согласен