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

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

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

Что такое TLS-отпечаток и почему он вообще работает

Когда клиент устанавливает TLS-соединение, он отправляет ClientHello — первый пакет рукопожатия. В нём куча параметров: версия протокола, список поддерживаемых шифров (cipher suites), расширения, эллиптические кривые, форматы подписи, ALPN-протоколы, порядок всего этого добра. Набор и порядок этих параметров зависит от конкретной реализации TLS-стека: OpenSSL 3.0, BoringSSL, NSS, Schannel, Go crypto/tls, Rustls. Каждая собирает ClientHello по-своему.

Антифрод-системы считают хеш от нормализованного ClientHello. Получается отпечаток — стабильная строка вроде `771,4865-4866-4867-49195-49199...,0-11-10-35-22-23-13-43-45-51,29-23-30-25-24,0-1-2`. Это JA3. Есть ещё JA4, который учитывает больше полей и устойчивее к рандомизации порядка расширений.

Логика простая. Два аккаунта заходят с одного устройства, одного браузера, одной версии ОС — у них одинаковый TLS-отпечаток. Даже если IP разные, даже если User-Agent подменён. Отпечаток выдаёт.

JA3, JA4 и что именно хешируется

JA3 собирает пять компонентов: версию TLS, список cipher suites, список расширений, список эллиптических кривых, форматы точек кривых. Всё через дефис, потом MD5. Проблема JA3 — он ломается от любого изменения порядка расширений. Chrome с 2023 года начал рандомизировать порядок расширений в ClientHello (GREASE + shuffle), и JA3 поплыл.

JA4 решает это. Он сортирует расширения, отдельно кодирует ALPN, SNI, версию, считает truncated SHA256. Формат: `t13d1516h2_8daaf6152771_02713d6af862`. Первая часть — метаданные (t=TCP, 13=TLS 1.3, d=domain, 15=cipher count, 16=extension count, h2=ALPN). Дальше хеши шифров и расширений.

Для антифрода важен не сам хеш, а его стабильность и редкость. Отпечаток, который встречается у 50 000 сессий в сутки — это Chrome на Windows. Отпечаток, который встречается у 3 сессий, но с 200 разных аккаунтов — красный флаг.

Вот как выглядит извлечение JA3 на Python через scapy:

```python

from scapy.all import rdpcap, TCP, Raw

import hashlib

def ja3_from_packet(pkt):

if not pkt.haslayer(Raw):

return None

payload = bytes(pkt[Raw].load)

if len(payload) < 6 or payload[0] != 0x16:

return None

TLS record: type(1) ver(2) len(2) handshake_type(1) len(3)

hs = payload[5:]

if hs[0] != 0x01:

return None

body = hs[4:]

ver = int.from_bytes(body[0:2], 'big')

pos = 2 + 32 # version + random

sid_len = body[pos]; pos += 1 + sid_len

cs_len = int.from_bytes(body[pos:pos+2], 'big'); pos += 2

ciphers = [int.from_bytes(body[pos+i:pos+i+2], 'big')

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

pos += cs_len

comp_len = body[pos]; pos += 1 + comp_len

ext_len = int.from_bytes(body[pos:pos+2], 'big'); pos += 2

end = pos + ext_len

exts, curves, pf = [], [], []

while pos < end:

etype = int.from_bytes(body[pos:pos+2], 'big')

elen = int.from_bytes(body[pos+2:pos+4], 'big')

edata = body[pos+4:pos+4+elen]

exts.append(etype)

if etype == 10 and len(edata) >= 2:

cl = int.from_bytes(edata[0:2], 'big')

curves = [int.from_bytes(edata[2+i:4+i], 'big')

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

if etype == 11 and len(edata) >= 1:

fl = edata[0]

pf = [edata[1+i] for i in range(fl)]

pos += 4 + elen

raw = f"{ver},{'-'.join(map(str,ciphers))},{'-'.join(map(str,exts))}," \

f"{'-'.join(map(str,curves))},{'-'.join(map(str,pf))}"

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

```

Это учебный пример. В продакшене ClientHello может фрагментироваться по нескольким TCP-сегментам, и парсер должен собирать поток, а не один пакет.

Почему подмена User-Agent не спасает

Классический мультиаккаунтер меняет User-Agent, куки, иногда canvas-отпечаток. Но TLS-стек остаётся тем же. Если человек сидит на Python requests с urllib3 — отпечаток будет Python. Если он подставил UA от Chrome — антифрод видит несоответствие: UA говорит Chrome 120, а ClientHello содержит cipher suites, которых Chrome не использует уже пять лет.

