Как TLS-фрагментация обходит DPI: настройка для прокси-цепочек без разрыва соединений
Содержание
- Почему DPI ломается на фрагментации
- Как выглядит фрагментированный ClientHello
- Настройка через nftables
- Python-прокси с фрагментацией
- Цепочки прокси и разрыв соединений
- Реальный пример: сервер с nginx и фрагментацией
- Проверка: видит ли DPI твой SNI
- JA3 и почему фрагментация не панацея
- Прокси-цепочка: клиент → socks5 → http → сервер
- Когда фрагментация вредит
- Итог: что работает в 2024
DPI не расшифровывает трафик. Ему это и не нужно. Большинство фильтров смотрят на первые пакеты TCP-сессии: ClientHello с полем SNI, размер сегмента, набор cipher suites. Если SNI совпадает со списком — соединение рвётся или подменяется сертификат. Задача обхода — не дать фильтру увидеть ClientHello целиком в одном сегменте.
TLS-фрагментация режет первый пакет рукопожатия на несколько TCP-сегментов. DPI, который работает на уровне потока, должен собрать их обратно. Многие не собирают — либо по производительности, либо по архитектуре. Они видят первый сегмент без полного SNI, не находят сигнатуру и пропускают трафик. Просто и эффективно.
Почему DPI ломается на фрагментации
Ключевая проблема фильтров — stateful-инспекция дорогая. Держать буфер на каждое TCP-соединение для 100 000 сессий одновременно — это гигабайты памяти и серьёзная нагрузка на CPU. Поэтому часть DPI работает в режиме «посмотрел первый пакет — вынес вердикт». Фрагментированный ClientHello в такой режим не вписывается.
Есть нюанс с TLS 1.3. В нём ClientHello шифруется частично — точнее, расширения после определённого поля. Но SNI остаётся в открытом виде, потому что серверу нужно выбрать сертификат. ESNI/ECH решает проблему, но поддержка на серверах мизерная. Так что SNI — главная цель фильтров.
Фрагментация работает не всегда. Продвинутые DPI (например, решения на базе Suricata с включённым stream reassembly) собирают сегменты и инспектируют полный поток. Против них нужны другие методы — десинхронизация TTL, подмена порядка пакетов, TCP-level обфускация. Но фрагментация — первый и самый дешёвый слой.
Как выглядит фрагментированный ClientHello
Обычный ClientHello — это один TCP-сегмент размером 500-700 байт. При фрагментации он режется на два-три куска. Например, первый сегмент содержит TLS record header (5 байт) и первые 10 байт handshake — недостаточно для полного SNI. Второй несёт остальное.
Фильтр видит первый сегмент. Он не может разобрать структуру — нет полного handshake. Если DPI наивный, он просто пропускает. Если умный — ждёт следующий сегмент. Но ждать он готов не всегда: таймауты, лимиты буфера, потеря пакетов.
Важно: фрагментация на уровне TLS (внутри записи) и на уровне TCP (сегментация) — разные вещи. Первое делается через `openssl` или специализированные библиотеки. Второе — через `iptables`, `nftables` или socks5-прокси с поддержкой фрагментации. Для обхода DPI чаще используют TCP-фрагментацию, потому что она не требует модификации TLS-стека.
Настройка через nftables
`nftables` умеет резать TCP-сегменты через `tcp option maxseg size`. Это грубый, но работающий метод. Уменьшение MSS заставляет отправителя дробить данные на мелкие сегменты.
```
nft add table ip mangle
nft add chain ip mangle output { type filter hook output priority mangle\; }
nft add rule ip mangle output tcp dport 443 tcp option maxseg size set 88
```
MSS 88 байт — это экстремально мало. Стандартный Ethernet MTU 1500, MSS обычно 1460. С 88 байтами ClientHello в 600 байт разобьётся на 7 сегментов. DPI точно подавится. Минус — падает пропускная способность. Для веб-сёрфинга терпимо, для скачивания файлов — боль.
Более щадящий вариант — MSS 536 (минимальный по RFC 879). ClientHello разобьётся на 2 сегмента. Этого часто достаточно.
```
nft add rule ip mangle output tcp dport 443 tcp option maxseg size set 536
```
Правило применяется ко всем исходящим соединениям на 443. Если нужно точечно — добавь `ip daddr` или `meta cgroup`.
Python-прокси с фрагментацией
Когда nftables недостаточно (например, нужна фрагментация только для конкретных доменов), пишут socks5-прокси с ручной фрагментацией. Логика простая: перехватить первые N байт от клиента, отправить их отдельным `send()`, потом остальное.
```python
import socket
import select
import struct
def fragment_forward(client_sock, remote_host, remote_port, fragment_size=10):
remote = socket.create_connection((remote_host, remote_port))
client_sock.setblocking(False)
remote.setblocking(False)
first_chunk = True
sockets = [client_sock, remote]
while True:
r, _, _ = select.select(sockets, [], [], 30)
if not r:
break
for s in r:
data = s.recv(65536)
if not data:
return
if s is client_sock:
if first_chunk and len(data) > fragment_size:
remote.sendall(data[:fragment_size])
remote.sendall(data[fragment_size:])
first_chunk = False
else:
remote.sendall(data)
else:
client_sock.sendall(data)
```
Ключевой момент — два отдельных `sendall()`. Первый уходит с 10 байтами, второй — с остальным. Между ними TCP-стек может склеить пакеты (Nagle), поэтому нужно `TCP_NODELAY`:
```python
remote.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
client_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
```
Без `TCP_NODELAY` ядро подождёт 40 мс (или до ACK) и отправит оба куска одним сегментом. Вся фрагментация насмарку. Это классические грабли.
Цепочки прокси и разрыв соединений
Фрагментация ломается, когда между клиентом и целевым сервером стоит цепочка прокси. Первый прокси фрагментирует ClientHello, второй — собирает обратно для пересылки. Или ещё хуже: промежуточный прокси использует HTTP CONNECT и не трогает тело туннеля, но при этом сам устанавливает TLS к следующему звену.
Проблема в том, что фрагментация должна происходить на последнем хопе перед DPI. Если DPI стоит между клиентом и первым прокси — фрагментируй на клиенте. Если DPI между первым и вторым — фрагментируй на первом. Универсального решения нет, нужно понимать топологию.
Второй момент — MTU. Если фрагментация уменьшает MSS, а в цепочке есть туннель (WireGuard, GRE), эффективный MTU падает. 1500 - 60 (WireGuard overhead) - 40 (IPv6) = 1400. С MSS 536 остаётся запас, но при MSS 88 возможны проблемы с производительностью.
Реальный пример: сервер с nginx и фрагментацией
Пример: сервер на nginx 1.24, Ubuntu 22.04, стоит за DPI, который режет по SNI. Клиенты подключаются напрямую, без прокси. Задача — пропустить трафик, не меняя клиентское ПО.
Решение на стороне сервера невозможно — фрагментация работает на клиенте или на промежуточном узле. Если DPI между клиентом и сервером, сервер ничего не сделает. Нужен прокси перед сервером или на клиенте.
Другой пример: клиент использует `curl` через socks5-прокси на localhost. Прокси фрагментирует ClientHello. DPI видит два сегмента, не находит SNI, пропускает. Соединение устанавливается, TLS-рукопожатие проходит. Проверить можно так:
```bash
curl -x socks5h://127.0.0.1:1080 https://example.com -v 2>&1 | grep -i "TLS\|SSL"
```
Если видишь `SSL connection using TLSv1.3` — работает. Если `Connection reset by peer` — DPI собрал сегменты или фрагментация не применилась.
Проверка: видит ли DPI твой SNI
Прежде чем настраивать фрагментацию, проверь, что DPI вообще смотрит на SNI. Запусти `tcpdump` на клиенте и посмотри, как выглядит ClientHello:
```bash
sudo tcpdump -i eth0 -A 'tcp port 443 and tcp[((tcp[12] & 0xf0) >> 2):4] = 0x16030100' -c 5
```
Ищешь строку с доменом в открытом виде. Если видишь `example.com` — DPI тоже видит. Если трафик уже фрагментирован (например, через VPN) — не увидишь.
Второй тест — подключение к заблокированному домену без фрагментации и с ней. Если без фрагментации соединение рвётся через 1-2 секунды после ClientHello, а с фрагментацией проходит — метод работает. Если рвётся в обоих случаях — DPI собирает сегменты или использует другой признак (например, JA3-фингерпринт).
JA3 и почему фрагментация не панацея
JA3 — это хеш от параметров ClientHello: версия TLS, cipher suites, расширения, эллиптические кривые. DPI может блокировать не по SNI, а по JA3. Например, если JA3 совпадает с известным клиентом Tor или прокси-библиотекой.
Фрагментация против JA3 не работает. DPI собирает сегменты, вычисляет хеш, сравнивает с чёрным списком. Здесь нужна рандомизация параметров ClientHello — смена порядка cipher suites, добавление фейковых расширений. Это делается через `curl_cffi`, `utls` или кастомные сборки OpenSSL.
Комбинация фрагментации и рандомизации JA3 закрывает больше сценариев. Но каждая дополнительная манипуляция увеличивает задержку. Фрагментация добавляет 1-2 мс на соединение (два `send()` вместо одного). Рандомизация JA3 — ещё 5-10 мс на генерацию параметров. Для веба терпимо, для API с тысячами запросов в секунду — заметно.
Прокси-цепочка: клиент → socks5 → http → сервер
Сложный сценарий: клиент подключается к socks5-прокси, тот форвардит через HTTP CONNECT-прокси, дальше — к серверу. DPI стоит между socks5 и HTTP-прокси. Фрагментация нужна на socks5-прокси, но он не видит TLS-трафик — он просто форвардит байты.
Решение: socks5-прокси должен фрагментировать первый пакет после установки туннеля. Но он не знает, где начинается ClientHello — клиент может отправить сначала HTTP-запрос, потом TLS. В socks5 с `CONNECT` клиент сразу шлёт TLS, так что первый пакет — почти всегда ClientHello.
Проблема в том, что socks5-прокси не парсит TLS. Он фрагментирует вслепую. Если первый пакет — не ClientHello (например, клиент шлёт HTTP/2 preface), фрагментация ломает протокол. Нужна проверка: первый байт `0x16` (TLS handshake), версия `0x0301`-`0x0303`.
```python
def is_tls_handshake(data):
return len(data) >= 3 and data[0] == 0x16 and data[1] == 0x03
```
Если `True` — фрагментируй. Если `False` — форварди как есть. Это защищает от поломки не-TLS трафика.
Когда фрагментация вредит
Фрагментация увеличивает количество пакетов. На плохих каналах (потеря 5-10%) это критично: больше пакетов — больше потерь. TCP-ретрансмиссии растут, задержка увеличивается. Для голосового трафика или игр — не вариант.
Второй минус — некоторые серверы и CDN режут фрагментированные ClientHello. Cloudflare, например, принимает, но Akamai в отдельных конфигурациях может сбросить соединение. Проверяй на целевых доменах.
Третий минус — детектируемость. DPI, который видит фрагментированный ClientHello, может пометить соединение как подозрительное. Не заблокировать сразу, но добавить в лог. Через месяц — заблокировать. Фрагментация — это не невидимость, это обход конкретного фильтра.
Итог: что работает в 2024
Фрагментация через `nftables` с MSS 536 — базовый уровень. Работает против простых DPI, ломается о stateful-инспекцию. Python-прокси с ручной фрагментацией — гибче, но требует контроля `TCP_NODELAY`. Для цепочек прокси фрагментация должна быть на последнем хопе перед DPI.
Комбинация с рандомизацией JA3 и ECH (когда появится поддержка) — следующий шаг. Пока ECH не везде, фрагментация остаётся дешёвым и быстрым способом. Главное — тестировать на конкретной инфраструктуре: то, что работает против одного DPI, может не работать против другого.