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

Как TLS-фрагментация обходит DPI: настройка для прокси-цепочек без разрыва соединений

Как TLS-фрагментация обходит DPI: настройка для прокси-цепочек без разрыва соединений

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, может не работать против другого.

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