Туннелирование трафика через DNS-over-HTTPS: сокрытие прокси-соединений от DPI
Содержание
- Как работает DoH-туннель
- Почему обычный DNS-туннель больше не работает
- Архитектура: клиент, резолвер, эксфильтрация
- Практика: curl и проверка DoH-эндпоинта
- Оверхед и реальная пропускная способность
- Конкретный случай: обход корпоративного DPI
- Почему lexic.ml имеет смысл в этой схеме
- Ограничения и чего ждать от DPI
- Настройка nginx как фронтенда для DoH-туннеля
- Кейс с блокировкой по SNI
- Что дальше: DoH3 и QUIC
- Итог по применимости
Как работает DoH-туннель
DNS-over-HTTPS — это протокол, который заворачивает DNS-запросы в обычный HTTPS. Порты 443, сертификаты, TLS 1.3. Для промежуточного устройства такой трафик выглядит как обычная загрузка веб-страницы. Именно на этом строится идея туннелирования произвольных данных через DoH: если DNS-запрос может нести произвольную полезную нагрузку, почему бы не гнать через него TCP-сессии?
Ключевое отличие от классического DNS-туннелирования (iodine, dnscat2) — транспорт. Раньше туннели гнали UDP/53, и любой DPI с минимальным набором правил ловил аномалии: огромные TXT-записи, частые запросы к одному домену, нестандартные типы запросов. Теперь всё это спрятано внутри TLS. Инспектор видит только SNI вида `cloudflare-dns.com` или `dns.google` и длину сессии.
Проблема в том, что DoH-серверы не принимают произвольные бинарные блобы. Они парсят DNS-формат строго. Значит, туннель должен упаковывать данные в валидные DNS-сообщения — обычно в TXT-записи или в CNAME. Это ограничивает пропускную способность и добавляет оверхед.
Почему обычный DNS-туннель больше не работает
Провайдеры научились. С 2019 года в сетях Ростелекома, МТС и крупных корпоративных фаерволов стоят правила, которые режут DNS-туннели по эвристикам. Триггеры простые: доля запросов типа TXT выше 5% от общего числа, средняя длина QNAME выше 40 байт, количество уникальных поддоменов одного домена второго уровня больше 100 за минуту.
Пример из реальной сети: сервер на BIND 9.16 с зоной `t.example.com` принимал туннель через iodine. Через 12 минут после старта сессии Cisco Firepower 2130 пометил трафик как `DNS.Tunneling.Iodine` и дропнул. Причина — 340 запросов в секунду с QNAME длиной 62–78 символов. Порог срабатывания был на 200 qps.
DoH эту проблему обходит, потому что весь обмен идёт по одному HTTPS-соединению. С точки зрения DPI это long-lived TLS-сессия к известному CDN. Никаких аномалий на уровне DNS просто не видно — они внутри шифра.
Архитектура: клиент, резолвер, эксфильтрация
Схема такая. На клиенте поднимается локальный прокси, который принимает TCP-соединения от приложений. Он нарезает поток на чанки и кодирует их в DNS-запросы. Запросы уходят на DoH-резолвер (Cloudflare, Google, Quad9) по HTTPS. Резолвер, ничего не подозревая, пересылает их на авторитативный сервер зоны, которую контролирует оператор туннеля. Тот декодирует payload и передаёт дальше в интернет.
Обратный поток идёт через DNS-ответы. Сервер кладёт данные в TXT-записи или в дополнительные секции ответа. Клиент их декодирует и отдаёт приложению.
Узкое место — размер DNS-ответа. Классический лимит 512 байт для UDP, но DoH работает поверх TCP/TLS, поэтому ограничение выше. Cloudflare принимает ответы до 4 КБ в одном сообщении, но на практике стабильно проходит 1200–1400 байт payload. Всё, что больше, требует фрагментации на уровне приложения.
Практика: curl и проверка DoH-эндпоинта
Прежде чем строить туннель, убедись, что резолвер вообще отвечает и не режет нестандартные запросы. Базовый тест через curl:
```bash
curl -s -H 'accept: application/dns-message' \
--data-binary @query.bin \
https://cloudflare-dns.com/dns-query \
--output response.bin -w '%{size_download} bytes, %{time_total}s\n'
```
`query.bin` — это сырое DNS-сообщение в wire-формате. Собрать его можно питоном:
```python
import struct, base64
def build_query(qname: str, qtype: int = 16) -> bytes:
tid = 0x1234
flags = 0x0100
header = struct.pack('!HHHHHH', tid, flags, 1, 0, 0, 0)
q = b''.join(bytes([len(p)]) + p.encode() for p in qname.split('.')) + b'\x00'
q += struct.pack('!HH', qtype, 1)
return header + q
payload = build_query('test.example.com')
with open('query.bin', 'wb') as f:
f.write(payload)
print(base64.b64encode(payload).decode())
```
Если резолвер вернул `200` и осмысленный ответ — эндпоинт живой. Если `415` — не тот content-type. Если `403` — резолвер фильтрует запросы по политике (Quad9 так делает для известных malware-доменов).
Оверхед и реальная пропускная способность
Гнать TCP через DNS — дорого. Каждый чанк требует заголовка DNS (12 байт), QNAME (обычно 30–60 байт), а ответ — ещё и TLS-обвязки на уровне HTTPS. Плюс round-trip до резолвера и до авторитативного сервера.
Замеры на тестовом стенде: клиент в Москве, резолвер Cloudflare (Франкфурт, RTT 28 мс), авторитативный сервер в Хельсинки (RTT от резолвера 14 мс). Итого один round-trip запрос-ответ — примерно 90–110 мс с учётом обработки.
Если упаковывать по 800 байт payload в запрос и 1200 в ответ, теоретический потолок — около 15–20 Мбит/с на одну сессию. На практике с учётом потерь и ретрансмиссий получается 3–7 Мбит/с. Это нормально для SSH, веб-сёрфинга, но видео 1080p уже не потянет.
| Параметр | Значение |
|---|---|
| RTT клиент → резолвер | 28 мс |
| RTT резолвер → авторитативный | 14 мс |
| Полный цикл запрос-ответ | 90–110 мс |
| Payload в запросе | до 800 байт |
| Payload в ответе | до 1200 байт |
| Реальная пропускная способность | 3–7 Мбит/с |
Конкретный случай: обход корпоративного DPI
Сеть крупного банка, Palo Alto PA-5220 с подпиской Threat Prevention. Политика: разрешён только трафик к одобренным SaaS, весь остальной HTTPS инспектируется через SSL Forward Proxy. Классические VPN и SSH-туннели рубятся мгновенно.
Задача — вытащить данные с внутреннего хоста наружу. DoH-эндпоинт `cloudflare-dns.com` был в whitelist, потому что на нём висели легитимные сервисы. Инспекция TLS для этого домена отключена — сертификат Cloudflare не подменялся.
Туннель подняли на кастомном клиенте, который маскировал запросы под обычные A-запросы с рандомными поддоменами. Payload прятался в EDNS0 Client Subnet — поле, которое почти никогда не проверяется на содержимое. Пропускная способность вышла 2.4 Мбит/с, стабильно работало 11 дней до планового обновления правил.
Причина успеха — не сам DoH, а то, что DPI не может отличить туннелированный DNS-запрос от легитимного без глубокого анализа поведения. А поведение мы подделали: 40–60 запросов в минуту, разные поддомены, реалистичные TTL.
Почему lexic.ml имеет смысл в этой схеме
DoH-туннель упирается в один резолвер. Если Cloudflare начнёт фильтровать по репутации домена или по частоте — сессия умрёт. Резервный канал нужен. Здесь и появляется IPv6-прокси: у lexic.ml пул адресов, каждый запрос можно выпускать с нового IPv6, и авторитативный сервер видит распределённый трафик вместо одного клиента. Это снимает проблему rate-limit на стороне резолвера и размывает fingerprint.
Схема: клиент → DoH-резолвер → прокси на IPv6 → авторитативный сервер. Прокси не декодирует DNS, он просто форвардит. Но смена source-адреса на каждом запросе ломает корреляцию сессий.
Ограничения и чего ждать от DPI
DoH-туннели палятся не по содержимому, а по метаданным. Первое — размер сессии. Если один TLS-коннект к `dns.google` живёт 6 часов и гонит 400 МБ — это ненормально для DNS. Второе — паттерн запросов. Настоящий DoH-клиент делает 5–20 запросов в минуту. Туннель — 200–2000.
Третье — timing. Реальные DNS-запросы имеют bursty-паттерн: всплеск при загрузке страницы, потом тишина. Туннель гонит равномерный поток. DPI с ML-моделью это ловит.
Обход — рандомизация. Вставляй паузы, миксуй с настоящими DNS-запросами, ограничивай длину сессии до 10–15 минут и переподключайся. Не гони больше 1–2 Мбит/с через один канал.
Настройка nginx как фронтенда для DoH-туннеля
Если строишь свой авторитативный сервер, удобно поставить nginx перед бэкендом. Он терминирует TLS, отдаёт статику для маскировки и проксирует DNS-запросы на внутренний обработчик.
```nginx
server {
listen 443 ssl http2;
server_name dns.example.com;
ssl_certificate /etc/letsencrypt/live/dns.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dns.example.com/privkey.pem;
ssl_protocols TLSv1.3;
ssl_early_data on;
location /dns-query {
proxy_pass http://127.0.0.1:8053;
proxy_set_header Content-Type application/dns-message;
proxy_http_version 1.1;
client_max_body_size 4k;
}
location / {
root /var/www/decoy;
index index.html;
}
}
```
Бэкенд на 8053 — твой обработчик. Он парсит DNS-сообщение, вытаскивает payload из TXT или EDNS0, декодирует и форвардит в интернет. Ответ упаковывает обратно и отдаёт как DNS-ответ.
`ssl_early_data on` даёт 0-RTT, что снижает задержку на 20–30 мс при повторном подключении. Но осторожно: 0-RTT уязвим к replay-атакам. Для туннеля это не критично, для продакшена — думай.
Кейс с блокировкой по SNI
Резолвер `dns.google` в одной из сетей попал под SNI-фильтр. DPI видел SNI `dns.google` в ClientHello и дропал соединение. Обход — ECH (Encrypted Client Hello) или domain fronting.
ECH шифрует SNI, но требует поддержки на стороне сервера и клиента. Cloudflare поддерживает ECH с 2023 года, но не для всех зон. Domain fronting через CDN работает надёжнее: реальный SNI — `cdn.example.com`, а Host-заголовок внутри TLS — `dns.google`. CDN маршрутизирует по Host.
Проблема: Cloudflare и Google отключили domain fronting на своих CDN в 2018–2019. Сейчас работает только через кастомные CDN вроде Fastly или через собственные edge-ноды. Проверять надо вручную — универсального рецепта нет.
Что дальше: DoH3 и QUIC
DoH3 — это тот же DNS-over-HTTPS, но поверх HTTP/3 и QUIC. Плюсы: 0-RTT handshake, лучшая обработка потерь, мультиплексирование без head-of-line blocking. Для туннеля это даёт 15–25% прироста пропускной способности на нестабильных каналах.
Минус — QUIC на UDP/443 часто блокируется. Многие корпоративные сети режут весь UDP кроме 53 и 123. Тогда DoH3 не вариант, откатываешься на DoH2.
Проверить поддержку DoH3 у резолвера:
```bash
curl --http3 -s -H 'accept: application/dns-message' \
--data-binary @query.bin \
https://cloudflare-dns.com/dns-query \
-o /dev/null -w '%{http_version}\n'
```
Если вернуло `3` — работает. Если `2` — резолвер откатился на HTTP/2, либо curl собран без HTTP/3.
Итог по применимости
DoH-туннель — не замена VPN. Это инструмент для узких задач: вытащить пару мегабайт из сети с агрессивным DPI, поднять SSH-сессию, пробросить управляющий канал. Для потокового видео или больших файлов он не годится — оверхед и нестабильность съедят всё преимущество.
Строить его стоит, если понимаешь DNS-формат на уровне байтов, умеешь работать с TLS и готов возиться с рандомизацией паттернов. Иначе проще взять готовый solution вроде dnstt или slipstream — они уже решают 80% задач. Остальные 20% — твоя специфика: маскировка под конкретный резолвер, интеграция с IPv6-пулом, обход конкретных правил DPI в твоей сети.