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

- Как антифрод-системы маркетплейсов находят один компьютер за десятью аккаунтами по TLS-отпечатку

Что вообще такое TLS-отпечаток и почему он живёт дольше cookies

Когда браузер стучится на HTTPS, первым делом он шлёт ClientHello. В нём — версия TLS, наборы шифров, расширения, эллиптические кривые, порядок всего этого добра. Клиент шлёт это до всякой аутентификации, до cookies, до JS. Сервер видит «паспорт» соединения ещё до того, как поймёт, кто пришёл.

Дальше — больше. Порядок расширений, наборы групп, ALPN, поддерживаемые сигнатуры, GREASE-значения (случайные заполнители в Chrome), длина списка шифров — это сотни бит энтропии. Комбинация получается настолько характерной, что Chrome 120 и Firefox 121 отличаются надёжнее, чем два разных человека по походке.

Cookies чистятся за секунды. User-Agent подменяется одной строкой в DevTools. А TLS-отпечаток меняется только если поменять сам стек — библиотеку, версию, набор патчей, порядок инициализации. Именно поэтому антифрод маркетплейсов с 2019–2020 годов массово пересел на JA3 и его наследников.

JA3: как считается и почему это не магия

JA3 — это MD5 от строки, собранной из полей ClientHello. Формат простой:

```

TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats

```

Каждое поле — список чисел через дефис, элементы — через запятую. Потом всё это хешируется. Получается 32-символьная строка вроде `cd08e31494f9531f560d64c695473da9`. Сервер (или балансировщик, или WAF) считает хеш на лету и пишет в лог рядом с IP и cookie.

Вот как выглядит парсинг JA3 из сырого ClientHello на Python — упрощённо, но рабоче:

```python

import hashlib

import struct

def parse_client_hello(data: bytes):

if data[0] != 0x16:

return None

if data[5] != 0x01:

return None

pos = 9

version = struct.unpack("!H", data[pos:pos+2])[0]

pos += 2 + 32

sid_len = data[pos]

pos += 1 + sid_len

cs_len = struct.unpack("!H", data[pos:pos+2])[0]

pos += 2

ciphers = [struct.unpack("!H", data[pos+i:pos+i+2])[0]

for i in range(0, cs_len, 2)]

pos += cs_len

pos += 1 + data[pos]

ext_len = struct.unpack("!H", data[pos:pos+2])[0]

pos += 2

end = pos + ext_len

exts, curves = [], []

while pos < end:

etype = struct.unpack("!H", data[pos:pos+2])[0]

elen = struct.unpack("!H", data[pos+2:pos+4])[0]

exts.append(etype)

if etype == 10:

clen = struct.unpack("!H", data[pos+4:pos+6])[0]

for i in range(0, clen, 2):

curves.append(struct.unpack("!H", data[pos+6+i:pos+8+i])[0])

pos += 4 + elen

return version, ciphers, exts, curves

def ja3(data: bytes) -> str:

v, c, e, cu = parse_client_hello(data)

s = f"{v},{'-'.join(map(str,c))},{'-'.join(map(str,e))},{'-'.join(map(str,cu))},"

return hashlib.md5(s.encode()).hexdigest()

```

Хеш отдаётся в лог nginx через `\$ssl_ja3` (модуль `nginx-ssl-ja3` от fooinha) или через Lua-модуль OpenResty. В стандартном nginx из коробки такого нет — приходится собирать с патчем или ставить перед nginx что-то вроде Envoy, у которого JA3 встроен в access log с версии 1.20.

JA4 и почему JA3 сломался

Проблема JA3 вылезла быстро. Порядок шифров и расширений в ClientHello у Chrome рандомизируется через GREASE — специальные значения вроде `0x0a0a`, которые браузер вставляет случайно. Chrome с 2018 года начал перемешивать расширения. Один и тот же Chrome 120 на одном и том же железе давал десятки разных JA3. Антифрод взвыл.

Ответ — JA4, предложенный Джоном Альтшулером в 2023-м. Он убирает GREASE, сортирует значения, добавляет ALPN и SNI-флаг. Формат:

