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

TLS-фингерпринт браузера через прокси: почему JA3 выдаёт вас с головой

TLS-фингерпринт браузера через прокси: почему JA3 выдаёт вас с головой

Что такое JA3 и откуда он взялся

JA3 придумали в Salesforce в 2017 году. Инженеры устали от того, что сигнатуры на основе User-Agent обходятся одной строкой в curl. Идея простая: TLS-рукопожатие у каждого клиента уникально на уровне порядка полей, наборов шифров и расширений. Собери эти параметры в строку, посчитай MD5 — получишь отпечаток.

Формат такой: `TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurvePointFormats`. Каждое поле — список чисел через дефис, значения разделяются запятыми. Всё это склеивается в строку, потом MD5.

Вот как выглядит классический Chrome 120:

```

771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0

```

А это curl 8.x с OpenSSL:

```

771,49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-21,29-23-24-25,0

```

MD5 от этих строк — разные. Всё, вы спалились.

Почему через прокси это критично

Прокси бывает разный. SOCKS5 просто туннелирует TCP — TLS-рукопожатие идёт от вашего клиента к серверу напрямую, JA3 остаётся ваш. HTTP CONNECT — то же самое, только устанавливается туннель через метод CONNECT.

А вот если прокси терминирует TLS (MITM, корпоративные шлюзы, некоторые «анонимайзеры»), то рукопожатие делает сам прокси. И тут два варианта. Либо он пробрасывает ClientHello как есть — тогда JA3 ваш. Либо собирает свой — и все клиенты через этот прокси выглядят одинаково.

Второй сценарий — подарок для DPI. Достаточно один раз зафиксировать, что с этого IP идёт трафик с JA3, не совпадающим ни с одним браузером, и блокировать всё.

Как JA3 вычисляется на практике

Возьмём Python и `scapy` или `dpkt`. В реальности проще слушать трафик через `tcpdump` и парсить pcap.

```python

from scapy.all import rdpcap, TCP, Raw

import hashlib

def parse_client_hello(payload):

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

return None

TLS record: type(1) version(2) length(2)

if payload[5] != 0x01: # handshake type: ClientHello

return None

skip: record header(5) + handshake header(4) + version(2) + random(32)

offset = 5 + 4 + 2 + 32

session_id_len = payload[offset]

offset += 1 + session_id_len

cipher_len = int.from_bytes(payload[offset:offset+2], 'big')

offset += 2

ciphers = []

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

ciphers.append(int.from_bytes(payload[offset+i:offset+i+2], 'big'))

offset += cipher_len

ext_len = int.from_bytes(payload[offset:offset+2], 'big')

offset += 2

extensions = []

curves = []

point_formats = []

end = offset + ext_len

while offset < end:

ext_type = int.from_bytes(payload[offset:offset+2], 'big')

ext_size = int.from_bytes(payload[offset+2:offset+4], 'big')

extensions.append(ext_type)

if ext_type == 10: # supported_groups

body = payload[offset+4:offset+4+ext_size]

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

for j in range(0, n, 2):

curves.append(int.from_bytes(body[2+j:2+j+2], 'big'))

if ext_type == 11: # ec_point_formats

body = payload[offset+4:offset+4+ext_size]

n = body[0]

point_formats = list(body[1:1+n])

offset += 4 + ext_size

ja3_str = ",".join([

"771",

"-".join(map(str, ciphers)),

"-".join(map(str, extensions)),

"-".join(map(str, curves)),

"-".join(map(str, point_formats)),

])

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

pkts = rdpcap("capture.pcap")

for p in pkts:

if p.haslayer(TCP) and p[TCP].dport == 443 and p.haslayer(Raw):

fp = parse_client_hello(bytes(p[Raw]))

if fp:

print(fp)

break

```

Код сырой, но рабочий. В продакшене берут `ja3` от Salesforce или `pyja3`. Главное — понять логику: порядок расширений и шифров важен. Перестановка двух элементов меняет MD5.

Что видят DPI-системы

Стандартный набор: JA3, JA3S (серверная сторона), JA4 (обновление 2023 года). JA4 учитывает ещё ALPN и SNI-присутствие, устойчивее к рандомизации порядка.

Провайдеры и корпоративные файрволы (Palo Alto, Fortinet, Cisco) уже годами поддерживают JA3-фильтрацию. Роскомнадзор через ТСПУ тоже. Если у вас JA3 от Python `requests` — вас видно за 10 миллисекунд.

Типичные отпечатки:

| Клиент | JA3 MD5 |

|---|---|

| Chrome 120 (Windows) | `cd08e31494f9531f560d64c695473da9` |

| Firefox 121 | `6f3f3d3e2f4f6c8f...` (меняется от версии) |

| curl 8.5 OpenSSL | `3b5074b1b5d032e5620f69f9f700ff0e` |

| Python requests | `1d1c2b3a...` (зависит от OpenSSL) |

