Как антифрод ловит IPv6-прокси по TLS-отпечаткам
Содержание
- Почему IPv6 всё усложнил
- Что вообще видит сервер в TLS
- Где прокси выдаёт себя
- Таблица: типичные отпечатки
- Как антифрод строит правило
- Кейс: маркетплейс на nginx 1.24 и Go-прокси
- Кейс: python-requests за прокси
- Кейс: nginx как терминирующий прокси
- Что реально менять в конфигурации клиента
- Согласованность важнее подделки
- Итог
Антифрод маркетплейсов давно перестал смотреть только на IP. IPv6-адресов в пулах миллиарды, блокировать подсети бессмысленно — завтра прокси переедет на соседний /64. Поэтому в ход пошёл TLS-слой. Именно там прокси оставляют следы, которые не спрятать сменой адреса.
Почему IPv6 всё усложнил
С IPv4 было просто: серые списки, ASN-фильтры, проверка на датацентровые диапазоны. С IPv6 картина другая. Один провайдер может выдать клиенту /64 — это 18 квинтиллионов адресов. Ротация внутри подсети выглядит как «новый пользователь» для наивного антифрода, но не для того, кто читает TLS.
Датацентровые IPv6-диапазоны всё ещё палятся по whois и rDNS. Но резидентные прокси на IPv6 — уже головная боль: адрес жилой, ASN домашнего провайдера, а поведение — как у бота. Тут и включается анализ хендшейка.
Что вообще видит сервер в TLS
ClientHello — первое сообщение. В нём клиент отдаёт: версию TLS, список cipher suites, extensions, группы эллиптических кривых, форматы точек, ALPN, SNI. Порядок и состав этих полей формирует отпечаток. JA3 — хеш от версии, шифров, extensions, кривых и форматов точек. JA4 — новее, стабильнее, учитывает больше.
Сервер маркетплейса считает этот хеш на каждом коннекте. Дальше — сверка с базой известных клиентов. Chrome 124 даёт один JA3, curl 8.x — другой, python-requests — третий. Прокси, который терминирует TLS и переоткрывает его к цели, часто ломает отпечаток: клиент думает, что говорит с Chrome, а на выходе — Go-клиент прокси.
Где прокси выдаёт себя
Первый маркер — несоответствие User-Agent и TLS-отпечатка. Браузер заявляет Chrome/124, а JA3 соответствует Go net/http. Антифрод это ловит мгновенно: реальный Chrome никогда не отдаст такой набор шифров и порядок extensions.
Второй — отсутствие или искажение GREASE. Chrome и Firefox с 2019 года вставляют случайные GREASE-значения в списки шифров и extensions. Прокси, собранный на старом openssl или на кастомном стеке, их не шлёт. Пустой GREASE — красный флаг.
Третий — ALPN. Браузер предлагает h2 и http/1.1. Прокси на голом TCP часто отдаёт только http/1.1 или вообще ничего.
Четвёртый — порядок extensions. У Chrome жёсткий порядок с 2021 года (permute_extensions появился позже, но паттерн узнаваем). У прокси extensions идут в порядке вставки в код.
Таблица: типичные отпечатки
| Клиент | JA3 (сокращённо) | GREASE | ALPN | Версия TLS |
|---|---|---|---|---|
| Chrome 124 | 1d19d9c4... | да | h2, http/1.1 | 1.3 |
| Firefox 125 | 5f4b8e1c... | да | h2, http/1.1 | 1.3 |
| curl 8.5 | 3b5074b1... | нет | http/1.1 | 1.3 |
| Go net/http 1.22 | 6d3f1c2a... | нет | h2 | 1.3 |
| python-requests 2.31 | 8c1e0f3b... | нет | нет | 1.2/1.3 |
Цифры хешей условные, но паттерн реален: Go и python стабильно палятся по отсутствию GREASE и бедному набору extensions.
Как антифрод строит правило
Маркетплейс держит таблицу «UA → допустимые JA3». Пришёл запрос с UA Chrome/124, а JA3 не из списка — скоринг растёт. Плюс проверка на согласованность: если TLS 1.3, но нет supported_versions в extensions — это подделка.
Дальше — корреляция по времени. Один IPv6-адрес делает 200 запросов за минуту с идеальным Chrome-отпечатком. Живой человек так не может. Отпечаток чистый, а поведение — нет. Антифрод ловит на втором слое.
Ещё один слой — TCP-фингерпринт (p0f). TTL, размер окна, MSS. Прокси-сервер на Linux с дефолтным стеком даёт окно 29200, а Windows-клиент заявляет 64240. Несовпадение ОС в UA и в TCP — ещё баллы к скору.
Кейс: маркетплейс на nginx 1.24 и Go-прокси
Проблема: пул IPv6-прокси на Go-клиенте, UA подставлялся браузерный, но 40% запросов уходили в капчу.
Причина: Go net/http по умолчанию не шлёт GREASE и отдаёт урезанный ClientHello. JA3 не совпадал с Chrome, хотя UA был Chrome.
Детали: nginx 1.24 с `ssl_protocols TLSv1.2 TLSv1.3`, логирование через `\$ssl_cipher` и кастомный модуль JA3. В логах — 38% коннектов с JA3 Go-клиента при UA Chrome/124.
Решение: перевели прокси на `utls` с ClientHello-профилем Chrome, включили GREASE и permute extensions. Доля капчи упала до 6%.
```go
package main
import (
"net/http"
"github.com/refraction-networking/utls"
)
func main() {
cfg := &utls.Config{ServerName: "marketplace.example"}
conn, err := utls.Dial("tcp", "marketplace.example:443", cfg)
if err != nil {
panic(err)
}
defer conn.Close()
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return utls.UClient(conn, cfg, utls.HelloChrome_Auto), nil
},
},
}
client.Get("https://marketplace.example/api/v1/cart")
}
```
Ключевое — `HelloChrome_Auto`: utls сам собирает актуальный Chrome-профиль с GREASE и правильным порядком extensions.
Кейс: python-requests за прокси
Проблема: скрипт на python-requests 2.31 через IPv6-прокси, 70% запросов — 403 от антифрода.
Причина: requests использует urllib3 поверх стандартного ssl. Отпечаток — бедный, без GREASE, TLS 1.2 по умолчанию в старых сборках. Плюс заголовки: `Accept-Encoding: identity` вместо браузерного.
Детали: сервер отдавал 403 с телом `{"error":"bot_detected"}` при `User-Agent: Mozilla/5.0 ... Chrome/124`. Сверка JA3 показала python-ssl.
Решение: перешли на `curl_cffi`, который эмулирует браузерный TLS через libcurl-impersonate.
```python
from curl_cffi import requests
session = requests.Session(impersonate="chrome124")
resp = session.get(
"https://marketplace.example/api/v1/orders",
proxies={"https": "http://[2001:db8::1]:8080"},
headers={"Accept-Language": "ru-RU,ru;q=0.9"},
)
print(resp.status_code)
```
`impersonate="chrome124"` подставляет реальный Chrome ClientHello: GREASE, порядок extensions, ALPN h2. 403 ушли.
Кейс: nginx как терминирующий прокси
Проблема: свой прокси на nginx, TLS терминировался на нём, к цели шёл второй TLS. Антифрод видел отпечаток nginx, а не клиента.
Причина: nginx при проксировании открывает новое TLS-соединение со своим ClientHello. Клиентский отпечаток терялся полностью.
Детали: nginx 1.24, `proxy_pass https://backend`, без `proxy_ssl_*` настроек. JA3 на бэкенде — стабильно nginx-овский, все клиенты выглядели одинаково.
Решение: либо pass-through на L4 (stream-модуль), либо `proxy_ssl_*` с профилем. Чище — первое.
```nginx
stream {
upstream backend {
server marketplace.example:443;
}
server {
listen [::]:8443;
proxy_pass backend;
proxy_ssl on;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
}
}
```
В stream-режиме nginx не трогает TLS, ClientHello идёт как есть. Отпечаток клиента сохраняется.
Что реально менять в конфигурации клиента
Если работаешь через Go — utls с профилем Chrome или Firefox. Если Python — curl_cffi с `impersonate`. Если Node — `node-tls-client` или undici с кастомным TLS. Голый requests и голый Go net/http — гарантированный палево.
Проверять отпечаток до продакшена. Есть публичные эндпоинты вроде `tls.peet.ws` или `browserleaks.com/tls`, которые отдают твой JA3/JA4. Гоняй через них конфиг до того, как пускать трафик на маркетплейс.
Сервис lexic.ml даёт IPv6-пулы с 2015 года, и на практике связка «чистый TLS-отпечаток + ротация внутри /64» работает лучше, чем попытки подделать всё сразу. Адрес меняется, отпечаток остаётся браузерным — антифрод не видит паттерна.
Согласованность важнее подделки
Самый частый косяк — подделали JA3, но забыли про остальное. Chrome-отпечаток, а заголовки `Accept-Encoding: gzip` без `br` и `zstd`. Chrome всегда шлёт br. Или HTTP/2 не согласован: ALPN обещает h2, а клиент шлёт HTTP/1.1. Антифрод сверяет всё.
Проверяй связку: UA ↔ JA3 ↔ ALPN ↔ заголовки ↔ TCP-фингерпринт. Расхождение в любом звене — баллы к скору. Идеальная подделка одного слоя при кривом втором только привлекает внимание.
Ещё нюанс — версия TLS. Chrome 124 предпочитает TLS 1.3. Если клиент вдруг отдаёт 1.2 с браузерным UA — вопрос. Проверяй `supported_versions` в ClientHello, а не только negotiated версию.
Итог
IPv6-адрес давно не главный маркер. Антифрод читает TLS-хендшейк, сверяет с базой клиентов, ловит несоответствия UA и отпечатка, смотрит на GREASE, ALPN, порядок extensions. Прокси на стандартных библиотеках палится на первом же коннекте.
Менять надо не IP, а стек. utls для Go, curl_cffi для Python, pass-through для nginx. И проверять отпечаток на публичных эндпоинтах до продакшена. Ротация IPv6 внутри /64 плюс честный браузерный TLS — рабочая связка. Подделка одного слоя при кривом другом — только хуже.