- Как антибот-системы ловят прокси по TLS-отпечатку (JA3/JA4) и что меняет ротация IP
Содержание
- Что вообще видит сервер
- Почему ротация IP — не панацея
- Как выглядит JA3 вживую
- data: raw TLS record starting from handshake
- skip record header (5 bytes), handshake header (4 bytes)
- Что делает ротация и чего не делает
- Кейс: парсер маркетплейса на 50 000 запросов в сутки
- Кейс: nginx-реверс с TLS-терминацией
- HTTP/2 fingerprint — то, что забывают
- Когда ротация всё-таки главное
- Собираем стек правильно
- Цифры, которые стоит помнить
- Итог
Что вообще видит сервер
Когда curl стучится на сайт по HTTPS, первым делом уходит TCP-хендшейк, потом TLS ClientHello. В этом ClientHello — набор шифров, расширений, эллиптических кривых, порядок байтов. Всё это железно зависит от библиотеки и её версии. OpenSSL 1.1.1 и OpenSSL 3.0 отправят разные приветствия. Python requests на своём urllib3 — третье. Chrome — четвёртое.
Антиботу не нужно ломать шифрование. Ему достаточно прочитать ClientHello, посчитать хэш от набора параметров — и сравнить с базой известных отпечатков. Если в заголовках написано «Chrome/120», а отпечаток принадлежит python-requests 2.31 — это красный флаг. Ротация IP тут не спасает: новый адрес, тот же отпечаток.
JA3 — это MD5 от строки вида `TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats`. JA4 — уже SHA256, с нормализацией порядка расширений и разделением на части (JA4a для TLS, JA4h для HTTP/2, JA4t для TCP). JA4 устойчивее к рандомизации порядка расширений — Chrome как раз рандомизирует, но JA4 это переживает.
Почему ротация IP — не панацея
Прокси-пул на 10 000 адресов звучит солидно. Пока не поймёшь, что все 10 000 запросов идут с одинаковым JA3. Антибот строит граф: «этот отпечаток встречается на 10 000 IP, каждый IP делает 50 запросов в час, все запросы с одинаковыми заголовками». Кластерный анализ — и весь пул в чёрном списке за пару суток.
Есть и второй слой. Поведенческий. Ротация IP меняет адрес, но не меняет тайминги: задержку между запросами, jitter, порядок обхода страниц, размеры пакетов. Если 5000 «разных пользователей» открывают страницу ровно через 340 мс после загрузки предыдущей — это бот, и никакой JA3 считать не надо.
Третий слой — TLS-сессия. Если прокси терминирует TLS и переустанавливает соединение к цели, у него свой стек. Часто это OpenSSL из состава squid или 3proxy. Отпечаток получается «прокси-специфичный», его знают все антибот-вендоры. Ротация IP здесь вообще ни при чём — отпечаток один на весь пул.
Как выглядит JA3 вживую
Считается он тривиально. Вот рабочий сниффер на Python, который слушает ClientHello и печатает JA3:
```python
import socket, struct, hashlib
def parse_client_hello(data):
data: raw TLS record starting from handshake
if data[0] != 0x16:
return None
skip record header (5 bytes), handshake header (4 bytes)
p = 5 + 4
version = data[p:p+2].hex()
p += 2 + 32 # version + random
sid_len = data[p]
p += 1 + sid_len
cs_len = struct.unpack(">H", data[p:p+2])[0]
p += 2
ciphers = data[p:p+cs_len]
p += cs_len
comp_len = data[p]
p += 1 + comp_len
if p >= len(data):
return version, ciphers.hex(), "", ""
ext_len = struct.unpack(">H", data[p:p+2])[0]
p += 2
exts, curves, ecpf = [], [], []
end = p + ext_len
while p < end:
etype = struct.unpack(">H", data[p:p+2])[0]
elen = struct.unpack(">H", data[p+2:p+4])[0]
body = data[p+4:p+4+elen]
exts.append(etype)
if etype == 10 and len(body) >= 2:
cl = struct.unpack(">H", body[:2])[0]
curves = [struct.unpack(">H", body[2+i:4+i])[0] for i in range(0, cl, 2)]
if etype == 11 and len(body) >= 1:
ecpf = list(body[1:1+body[0]])
p += 4 + elen
return version, ciphers.hex(), ",".join(map(str, exts)), ",".join(map(str, curves))
def ja3(version, ciphers, exts, curves):
raw = f"{int(version,16)},{ciphers},{exts},{curves},0"
return hashlib.md5(raw.encode()).hexdigest()
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 8443))
s.listen(1)
conn, _ = s.accept()
data = conn.recv(4096)
v, c, e, cu = parse_client_hello(data)
print("JA3:", ja3(v, c, e, cu))
```
Запускаешь, стучишься `curl -k https://localhost:8443` — получаешь `b32309a26951912be7dba376398abc3b`. Это дефолтный curl на OpenSSL 3.x. Chrome даст совсем другой хэш. Список известных JA3 лежит в открытом репозитории `salesforce/ja3` — там же таблица соответствия «хэш → библиотека».
Что делает ротация и чего не делает
Ротация IP решает ровно одну задачу: размазывает rate-limit по адресам. Если сайт блокирует по «100 запросов с одного IP в минуту», пул из 200 адресов даст тебе 20 000 запросов. Полезно. Но антиботы давно не считают только по IP.
Вот что ротация НЕ меняет: JA3/JA4, порядок HTTP-заголовков (JA4h), HTTP/2 fingerprint (настройки SETTINGS-фрейма: initial window size, max concurrent streams), TCP-опции (TTL, window size — JA4t), поведение при редиректах, cookie-жара. Всё это привязано к клиенту, а не к адресу.
Практический вывод: ротация IP нужна, но она — второй эшелон. Первый — сделать так, чтобы отпечаток клиента выглядел как отпечаток браузера. Иначе 10 000 чистых адресов сгорят за неделю.
Кейс: парсер маркетплейса на 50 000 запросов в сутки
Была задача собирать цены с площадки, которая крутит Cloudflare с включённым Bot Management. Начали с резидентских прокси, 500 адресов, `python-requests`. Первые 2000 запросов прошли. На 2001-м — 403 с `cf-mitigated: challenge`. Все 500 IP в бане за сутки.
Причина: JA3 от `requests` = `3b5074b1b5d032e5620f69f9f700ff0e` (OpenSSL 1.1.1 + urllib3). Cloudflare знает этот хэш наизусть. Плюс заголовки шли в порядке, который `requests` не контролирует: `Host`, `User-Agent`, `Accept-Encoding`, `Accept`, `Connection`. У Chrome порядок другой: `Host`, `Connection`, `sec-ch-ua`, `sec-ch-ua-mobile`, `User-Agent`, `Accept`, `Sec-Fetch-*`, `Accept-Encoding`.
Решение: перешли на `curl_cffi` (Python-биндинг к libcurl с impersonate). Одна строка `impersonate="chrome120"` — и JA3 совпадает с Chrome 120, HTTP/2 fingerprint тоже, порядок заголовков воспроизводится. Плюс оставили ротацию, но уже по 200 адресов на 50 000 запросов — меньше в 2.5 раза. Прокси-пул перестал выгорать: с включённым impersonate блокировки упали с 100% до 3%.
```python
from curl_cffi import requests
r = requests.get(
"https://example.com/api/prices",
impersonate="chrome120",
proxies={"https": "http://user:pass@proxy.lexic.ml:8080"},
timeout=15,
)
print(r.status_code, r.json()["price"])
```
Обрати внимание: `curl_cffi` подменяет не только TLS, но и HTTP/2 SETTINGS. Без этого Cloudflare ловит по JA4h за один запрос.
Кейс: nginx-реверс с TLS-терминацией
Схема: клиент → наш nginx (TLS termination) → upstream. Nginx переустанавливает соединение к upstream через свой OpenSSL. В логах upstream видит JA3 nginx — `9c8b2f...`, характерный для OpenSSL 3.0 без кастомизации. Антибот upstream-а (если это, скажем, защищённый API) отбивает 100% запросов.
Причина не в IP. Причина в том, что nginx не умеет притворяться браузером — у него фиксированный набор шифров и расширений, заданный в `ssl_ciphers` и `ssl_conf_command`. Даже если выставить `ssl_ciphers` как у Chrome, порядок расширений и набор кривых останутся nginx-овскими.
Лечится либо `proxy_ssl_*` с подбором параметров (частично), либо отказом от TLS-терминации на nginx — пусть клиент говорит с upstream напрямую через CONNECT-прокси. Второй вариант обычно и выбирают: nginx отдаёт 200 на CONNECT, дальше трафик идёт байт-в-байт, отпечаток клиента сохраняется.
```nginx
stream {
server {
listen 443;
proxy_pass backend:443;
proxy_ssl off;
proxy_connect_timeout 5s;
proxy_timeout 60s;
}
}
```
Здесь `proxy_ssl off` — ключевое. Nginx не трогает TLS, просто перекладывает байты. JA3 клиента доходит до цели без изменений.
HTTP/2 fingerprint — то, что забывают
JA3 смотрят на TLS. Но если сайт на HTTP/2 (а сейчас почти все), антибот читает ещё и SETTINGS-фрейм. У Chrome: `HEADER_TABLE_SIZE=65536, ENABLE_PUSH=0, MAX_CONCURRENT_STREAMS=1000, INITIAL_WINDOW_SIZE=6291456, MAX_HEADER_LIST_SIZE=262144`. У python-h2: другие цифры. У Go net/http — третьи.
Cloudflare и Akamai считают JA4h — хэш от этих параметров плюс порядка заголовков. Если TLS ты подделал через curl_cffi, а HTTP/2 оставил дефолтный — тебя поймают на втором слое. `curl_cffi` с impersonate закрывает оба. Голый `httpx` с `http2=True` — только TLS, HTTP/2 останется палевным.
Проверить свой отпечаток можно на `tls.browserleaks.com` или `ja3er.com`. Открой сайт из браузера и из своего скрипта — сравни хэши. Разница в первых символах = разный стек.
Когда ротация всё-таки главное
Есть класс антиботов, которые вообще не смотрят на TLS. Простые rate-limit по IP, гео-фильтры, лимиты на подписку. Тут ротация решает всё, и городить impersonate смысла нет.
Пример: API одного сервиса статистики. Лимит — 60 запросов в минуту с IP, TLS не проверяется, User-Agent не важен. Пул из 10 адресов даёт 600 запросов в минуту. Задача закрыта ротацией, отпечаток не трогаем.
Другой пример: сбор данных с сайтов, где стоит только nginx + `limit_req`. Тут `requests` с ротацией работает годами. Проблемы начинаются, когда владелец сайта ставит Cloudflare «на всякий случай» — и весь пул сгорает за день.
Правило простое: сначала выясни, что именно считает антибот. Если в ответе есть `cf-ray`, `server: cloudflare`, `x-akamai-*` — готовься к отпечаткам. Если просто 429 без заголовков — хватит ротации.
Собираем стек правильно
Порядок действий для парсера, который должен жить долго.
Сначала выбери HTTP-клиент с управляемым TLS. `curl_cffi` — лучший вариант на Python сегодня. `tls-client` (Go-биндинг) — второй по популярности. Голый `requests` и `httpx` — только для сайтов без защиты.
Потом настрой заголовки в правильном порядке. `curl_cffi` с impersonate делает это сам, но если пишешь свой клиент — порядок критичен. Chrome шлёт `sec-ch-ua` до `User-Agent`, Firefox — иначе.
Дальше — прокси. Ротация по сессиям, а не по каждому запросу. Один IP на 5-10 минут, потом смена. Резкая смена адреса между запросами внутри одной «сессии» — сама по себе сигнал.
И последнее — тайминги. Случайная задержка 1.5-4 секунды между запросами, иногда паузы 30-60 секунд. Без этого даже идеальный отпечаток палится по ритму.
Цифры, которые стоит помнить
JA3 от Chrome 120 стабилен и известен всем антиботам — его подделка не даёт преимущества, но её отсутствие палит сразу. JA4 меняется между минорными версиями Chrome — 120 и 121 дают разные хэши, это нормально.
Дефолтный `requests` на Python 3.11 + OpenSSL 3.0 даёт JA3 `3b5074b1b5d032e5620f69f9f700ff0e`. Тот же `requests` на Python 3.8 + OpenSSL 1.1.1 — `3b5074b1b5d032e5620f69f9f700ff0e` тоже, но с другим набором расширений. Хэш совпадает случайно, а вот JA4 — нет.
Cloudflare Bot Management ловит несовпадение UA и JA3 за один запрос. Akamai — за два (второй нужен для подтверждения). DataDome смотрит ещё и на canvas/WebGL, но для API-запросов хватает TLS.
Ротация IP без смены отпечатка продлевает жизнь пула в среднем на 3-7 дней на защищённых сайтах. С правильным отпечатком — на месяцы, пока не поменяется версия Chrome в базе антибота.
Итог
Ротация IP и подмена отпечатка — не альтернативы, а слои. Ротация размазывает нагрузку, отпечаток маскирует клиента. Убрать любой из слоёв — и защита тебя видит. Порядок настройки: сначала клиент с правильным TLS/HTTP2, потом прокси с умной ротацией, потом тайминги. В обратном порядке — деньги на ветер.