← Назад в базу знаний

Почему TLS-отпечаток выдаёт прокси даже при идеальной подмене IP

Почему TLS-отпечаток выдаёт прокси даже при идеальной подмене IP

Прокси меняет 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, но это отдельная задача. Два слоя, две разные проблемы. Путать их — классические грабли, на которые наступают при построении скраперов и ботов.

✔️Купить прокси