Это называется mismatch detection. Антифрод строит таблицу «UA → ожидаемый JA3/JA4». Chrome 120 на Windows 11 имеет конкретный набор. Safari на macOS — другой. Firefox — третий. Если пришёл UA Chrome, а отпечаток от Go — бан.

Пример: сервер на nginx 1.24 с модулем `ssl_preread` и логированием JA3 через `proxy_protocol` показал, что из 1200 регистраций за сутки 340 имели отпечаток `cd08e31494f9531f560d64c695473da9` — это дефолтный Python requests. При этом UA у всех был рандомизирован из списка топ-50 браузеров. 28% регистраций — боты.

Рандомизация отпечатка: как её делают и где она течёт

Курлы типа curl-impersonate, tls-client, utls умеют подделывать ClientHello под Chrome или Firefox. Это уже не костыль, а рабочий инструмент. Go-библиотека utls позволяет собрать ClientHello байт в байт как у нужного браузера.

Но есть нюансы. Первый — порядок расширений. Chrome рандомизирует его при каждой сессии, но с сохранением определённых групп. Если рандомизатор тупой и просто перемешивает всё — JA4 всё равно поймает аномалию, потому что распределение позиций расширений не совпадёт с настоящим Chrome.

Второй — GREASE. Chrome вставляет GREASE-значения (0x0a0a, 0x1a1a и т.д.) в случайные позиции. Если их нет — отпечаток палится. JA3 с GREASE и без него даёт разные хеши.

Третий — TLS-версия и поддержка TLS 1.3. Если клиент заявляет TLS 1.3, но не отправляет key_share для X25519 — это подделка. Настоящий Chrome всегда отправляет.

Четвёртый — тайминги. Реальный браузер отправляет ClientHello через 0-5 мс после TCP-ACK. Python-скрипт с прокси — через 50-200 мс. Антифрод меряет время между SYN-ACK и ClientHello.

Комбинация с другими сигналами

TLS-отпечаток — не единственный сигнал. Антифрод маркетплейса смотрит на:

- HTTP/2 fingerprint (SETTINGS frame, WINDOW_UPDATE, порядок заголовков pseudo-headers)

- TCP fingerprint (p0f-style: TTL, window size, MSS, опции)

- Canvas/WebGL fingerprint

- Timing между запросами

- Поведенческие паттерны (скорость заполнения форм, движение мыши)

Совпадение TLS-отпечатка у 200 аккаунтов — это уже повод. Совпадение TLS + HTTP/2 + TCP — почти гарантия. Настоящий пользователь с одного устройства даёт одинаковый стек, но у него один-два аккаунта. Мультиаккаунтер с одной машины даёт 50 аккаунтов с одним стеком.

Вот пример запроса через curl-impersonate, который маскируется под Chrome 120:

```bash

curl-impersonate-chrome \

--ciphers TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256 \

-H "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" \

-H "Accept-Language: ru-RU,ru;q=0.9,en;q=0.8" \

--http2 \

https://marketplace.example.com/api/register

```

Даже это не гарантия. curl-impersonate воспроизводит ClientHello, но HTTP/2-фреймы могут отличаться. Chrome отправляет SETTINGS с конкретными значениями (HEADER_TABLE_SIZE=65536, MAX_CONCURRENT_STREAMS=1000, INITIAL_WINDOW_SIZE=6291456). Если библиотека ставит другие — палево.

Как обходят и как ловят

Обход строится на трёх уровнях. Первый — подмена TLS-отпечатка через utls или curl-impersonate. Второй — подмена HTTP/2-отпечатка. Третий — рандомизация каждого аккаунта так, чтобы отпечатки не повторялись.

Рандомизация — ключевое. Если 200 аккаунтов имеют одинаковый JA4, это бан. Если у каждого свой уникальный JA4 (в пределах разумного распределения реальных браузеров) — уже сложнее. Но тут вылезает другая проблема: реальные браузеры имеют ограниченный набор отпечатков. Если антифрод видит 200 отпечатков, которых нет в базе реальных браузеров — это тоже флаг.

Ловят через базу. Антифрод-системы (Shape, Akamai Bot Manager, Datadome, Cloudflare) держат базу из миллионов реальных отпечатков. Новый отпечаток, которого нет в базе, но который ведёт себя как бот — помечается. Через сутки-двое он попадает в чёрный список.

Прокси-серверы тут играют роль на уровне IP. IPv6-прокси дают большой пул адресов, но TLS-отпечаток остаётся на клиенте. Если клиент — Python-скрипт, никакой прокси не спасёт. Прокси меняет IP, но не стек. На lexic.ml, например, IPv6-подсети выделяются /64 — это 2^64 адресов на клиента, что решает проблему «один IP на 50 аккаунтов», но не решает проблему отпечатка.

