«Если ваш VPN не детектирует зонды — он уже скомпрометирован»
Стандартная схема Reality+Xray работает по принципу «не пойман — не вор». Зонд подключается без правильного SNI → получает редирект на легитимный сайт → уходит. Но что если зонд знает ваш точный SNI? Что если он стучится не один раз, а методично, каждые 5 минут, из разных подсетей?
Я столкнулся с этим в Беларуси: за трое суток работы скрипта/детектора — ни одного «глупого» зонда с пустым SNI, только аккуратные коннекты с правильным google.com и аномальным поведением. Пришлось добавить два слоя защиты поверх стандартной схемы:
-
Детектор — анализирует nginx-лог и pcap-файлы, считает «очки подозрительности» по поведенческим признакам
-
Серый список — автоматически перенаправляет выявленные зонды на fallback-сайт, не давая им добраться до VPN
Ключевая идея: зонд не должен понимать, что его поймали. Он должен получить то, что ожидает увидеть, и уйти с мыслью «тут ничего интересного». То есть обнаруженный зонд помещается в серый список и при всех повторных попытках сканировать 443 порт, даже с верным SNI, будет выполнен принудительный редирект на fallback.
Почему просто забанить через iptables не вариант?
Казалось бы: поймали подозрительный IP → iptables -A INPUT -s 1.2.3.4 -j DROP. Но это ошибка:
|
Действие |
Что видит зонд |
Что думает оператор / система |
|---|---|---|
|
|
Таймаут или RST |
«Тут что-то скрывают → помечаем как устойчивый к зондам → добавляем в блок-лист» |
|
Перенаправление на fallback |
Обычный сайт с валидным сертификатом, нормальный TLS-хендшейк |
«Просто сайт, идём дальше» |
Вывод: лучший способ скрыть, что вас детектируют — дать зонду ожидаемый ответ.
Архитектура

