TLS-фингерпринтинг JA4: почему ваш прокси палится ещё до первого HTTP-запроса
Содержание
Что такое JA4 и откуда он взялся
TLS-фингерпринтинг существует давно. Первой ласточкой был JA3 — хеш от набора параметров ClientHello: версия TLS, список шифров, расширения, эллиптические кривые, форматы точек. Сервер считает MD5 и получает строку вида `a0e9f5d64349fb13191bc781f81f42e1`. Проблема JA3 в хрупкости: любое изменение порядка шифров ломает хеш, даже если клиент тот же. Плюс MD5 коллизии — не теоретические, а вполне рабочие.
В 2023 году Джон Альтхаус из FoxIO выпустил JA4. Это не просто «JA3 v2». Формат другой: `t13d1516h2_8daaf6152771_b186095e22b6`. Первая часть — метаданные: версия TLS (`t13`), наличие SNI (`d`/`i`), количество шифров (`15`), количество расширений (`16`), ALPN (`h2`). Вторая — усечённый SHA256 от отсортированного списка шифров. Третья — хеш от расширений с сохранением порядка и значений сигнатур ALPN.
Сортировка шифров — ключевое отличие. JA3 зависел от порядка, и браузеры его тасовали при каждой сборке. JA4 сортирует — хеш стабилен между версиями Chrome. Зато добавление нового шифра меняет хеш целиком. Компромисс, но куда полезнее на практике.
Почему прокси палится до HTTP
Прокси перехватывает TCP, читает ClientHello, устанавливает своё TLS-соединение с целевым сервером. Если прокси использует Go `crypto/tls` с дефолтным конфигом — он отправит ClientHello, который не совпадёт ни с одним браузером. Go-стек шлёт 16 шифров в фиксированном порядке, набор расширений урезан, GREASE не используется. JA4 получается уникальный, и он моментально попадает в базу «известных ботов».
Cloudflare, Akamai, DataDome собирают JA4 с 2023 года. Проверка идёт на этапе TLS handshake — до того, как прокси успевает отправить хоть один байт HTTP. Если JA4 не в whitelist браузеров, соединение рвётся с `tls: handshake failure` или, что хуже, молча пропускается в «песочницу» с капчей.
Вот как выглядит типичный Go-клиент:
```go
package main
import (
"crypto/tls"
"fmt"
"net"
)
func main() {
conn, err := tls.Dial("tcp", "example.com:443", &tls.Config{
ServerName: "example.com",
})
if err != nil {
panic(err)
}
defer conn.Close()
state := conn.ConnectionState()
fmt.Printf("TLS version: %x\n", state.Version)
fmt.Printf("Cipher: %x\n", state.CipherSuite)
}
```
JA4 этого соединения — `t13d1516h2_8daaf6152771_b186095e22b6` (пример для Go 1.21). Chrome 120 даёт `t13d1516h2_8daaf6152771_02713d6af862`. Первая часть совпадает, третья — нет. Расширения разные.
Как читать JA4
Разберём строку `t13d1516h2_8daaf6152771_b186095e22b6`.
`t` — TCP (бывает `q` для QUIC). `13` — TLS 1.3. `d` — domain в SNI есть. `15` — 15 шифров в ClientHello. `16` — 16 расширений. `h2` — ALPN предлагает HTTP/2. Дальше два хеша через подчёркивание.
Вторая часть — SHA256 от отсортированных hex-кодов шифров, обрезанный до 12 символов. Сортировка лексикографическая, не по приоритету.
Третья часть — SHA256 от расширений и сигнатур ALPN. Здесь порядок сохраняется, потому что он значим для сервера. Формат: `extension_code_signature_algorithms`. Если расширение имеет данные (например, `supported_versions`), они тоже участвуют.
Полная JA4-строка для curl 8.5 с OpenSSL 3.1:
```
t13d1715h2_5b57614c22b0_3d5424432f57
```
Для Python `requests` (urllib3 + OpenSSL 3.0):
```
t13d1517h2_8daaf6152771_9c3a5f7e1d2b
```
Разница в количестве шифров (17 vs 15) и хеше расширений. Сервер видит, что идёт Python-скрипт, а не браузер.
Сравнение подходов к маскировке
| Инструмент | JA4 совпадает с Chrome | Поддержка TLS 1.3 | Сложность настройки |
|---|---|---|---|
| curl + OpenSSL | Нет | Да | Низкая |
| Python requests | Нет | Да | Низкая |
| Go crypto/tls | Нет | Да | Низкая |
| uTLS (Go) | Да (с шаблоном) | Да | Средняя |
| curl-impersonate | Да | Да | Средняя |
| NSS (Firefox) | Частично | Да | Высокая |
uTLS — библиотека от refraction-networking, которая позволяет подменить ClientHello на любой браузерный. Она пересобирает рукопожатие байт за байтом. curl-impersonate делает то же самое, но на уровне C-биндингов к BoringSSL.
Практика: проверка своего JA4
Самый быстрый способ — отправить ClientHello на публичный JA4-эндпоинт. Есть сервис `tls.peet.ws`, который возвращает JSON с полным фингерпринтом.
```bash
curl -s https://tls.peet.ws/api/all | jq '.tls.ja4'
```
Для Go-клиента:
```go
package main
import (
"crypto/tls"
"fmt"
"io"
"net/http"
)
func main() {
tr := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
},
}
client := &http.Client{Transport: tr}
resp, err := client.Get("https://tls.peet.ws/api/all")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
```
В ответе ищите поле `ja4`. Если там `t13d1516h2_...` с хешем, который не в списке браузеров — вас палят.
Кейс: парсер маркетплейса
Задача: собирать цены с крупного маркетплейса. Прокси на Go, ротация IP через пул /64. Первые 200 запросов прошли. На 201-м — `403` с заголовком `cf-mitigated: challenge`.
Причина: Cloudflare собрал JA4 всех соединений с этой /64. Первые 200 шли с одного JA4 (`t13d1516h2_8daaf6152771_...`), потом включилась эвристика «один фингерпринт на подсеть = бот». Плюс сам JA4 не совпадал с Chrome.
Решение: переписали клиент на uTLS с шаблоном `HelloChrome_120`. JA4 стал `t13d1516h2_8daaf6152771_02713d6af862`. Но этого мало — Cloudflare проверяет ещё HTTP/2-фингерпринт (SETTINGS-фреймы, порядок заголовков). Пришлось добавить `fhttp` от bogdanfinn. После этого 403 прекратились, но появился rate limit по подсети — уже другая история.
Кейс: Telegram-бот с прокси
Бот на Python ходил через SOCKS5-прокси на сервере в Нидерландах. Прокси терминировал TLS и переустанавливал соединение с `api.telegram.org` через `aiohttp`. Через сутки Telegram начал отвечать `Connection reset by peer` на 30% запросов.
Причина: `aiohttp` использует OpenSSL с дефолтным конфигом. JA4 — `t13d1516h2_8daaf6152771_...`. Telegram банит такие фингерпринты на уровне TLS, потому что все скрипты-спамеры ходят с одинаковым.
Решение: заменили `aiohttp` на `curl_cffi` с `impersonate="chrome110"`. JA4 стал браузерным. Сбросы прекратились. Нюанс: `curl_cffi` тянет BoringSSL, бинарник весит 15 МБ вместо 3 МБ у `aiohttp`.
Кейс: антибот на собственном сайте
Свой сервис, nginx 1.24 + Cloudflare. Атака: 50 000 запросов в минуту с 2000 IP, все с JA4 `t13d1516h2_8daaf6152771_...`. Явно Python-скрипт.
Настроили на nginx модуль `ngx_http_ssl_ja4_module` (форк от FoxIO). Конфиг:
```nginx
http {
ja4_whitelist /etc/nginx/ja4_whitelist.txt;
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
if (\$ja4_fingerprint !~ "^(t13d1516h2_02713d6af862|t13d1516h2_3d5424432f57)") {
return 403;
}
}
}
```
В whitelist — JA4 Chrome и Firefox. Всё остальное режется до TLS handshake. Нагрузка на CPU упала с 80% до 12%, потому что боты отсекаются до расшифровки. Минус: старые браузеры (Safari 14, Edge Legacy) тоже режутся — пришлось добавить их JA4 в список вручную.
Почему JA4 не панацея
Фингерпринт — это не идентификатор. Два разных Chrome на одной машине дадут одинаковый JA4. Более того, JA4 не учитывает порядок расширений (в отличие от JA3), поэтому некоторые патчи безопасности в браузерах не видны.
Есть JA4H — фингерпринт HTTP-заголовков. И JA4T — TCP-фингерпринт (TTL, window size, MSS). Комбинация трёх даёт устойчивый идентификатор. Cloudflare использует все три плюс поведенческий анализ.
Для прокси это значит: подмена JA4 — необходимый, но недостаточный шаг. Нужно маскировать HTTP/2-фреймы, порядок заголовков, TCP-параметры. Иначе палево останется, просто на другом уровне.
Что делать с ротацией
JA4 привязан к клиенту, не к IP. Если у вас 1000 прокси, но все ходят через один `requests` — у всех одинаковый JA4. Антибот видит 1000 IP с одним фингерпринтом и банит подсети целиком.
Рабочая схема: 3-5 разных TLS-стеков (uTLS с шаблонами Chrome, Firefox, Safari), рандомизация между запросами. Плюс ротация HTTP/2-фингерпринтов. Тогда каждый IP выглядит как отдельный пользователь.
lexic.ml поддерживает ротацию по /64 с сохранением сессии — это важно, потому что смена JA4 в середине сессии выглядит подозрительно. Сервер запоминает фингерпринт и ждёт его при следующем запросе с того же IP.
Итог
JA4 — обязательная проверка для любого прокси, который работает с серьёзными сайтами. Игнорировать её — значит получать 403 ещё до отправки HTTP-запроса и не понимать, почему. Подмена через uTLS или curl-impersonate решает 80% проблем. Оставшиеся 20% — HTTP/2, TCP и поведенческий анализ, но это уже следующий уровень.