TLS-отпечаток JA4: как сайты отличают прокси-клиенты от обычных браузеров
Содержание
- Что такое JA4 и зачем он появился
- Почему прокси палятся по отпечатку
- Как читать JA4 на практике
- Инструменты для проверки
- пропускаем record header
- session id length
- Подмена отпечатка: рабочие подходы
- Где ломается даже подмена
- Кейс: Cloudflare режет Python-скрипт
- Кейс: Go-бот на utls и странный ALPN
- Кейс: nginx-прокси ломает отпечаток
- Что дальше: JA4+ и квантовые расширения
Что такое JA4 и зачем он появился
JA3 своё отработал. Пять лет назад Cloudflare и Akamai начали массово использовать хеш от ClientHello, чтобы отсекать ботов и автоматизацию. Проблема в том, что JA3 строится по простому алгоритму: склеиваем версию TLS, список шифров, список расширений, эллиптические кривые и точки EC, разделители — запятые, дефисы. Полученную строку — в MD5. Всё.
Ломается это на раз-два. Любой скрипт на Python с `requests` или `curl` даёт стабильный отпечаток, но стоит подменить порядок шифров или добавить пару расширений — хеш меняется. Антибот-системы натыкались на лавину ложных срабатываний: разные версии одного и того же браузера выдавали разные JA3. Chrome 110 и Chrome 120 — разные хеши. Firefox на Windows и на Linux — тоже.
JA4 придумал Джон Алпа, инженер FoxIO. Идея — сделать отпечаток читаемым и стабильным. Не просто хеш, а структурированная строка, где видно: протокол, версия TLS, наличие SNI, количество шифров, количество расширений, ALPN. И только потом — короткий SHA-256 от отсортированных значений. Формат такой:
```
t13d1516h2_8daaf6152771_02713d6af862
```
Здесь `t` — TCP, `13` — TLS 1.3, `d` — domain (SNI есть), `15` — 15 шифров, `16` — 16 расширений, `h2` — HTTP/2 в ALPN. Дальше два 12-символьных хеша: первый от шифров и расширений, второй от сигнатур (elliptic curves, EC point formats).
Ключевое отличие от JA3 — сортировка. JA4 сортирует шифры и расширения перед хешированием, оставляя только первый шифр на месте (он значим для согласования). Это убирает шум от перестановок, которые браузеры иногда делают.
Почему прокси палятся по отпечатку
Прокси-серверы обычно работают на уровне TCP или SOCKS. Они пересылают байты как есть, не трогая TLS. Значит, ClientHello формирует клиент — curl, Python, Go-программа, браузер под управлением Selenium. И вот тут начинается зоопарк.
`curl` с OpenSSL 3.0 выдаёт один отпечаток. Тот же curl, собранный с BoringSSL, — другой. Python `requests` через `urllib3` — третий. `httpx` с `httpcore` — четвёртый. Chrome 121 — пятый. И только последний выглядит как настоящий браузер.
Антибот-системы (Cloudflare Bot Management, DataDome, PerimeterX, Akamai Bot Manager) давно перешли на JA4. Они сравнивают отпечаток с базой известных браузеров. Если приходит JA4 от curl, но User-Agent говорит «Chrome/121.0.0.0» — это красный флаг. Пользователь с таким отпечатком либо скрипт, либо прокси с кривой настройкой.
Как читать JA4 на практике
Разберём реальный отпечаток Chrome 121 на Windows:
```
t13d1516h2_8daaf6152771_02713d6af862
```
Расшифровка по полям:
| Поле | Значение | Что означает |
|------|----------|--------------|
| t | TCP | Транспорт |
| 13 | TLS 1.3 | Максимальная версия |
| d | domain | SNI присутствует |
| 15 | 15 | Количество шифров |
| 16 | 16 | Количество расширений |
| h2 | HTTP/2 | ALPN |
| 8daaf6152771 | хеш | SHA-256 от шифров+расширений |
| 02713d6af862 | хеш | SHA-256 от сигнатур |
Теперь curl 8.5.0 с OpenSSL 3.2:
```
t13d1516h2_8daaf6152771_02713d6af862
```
Стоп. Тот же самый? Да, бывает. Некоторые сборки curl подтягивают OpenSSL с настройками, близкими к Chrome. Но чаще картина иная:
```
t13d1310h2_5c4e1f8a3b9d_1a2b3c4d5e6f
```
Здесь 13 шифров вместо 15, 10 расширений вместо 16. Хеши другие. Антибот видит: «Chrome не бывает с 13 шифрами в TLS 1.3». Блок.
Инструменты для проверки
Самый быстрый способ — Python-скрипт на базе `scapy` или готовый `ja4` от FoxIO. Ставим:
```bash
pip install ja4
```
Слушаем трафик или парсим pcap:
```python
from ja4 import JA4
import pyshark
cap = pyshark.FileCapture('capture.pcap', display_filter='tls.handshake.type == 1')
for pkt in cap:
raw = bytes.fromhex(pkt.tls.raw_handshake.replace(':', ''))
fp = JA4.fingerprint(raw)
print(f"JA4: {fp}")
```
Для проверки своего клиента без перехвата — используем `curl` с `--tls-max 1.3` и смотрим, что уходит. Но проще поднять локальный сервер на Python, который логирует ClientHello:
```python
import socket, ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain('cert.pem', 'key.pem')
def parse_client_hello(data):
пропускаем record header
hs = data[5:]
session id length
sid_len = hs[38]
pos = 39 + sid_len
cs_len = int.from_bytes(hs[pos:pos+2], 'big')
pos += 2
ciphers = [hs[pos+i:pos+i+2].hex() for i in range(0, cs_len, 2)]
return ciphers
sock = socket.socket()
sock.bind(('0.0.0.0', 4433))
sock.listen(1)
conn, addr = sock.accept()
data = conn.recv(4096)
print(parse_client_hello(data))
```
Запускаем, подключаемся curl'ом, смотрим список шифров. Сравниваем с эталонным Chrome через `tls.peet.ws` или `browserleaks.com/tls`.
Подмена отпечатка: рабочие подходы
Первый путь — использовать библиотеки с кастомным TLS-стеком. `curl-impersonate` — патченный curl, который эмулирует Chrome, Firefox, Safari. Внутри — BoringSSL с подменёнными параметрами ClientHello. Установка:
```bash
git clone https://github.com/lwthiker/curl-impersonate
cd curl-impersonate
make chrome
./chrome/curl_chrome116 https://tls.peet.ws/api/all
```
Второй путь — Python-библиотеки. `curl_cffi` — обёртка над curl-impersonate:
```python
from curl_cffi import requests
r = requests.get(
'https://tls.peet.ws/api/all',
impersonate='chrome120'
)
print(r.json()['tls']['ja4'])
```
Третий путь — Go с `utls`. Библиотека позволяет собрать ClientHello вручную:
```go
package main
import (
"fmt"
"net/http"
"github.com/refraction-networking/utls"
)
func main() {
config := &tls.Config{ServerName: "tls.peet.ws"}
conn, _ := tls.Dial("tcp", "tls.peet.ws:443", config)
defer conn.Close()
fmt.Println(conn.ConnectionState().NegotiatedProtocol)
}
```
`utls` даёт готовые профили: `HelloChrome_120`, `HelloFirefox_120`, `HelloSafari_16_0`. Подключаем — отпечаток совпадает с браузером.
Где ломается даже подмена
JA4 — не единственный сигнал. Антиботы смотрят на порядок заголовков HTTP/2, приоритеты потоков, поддержку `SETTINGS` фреймов. Chrome отправляет `SETTINGS` с конкретными значениями: `HEADER_TABLE_SIZE=65536`, `MAX_CONCURRENT_STREAMS=1000`, `INITIAL_WINDOW_SIZE=6291456`. curl-impersonate это эмулирует. Но если прокси-сервер пересобирает HTTP/2-соединение (например, nginx в режиме reverse proxy), параметры могут поменяться.
Пример: сервер на nginx 1.24 с `proxy_http_version 2.0` и `grpc_pass` пересобирает фреймы. Клиент шлёт Chrome-подобный JA4, но HTTP/2-отпечаток уходит в сторону nginx. Cloudflare это ловит через `cf-ja3-hash` + анализ `SETTINGS`. Решение — использовать прокси на уровне TCP (SOCKS5), чтобы TLS-сессия шла end-to-end.
Кейс: Cloudflare режет Python-скрипт
Клиент писал парсер на `requests`. Скрипт работал месяц, потом начал получать 403. Проверяем JA4:
```
t13d1310h2_5c4e1f8a3b9d_1a2b3c4d5e6f
```
Chrome 121 даёт `t13d1516h2_8daaf6152771_02713d6af862`. Разница: 13 шифров против 15, 10 расширений против 16. Cloudflare в логах помечает такие запросы как `bot_management_score=87`. Причина — `urllib3` использует дефолтный OpenSSL-контекст без GREASE-значений и без расширения `application_settings` (ALPS), которое Chrome шлёт с 2023 года.
Решение: перейти на `curl_cffi` с `impersonate='chrome120'`. JA4 совпал, 403 пропали. Но через две недели снова блок — теперь по HTTP/2-отпечатку. Пришлось переключиться на SOCKS5-прокси с `curl_cffi`, чтобы TLS и HTTP/2 шли напрямую от клиента.
Кейс: Go-бот на utls и странный ALPN
Разработчик собрал бота на Go с `utls`, профиль `HelloChrome_120`. JA4 совпадал идеально. Но сайт возвращал капчу. Смотрим pcap: в ClientHello ALPN — `h2,http/1.1`. Chrome отправляет `h2,http/1.1` тоже. Но порядок расширений отличался: `utls` ставит `ALPN` после `SNI`, Chrome — после `supported_versions`.
Антибот на PerimeterX сверяет не только JA4-хеш, но и позицию расширений. Формально JA4 сортирует перед хешированием, но сырой ClientHello тоже анализируется. Решение — использовать профиль `HelloChrome_120_PQ` (post-quantum), который повторяет порядок расширений Chrome 120+ с Kyber-расширением.
Кейс: nginx-прокси ломает отпечаток
Инфраструктура: клиент → nginx (TLS termination) → backend. Клиент шлёт Chrome-подобный ClientHello. nginx принимает TLS, расшифровывает, шлёт новый ClientHello к backend. JA4 на backend — от nginx, не от клиента.
```
t13d1310h2_5c4e1f8a3b9d_1a2b3c4d5e6f # nginx 1.24
```
Backend — Cloudflare-защищённый API. Он видит JA4 от nginx, а не от браузера. Блок. Решение: `proxy_ssl_*` директивы в nginx не помогут — они не эмулируют Chrome. Нужно либо `stream`-модуль с SNI-роутингом (TLS passthrough), либо `ssl_preread` без терминации. В конфиге:
```nginx
stream {
upstream backend {
server api.example.com:443;
}
server {
listen 443;
proxy_pass backend;
ssl_preread on;
}
}
```
Трафик идёт насквозь, JA4 сохраняется. Минус — нельзя читать HTTP-заголовки для роутинга.
Что дальше: JA4+ и квантовые расширения
FoxIO выпустил семейство JA4+: JA4H (HTTP), JA4S (server-side), JA4X (X.509), JA4SSH. Каждый — для своего слоя. Антиботы комбинируют их: JA4 + JA4H дают почти уникальный отпечаток клиента. Совпадение по обоим — вероятность, что это реальный Chrome, выше 95%.
С 2024 года Chrome и Firefox шлют post-quantum расширения (X25519Kyber768). JA4 учитывает их в хеше. Старые библиотеки без Kyber дают другой отпечаток. Если сайт требует Chrome 124+, а клиент на `curl_cffi` с профилем `chrome120` — провал. Нужно обновлять профили.
Прокси-серверы на уровне TCP (SOCKS5, shadowsocks, wireguard) не трогают TLS. Отпечаток формирует клиент. Значит, задача — настроить клиент так, чтобы его ClientHello совпадал с целевым браузером. Сервисы вроде lexic.ml дают чистый IPv6-транспорт, но TLS-стек всё равно на стороне клиента — за него отвечает `curl_cffi`, `utls` или `curl-impersonate`.
Проверка занимает минуту: `curl https://tls.peet.ws/api/all | jq .tls.ja4`. Если хеш совпадает с эталоном браузера — половина дела сделана. Вторая половина — HTTP/2-отпечаток, порядок заголовков, куки, поведенческие факторы.