```

t13d1516h2_8daaf6152771_b186095e22b6

```

Первая часть — версия TLS, тип (d — domain, i — IP), число шифров, число расширений, ALPN. Вторая — SHA-256 от отсортированных шифров. Третья — SHA-256 от отсортированных расширений плюс сигнатур. Устойчивее к рандомизации, стабильнее между сессиями.

Но и JA4 не панацея. Порядок шифров в новом Chrome всё равно перемешивается, поэтому антифрод держит базу «нормальных» отпечатков и сравнивает по расстоянию Хэмминга. Отклонение больше N бит — флаг.

Что маркетплейс видит на самом деле

Один компьютер, десять аккаунтов. С точки зрения cookies — десять разных людей. С точки зрения IP — может быть один, а может десять прокси. С точки зрения TLS — один и тот же хеш, если все десять сессий идут из одного браузера или из одной автоматизированной библиотеки.

Антифрод маркетплейса строит граф: узлы — аккаунты, рёбра — общие признаки. TLS-хеш — одно из самых тяжёлых рёбер. Если у 10 аккаунтов совпадает JA4 плюс набор шрифтов плюс таймзона плюс разрешение экрана — это уже не совпадение, это ферма. Порог срабатывания обычно: 3+ аккаунта с одинаковым отпечатком за сутки, плюс пересечение по другим сигналам.

Вот как это выглядит в реальном пайплайне (ClickHouse, схема упрощена):

```sql

SELECT

ja4,

uniqExact(account_id) AS accounts,

count() AS sessions,

min(ts) AS first_seen,

max(ts) AS last_seen

FROM sessions

WHERE ts > now() - INTERVAL 1 DAY

GROUP BY ja4

HAVING accounts >= 3

ORDER BY accounts DESC

LIMIT 100;

```

Дальше — ручная или автоматическая проверка: смотрят на пересечение IP-подсетей, поведение (одинаковые интервалы между действиями, одинаковые товары в корзине), платёжные данные. TLS-отпечаток — это не приговор, это зацепка.

Почему прокси и VPN не спасают от этого

Классическая ошибка: «я поставил прокси, значит я другой человек». Прокси меняет IP. TLS-отпечаток остаётся тем же — он генерируется на стороне клиента, до того как пакет уйдёт в туннель. Хеш ClientHello не зависит от того, через какой SOCKS5 он прошёл.

Хуже того, если прокси терминирует TLS (MITM, как делают корпоративные шлюзы), отпечаток становится отпечатком прокси-сервера, а не браузера. Все клиенты за таким прокси сливаются в один JA4. Антифрод видит: 200 аккаунтов с одного отпечатка, который в жизни не встречается у настоящих браузеров. Мгновенный бан-лист.

Единственный способ реально разнести отпечатки — использовать разные стеки. Разные версии браузеров, разные движки (Chromium vs Gecko), разные библиотеки (curl, requests, Go net/http, Node.js, Rust reqwest) — каждая даёт свой JA3/JA4. Но тут начинаются грабли: если стек не совпадает с User-Agent, это ещё более красный флаг.

Согласованность UA и отпечатка — главная ловушка

Антифрод не просто смотрит на JA4. Он сверяет его с заявленным User-Agent. Пример: клиент говорит `User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... Chrome/121.0.0.0`. А JA4 у него — от Python `requests` с библиотекой `urllib3`. Chrome 121 так не умеет. Это как человек в костюме директора, который говорит голосом робота.

Таблица типичных несоответствий, которые ловят антифроды:

| Заявлено | Реальный стек | JA3 (пример) | Палится |

|---|---|---|---|

| Chrome 121 | Python requests | `3b5074b1b5d032e5620f69f9f700ff0e` | Да, всегда |

| Chrome 121 | curl 8.x | `3b5074b1b5d032e5620f69f9f700ff0e` (тот же!) | Да |

| Firefox 122 | Go net/http | `c4a8a1c1e2b1d5a1e3f4b6c7d8e9f0a1` | Да |

| Chrome 121 | Chrome 121 | `cd08e31494f9531f560d64c695473da9` | Нет |

