TLS-фрагментация против DPI: почему IPv6-прокси обходят блокировки там, где VPN сдаётся
Содержание
- Почему TLS-фрагментация вообще работает
- Как выглядит ClientHello и что там ищут
- Перехватываем ClientHello через кастомный BIO
- IPv6 меняет правила игры
- Комбинация с доменным фронтенгом
- Почему VPN проигрывают
- Практическая настройка фрагментации
- Замеры и реальные цифры
- Ошибки и грабли
- Когда фрагментация не поможет
- Итог
Почему TLS-фрагментация вообще работает
DPI-системы анализируют трафик пакет за пакетом. Они ищут характерные сигнатуры: SNI в ClientHello, определённые расширения TLS, размеры пакетов, тайминги. Логика простая — если разбить ClientHello на куски так, что ни один отдельный пакет не содержит полного SNI, сигнатура не собирается. DPI видит мусор из фрагментов и пропускает соединение.
Ключевой момент: большинство DPI не собирают TCP-поток заново. Они работают на уровне отдельных пакетов или небольших окон. Это архитектурное ограничение — реконструкция потока требует буферизации, памяти, CPU. Провайдеры экономят. Именно на этой экономии строится вся техника фрагментации.
TCP-фрагментация отличается от IP-фрагментации. При TCP-фрагментации мы режем данные на уровне сегментов, но каждый пакет остаётся валидным TCP-сегментом с корректными sequence numbers. DPI должен собрать их заново, чтобы увидеть полный TLS record. При IP-фрагментации режется сам IP-пакет, и промежуточные узлы обязаны собрать его до передачи дальше. Многие DPI IP-фрагменты просто дропают или обрабатывают некорректно.
Как выглядит ClientHello и что там ищут
TLS 1.3 ClientHello — это структура фиксированного формата с расширениями переменной длины. SNI находится в расширении server_name, тип 0x0000. DPI ищет паттерн: байты 0x00 0x00 (тип расширения), затем длина, затем доменное имя в ASCII.
Вот как выглядит сырой ClientHello в Python:
```python
import socket
import ssl
context = ssl.create_default_context()
sock = socket.create_connection(("example.com", 443))
ssock = context.wrap_socket(sock, server_hostname="example.com")
Перехватываем ClientHello через кастомный BIO
import io
class CaptureBIO(io.RawIOBase):
def __init__(self):
self.buffer = b""
def write(self, data):
self.buffer += data
return len(data)
bio = CaptureBIO()
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.wrap_bio(bio, bio, server_hostname="example.com")
print(f"ClientHello size: {len(bio.buffer)} bytes")
print(f"First 16 bytes: {bio.buffer[:16].hex()}")
```
Типичный ClientHello с SNI занимает 250-520 байт. SNI обычно попадает в первые 100-200 байт. Если разрезать первый сегмент на куски по 1-3 байта, SNI размазывается по нескольким пакетам. DPI, который смотрит только на первый пакет в потоке, видит 1-3 байта и ничего не находит.
Проблема в том, что некоторые DPI научились ждать. Они буферизуют первые N байт потока, скажем, 1500, и только потом анализируют. Против таких нужна более агрессивная стратегия — рандомизация размеров фрагментов, добавление задержек между ними, или комбинация с другими техниками.
IPv6 меняет правила игры
IPv6-трафик в большинстве сетей обрабатывается иначе, чем IPv4. Провайдеры часто ставят DPI только на IPv4-маршруты, а IPv6 пускают через более простые фильтры или вообще без инспекции. Причины разные: оборудование старое, IPv6-трафик составляет малую долю, инженеры не настроили зеркалирование.
Вторая причина — архитектура IPv6. Заголовок фиксированный, 40 байт, расширения идут цепочкой. DPI, заточенный под IPv4, часто не умеет корректно парсить цепочку extension headers. Если вставить Hop-by-Hop или Destination Options между основным заголовком и TCP, многие DPI просто теряют пакет из виду.
Третья — MTU. В IPv6 минимальный MTU 1280 байт, но реальный часто 1500. Фрагментация на уровне IPv6 делается через extension header Fragment. DPI, который не обрабатывает этот заголовок, видит только первый фрагмент, а остальные пропускает как неопознанные.
На практике это означает: одна и та же техника фрагментации на IPv4 может не работать, а на IPv6 — работать. Провайдер режет IPv4-DPI, а IPv6 идёт мимо. Прокси на lexic.ml использует это как один из слоёв — IPv6-транспорт плюс фрагментация на уровне приложения.
Комбинация с доменным фронтенгом
Чистая фрагментация работает не везде. Если DPI настроен агрессивно — буферизует поток, собирает сегменты, проверяет тайминги — фрагментация сама по себе не спасёт. Нужен второй слой: маскировка под легитимный домен.
Идея: ClientHello содержит SNI реального домена, который DPI пропускает. Например, cloudflare.com или любого крупного CDN. DPI видит легитимный SNI, пропускает соединение. Но трафик идёт не на cloudflare, а на прокси-сервер, который слушает на том же IP или использует SNI-роутинг.
Схема работает так: клиент отправляет ClientHello с SNI=allowed-domain.com. DPI пропускает. Прокси-сервер принимает соединение, читает SNI, понимает, что это его клиент (по дополнительному маркеру в расширениях или по IP), и начинает проксировать трафик. Если DPI проверяет сертификат — прокси отдаёт валидный сертификат для allowed-domain.com, полученный через Let's Encrypt с DNS-валидацией.
Комбинация фрагментации и доменного фронтенда даёт устойчивость: даже если DPI соберёт ClientHello, он увидит легитимный SNI. Даже если DPI проверит сертификат, он будет валидным.
Почему VPN проигрывают
VPN использует фиксированные протоколы: OpenVPN, WireGuard, IPsec. У каждого есть сигнатура. WireGuard — фиксированный размер handshake, 148 байт, характерные байты. OpenVPN — свой handshake с определёнными паттернами. DPI ловит их за миллисекунды.
Обфускация VPN существует: obfs4, Shadowsocks, VMess. Но это отдельные протоколы, которые тоже детектируются. Shadowsocks долгое время был невидим, пока DPI не научились энтропийному анализу. VMess с TLS-маскировкой работает, но требует правильной настройки, и при ошибке палится.
Главная проблема VPN — централизация. Один IP-адрес сервера, тысячи клиентов. DPI видит: много соединений на один IP, все с похожими паттернами. Блокировка по IP убивает весь сервис. Прокси с IPv6-пулом решает это иначе: каждый клиент получает свой IPv6-адрес из /64 или /48. Блокировка одного адреса не влияет на остальных.
Практическая настройка фрагментации
Вот рабочий пример на Python с использованием scapy для TCP-фрагментации ClientHello:
```python
from scapy.all import *
import random
def fragment_clienthello(target_ip, target_port, clienthello_data):
fragments = []
offset = 0
seq_base = random.randint(1000000, 4000000)
while offset < len(clienthello_data):
chunk_size = random.choice([1, 2, 3, 5, 8])
chunk = clienthello_data[offset:offset + chunk_size]
pkt = IP(dst=target_ip) / TCP(
sport=random.randint(30000, 60000),
dport=target_port,
flags="PA",
seq=seq_base + offset
) / Raw(load=chunk)
fragments.append(pkt)
offset += chunk_size
return fragments
packets = fragment_clienthello("2001:db8::1", 443, clienthello_bytes)
for pkt in packets:
send(pkt)
time.sleep(0.001)
```
Размеры фрагментов рандомизированы: 1, 2, 3, 5, 8 байт. Задержка 1 мс между пакетами. Это создаёт поток из 50-200 мелких пакетов. DPI, который смотрит на первые 5 пакетов, видит 5-20 байт мусора. Некоторые DPI начинают троттлить такие потоки — слишком много мелких пакетов, похоже на атаку. Поэтому важно не переусердствовать: 1-3 байта на фрагмент, но не больше 100 фрагментов.
Альтернатива — фрагментация только первых 100 байт. SNI обычно в этой зоне. Остальной ClientHello идёт нормальными пакетами.
Замеры и реальные цифры
Фрагментация добавляет задержку. Каждый фрагмент — отдельный пакет, а значит, отдельный round-trip на уровне TCP ACK. Если фрагментов 50 и RTT 20 мс, теоретическая задержка 1 секунда. На практике ACK приходят асинхронно, и задержка составляет 50-150 мс для 50 фрагментов.
MTU тоже играет роль. При MTU 1500 и 50 фрагментах по 3 байта общий объём данных 150 байт, но накладные расходы: 40 байт IPv6 + 20 байт TCP = 60 байт на пакет. Итого 50 * 60 = 3000 байт overhead. Это в 20 раз больше полезной нагрузки. Для ClientHello это нормально — он отправляется один раз. Для постоянного трафика такая схема не годится.
Вот таблица сравнения техник по устойчивости к разным типам DPI:
| Техника | Против packet-based DPI | Против stream-based DPI | Задержка (мс) | Overhead |
|---------|------------------------|------------------------|---------------|----------|
| TCP-фрагментация | Работает | Не работает | 50-150 | 20x |
| IP-фрагментация | Работает | Работает частично | 30-80 | 10x |
| SNI-маскировка | Работает | Работает | 0-20 | 1.1x |
| IPv6 + фрагментация | Работает | Работает | 60-180 | 25x |
| VPN (WireGuard) | Не работает | Не работает | 5-15 | 1.05x |
Цифры приблизительные, зависят от сети. Но порядок величин понятен.
Ошибки и грабли
Первая грабля — фрагментация после установки соединения. Некоторые пытаются фрагментировать весь трафик. Это убивает пропускную способность. Фрагментировать нужно только handshake, первые 1-3 пакета.
Вторая — игнорирование MSS. Если фрагменты больше MSS, они всё равно фрагментируются на уровне IP. Двойная фрагментация ломает некоторые DPI, но и некоторые клиенты. Лучше держать фрагменты меньше MSS.
Третья — отсутствие fallback. Если фрагментация не сработала, соединение зависает. Нужен таймаут и повторная попытка без фрагментации или с другой стратегией. Реализация: пробуем фрагментацию, ждём 3 секунды, если нет ответа — пробуем SNI-маскировку, потом прямое соединение.
Четвёртая — IPv6-адресация. Если провайдер выдаёт /128, а не /64, пул адресов не сделать. Нужен туннель до брокера, который даёт /48 или /56. Это отдельная головная боль.
Когда фрагментация не поможет
Если DPI использует ML-модели для классификации трафика, фрагментация может не сработать. Модель обучена на паттернах фрагментированных ClientHello и распознаёт их. Такие системы пока редки, но появляются.
Если провайдер блокирует по IP-адресу назначения, фрагментация бесполезна. Нужен пул адресов и ротация.
Если используется активное зондирование — DPI сам подключается к серверу и проверяет, что он отвечает. Прокси должен корректно отвечать на зонды, маскируясь под легитимный сервер.
Фрагментация — не серебряная пуля. Это один слой в стратегии обхода. Работает в комбинации с IPv6-пулом, SNI-маскировкой, ротацией адресов. По отдельности каждый слой пробивается, вместе — создают достаточную энтропию, чтобы DPI не мог принять решение.
Итог
TLS-фрагментация эксплуатирует архитектурное ограничение DPI: анализ на уровне пакетов вместо потока. IPv6 усиливает эффект, потому что DPI часто не настроен на IPv6 или не умеет парсить extension headers. Комбинация с SNI-маскировкой и пулом адресов даёт устойчивость к большинству известных DPI.
VPN проигрывает из-за фиксированных сигнатур и централизации. Прокси с фрагментацией и IPv6-пулом — гибче. Но требует правильной настройки: рандомизация фрагментов, таймауты, fallback-стратегии. Без этого — просто ещё один костыль, который палится на второй день.