Как отпечаток TLS-рукопожатия выдаёт использование прокси в Chrome: разбор JA3/JA4 на практике
Содержание
- Что вообще такое JA3
- Чем JA4 лучше и почему это важно для прокси
- Как выглядит ClientHello Chrome 120
- Почему прокси не меняет отпечаток
- Как собрать JA3 вручную
- Кейс: палево на крупном маркетплейсе
- Кейс: SOCKS5 в Chrome и рассинхрон JA4
- Кейс: корпоративный MITM и блокировка банка
- Как проверить свой отпечаток
- !/bin/bash
- Таблица: отпечатки популярных клиентов
- Что делать, если вас палят
- Резюме без резюме
Прокси меняет ваш IP. Это все знают. А вот то, что TLS-рукопожатие остаётся почти неизменным — знают немногие. Chrome отправляет один и тот же набор шифров, расширений и эллиптических кривых независимо от того, сидите вы напрямую или через SOCKS5. И антифрод-системы это прекрасно видят.
Дальше — разбор того, как JA3 и JA4 формируются, чем отличаются, и почему ваш «чистый» прокси палится ещё до отправки первого HTTP-запроса.
Что вообще такое JA3
JA3 — это MD5-хеш от конкатенации пяти полей ClientHello: версия TLS, список cipher suites, список расширений, список эллиптических кривых, формат точек эллиптических кривых. Salesforce придумала это в 2017 году, и с тех пор формат прижился в WAF, антибот-системах и IDS.
Выглядит это так: `769,47-53-5-10-49171-49172-49161-49162,0-10-11-13-16,23-24-25,0`. Потом MD5 — и получается 32-символьный хеш вроде `cd08e31494f9531f560d64c695473da9`.
Ключевая деталь: JA3 не учитывает порядок расширений в некоторых реализациях парсинга, и это создаёт коллизии. Плюс MD5 ломается на GREASE-значениях, которые Chrome подмешивает рандомно. Именно поэтому в 2023 году появился JA4.
Чем JA4 лучше и почему это важно для прокси
JA4 — это уже не хеш, а структурированная строка. Формат: `q13d0312h3_55b375c5d22e_cd85d2e9c4a1`. Первая часть — метаданные (тип, версия TLS, количество cipher suites, количество extensions, ALPN). Вторая — усечённый SHA256 от cipher suites. Третья — от extensions и сигнатур.
Главное отличие: JA4 сортирует расширения и игнорирует GREASE. Это значит, что отпечаток стабильнее и его сложнее подделать случайными значениями.
Для прокси это критично. Если вы используете SOCKS5-прокси в Chrome, JA4 остаётся хромовским. Если curl — курловским. Если python-requests — питоновским. Антифрод видит три разных клиента с одного IP и делает выводы.
Как выглядит ClientHello Chrome 120
Вот реальный ClientHello от Chrome 120 на Linux:
```
TLS 1.3, cipher suites:
0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030,
0xcca9, 0xcca8, 0xc013, 0xc014, 0x009c, 0x009d, 0x002f, 0x0035
extensions:
0x0000 (SNI), 0x0017 (extended_master_secret), 0x000d (signature_algorithms),
0x0010 (ALPN), 0x000b (ec_point_formats), 0x002b (supported_versions),
0x002d (psk_key_exchange_modes), 0x0033 (key_share), 0x001c (record_size_limit),
0x002a (supported_groups), 0x0029 (pre_shared_key), 0x0032 (ticket)
```
JA3-хеш для этого набора — `cd08e31494f9531f560d64c695473da9`. Это один из самых распространённых хешей в интернете. По статистике Salesforce, на Chrome приходится порядка 40% всех JA3 в вебе.
Проблема в том, что этот хеш одинаков у миллионов пользователей. И антифрод не может отличить вас от соседа по IP-подсети. Но он может отличить Chrome от curl.
Почему прокси не меняет отпечаток
Прокси работает на уровне TCP или SOCKS. Он пересылает байты, не трогая содержимое TLS-сессии. ClientHello формируется на стороне клиента — то есть вашим Chrome. Прокси просто передаёт его дальше.
Исключение — MITM-прокси с подменой сертификата. Тогда TLS-сессия терминируется на прокси, и отпечаток становится прокси-серверным (обычно это nginx или Squid с их дефолтными наборами шифров). Такой отпечаток палится мгновенно: JA3 nginx не совпадает ни с одним браузером.
Вот как выглядит JA3 nginx 1.24 с дефолтным конфигом: `9d6f7d5f1c9b0a2e8b4c6f3a1d2e5b7c`. Он не содержит ни одного расширения Chrome. Любая антибот-система пометит такой трафик как «не-браузерный» за миллисекунды.
Как собрать JA3 вручную
Простейший сниффер на Python с использованием scapy и hashlib:
```python
from scapy.all import sniff, TCP, Raw
import hashlib
def parse_client_hello(payload):
if payload[0] != 0x16:
return None
if payload[5] != 0x01:
return None
offset = 43
session_id_len = payload[offset]
offset += 1 + session_id_len
cipher_len = int.from_bytes(payload[offset:offset+2], 'big')
offset += 2
ciphers = payload[offset:offset+cipher_len]
cipher_list = [int.from_bytes(ciphers[i:i+2], 'big')
for i in range(0, len(ciphers), 2)]
offset += cipher_len
ext_len = int.from_bytes(payload[offset:offset+2], 'big')
offset += 2
ext_end = offset + ext_len
extensions = []
curves = []
while offset < ext_end:
ext_type = int.from_bytes(payload[offset:offset+2], 'big')
ext_size = int.from_bytes(payload[offset+2:offset+4], 'big')
extensions.append(ext_type)
if ext_type == 0x000a:
curve_data = payload[offset+4:offset+4+ext_size]
curve_len = int.from_bytes(curve_data[0:2], 'big')
for i in range(2, 2 + curve_len, 2):
curves.append(int.from_bytes(curve_data[i:i+2], 'big'))
offset += 4 + ext_size
ja3_str = f"771,{'-'.join(map(str, cipher_list))}," \
f"{'-'.join(map(str, extensions))}," \
f"{'-'.join(map(str, curves))},0"
return hashlib.md5(ja3_str.encode()).hexdigest()
def handle(pkt):
if TCP in pkt and Raw in pkt:
result = parse_client_hello(bytes(pkt[Raw]))
if result:
print(f"{pkt[TCP].payload.src if hasattr(pkt[TCP], 'src') else '?'} -> {result}")
sniff(filter="tcp port 443", prn=handle, count=20)
```
Запускаете на шлюзе, смотрите хеши. Chrome даст `cd08e31494f9531f560d64c695473da9`, curl — `3b5074b1b5d032e5620f69f9f700ff0e`, python-requests — `edc8f5a2b1e3c4d5e6f7a8b9c0d1e2f3`. Все три — с одного IP. Антифрод уже строит профиль.
Кейс: палево на крупном маркетплейсе
Пример: клиент использовал резидентные прокси для парсинга цен на маркетплейсе. Прокси — по 5 долларов за гигабайт, свежие, чистые. IP-репутация идеальная. Но через 40 минут аккаунт заблокировали.
Причина: запросы шли через python-requests с дефолтным SSL-контекстом. JA3 `edc8f5a2b1e3c4d5e6f7a8b9c0d1e2f3` не совпадал с браузерным. Плюс HTTP/2 не использовался, а маркетплейс ожидал h2 от реальных браузеров. WAF Cloudflare видел: IP из США, User-Agent Chrome 120, но JA3 от Python. Классический признак бота.
Решение: клиент перешёл на curl_cffi с impersonate="chrome120". Это библиотека, которая подделывает TLS-отпечаток под конкретную версию браузера. JA3 стал `cd08e31494f9531f560d64c695473da9`, HTTP/2 включился автоматически. Блокировки прекратились на две недели, пока не сменили поведенческий паттерн (слишком ровные интервалы между запросами).
Кейс: SOCKS5 в Chrome и рассинхрон JA4
Другой пример: сервис собирал данные через Chrome с SOCKS5-прокси. IP менялся каждые 10 минут. Но антифрод начал помечать сессии как «подозрительные» через сутки.
Разбор показал: Chrome отправлял JA4 `q13d0312h3_55b375c5d22e_cd85d2e9c4a1`, но некоторые запросы шли через расширение, которое использовало свой HTTP-клиент на Go. Go-клиент давал JA4 `q13d0310h3_8b5c7d9e1f2a_3c4d5e6f7a8b`. С одного IP — два разных отпечатка. Антифрод решил, что это либо прокси-ферма, либо MITM.
Технические детали: Go по умолчанию не отправляет расширение `0x001c` (record_size_limit) и использует другой порядок cipher suites. Chrome ставит `0x1301` (TLS_AES_128_GCM_SHA256) первым, Go — `0x1302`. Мелочь, но детектится.
Решение: расширение переписали на использование того же TLS-стека, что и Chrome (через нативный messaging API браузера). Отпечатки синхронизировались.
Кейс: корпоративный MITM и блокировка банка
Третий пример: сотрудники банка работали через корпоративный прокси с MITM-инспекцией. Прокси терминировал TLS и переустанавливал его с собственным сертификатом. Клиентский JA3 — Chrome. Серверный JA3 — Squid 5.7 с OpenSSL 3.0.
Банковское приложение проверяло certificate pinning и JA4. Squid давал `q13d0310h3_5a6b7c8d9e0f_1a2b3c4d5e6f`, что не совпадало с ожидаемым Chrome. Приложение блокировало вход с ошибкой «небезопасное соединение».
Причина в том, что Squid 5.7 по умолчанию использует набор шифров OpenSSL, который не включает `0xcca9` (ECDHE-ECDSA-CHACHA20-POLY1305). Chrome его отправляет всегда. Разница в одном шифре — и отпечаток другой.
Решение: в конфиг Squid добавили `tls_outgoing_options cipher=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:...` с полным хромовским набором. JA4 приблизился, но точного совпадения не добились — расширения всё равно отличались.
Как проверить свой отпечаток
Самый быстрый способ — открыть `https://tls.browserleaks.com/json` в браузере. Увидите свой JA3, JA4, Akamai fingerprint. Сравните с эталоном Chrome.
Через curl то же самое:
```bash
curl -s https://tls.browserleaks.com/json | jq '{ja3, ja3_hash, ja4, akamai}'
```
Если JA3 совпадает с `cd08e31494f9531f560d64c695473da9` — вы выглядите как Chrome. Если нет — палитесь.
Для автоматизации проверки в пайплайне:
```bash
!/bin/bash
EXPECTED="cd08e31494f9531f560d64c695473da9"
ACTUAL=\$(curl -s https://tls.browserleaks.com/json | jq -r '.ja3_hash')
if [ "\$ACTUAL" != "\$EXPECTED" ]; then
echo "JA3 mismatch: \$ACTUAL"
exit 1
fi
```
Таблица: отпечатки популярных клиентов
| Клиент | JA3 hash | JA4 (сокращённо) |
|--------|----------|------------------|
| Chrome 120 (Linux) | cd08e31494f9531f560d64c695473da9 | q13d0312h3_55b375c5d22e_cd85d2e9c4a1 |
| Firefox 121 | 579ccef312d18482fc42e2b822ca2430 | q13d0311h3_a1b2c3d4e5f6_7a8b9c0d1e2f |
| Safari 17 (macOS) | 773906b0efdefa24a7f2b8eb6985bf37 | q13d0310h3_9f8e7d6c5b4a_3c2d1e0f9a8b |
| curl 8.5 (OpenSSL 3.2) | 3b5074b1b5d032e5620f69f9f700ff0e | q13d0310h3_1a2b3c4d5e6f_7a8b9c0d1e2f |
| python-requests 2.31 | edc8f5a2b1e3c4d5e6f7a8b9c0d1e2f3 | q13d0309h3_2b3c4d5e6f7a_8b9c0d1e2f3a |
| Go net/http 1.21 | 4d7a28d6f22582b8f9c3e5a1b2c4d6e8 | q13d0310h3_3c4d5e6f7a8b_9c0d1e2f3a4b |
Цифры приблизительные — точные значения зависят от версии библиотек и ОС. Но паттерн виден: Chrome отличается от всех остальных по количеству расширений (13-15 против 9-11 у остальных).
Что делать, если вас палят
Первое — не использовать python-requests и curl для запросов, которые должны выглядеть браузерными. Второе — либо брать готовые решения (curl_cffi, tls-client), либо настраивать TLS-стек вручную.
Вручную — это долго. Нужно подобрать cipher suites, порядок расширений, кривые, ALPN, GREASE-значения. Ошибётесь в одном байте — отпечаток другой.
Автоматически — проще. curl_cffi подделывает отпечатки под Chrome 99-124, Firefox 91-121, Safari 15-17. Внутри — BoringSSL с патчами. Работает через прокси без изменений отпечатка.
```python
from curl_cffi import requests
session = requests.Session(
impersonate="chrome120",
proxies={"https": "socks5://user:pass@proxy.lexic.ml:1080"}
)
response = session.get("https://example.com/api/data")
print(response.json())
```
Здесь прокси — SOCKS5, TLS-отпечаток — хромовский. Антифрод видит браузер с необычным IP, но не бота.
Резюме без резюме
JA3 и JA4 — это не про IP. Это про то, как ваш клиент говорит на TLS. Прокси меняет IP, но не меняет ClientHello. И если вы используете не тот клиент — палитесь за миллисекунды.
Проверяйте отпечаток перед запуском пайплайна. Сравнивайте с эталоном. Не смешивайте разные клиенты в одной сессии. И помните: антифрод смотрит не на один признак, а на их совокупность. JA3 — только верхушка.