Почему сайты палят прокси по TLS-отпечатку JA3/JA4, а не по IP
Содержание
- Что вообще такое JA3 и откуда он взялся
- Почему это работает лучше IP
- Как выглядит реальный отпечаток прокси
- Почему прокси-серверы палятся
- Таблица: типичные отпечатки и их владельцы
- Кейс: Cloudflare и Python-скрипт
- Кейс: nginx как MITM-прокси
- Кейс: ротация IP без смены отпечатка
- Как сайты строят базу отпечатков
- Почему IPv6-прокси не спасает
- Как проверить свой отпечаток
- Что делать, если палишься
- Итог
Что вообще такое JA3 и откуда он взялся
Salesforce в 2017 году выкатил внутренний формат для описания TLS ClientHello. Идея простая: собрать в одну строку набор параметров, которые клиент отправляет в первом пакете рукопожатия, и посчитать MD5. Получается 32-символьный хеш — отпечаток.
Что входит в строку: версия TLS, набор шифров (cipher suites), список расширений, эллиптические кривые, форматы точек эллиптических кривых. Всё через дефис, в определённом порядке.
```
771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0
```
Это Chrome 120 на Linux. Если прогнать через MD5 — получится `bd1a4d5c1c2b7e8f...` и так далее. Дальше сервер смотрит: пришёл браузер Chrome, а хеш не совпадает с известными хешами Chrome — значит, клиент не тот, за кого себя выдаёт.
JA4 — это переработка той же идеи от FoxIO, вышедшая в 2023. Формат другой, но принцип тот же. Добавили учёт ALPN, версию SNI, количество расширений. Хеш стал длиннее, устойчивее к коллизиям, и главное — стабильнее между версиями клиентов.
Почему это работает лучше IP
IP-адрес — это идентификатор сетевого уровня. Он меняется от перезапуска прокси, от ротации пула, от смены датацентра. Сайт не может на него опираться: сегодня с этого IP пришёл миллион пользователей, завтра — ноль. Блокировка по IP — грубая, даёт ложные срабатывания и легко обходится ротацией пула.
TLS-отпечаток — идентификатор стека. Он не меняется при смене IP. Он не меняется при переподключении. Он остаётся тем же самым, пока клиент использует ту же библиотеку с теми же настройками. И вот здесь начинается веселье.
Возьмём Python с библиотекой `requests`. Она под капотом использует `urllib3`, который использует OpenSSL. Набор шифров, порядок расширений, поддержка GREASE — всё это определяется версией OpenSSL и настройками `ssl.create_default_context()`. Отпечаток получается стабильный и очень характерный.
Как выглядит реальный отпечаток прокси
Вот что отправляет `curl` 7.68 с OpenSSL 1.1.1:
```
771,49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53-10,65281-0-23-35-13-5-11-10-16-43-45-51,29-23-24-25,0
```
А вот Chrome 120 на Windows 10:
```
771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0
```
Разница видна невооружённым глазом. У Chrome первым идёт `4865` (TLS_AES_128_GCM_SHA256) — это TLS 1.3 шифр. У curl его нет в начале. Расширение `17513` (ALPS) есть у Chrome, нет у curl. Порядок расширений в Chrome — `0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513`, у curl — `65281-0-23-35-13-5-11-10-16-43-45-51`. Это два разных мира.
Сервер видит этот набор, считает хеш, сверяет с базой. Если клиент говорит `User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...`, а отпечаток не совпадает с Chrome на Windows — сразу подозрение.
Почему прокси-серверы палятся
Прокси на уровне HTTP (CONNECT) не трогает TLS. Он просто туннелирует байты. Клиент сам делает рукопожатие с целевым сайтом. Значит, отпечаток — это отпечаток клиента, а не прокси. Если клиент — браузер, всё нормально. Если клиент — `requests` — палево.
Но есть нюанс. Многие прокси работают как MITM: расшифровывают трафик, чтобы кэшировать, фильтровать, логировать. Тогда рукопожатие делает уже сам прокси. И вот здесь его отпечаток вылезает наружу. Прокси на nginx с OpenSSL 1.1.1 отправляет один отпечаток. Прокси на Go — другой. Прокси на Python — третий. И все они не совпадают с браузерными.
Ещё хуже: если прокси переиспользует TLS-сессию между разными клиентами, сайт видит один и тот же отпечаток с разных IP. Это прямой сигнал: за десятком IP стоит один прокси.
Таблица: типичные отпечатки и их владельцы
| Клиент | JA3 (MD5, сокращённо) | Характерные черты |
|---|---|---|
| Chrome 120 Win | `bd1a4d5c...` | GREASE, ALPS, 17 расширений |
| Firefox 121 | `3f5e7a2b...` | GREASE, нет ALPS, 15 расширений |
| curl 7.68 | `a0e9f5c1...` | Нет GREASE, 12 расширений |
| Python requests | `7c4a8d09...` | OpenSSL default, 11 расширений |
| Go net/http | `e35d6a1f...` | Минимализм, 9 расширений |
| nginx 1.24 (MITM) | `1b2c3d4e...` | OpenSSL default, без GREASE |
Цифры хешей условные — они зависят от версии библиотек. Но паттерн стабилен: каждый стек даёт свой уникальный набор.
Кейс: Cloudflare и Python-скрипт
Пример: сервер на Cloudflare, клиент — Python 3.10 с `requests` 2.28. Скрипт шлёт GET на API. Cloudflare видит JA3 `7c4a8d09...`, сверяет с базой. В базе этот хеш помечен как «Python requests». Дальше — challenge. Пользователь получает 403 с `cf-mitigated: challenge`.
Причина: `requests` использует OpenSSL с дефолтными настройками. Никакого GREASE, порядок шифров фиксированный, расширений мало. Cloudflare знает этот отпечаток и не пускает.
Решение: подменить TLS-стек. Есть библиотека `curl_cffi`, которая использует BoringSSL с настройками Chrome. Отпечаток становится браузерным. Скрипт проходит.
```python
from curl_cffi import requests
response = requests.get(
"https://api.example.com/data",
impersonate="chrome120"
)
print(response.status_code)
```
`impersonate="chrome120"` подставляет нужный набор шифров, расширений, порядок. JA3 совпадает с Chrome 120. Cloudflare пропускает.
Кейс: nginx как MITM-прокси
Пример: корпоративный прокси на nginx 1.24 с `proxy_ssl_server_name on`. Nginx расшифровывает трафик клиентов и делает своё рукопожатие с целевым сайтом. Клиент — Chrome. Сайт — условный антифрод-сервис.
Что видит сайт: JA3 от nginx. Это OpenSSL 3.0 с дефолтным набором. Хеш не совпадает ни с одним браузером. Антифрод помечает сессию как «не-браузер». Дальше — капча, блокировка, требование верификации.
Причина: nginx не эмулирует браузерный TLS. Он использует то, что даёт OpenSSL. Порядок шифров, расширения, отсутствие GREASE — всё выдаёт серверный стек.
Решение: либо отказаться от MITM (туннелировать на уровне TCP), либо использовать прокси, который умеет эмулировать браузерный отпечаток. Второе — сложно, потому что нужно патчить OpenSSL или использовать BoringSSL с кастомными настройками.
Кейс: ротация IP без смены отпечатка
Пример: пул из 500 IPv6-адресов, клиент — Go-скрипт на `net/http`. Скрипт ротирует IP каждые 10 запросов. Сайт видит 500 разных адресов, но один и тот же JA3.
Причина: Go использует свой TLS-стек. Отпечаток стабилен, не зависит от IP. Сайт строит граф: 500 IP, один отпечаток, одинаковые интервалы между запросами. Вывод — ботнет или прокси-ферма.
Решение: либо менять отпечаток вместе с IP (сложно, нужен кастомный TLS), либо использовать клиент, который изначально выглядит как браузер. Второе проще: `curl_cffi`, `tls-client`, `undetected-chromedriver`.
Как сайты строят базу отпечатков
Есть открытые базы: `ja3er.com`, `tlsfingerprint.io`. Там собраны тысячи отпечатков с привязкой к клиентам. Сайты либо используют эти базы, либо строят свои.
Методика простая: берут легитимный трафик (браузеры реальных пользователей), собирают отпечатки, кластеризуют. Потом смотрят на новый трафик: если отпечаток не входит ни в один кластер — подозрение.
Дополнительно смотрят на поведение: порядок запросов, тайминги, заголовки. Отпечаток — это один сигнал из десятка. Но он очень весомый, потому что его сложно подделать без серьёзных усилий.
Почему IPv6-прокси не спасает
IPv6 даёт огромный пул адресов. Можно ротировать хоть миллион. Но если отпечаток один и тот же — сайт видит миллион IP с одним TLS-стеком. Это не распределённая сеть пользователей, это один клиент с ротацией.
Плюс IPv6-адреса часто идут блоками. Если сайт видит, что 1000 адресов из одного /64 используют один отпечаток — это явный признак прокси-фермы. /64 — это стандартный блок для одной подсети. Реальные пользователи так не распределяются.
На lexic.ml это учитывают: прокси выдаёт разные IPv6 из пула, но TLS-отпечаток остаётся клиентским. Если клиент — браузер, всё нормально. Если клиент — скрипт с дефолтным OpenSSL — палево.
Как проверить свой отпечаток
Проще всего — через `tlsfingerprint.io` или `ja3er.com`. Шлёшь запрос, получаешь свой JA3 и JA4.
Через curl:
```bash
curl -s https://tlsfingerprint.io/api/echo | jq .
```
Через Python с `curl_cffi`:
```python
from curl_cffi import requests
r = requests.get("https://tlsfingerprint.io/api/echo", impersonate="chrome120")
print(r.json()["ja3_hash"])
print(r.json()["ja4"])
```
Сравниваешь с эталоном. Если хеш не совпадает с браузером, который ты эмулируешь — сайт это увидит.
Что делать, если палишься
Первое: перестать использовать дефолтные TLS-стеки. `requests`, `urllib`, `net/http` — всё это палево. Второе: взять библиотеку с эмуляцией браузера. `curl_cffi`, `tls-client`, `httpx` с кастомным контекстом.
Третье: проверить, не MITM ли прокси. Если да — либо отключить MITM, либо использовать прокси с браузерным отпечатком. Четвёртое: не ротировать IP без смены отпечатка. Это создаёт паттерн, который легко детектится.
Пятое: смотреть на JA4, а не только на JA3. JA4 устойчивее к коллизиям и учитывает больше параметров. Если сайт использует JA4 — подделка JA3 не поможет.
Итог
IP — это адрес. TLS-отпечаток — это личность. Сайты давно поняли: блокировать по адресу бессмысленно, адреса меняются. А вот стек — он стабилен. Пока клиент использует ту же библиотеку, отпечаток не меняется. И это главная проблема прокси, которые не умеют эмулировать браузер.
Решение есть, но оно требует понимания, как устроен TLS-стек изнутри. Просто подменить User-Agent недостаточно. Нужно подменить весь набор параметров рукопожатия. Иначе сайт видит: заголовки от Chrome, отпечаток от Python. И делает выводы.