TLS-фингерпринт браузера через прокси: почему JA3 выдаёт вас с головой
Содержание
- Что такое JA3 и откуда он взялся
- Почему через прокси это критично
- Как JA3 вычисляется на практике
- TLS record: type(1) version(2) length(2)
- skip: record header(5) + handshake header(4) + version(2) + random(32)
- Что видят DPI-системы
- Где прокси помогает, а где нет
- Как подделать JA3
- Реальный пример: сервер за nginx
- JA4 и что дальше
- Что делать на практике
- Итог
Что такое 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` сэкономит часы отладки.