| Chrome 121 | curl-impersonate | `cd08e31494f9531f560d64c695473da9` | Нет (при совпадении версии) |

Забавно, что `requests` и `curl` часто дают одинаковый JA3 — обе библиотеки используют OpenSSL с дефолтным набором шифров. Это значит, что если антифрод видит такой хеш, он не различает «скрипт на Python» и «скрипт на bash». Оба — автоматизация.

curl-impersonate и почему это работает не всегда

Появился инструмент `curl-impersonate` — патченный curl, который воспроизводит ClientHello конкретных браузеров. Умеет Chrome 99–124, Firefox 91–121, Safari 15–17. Задаёшь `--impersonate chrome124` — и JA3/JA4 совпадает с настоящим Chrome 124.

Но ClientHello — это только первый слой. Дальше идут HTTP/2-фреймы, порядок заголовков, приоритеты потоков, псевдозаголовки. Антифроды с 2022 года смотрят на HTTP/2-отпечаток (Akamai fingerprint) — он складывается из SETTINGS-фрейма, WINDOW_UPDATE, порядка заголовков. curl-impersonate это тоже воспроизводит, но уже не идеально. А ещё есть TCP/IP-отпечаток (p0f), JS-отпечаток (Canvas, WebGL, AudioContext), поведенческий.

Итог: curl-impersonate поднимает планку, но не делает тебя невидимым. Если с одного IP десять аккаунтов с одинаковым «Chrome 124» и одинаковым HTTP/2-отпечатком — это всё равно ферма, просто аккуратнее замаскированная.

Кейс: маркетплейс с 40 тысячами сессий в сутки

Пример: сервер на nginx 1.24 за балансировщиком Envoy 1.29. В Envoy включён `envoy.filters.listener.tls_inspector` с расчётом JA3 и JA4 в access log. Логи уходят в ClickHouse, оттуда — в антифрод-сервис на Go.

Проблема проявилась через две недели после запуска: 40 000 сессий в сутки, из них 6 200 с одного JA4 `t13d1516h2_8daaf6152771_b186095e22b6`. Это оказался отпечаток `python-requests/2.31` под OpenSSL 3.0. Он совпал у 870 аккаунтов, зарегистрированных за последние 10 дней.

Причина: продавцы накрутки использовали один и тот же скрипт на Python для регистрации. Меняли IP через пул мобильных прокси, меняли UA, но стек оставался тем же. Антифрод не смотрел на JA4 в реальном времени — только на постфактум-агрегации.

Решение: вынесли JA4 в отдельный материализованный view, обновление каждые 5 минут. Добавили правило: если JA4 встречается у 5+ аккаунтов за час и при этом не входит в белый список (Chrome/Firefox/Safari актуальных версий) — блок регистрации для новых аккаунтов с этого отпечатка на 24 часа. Ложных срабатываний — 0.3%, все разобрались вручную (корпоративные сети с общим прокси).

Кейс: подмена User-Agent без смены стека

Другой пример: интернет-магазин электроники, 12 000 заказов в сутки. Антифрод на базе собственной разработки, ловит по комбинации JA3 + UA + Accept-Language.

Проблема: всплеск заказов с одного товара, все с разных IP (резидентные прокси, 8 стран). Аккаунты свежие, но с историей просмотров. На первый взгляд — органика.

Причина: скрипт на Node.js 20 с библиотекой `undici` (встроенный fetch). Разработчик подменял `User-Agent` на Chrome 120, но `undici` использует OpenSSL с другим набором шифров, чем Chrome. JA3 получался `52d1f9a1e5b3c4d6e7f8a9b0c1d2e3f4` — не совпадал ни с одним реальным браузером. Плюс порядок HTTP/2-заголовков у `undici` отличается от Chrome: `:method, :authority, :scheme, :path` вместо хромовского `:method, :authority, :scheme, :path` с другими приоритетами.

Решение: антифрод добавил правило «JA3 не из белого списка + UA заявляет браузер = блок». За неделю отсекли 4 300 попыток. Скрипт переписали на `curl-impersonate`, но к тому моменту аккаунты уже были в чёрном списке по другим признакам (одинаковые платёжные карты).

