Как TLS-фрагментация пакетов обходит DPI: настройка разбиения ClientHello
Содержание
- Почему SNI вообще виден
- Механика: что именно режем
- tcpdump как первый шаг диагностики
- Linux: iptables и nftables
- zapret, split на 2 сегмента с задержкой 1 мс
- Стратегии под разных провайдеров
- Python: ручная фрагментация через scapy
- nginx как точка тестирования
- Где фрагментация не спасает
- Замеры: что реально меняется
- Кейс: провайдер режет по первому пакету
- Кейс: мобильный оператор и TLS 1.3
- Кейс: корпоративный прокси и JA3
- Чек-лист перед настройкой
Первый пакет TLS-рукопожатия — лакомый кусок для DPI. В нём открытым текстом лежит SNI: имя сайта, к которому вы идёте. Провайдер видит `example.com` ещё до того, как установится шифрованный канал. Фрагментация ломает эту идиллию: разрезаем ClientHello на куски так, что SNI не помещается целиком ни в один TCP-сегмент. DPI, который смотрит на один пакет за раз, видит мусор.
Почему SNI вообще виден
TLS 1.2 и 1.3 передают расширение `server_name` в ClientHello открытым текстом. Это не баг, это фича: сервер по SNI выбирает сертификат, если на одном IP висит сотня сайтов. Шифрование начинается только после ServerHello. До этого момента весь handshake — plaintext.
DPI-системы этим пользуются. Простой парсер ищет в TCP-потоке байтовую сигнатуру `0x00 0x00` (тип расширения server_name) и вытаскивает домен. Дальше — сравнение со списком запрещённых. Совсем простые системы смотрят только на первый пакет соединения. Умные собирают поток и режут по сигнатурам. Против первых и работает фрагментация.
Механика: что именно режем
ClientHello — это структура с заголовком 5 байт (тип 0x16, версия, длина) и телом. Внутри тела — версия TLS, random, session_id, cipher_suites, compression, extensions. SNI сидит в extensions, ближе к концу. Если разрезать пакет так, чтобы граница пришлась ровно на середину имени хоста, DPI получит первый сегмент с обрывком строки.
TCP сам по себе потоковый, сегментация — деталь реализации. Но DPI часто работает на уровне «один пакет = одна единица анализа». Разрыв в 1 байт между сегментами ломает парсер, который не умеет склеивать поток. Есть варианты: разбить на два куска, на N кусков по 1 байту, вставить пустой сегмент между ними.
tcpdump как первый шаг диагностики
Прежде чем что-то ломать, посмотрите, как выглядит ваш ClientHello. Захват на loopback при обращении к любому HTTPS-хосту:
```bash
sudo tcpdump -i lo -s 0 -w /tmp/hs.pcap 'tcp port 443'
curl -s https://example.com -o /dev/null
sudo tcpdump -r /tmp/hs.pcap -A | grep -A2 example
```
Вы увидите ASCII-строку `example.com` прямо в дампе. Это и есть SNI. Если провайдер режет по ней — фрагментация поможет. Если режет по IP или по TLS-фингерпринту (JA3) — не поможет, там другая история.
Linux: iptables и nftables
Классический костыль — модуль `tpws` из zapret или самописные правила через `NFQUEUE`. Но есть и грубый способ на уровне ядра: `iptables` с `TCPMSS`. Он не режет ClientHello, он уменьшает MSS, из-за чего сегменты становятся мельче. Работает криво, ломает MTU на некоторых путях.
Гибче — `nftables` с payload matching. Пример: пометить первые пакеты нового соединения и отправить в userspace-демон, который разрежет их вручную. Но проще взять готовое. `zapret` умеет `--dpi-desync=split2` и `--dpi-desync=disorder`. Первый режет на два сегмента, второй отправляет их в обратном порядке — DPI видит второй кусок раньше первого и сходит с ума.
```bash
zapret, split на 2 сегмента с задержкой 1 мс
nfqws --dpi-desync=split2 --dpi-desync-split-pos=2 \
--dpi-desync-repeats=2 --dpi-desync-fooling=md5sig \
--qnum=200
```
`split-pos=2` означает разрез после второго байта TLS-записи. SNI гарантированно попадает во второй сегмент и не виден в первом. `fooling=md5sig` добавляет фейковый TCP-опции, чтобы некоторые DPI отбросили пакет как битый.
Стратегии под разных провайдеров
Универсальной настройки нет. Ростелеком, МТС, Билайн, мелкие региональные — у всех свои DPI-коробки. Т-Банк (Тинькофф) мобильный режет агрессивнее проводного. Иногда `split2` работает, иногда нужен `disorder`, иногда — `fake` с подменой TTL.
Практика: начинайте с `split2` на позиции 2. Если не работает — `disorder`. Если и это мимо — `fake` с TTL 8 (пакет-обманка умирает, не доходя до сервера, но DPI его видит и запоминает «плохой» поток). Тестировать на конкретном домене:
```bash
curl -v --max-time 5 https://rutracker.org 2>&1 | head -20
```
Пустой ответ или `Connection reset` — DPI режет. Успешный handshake — обход работает. Гоняйте по списку доменов, смотрите, где рвётся.
Python: ручная фрагментация через scapy
Если хочется понять, как это устроено под капотом, — соберите ClientHello сами и разрежьте. Ниже — грубый, но рабочий перехват: слушаем исходящий SYN, ждём первый data-пакет, режем его на два и шлём через raw socket.
```python
from scapy.all import *
import random
def split_client_hello(pkt, pos=2):
if not pkt.haslayer(TCP) or not pkt.haslayer(Raw):
return
payload = bytes(pkt[Raw].load)
if len(payload) < pos + 1 or payload[0] != 0x16:
return
p1, p2 = payload[:pos], payload[pos:]
ip = pkt[IP]
tcp = pkt[TCP]
seq = tcp.seq
s1 = IP(dst=ip.dst)/TCP(sport=tcp.sport, dport=tcp.dport,
flags="PA", seq=seq, ack=tcp.ack)/Raw(load=p1)
s2 = IP(dst=ip.dst)/TCP(sport=tcp.sport, dport=tcp.dport,
flags="PA", seq=seq+pos, ack=tcp.ack)/Raw(load=p2)
send(s1, verbose=0)
time.sleep(0.001)
send(s2, verbose=0)
```
Код учебный: он не глушит оригинальный пакет, поэтому настоящий ClientHello тоже уйдёт. Для реального перехвата нужен `NFQUEUE` или `netfilter`-хук. Но механику видно: `pos=2` режет после второго байта, SNI уезжает во второй сегмент. DPI, который смотрит только первый пакет, видит `16 03` и ничего больше.
nginx как точка тестирования
Чтобы проверить обход без внешнего сервиса, поднимите свой TLS-сервер и смотрите, доходят ли до него полные ClientHello. Конфиг минимальный:
```nginx
server {
listen 443 ssl;
server_name test.local;
ssl_certificate /etc/ssl/test.crt;
ssl_certificate_key /etc/ssl/test.key;
ssl_protocols TLSv1.2 TLSv1.3;
access_log /var/log/nginx/tls.log;
}
```
Если фрагментация ломает handshake на стороне сервера (не DPI, а сам nginx), вы увидите `SSL_do_handshake() failed` в error.log. Правильная фрагментация для сервера прозрачна: TCP склеит сегменты, nginx соберёт ClientHello целиком. Ломается только то, что смотрит на пакеты по отдельности.
Где фрагментация не спасает
Против ESNI/ECH (Encrypted Client Hello) фрагментация не нужна — там SNI уже зашифрован. Против DPI, который собирает поток и ищет сигнатуры в склейке, — тоже мимо. Против блокировки по IP — вообще бесполезно.
Отдельная история — QUIC. Там ClientHello уезжает в UDP, и TCP-фрагментация не при делах. Нужен `--dpi-desync` с поддержкой UDP или отключение QUIC в браузере (`chrome://flags/#enable-quic` → Disabled), чтобы трафик ушёл по TCP и попал под ваши правила.
Замеры: что реально меняется
Фрагментация стоит задержки. Разрез на 2 сегмента добавляет ~1 мс на handshake в локальной сети. `disorder` с реордерингом — до 5–15 мс, потому что сервер ждёт второй сегмент. `fake` с TTL-обманкой — почти без задержки, но требует аккуратной настройки TTL, иначе обманка долетит до сервера и он сбросит соединение.
| Метод | Добавка к handshake | Совместимость | Ломает что |
|---|---|---|---|
| split2 | 1–3 мс | высокая | ничего |
| disorder | 5–15 мс | средняя | некоторые фаерволы |
| fake (TTL 8) | <1 мс | низкая | пути с малым TTL |
| фрагментация на 1 байт | 10–40 мс | высокая | медленные каналы |
Цифры — с моих замеров на канале 100 Мбит/с, RTT до сервера 12 мс. На мобильном интернете всё хуже: реордеринг там штатное явление, `disorder` может слиться с естественными перестановками и не дать эффекта.
Кейс: провайдер режет по первому пакету
Сервер на nginx 1.24 за NAT, клиент за МТС домашним. `curl https://blocked.example` падает с `Connection reset by peer` через 200 мс после начала handshake. tcpdump показывает: клиент шлёт ClientHello, сервер отвечает ServerHello, но клиентский ACK не доходит — RST от провайдера.
Причина: DPI видит SNI в первом data-пакете и инжектит RST. Решение: `zapret` с `--dpi-desync=split2 --dpi-desync-split-pos=2`. После включения — handshake проходит, скорость падает на 2 мс. Проверено на 15 доменах, все открываются.
Кейс: мобильный оператор и TLS 1.3
Билайн мобильный, Android 13, Chrome. Сайт грузится через раз. Захват показывает: при TLS 1.3 ClientHello уходит целиком, DPI режет. При TLS 1.2 — тоже режет, но иначе: рвёт после ServerHello.
Причина: DPI различает версии по байту в заголовке и применяет разные правила. Решение: `--dpi-desync=disorder --dpi-desync-split-pos=1 --dpi-desync-repeats=3`. Три повтора нужны, потому что мобильная сеть теряет сегменты. После настройки — стабильно, но handshake вырос с 80 до 140 мс.
Кейс: корпоративный прокси и JA3
Офис за корпоративным прокси, который фильтрует по JA3-фингерпринту (хэш параметров ClientHello). Фрагментация не помогает: прокси собирает поток и считает хэш по склеенному ClientHello. Хоть на 100 кусков режь.
Причина: JA3 считается по содержимому, а не по разбиению. Решение — менять сам ClientHello: другие cipher_suites, другой порядок extensions, другой TLS-стек. В браузере это `curl_cffi` или `curl-impersonate`. Фрагментация тут — мёртвый номер, и это важно понимать до того, как полдня убить на подбор `split-pos`.
Чек-лист перед настройкой
Сначала выясните, что именно режет. tcpdump + Wireshark, фильтр `tcp.port==443 && tcp.len>0`. Смотрите, на каком пакете приходит RST или FIN. Если RST после первого data-пакета — фрагментация поможет. Если после ServerHello — режут по ответу сервера, нужны другие методы.
Проверьте MTU пути: `ping -M do -s 1472 example.com`. Если пакеты больше 1400 не проходят, фрагментация усугубит проблему — сегменты будут дробиться на IP-уровне, и DPI увидит ещё больше мусора. В таких случаях `split-pos` ставят ближе к концу, чтобы куски влезали в MTU.
И последнее: тестируйте на живом трафике, а не на `curl`. Браузер шлёт ClientHello иначе — другие extensions, другой размер. То, что работает в curl, может не работать в Chrome. Если нужен стабильный обход на разных провайдерах без ручной возни с `split-pos`, посмотрите в сторону готовых решений вроде lexic.ml — там IPv6-прокси с 2015 года, и трафик идёт мимо DPI на уровне маршрутизации, а не парсинга пакетов.