Контейнеры (docker-compose)
services:
vless: # xray-core, слушает 1080
fallback: # nginx, отдаёт легитимный сайт на :443
nginx_stream: # фронтенд, слушает :443, SNI-роутинг + graylist
nginx-proxy: # HTTP :80, ACME challenge для letsencrypt
dockergen: # генерирует конфиг nginx-proxy по меткам контейнеров
letsencrypt: # acme-companion, автовыпуск сертификатов
watchtower: # автообновление образов
nginx-stream.conf
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
stream {
resolver 127.0.0.11 valid=10s ipv6=off;
# === Серый список — читается из файла на хосте ===
geo $suspicious {
default 0;
include /etc/nginx/graylist.conf; # формат: "1.2.3.4 1;"
}
# === SNI-роутинг ===
map $ssl_preread_server_name $backend {
google.com vless:1080;
default fallback:443;
}
# === Финальное решение: зонды из серого списка — только на fallback ===
map $suspicious $final_backend {
0 $backend;
1 fallback:443;
}
log_format proxy '$remote_addr -> $backend [$time_local] $status '
'bytes=$bytes_sent/$bytes_received conn=$connection '
'sni=$ssl_preread_server_name duration=$session_time '
'proto=$protocol';
access_log /var/log/nginx/stream-access.log proxy;
server {
listen 443;
ssl_preread on; # читаем SNI, не терминируя TLS
proxy_pass $final_backend; # ← ключевая директива
proxy_connect_timeout 5s;
proxy_timeout 3600s;
proxy_socket_keepalive on;
}
}
Ключевые моменты:
-
ssl_preread on— nginx читает SNI из ClientHello, не расшифровывая трафик -
geo $suspicious— подгружает серый список из файла на хосте (не в контейнере) -
$final_backend— финальное решение с учётом и SNI, и серого списка -
duration=$session_time— длительность сессии, критична для детектора
Серый список: как это работает
Файл /etc/nginx/graylist.conf — обычный список IP в формате nginx geo:
# Graylist IPs — формат: "IP 1;"
80.94.95.221 1; # added 2026-04-02T14:59:20
193.232.56.12 1; # added 2026-04-03T09:15:44
После добавления IP детектор выполняет:
docker exec nginx_stream nginx -s reload
Это graceful reload: nginx перечитывает конфиг за ~50 мс, не разрывая активные соединения. Зонд с этого IP при следующем подключении получит fallback-сайт. С его точки зрения — обычный HTTPS-сервер, ничего интересного.
Детектор: поведенческий анализ вместо сигнатур
|
Источник |
Что даёт |
Частота обновления |
|---|---|---|
|
nginx stream log |
SNI, backend, bytes, duration |
В реальном времени |
|
tcpdump pcap |
TCP-fingerprint: window, MSS, TTL |
В реальном времени |
Захват пакетов: tcpdump-tls.sh
#!/usr/bin/env bash
# /usr/local/sbin/tcpdump-tls.sh
exec /usr/bin/tcpdump
-i eth0
-Z root
-w /var/log/tcpdump/tls-%s.pcap
-G 300 # ротация каждые 5 минут
-n
'tcp port 443 and tcp[tcpflags] & tcp-syn != 0' # только SYN-пакеты
Scoring: система очков подозрительности
Детектор не принимает бинарных решений. Каждый признак добавляет очки, алерт срабатывает при достижении порога (score_threshold: 5).
|
Признак |
Баллы |
Комментарий |
|---|---|---|
|
|
+2 |
Не прошёл SNI-роутинг |
|
|
+3 |
Зонд без домена (браузеры так не делают) |
|
|
+1 |
Слабый сигнал, но в плюс |
|
|
+2 |
Только хендшейк, нет данных приложения |
|
|
+3 |
Соединение оборвано сразу |
|
|
+2 |
Частые коннекты с одного IP |
|
|
+3 |
Всплеск за короткое окно |
|
|
+5 |
Главный признак: зонд появился через 1-3с после легитимного пакета |
|
|
+2..+5 |
Повторные визиты того же IP |
|
|
+3 |
Из pcap: 0, 512, 1024 — типично для сканеров |
|
|
+2 |
Из pcap |
|
|
+1 |
Из pcap: 255 или <50 |
Корреляция по времени — самый весомый признак
DPI реагирует на трафик: зонд появляется через 1-3 секунды после легитимного пакета клиента. Случайный сканер такой корреляции не даст.
14:59:15 37.212.28.144 → vless:1080 sni=google.com ← ваш клиент
14:59:16 80.94.95.221 → fallback sni=- ← зонд (+5 за correlation)
Как повторить
Требования
-
✅ Работающий VLESS+Reality setup (xray-core)
-
✅ Регистрация субдомена на ваш IP
-
✅ Docker + docker-compose на хосте
-
✅ root-доступ к хосту (для iptables/tcpdump)
-
✅ Python 3.10+ с пакетами
pyyaml,dpkt
$cd /home/$USER
$git clone https://github.com/segflt-wq/vless.git
$cd /home/$USER/vless
Правим в docker-compose.yml переменные окружения под ваш субдомен и токен, в fallback.conf правим субдомен, в server.json генерим uuid и privateKey, меняем shortIds на свои, что нибудь еще по желанию. Настройки клиента подгоняем под сервер. Правим файл telegram под своего бота, или не правим, скрипт что нибудь напишет в логи. Детектор настроен на белорусский сегмент сети, адаптация под ru в папке ./vless/dpi-alert/ru. После всех правок делаем $./setup.sh — создаст каталоги, скопирует файлы конфигурации.
$docker compose up -d
Смотрим логи контов, материмся, донастраиваем letsencrypt и что нибудь еще…
Заключение
Защита от DPI — это не «поставил и забыл», а постоянная игра в кошки-мышки. Но даже простые эвристики (SNI + время + объём трафика) позволяют отсеивать 90% автоматических зондов.
Три главных вывода:
-
Не баньте — обманывайте. Зонд, получивший нормальный сайт, не понимает, что его поймали.
-
Поведение важнее сигнатур. Корреляция по времени ловит даже «умных» зондов с правильным SNI.
-
Автоматизация — ваш друг. Серый список + graceful reload работают без вашего участия.
Может возникнуть вопрос — а есть ли в этом смысл, ведь xray сам может отправлять на fallback трафик который не прошел reality handshake? Смысл есть — это разные слои защиты. Xray fallback отвечает на вопрос «что показать если уже достучались», детектор отвечает на вопрос «кого вообще не пускать дальше nginx». Вместе это defence‑in‑depth: зонд не только видит легитимный контент, но и активно блокируется на сетевом уровне после обнаружения паттерна.
P.S. И да, я конечно пользовался AI для подготовки материала
Автор: segflt