Практика: что видит антифрод на стороне сервера

На стороне сервера антифрод собирает всё, что можно. Nginx с модулем `ssl_preread` может логировать SNI и ALPN. Но для JA3/JA4 нужен либо патченный nginx, либо терминирование TLS на приложении (Go, Node.js), либо отдельный sniffer на входе.

Пример на Go: перехват ClientHello через `GetConfigForClient`:

```go

package main

import (

"crypto/tls"

"crypto/md5"

"fmt"

"log"

"net"

"strings"

)

func ja3FromClientHello(chi *tls.ClientHelloInfo) string {

var ciphers []string

for _, c := range chi.CipherSuites {

ciphers = append(ciphers, fmt.Sprintf("%d", c))

}

var curves []string

for _, c := range chi.SupportedCurves {

curves = append(curves, fmt.Sprintf("%d", c))

}

var points []string

for _, p := range chi.SupportedPoints {

points = append(points, fmt.Sprintf("%d", p))

}

var exts []string

for _, e := range chi.Extensions {

exts = append(exts, fmt.Sprintf("%d", e))

}

raw := fmt.Sprintf("%d,%s,%s,%s,%s",

chi.Version,

strings.Join(ciphers, "-"),

strings.Join(exts, "-"),

strings.Join(curves, "-"),

strings.Join(points, "-"))

return fmt.Sprintf("%x", md5.Sum([]byte(raw)))

}

func main() {

cfg := &tls.Config{

GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {

log.Printf("ja3=%s sni=%s alpn=%v",

ja3FromClientHello(chi), chi.ServerName, chi.SupportedProtos)

return nil, nil

},

}

ln, err := tls.Listen("tcp", ":443", cfg)

if err != nil {

log.Fatal(err)

}

for {

conn, err := ln.Accept()

if err != nil {

continue

}

go func(c net.Conn) {

defer c.Close()

buf := make([]byte, 1024)

c.Read(buf)

}(conn)

}

}

```

Этот код логирует JA3 для каждого входящего соединения. Дальше — агрегация: сколько уникальных аккаунтов пришло с одним JA3 за час, за сутки. Порог зависит от площадки. Для маркетплейса с 100k DAU порог в 20 аккаунтов на один редкий отпечаток уже подозрителен.

Реальный случай: 400 аккаунтов на одном отпечатке

Маркетплейс электроники, 80 000 активных продавцов. Служба безопасности заметила аномалию: за неделю зарегистрировалось 400 новых аккаунтов, все с разных IP (IPv6 /64 подсети), все с разных email-доменов, все с разными именами. Классический мультиаккаунт.

Но JA4 у всех был одинаковый: `t13d1516h2_8daaf6152771_02713d6af862`. Это отпечаток Go crypto/tls с кастомным набором шифров. Не Chrome, не Firefox. Плюс HTTP/2 fingerprint совпадал: SETTINGS с INITIAL_WINDOW_SIZE=4194304, что не соответствует ни одному браузеру.

Причина: злоумышленник использовал Go-скрипт с utls, но неправильно настроил профиль. Он скопировал cipher suites от Chrome, но оставил Go-шные расширения и Go-шный HTTP/2. Антифрод поймал несоответствие за 6 часов.

Решение: все 400 аккаунтов заблокированы, IP-подсети внесены в чёрный список, добавлено правило в WAF: если JA4 начинается с `t13d1516h2` и HTTP/2 SETTINGS не совпадает с известными браузерами — challenge.

Что делать, если ты на другой стороне

Если задача — не палиться, нужно понимать: TLS-отпечаток это не одна строка, а совокупность. Подмена только JA3 бессмысленна. Нужна консистентность на всех уровнях: TLS, HTTP/2, TCP, JS-окружение, тайминги.

Практический минимум: использовать utls или curl-impersonate с профилем реального браузера, проверять отпечаток через `tls.peet.ws` или `browserleaks.com/tls`, сверять с эталоном. Если отпечаток отличается от эталона хотя бы в одном расширении — антифрод это увидит.

Второй момент — распределение. Один отпечаток на 50 аккаунтов палится. Нужна ротация профилей: разные версии Chrome, разные ОС, разные наборы расширений. Но ротация должна быть в пределах реального распределения. Если у тебя 50 аккаунтов с 50 разными отпечатками, которых нет в базе реальных браузеров — это ещё более явный флаг.

Третий момент — тайминги. Реальный пользователь не регистрируется в 3 часа ночи каждые 40 секунд. Антифрод смотрит на интервалы между запросами, на время заполнения форм, на движение мыши (если есть JS-трекинг). TLS-отпечаток — только один слой. Без консистентности на остальных слоях он бесполезен.

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