Кейс: корпоративный прокси и ложные срабатывания

Обратная история. Крупный B2B-маркетплейс, 200 000 компаний-покупателей. Антифрод настроен агрессивно: любой JA4, встречающийся у 10+ аккаунтов, идёт на ручную проверку.

Проблема: за неделю 1 800 аккаунтов улетели в карантин. Все — из одной корпорации с централизованным прокси-сервером. Прокси терминировал TLS и перевыпускал сертификаты (MITM), поэтому все сотрудники шли с отпечатком прокси, а не своих браузеров.

Причина: прокси на базе Squid 5.7 с OpenSSL 1.1.1. JA4 у него — `t13d1715h2_5b57614c22b0_3d5424432f57`, стабильный и одинаковый для всех 1 800 сотрудников. Антифрод видел: 1 800 аккаунтов с одного отпечатка. Классическая ферма по всем признакам.

Решение: добавили в белый список известные корпоративные прокси по комбинации JA4 + диапазон IP. Плюс изменили логику: если JA4 совпадает, но IP-подсети разные и поведение разное (разные товары, разные графики активности) — не блокировать, а только логировать. Ложные срабатывания упали до 0.1%. Оставшиеся — реальные фермы, которые прятались за корпоративными прокси.

Как реально разносить отпечатки (и почему это дорого)

Если задача — легально работать с нескольких аккаунтов (не для накрутки, а, скажем, для тестирования или агентской работы), есть несколько путей. Каждый со своей ценой.

Первый — разные реальные браузеры на разных машинах. Дорого по железу, но отпечатки честные. Второй — антидетект-браузеры (Multilogin, Dolphin Anty, AdsPower). Они патчат Chromium на уровне сборки, подменяют Canvas, WebGL, TLS-стек. Работает, но антифроды учатся их ловить по несоответствиям (например, Canvas подменён, а WebGL — нет). Третий — `curl-impersonate` с рандомизацией версий между запросами. Дешёво, но HTTP/2-отпечаток всё равно выдаёт.

Четвёртый путь, который часто недооценивают, — разные сетевые выходы с разными стеками. Если каждый аккаунт идёт через свой прокси со своим TLS-терминатором (например, через пул IPv6-адресов с разными сборками nginx/OpenSSL), отпечатки на выходе различаются. Именно так работают некоторые пулы IPv6-прокси: у каждого адреса свой стек, своя версия OpenSSL, свой набор шифров. Сервис вроде lexic.ml как раз держит пул IPv6-адресов с 2015 года, и разные адреса дают разные TLS-отпечатки на выходе — не потому что это фича для обхода, а потому что это разные машины с разными сборками. Антифрод видит не «один скрипт с десятью IP», а десять разных клиентов.

Но и это не серебряная пуля. Если поведение одинаковое (те же товары, те же интервалы, те же платёжные данные) — никакой TLS не спасёт. Отпечаток — один сигнал из десятков.

Что делать, если ты на стороне защиты

Если строишь антифрод и хочешь ловить фермы по TLS — вот минимальный набор. Первое: логируй JA3 и JA4 на уровне балансировщика, не на уровне приложения. Приложение видит уже расшифрованный запрос, там ClientHello нет. Второе: строй материализованные view по частоте отпечатков за час и за сутки. Третье: веди белый список отпечатков реальных браузеров — обновляй раз в месяц, версии меняются.

Четвёртое: сверяй JA4 с UA и с HTTP/2-отпечатком. Несовпадение — красный флаг. Пятое: не блокируй по одному признаку, только по совокупности. Шестое: держи ручную разборку для ложных срабатываний — корпоративные прокси, VPN-сервисы, антивирусы с MITM (Kaspersky, ESET) дают стабильные «подозрительные» отпечатки у тысяч легальных пользователей.

И последнее: TLS-отпечаток — это не приговор, это зацепка. Он говорит «возможно, эти аккаунты связаны». Дальше нужны другие сигналы: платёжные, поведенческие, графовые. Антифрод, который банит по одному JA4, через месяц останется без клиентов.

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