Эту статью я готовил с прошлой недели, и пока готовил, ТСПУ выкатил новые правила фильтрации, целящиеся именно в Reality-handshake, о котором тут речь. То есть статья стала актуальнее, чем когда я её начинал.
В прошлой статье я рассказывал, как мы встроили VLESS + Reality прямо в наше iOS-приложение через sing-box, чтобы обход блокировок был не задачей пользователя, а деталью реализации. Если коротко: TLS-рукопожатие проксируется на посторонний крупный сайт, активный пробинг упирается в этот сайт, IP относимся как к расходнику, конфиг доставляется отдельно от сборки. Подход работает, и для подавляющего большинства соединений из России работает прямо сейчас.
Кроме одного класса сетей, в которых не работал.
Внутри этого класса оказались, в том числе, корпоративные подсети, гостевой Wi-Fi в некоторых аэропортах и часть регионального покрытия одного из операторов. Картина в логах одна и та же. Туннель поднимается, TCP-соединение на relay открывается, TLS-рукопожатие начинается, и через секунду sing-box на сервере пишет в журнал: REALITY: processed invalid connection. Сразу обрыв, нет ретраев которые что-то меняют.
Эта статья про то, что мы увидели в этих сетях, почему Reality в одиночку их не пробивает, и что мы поставили рядом, чтобы пробивал. Если читали предыдущую часть, продолжайте отсюда. Если не читали, важен один тезис: туннель у нас живёт внутри приложения, через sing-box, скомпилированный в нативный фреймворк, без системного VPN.
Что такое «белый список» в DPI
Привычная модель цензуры это чёрный список: оператор знает плохие хосты, режет их, остальное идёт. Обычные методы обхода (Reality, прокси с маскировкой) хорошо работают против чёрного списка, потому что выглядят как «остальное».
Белый список это инверсия. Оператор разрешает трафик к небольшому набору заранее одобренных доменов и IP, а всё, что не подходит под этот список, обрубается с разной степенью аккуратности. На уровне SNI это тривиально: ClientHello открытый, в нём видно куда вы идёте, и если этого домена нет в разрешённом наборе, соединение режется. На уровне IP то же самое: пакеты на адреса вне разрешённого подмножества просто не доходят. Это та же DPI-инфраструктура, что обычно, только настроенная по противоположному принципу.
Тут возникает интересный вопрос. Если белый список идёт по SNI, то Reality, который во время TLS-рукопожатия проксирует трафик на постороний крупный сайт (например, microsoft.com), формально должен пройти: цензор видит в SNI разрешённый домен. Но не проходит. И вот тут начинается интересное.
Что именно ловит белый список DPI
Reality прячет содержимое прокси-туннеля внутри валидного TLS-рукопожатия к разрешённому домену. ClientHello идёт настоящий, отпечатки TLS совпадают с настоящим Chrome (через uTLS), сертификат и цепочка валидные. Пассивный наблюдатель и активный пробер не отличат это от обычного браузера, ходящего на microsoft.com.
Но «не отличит» это про обычный DPI, который смотрит набор сигнатур и принимает решение «разрешать или нет». Белый список DPI ведёт себя иначе. Он не пытается опознать плохой трафик, он пытается убедиться в хорошем. И это другой алгоритм.
Тут оговорюсь сразу: я не ковырял исходники конкретного DPI-вендора, и формулировка ниже это моя реконструкция по поведению, а не подтверждённая истина. Параллельно я задавал вопросы знакомым из тель-авивского инженерного слоя (на стороне коммерческих DPI-вендоров там исторически плотно), и формулировка, которую от них слышал чаще всего, звучит примерно как «современные продукты не опознают плохое по одной сигнатуре, они собирают уверенность что трафик хороший из нескольких признаков сразу». Это согласуется с тем, что мы наблюдали, но это всё равно эвристический вывод снаружи.
Дальше я просто перечислю, что в этой картине, по моим наблюдениям, влияет. Если ваше «обращение к microsoft.com» отличается от настоящего по любому набору микро-параметров, эвристика поднимает руку. И таких параметров много: порядок TLS-расширений в ClientHello, паттерн GREASE-значений, тайминги ответов после рукопожатия, размеры записей. uTLS аккуратно копирует ClientHello Chrome версии N, но реальный Chrome ещё и ходит по сети как Chrome: подключение из обычного браузера тянет за собой TCP fast open, HTTP/2 PING-фреймы в характерные моменты, OCSP-staple проверки, ALPN-переговоры, и десятки других мелочей.
Reality всё это не воспроизводит. Reality безупречно вышел из TLS-рукопожатия и переключился на туннель, и дальше через канал идёт ваш прокси-трафик. С точки зрения DPI это TLS на microsoft.com, в котором сразу после рукопожатия начинается странный поток данных, не похожий на HTTPS. Для классического чёрного списка это пройдёт, потому что сигнатуры «странности» в нём не прописаны явно. Для белого списка, где эвристика по умолчанию говорит «не разрешать, если не убедился», этого хватает чтобы зарезать.
Мы попробовали несколько настроек: разные SNI для маскировки, разные uTLS-отпечатки (chrome 120, chrome 131, firefox), разное поведение xtls-rprx-vision. Картина не менялась. Это не вопрос подобрать правильный отпечаток, это вопрос самой парадигмы.
Значит, в этих сетях нужен другой подход.
Почему UDP, и почему именно Hysteria2
В TCP-мире DPI на белый список это решённая задача с понятным ответом: смотрим на TLS, сравниваем со списком, режем неподходящее. В UDP-мире у DPI задача сложнее, потому что UDP сам по себе разный. QUIC к youtube.com и QUIC к Cloudflare и игровой трафик и видеозвонки и просто чей-то самописный протокол выглядят достаточно по-разному, чтобы единая эвристика «это разрешённый UDP» работала плохо. И слишком агрессивно резать UDP опасно для самой сети: половина мобильного трафика сейчас идёт по QUIC, ломать QUIC к крупным сайтам это себе же в ногу.
Поэтому UDP-транспорты в практическом обходе блокировок ведут себя лучше. Но просто QUIC «как есть» это тоже не решение: QUIC-handshake тоже маркируется, и DPI, который натренирован на конкретных публичных версиях QUIC (Chrome, Cloudflare, Google QUIC v1), может опознать и его.
Hysteria2 это собственный протокол поверх QUIC, ориентированный именно на обход блокировок. И главное, что нас интересовало, это его obfs-плагин по имени Salamander. Salamander накладывает на каждый UDP-пакет внешний XOR-слой с ключом, который выводится из пароля. То есть DPI, который попытается посмотреть в первый пакет hy2 и опознать QUIC-handshake по байтам, видит просто бессмысленный поток. Никаких узнаваемых сигнатур внутри пакета не выживает.
Стороны (клиент и сервер) знают пароль, выводят из него ключ, и пишут/читают одинаково. Снаружи не видно ничего полезного.
Этого вполне достаточно для того DPI, что нам нужно было обойти. Подчеркну «вполне достаточно для того» сознательно: я не знаю, как этот же подход поведёт себя на других реализациях DPI, и предположение «UDP+obfs всегда лучше» в общем виде наверняка неверно. Конкретно в наших сценариях оно сработало.
Как это устроено
Архитектура простая. Тот же relay-сервер, на котором уже жил VLESS + Reality на TCP/443, теперь дополнительно слушает hy2 на UDP/443. Один порт, разные транспорты, никакого конфликта (TCP и UDP это разные стеки в ядре).
Серверный конфиг sing-box, ядро:
{
"inbounds": [{
"type": "hysteria2",
"listen": "::",
"listen_port": 443,
"users": [{ "password": "..." }],
"obfs": {
"type": "salamander",
"password": "..."
},
"tls": {
"enabled": true,
"server_name": "www.apple.com",
"certificate_path": "/etc/sing-box/hy2_cert.pem",
"key_path": "/etc/sing-box/hy2_key.pem"
}
}]
}
Два пароля, не один. Первый это аутентификация пользователя на hy2. Второй это пароль для Salamander, отдельный, потому что obfs работает на слое до того, как мы вообще разбираем hy2-пакет. Если obfs-пароль не совпал, сервер вообще не поймёт что к нему пришло.
Сертификат self-signed, на тот же CN, что и SNI разрешённого домена (мы используем тот же домен, что и в Reality, по соображениям связности логов и по тому, что cert CN внутри TLS виден только клиенту, для DPI он внутри obfs-слоя и не имеет значения). Клиент ходит с insecure: true, потому что аутентификация выносится на пароли, не на PKI.
Клиентский outbound (тот же sing-box, тот же фреймворк, что и в первой статье):
{
"outbounds": [{
"type": "hysteria2",
"server": "RELAY_ADDR",
"server_port": 443,
"password": "...",
"obfs": {
"type": "salamander",
"password": "..."
},
"tls": {
"enabled": true,
"server_name": "www.apple.com",
"insecure": true
}
}]
}
Один нюанс по сборке gomobile. Если вы собирали sing-box для прошлой статьи, у вас уже есть тег with_utls. Под hy2 + obfs понадобится дополнительно with_quic, иначе нужные модули просто не попадут во фреймворк. На размер бинарника это влияет ощутимо (десятки мегабайт), но это плата за UDP-транспорт, ничего не поделать.
Как клиент выбирает между Reality и hy2
В sing-box есть outbound типа urltest, который параллельно поднимает несколько подоблочных outbound и выбирает самый быстрый по результатам HTTP-проб. Мы сложили в urltest оба варианта, Reality и hy2, для каждого relay. На сетях, где Reality проходит, побеждает он (на TCP мы получаем чуть меньшую латентность). На сетях с белым списком Reality проба тихо не проходит, hy2 проходит, urltest это видит и переключает основной поток на hy2.
Пользователь ничего не настраивает. Приложение в первый раз делает прямое подключение, если не получается, поднимает sing-box, sing-box внутри себя выбирает рабочий транспорт.
Грабли по дороге
С самим протоколом и с конфигами всё было аккуратно, документация sing-box достаточная. Грабли вылезли в инфраструктуре.
Облачные файрволы на UDP. На этой грабле я честно потерял часть вечера, и пишу про неё подробно, потому что это та категория ошибок, в которой ты долго винишь свой конфиг. На двух хостингах из четырёх iptables на самой машине настроили правильно (UDP/443 ACCEPT), сертификаты разложили, sing-box стартанул и слушает, всё хорошо. Снаружи трафика на машину не приходит вообще. Сидишь с tcpdump на хосте, видишь ноль пакетов, и думаешь «что я сломал в iptables», хотя в iptables всё в порядке. Это не баг конфига, это облачный firewall на уровне провайдера, который по умолчанию открывает только привычные порты, а UDP/443 закрыт. У одного из них, к слову, у API-роли инстанса не оказалось права открывать порты программно, пришлось руками заходить в UI. У второго подобная история была с named security group. Это пятиминутная задача, когда вы знаете, что её надо сделать, и часовое расследование, когда не знаете.
Урок: после того, как вы подняли listener на новом порту, обязательно проверяйте c посторонней машины, что пакеты доходят. Никогда не верить «iptables говорит open», верить tcpdump на машине, в котором вы видите входящие пакеты. И если tcpdump молчит, искать причину не в правилах самой машины, а слоем выше.
Один общий SNI для пары транспортов на одном relay. Внутри Reality TLS-рукопожатие настоящее, проксируется на microsoft.com (например), и cert CN должен соответствовать. Внутри hy2 TLS зашифрован obfs-слоем и снаружи не виден, но cert CN тоже microsoft.com, чтобы внутренние логи и панель администрирования не выглядели разнокалиберно. Это не функциональная необходимость, это операционная гигиена, чтобы через полгода смотреть в логи и не теряться.
Self-signed cert и insecure: true. Это нормально для hy2 с паролями, но при первом запуске рука сама тянется проверять «а валидный ли cert». Не валидный, и это нормально. Вся аутентификация на паролях. Если вы для какого-то будущего этапа захотите выпустить настоящий cert (через DNS-01 challenge на поддомен relay), это не сделает hy2 лучше с точки зрения обхода, это просто уберёт строку с insecure из конфига клиента.
Hosting diversity сильнее, чем кажется. Reality горит по IP, и hy2 горит по IP примерно так же. Имеет смысл, чтобы пара (Reality, hy2) у вас была на разных провайдерах, в разных регионах. Если cidr одного провайдера попадает под массовый блок, у вас остаются другие. Мы держим набор из нескольких сочетаний (один провайдер, второй провайдер, третий) и в конфиге, который доставляется отдельно от сборки, прописаны они все. Подробнее об этом подходе писал в первой статье.
Честно про границы
Уже писал в первой статье, повторю кратко: туннель меняет то, как соединение выглядит для цензора по дороге, и не меняет того, кто стоит на концах. Содержимое переписки защищается отдельно, на уровне приложения (мы используем libsignal). Hy2 + Salamander это про другой слой: про то, чтобы пакеты вообще доходили до сервера.
Salamander обфускация на пароле, не на полноценной криптографии. Если пароль вытащить (например, из вашей же скомпрометированной сборки), obfs снимается тривиально и DPI снова видит QUIC-handshake внутри. Поэтому пароли мы (как и всё, что должно быстро меняться) храним не в бинарнике, а в подписанном конфиге, который доставляется в рантайме. Сменили relay, сменили пароли, перевыпустили подпись, клиент подтянул.
Ещё одно важное наблюдение, которое мы видим по логам и обратной связи. Даже с hy2 в дополнение к Reality у части пользователей всё равно не поднимается транспорт. Это меньшинство, но устойчивое. По косвенным признакам (геолокация, оператор, время суток когда проблема обостряется) картина довольно последовательная: чем ближе сеть пользователя к зонам с особо чувствительной для государства активностью, тем плотнее настроены белые списки и тем агрессивнее режется всё, что не вписывается. То есть DPI это не однородный слой по стране, это градиент с локальными ужесточениями. Я не могу это доказать строго, у меня нет доступа к настройкам ни одного оператора, но картина совпадает у достаточного числа пользователей, чтобы упоминать это как наблюдение, а не совпадение.
И отдельно: всё это гонка. Сегодня белый список DPI не умеет хорошо резать обфускированный UDP, через год может научиться. Это нормальный процесс, к нему просто надо относиться как к процессу: иметь запас инструментов, мониторить, что отвалилось, и не считать ни один из них «навсегда».
Что забрать
Если вы делаете похожую задачу:
В сетях с белым списком DPI один Reality поверх TCP может не сработать, потому что DPI работает не на «опознать плохое», а на «убедиться в хорошем». Это другая парадигма, и Reality в неё не попадает по построению.
UDP + obfs (в нашем случае Hysteria2 + Salamander) в этих сетях работает лучше, потому что UDP-DPI в принципе слабее, а Salamander дополнительно убирает любые сигнатуры внутри пакета.
Не выбирайте один транспорт, держите оба. Reality экономит ресурс на сетях без белого списка, hy2 пробивает то, что Reality не пробивает. urltest в sing-box решает это автоматически.
Облачные firewall на UDP это отдельная тема и почти всегда отдельная боль. Закладывайте время на «открыть нужный порт в UI провайдера», особенно если у вас несколько разных провайдеров.
И сразу планируйте, что и hy2, и Reality будут гореть по IP. Один из них может прожить дольше, но оба расходники. Конфиг доставляется отдельно от сборки, иначе каждый горящий IP это новый релиз в App Store.
Всё, что описано, живёт в нашем мессенджере RCQ, сейчас он в открытой бете на iOS. Клиент с открытым исходным кодом, можно посмотреть, как именно устроен транспорт и переключение между ним: github.com/rcq-messenger/rcq-ios.
Если занимаетесь похожим и видели у себя поведение белого списка DPI или работали с hy2 в проде, расскажите в комментариях. Особенно интересно про сети, в которых не сработал ни Reality, ни hy2: с такими мы пока не встречались, но это не значит, что их нет.

