| Go net/http | `4d4c3f8e...` |

Цифры MD5 меняются от версии библиотеки. Но факт: у скриптовых клиентов JA3 стабильно не совпадает с браузерным.

Где прокси помогает, а где нет

Прокси не меняет JA3, если это прозрачный TCP-туннель. SOCKS5, HTTP CONNECT, Shadowsocks, WireGuard — все они передают ClientHello как есть. Ваш Python через SOCKS5 всё равно отправит свой JA3.

Другое дело — прокси, который сам инициирует TLS-соединение. Например, корпоративный Squid с `ssl_bump`. Тогда ClientHello генерирует Squid, и его JA3 отличается от браузерного. Все сотрудники выглядят одинаково.

Есть и обратный сценарий: прокси, который умеет мимикрировать. Такие есть, но их мало. Обычно это специализированные решения для обхода DPI.

Как подделать JA3

Самый простой путь — использовать `curl-impersonate`. Это форк curl, который воспроизводит TLS-рукопожатие Chrome или Firefox.

```bash

curl-impersonate-chrome https://tls.browserleaks.com/json

```

Ответ покажет ваш JA3 и JA3N. Если всё настроено — увидите хромовский отпечаток.

Альтернатива — Python с `curl_cffi`:

```python

from curl_cffi import requests

r = requests.get(

"https://tls.browserleaks.com/json",

impersonate="chrome120",

proxies={"https": "socks5://127.0.0.1:1080"}

)

print(r.json())

```

`curl_cffi` подменяет ClientHello на уровне BoringSSL. Работает через SOCKS5 без потери отпечатка.

Третий путь — Go с `utls`:

```go

package main

import (

"fmt"

"io"

"net/http"

"github.com/refraction-networking/utls"

)

func main() {

config := &utls.Config{ServerName: "tls.browserleaks.com"}

conn, _ := utls.Dial("tcp", "tls.browserleaks.com:443", config)

defer conn.Close()

// ... HTTP поверх conn

}

```

`utls` позволяет вручную задать набор шифров, расширений и их порядок. Гибко, но требует понимания TLS.

Реальный пример: сервер за nginx

Допустим, у вас nginx 1.24 с проксированием на бэкенд. Клиент идёт через SOCKS5-прокси. nginx видит ClientHello клиента, не прокси. Если клиент — Python `requests`, JA3 будет `1d1c2b3a...`. DPI зафиксирует это и заблокирует.

Решение: поставить `curl_cffi` на клиенте. Тогда nginx увидит хромовский JA3. Прокси не участвует в TLS, отпечаток сохраняется.

Если же nginx сам терминирует TLS (например, для mTLS или инспекции), то он генерирует свой ClientHello к бэкенду. Тут уже не важно, какой клиент снаружи — внутренний отпечаток будет nginx-овский.

JA4 и что дальше

JA4 появился в 2023. Он разбивает отпечаток на части: `q13d0312h3_55b375c5d22e_...`. Первая часть — версия TLS, ALPN, количество шифров. Вторая — хеш расширений. Третья — сигнатуры.

JA4 устойчивее к рандомизации порядка шифров (некоторые клиенты перемешивают их). Но логика та же: несовпадение с браузерным профилем = красный флаг.

Фильтрация по JA4 пока реже, но крупные CDN (Cloudflare, Akamai) уже используют. Через год-два будет стандартом.

Что делать на практике

Если вам нужно, чтобы трафик через прокси выглядел как браузерный:

1. Используйте `curl_cffi` или `curl-impersonate` на клиенте.

2. Прокси должен быть прозрачным (SOCKS5, CONNECT), не терминировать TLS.

3. Проверяйте отпечаток через `tls.browserleaks.com` или `ja3er.com`.

4. Учитывайте JA3S — серверный отпечаток тоже важен.

5. Следите за версиями: Chrome 120 и Chrome 121 имеют разные JA3.

Если прокси терминирует TLS — вы теряете контроль над отпечатком. В таком случае нужен либо прозрачный туннель, либо прокси с поддержкой мимикрии.

Сервис lexic.ml отдаёт IPv6-прокси, которые работают как прозрачные TCP-туннели. TLS-рукопожатие идёт от клиента к серверу напрямую, JA3 не подменяется. Это значит, что при использовании `curl_cffi` через такой прокси отпечаток остаётся браузерным. Проверить можно тем же `browserleaks`.

Итог

JA3 — это не магия, а просто хеш от параметров ClientHello. Подделать его можно, но нужно понимать, где именно происходит рукопожатие. Прозрачный прокси не трогает TLS — значит, отпечаток ваш. Терминирующий прокси генерирует свой — и все клиенты становятся одинаковыми.

Проверяйте свой JA3 перед тем, как строить схему обхода. Одна команда `curl-impersonate-chrome https://tls.browserleaks.com/json` сэкономит часы отладки.

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