Почему TLS-отпечаток выдаёт прокси даже при идеальной подмене IP
Содержание
- Что такое TLS ClientHello и почему он важен
- Как считают JA3: MD5 по пяти полям
- Chrome 120 на Linux
- JA4: почему Salesforce-хеш устарел
- Wireshark с плагином ja4
- 192.168.1.10 t13d1516h2_8daaf6152771_b186095e22b6 t13d1516h2_...
- 10.0.0.5 t13d1715h2_5b57614c22b0_3d5424432f57 t13d1715h2_...
- Что утекает помимо JA3/JA4
- Почему SOCKS5 и HTTP CONNECT не спасают
- Таблица: реальные JA4 популярных клиентов
- Как серверы используют эти данные
- Кейс: скрапер на requests через резидентный прокси
- Кейс: nginx reverse proxy ломает отпечаток
- Кейс: Go-микросервис палится как бот
- Что делать, если вы за прокси и не хотите палиться
- Ограничения имперсонации
- Итог
Прокси меняет IP. Иногда — идеально: чистый адрес, нормальная ASN, rDNS не палит хостера. И всё равно через 30 секунд прилетает капча или 403. Причина в TLS-рукопожатии. Оно уходит до первого HTTP-запроса, до того как прокси вообще увидит, куда вы идёте. А fingerprint этого рукопожатия остаётся тем же, что и без прокси.
Разберём, что именно утекает в ClientHello, как это собирают в JA3/JA4, и почему подмена IP тут не помогает.
Что такое TLS ClientHello и почему он важен
Первое сообщение в TLS-сессии — ClientHello. Отправляется клиентом до всякой аутентификации. Внутри: версия TLS, список cipher suites, список extensions, поддерживаемые группы эллиптических кривых, форматы точек, список сигнатурных алгоритмов, ALPN.
Порядок элементов в каждом списке — тоже часть данных. Не только состав, но и последовательность. Chrome сортирует cipher suites одним образом, Firefox — другим, curl — третьим. Раньше это списывали на случайность, потом заметили: у каждой библиотеки порядок стабилен от запуска к запуску.
Прокси на уровне SOCKS5 или HTTP CONNECT просто перекладывает байты. ClientHello проходит насквозь, как есть. Сервер видит ровно тот же отпечаток, что и при прямом подключении. Подмена IP меняет src-адрес в IP-заголовке, но не содержимое TLS-записи.
Как считают JA3: MD5 по пяти полям
JA3 — простой хеш. Salesforce придумали его в 2017. Берут ClientHello и вытаскивают пять полей:
- версия TLS (2 байта)
- список cipher suites (через дефис)
- список extensions (через дефис)
- список групп эллиптических кривых
- список форматов точек EC
Склеивают через запятую, считают MD5. Получают строку вида `cd08e31494f9531f560d64c695473da9`.
```python
import hashlib
def ja3_from_fields(version, ciphers, extensions, curves, ec_formats):
raw = ",".join([
str(version),
"-".join(str(c) for c in ciphers),
"-".join(str(e) for e in extensions),
"-".join(str(c) for c in curves),
"-".join(str(f) for f in ec_formats),
])
return hashlib.md5(raw.encode()).hexdigest(), raw
Chrome 120 на Linux
ja3, raw = ja3_from_fields(
771,
[4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53],
[0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43, 27, 21],
[29, 23, 24],
[0],
)
print(ja3)
```
Проблема JA3 в том, что GREASE-значения (Chrome рандомизирует часть cipher suites и extensions) в хеш не попадают — их вырезают. Но всё остальное фиксировано. Если у вас Python requests с ssl-модулем, JA3 будет `3b5074b1b5d032e5620f69f9f700ff0e` — ни на что не похожий, палится мгновенно.
JA4: почему Salesforce-хеш устарел
К 2023 стало ясно: MD5-хеш теряет информацию. Два разных ClientHello могут дать одинаковый JA3. Плюс обратная проблема — один и тот же браузер на разных платформах даёт разные JA3, хотя должен бы схлопываться.
FoxIO сделали JA4. Формат другой: `t13d1516h2_8daaf6152771_b186095e22b6`. Разбирается на три части:
- `t13d1516h2` — тип (t=TLS), версия (13), d=domain (есть SNI), число cipher suites (15), число extensions (16), ALPN (h2)
- `8daaf6152771` — хеш отсортированного списка cipher suites
- `b186095e22b6` — хеш extensions с сигнатурами
Ключевое отличие: JA4 сортирует списки перед хешированием. Порядок больше не важен, но состав — важен. И GREASE вырезается правильно, а не как попало. Плюс JA4 учитывает ALPN, что JA3 игнорировал.
```bash
Wireshark с плагином ja4
tshark -r capture.pcap -Y "tls.handshake.type == 1" \
-T fields -e ip.src -e tls.handshake.ja4 -e tls.handshake.ja4_r
192.168.1.10 t13d1516h2_8daaf6152771_b186095e22b6 t13d1516h2_...
10.0.0.5 t13d1715h2_5b57614c22b0_3d5424432f57 t13d1715h2_...
```
Первая строка — Chrome. Вторая — Go-клиент. Разница видна сразу, без всякого DPI.
Что утекает помимо JA3/JA4
Отпечаток — это не только хеш. Есть поля, которые в хеш не входят, но палят не хуже.
**SNI.** Server Name Indication уходит в открытом виде (до ECH). Если вы идёте через прокси на `api.example.com`, SNI содержит `api.example.com`. Прокси это не скрывает. ECH (Encrypted Client Hello) решает проблему, но поддержка в 2024 году — только у Cloudflare и Firefox за флагом.
**ALPN.** Список протоколов прикладного уровня: `h2`, `http/1.1`, `h3`. Chrome предлагает `h2,http/1.1`. curl — `http/1.1`. Python requests — ничего. Это видно в открытом виде в extension 16.
**Порядок extensions.** Даже в JA4, где списки сортируются, некоторые расширения имеют позиционные зависимости. Padding, например, всегда последний.
**Длина ClientHello.** Chrome генерирует ClientHello длиной 512-517 байт (с GREASE). Если у вас ровно 289 — это не браузер.
**Session ID и ticket.** Первый коннект без ticket, второй — с. Поведение при resumption тоже отпечаток.
Почему SOCKS5 и HTTP CONNECT не спасают
SOCKS5 — это туннель на уровне TCP. Клиент устанавливает соединение с прокси, просит соединить с целевым хостом, дальше байты идут как есть. TLS-рукопожатие происходит между клиентом и целевым сервером, прокси видит только шифрованный поток.
HTTP CONNECT работает так же. Клиент отправляет `CONNECT example.com:443 HTTP/1.1`, получает `200 Connection Established`, дальше — прозрачный туннель.
В обоих случаях ClientHello идёт от вашего клиента. Прокси его не переписывает. Если у вас Python с OpenSSL 3.0, а вы притворяетесь Chrome 120 — сервер увидит Python.
Единственный способ это обойти — терминировать TLS на прокси и пересобирать ClientHello. Но тогда прокси видит plaintext, что для многих сценариев неприемлемо. И да, lexic.ml работает именно как туннель — IP меняется, TLS-отпечаток остаётся ваш. Это не баг, это архитектура.
Таблица: реальные JA4 популярных клиентов
| Клиент | JA4 | ALPN | Длина ClientHello |
|---|---|---|---|
| Chrome 120 (Linux) | t13d1516h2_8daaf6152771_b186095e22b6 | h2,http/1.1 | 517 |
| Firefox 121 | t13d1715h2_5b57614c22b0_3d5424432f57 | h2,http/1.1 | 1682 |
| curl 8.5 (OpenSSL 3.2) | t13d1715h2_5b57614c22b0_3d5424432f57 | http/1.1 | 289 |
| Python requests 2.31 | t13d1715h2_5b57614c22b0_3d5424432f57 | — | 275 |
| Go net/http 1.21 | t13d1715h2_5b57614c22b0_3d5424432f57 | h2 | 312 |
| Safari 17 (macOS) | t13d2014h2_a09f3c656075_8b3e6d5f2c4a | h2,http/1.1 | 512 |
Обратите внимание: Firefox, curl, Python и Go дают одинаковый первый блок (t13d1715h2) — потому что все используют OpenSSL или его форк с похожими настройками. Различия в хешах cipher suites и extensions. Это и есть грабли: если вы думаете, что curl и Python неотличимы, вы ошибаетесь.
Как серверы используют эти данные
Cloudflare с 2019 использует JA3 в bot management. Akamai — в Bot Manager Premier. AWS WAF добавил JA3 в правила в 2021. Imperva — в Advanced Bot Protection.
Логика простая: если JA4 не в белом списке браузеров, а User-Agent говорит `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...` — это бот. Даже если IP чистый, даже если cookies валидные, даже если поведение похоже на человеческое.
Более того, есть базы «плохих» JA4. Если ваш отпечаток совпадает с известным скрапером (например, старый JA3 от `python-requests`), вас блокируют по хешу, не глядя на IP.
Кейс: скрапер на requests через резидентный прокси
Задача: собрать 50 000 страниц товаров с сайта-конкурента. Стек: Python 3.11, requests 2.31, ротация резидентных прокси (100 000 IP, ASN жилых провайдеров).
Проблема: 95% запросов возвращают 403 после 200-300 успешных. Cloudflare challenge не проходится.
Причина: JA4 от requests — `t13d1715h2_5b57614c22b0_3d5424432f57`, что совпадает с curl и Go. User-Agent выставлен как Chrome. Несоответствие отпечатка и UA — триггер. Плюс ALPN пустой, у Chrome — `h2,http/1.1`. Плюс длина ClientHello 275 байт против 517 у Chrome.
Решение: переход на `curl_cffi` с имперсонацией Chrome. Библиотека собирает ClientHello байт-в-байт как у реального Chrome, включая GREASE и padding.
```python
from curl_cffi import requests
session = requests.Session(impersonate="chrome120")
resp = session.get(
"https://target.example.com/product/12345",
proxies={"https": "socks5://user:pass@proxy.example.com:1080"},
timeout=30,
)
print(resp.status_code, len(resp.text))
```
После замены — 98% успешных запросов. IP-пул тот же, прокси те же, User-Agent тот же. Изменился только TLS-отпечаток.
Кейс: nginx reverse proxy ломает отпечаток
Схема: nginx 1.24 стоит перед приложением, терминирует TLS, проксирует на бэкенд по HTTP. Клиенты — мобильные приложения на разных SDK.
Проблема: аналитика на бэкенде видит одинаковый отпечаток для всех клиентов. Невозможно отличить iOS от Android, невозможно детектить аномалии.
Причина: nginx терминирует TLS и открывает новое соединение к бэкенду. ClientHello от nginx к бэкенду — всегда один и тот же, зависит от версии OpenSSL, с которой собран nginx. Версия 1.24 с OpenSSL 3.0 даёт `t13d1715h2_5b57614c22b0_3d5424432f57` независимо от того, кто пришёл снаружи.
Решение: либо передавать оригинальный отпечаток через заголовок (nginx сам его не считает, нужен модуль вроде `nginx-ssl-ja3`), либо не терминировать TLS на nginx, а проксировать на уровне TCP (stream module).
```nginx
stream {
upstream backend {
server 10.0.0.5:443;
}
server {
listen 443;
proxy_pass backend;
proxy_protocol on;
}
}
```
В этом случае TLS-рукопожатие идёт напрямую между клиентом и бэкендом, отпечаток сохраняется. Минус — nginx не видит HTTP, балансировка только по IP.
Кейс: Go-микросервис палится как бот
Внутренний сервис на Go 1.21 ходит на партнёрский API. Партнёр начал отдавать 429 после миграции на Cloudflare.
Проблема: запросы идут с корпоративного IP, User-Agent выставлен как `MyCompany-Service/1.0`. Cloudflare блокирует.
Причина: JA4 от Go net/http — `t13d1715h2_5b57614c22b0_3d5424432f57`. Это отпечаток, который Cloudflare видит у тысяч скраперов. Плюс Go по умолчанию не отправляет SNI, если хост — IP. Плюс ALPN только `h2`, без `http/1.1`.
Решение: явная настройка `tls.Config` с кастомным ClientHello. В Go 1.21 появилась возможность задавать `CipherSuites` и `CurvePreferences`, но полный контроль над порядком extensions недоступен. Пришлось использовать `utls` — форк crypto/tls с имперсонацией.
```go
package main
import (
"fmt"
"io"
"net/http"
"github.com/refraction-networking/utls"
)
func main() {
config := &utls.Config{ServerName: "api.partner.com"}
conn, err := utls.Dial("tcp", "api.partner.com:443", config,
&utls.HelloChrome_Auto)
if err != nil {
panic(err)
}
defer conn.Close()
req, _ := http.NewRequest("GET", "https://api.partner.com/v1/data", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
if err := req.Write(conn); err != nil {
panic(err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, len(body))
}
```
После перехода на utls с `HelloChrome_Auto` — 200 OK. Отпечаток стал `t13d1516h2_8daaf6152771_b186095e22b6`, как у настоящего Chrome.
Что делать, если вы за прокси и не хотите палиться
Порядок действий такой. Сначала проверьте свой JA4 на `tls.browserleaks.com` или `ja4db.com`. Если он не в списке браузеров — вы уже палитесь, независимо от IP.
Дальше — выбор инструмента под язык:
- Python: `curl_cffi` с `impersonate="chrome120"` или `tls-client`
- Go: `utls` с `HelloChrome_Auto` или `HelloFirefox_Auto`
- Node.js: `node-tls-client` или нативный `tls.connect` с кастомным `secureContext`
- Rust: `rquest` (форк reqwest с имперсонацией)
Прокси при этом остаётся нужен — для смены IP. Но прокси и отпечаток решают разные задачи. IP — это «откуда», отпечаток — это «кто». Меняете одно, второе остаётся.
Ограничения имперсонации
Имперсонация не всесильна. Если вы имперсонируете Chrome 120, а сервер уже знает, что Chrome 120 не поддерживает какой-то cipher suite, который вы предлагаете — палево. Версии библиотек обновляются, отпечатки дрейфуют. Chrome 120 и Chrome 121 отличаются в JA4.
Плюс есть поведенческие метрики: тайминги, порядок запросов, паттерны навигации. TLS-отпечаток — один слой, не единственный. Но без него остальные слои бесполезны: вас отсекут на первом рукопожатии.
Ещё нюанс — TLS 1.3 с ECH. Когда ECH станет массовым (прогноз — 2025-2026), SNI уйдёт в шифр, и часть детекта отвалится. Но JA4 останется: он считается по ClientHello, который всё равно виден. ECH шифрует только SNI, не весь hello.
Итог
Подмена IP через прокси не скрывает TLS-отпечаток. ClientHello уходит от вашего клиента, прокси его не трогает. JA3 и JA4 считаются по составу и порядку полей в этом hello. Если ваш стек — Python requests или Go net/http без имперсонации, вы палитесь с первого пакета, независимо от качества прокси.
Решение — имперсонация на уровне TLS: `curl_cffi`, `utls`, `rquest`. Прокси остаётся нужен для смены IP, но это отдельная задача. Два слоя, две разные проблемы. Путать их — классические грабли, на которые наступают при построении скраперов и ботов.