Весной на FOCI 2026 вышла работа Heitmann, Niere, Graf Lange, Somorovsky «On Russia’s Early Introduction of QUIC SNI Censorship». Авторы мерили в марте–апреле с трёх точек (Москва, Петербург, Новосибирск) и пришли к выводу, который сразу разошёлся по чатам:
it targets only QUICv1; the standardized update QUICv2 is not censored, facilitating potential circumvention at all our vantage points
То есть ТСПУ разбирает только первую версию QUIC, а вторую, которая отличается от первой парой констант, пропускает. Мне стало интересно, сколько живёт такая дыра. Я написал свой измеритель и 23 сентября прогнал его с пяти российских сетей и одной белорусской.
Коротко: на всех пяти российских сетях QUICv2 режется так же, как v1. Но интереснее, по-моему, другое: за один день мой инструмент трижды выдал правдоподобный и неверный результат, и один раз — ровно тот, который я хотел увидеть.
Почему цензор вообще видит SNI в QUIC
QUIC шифрует почти всё, включая хендшейк. Но первый пакет клиента, Initial, шифруется ключами, которые выводятся из Destination Connection ID — поля, лежащего в заголовке открытым текстом (RFC 9001, §5.2). Секрета нет: любой на пути берёт DCID, повторяет вывод ключей, снимает защиту заголовка, расшифровывает пакет и читает из ClientHello имя сервера. Это осознанный компромисс протокола: Initial защищён от случайной порчи и от middlebox’ов, которые хотят его менять, но не от чтения.
Вывод ключей укладывается в несколько строк на node:crypto:
// HKDF-Extract с солью версии, дальше HKDF-Expand-Label из TLS 1.3
function initialKeys(ver, dcid) { const initialSecret = hkdfExtract(ver.salt, dcid); const client = hkdfExpandLabel(initialSecret, 'client in', 32); return { key: hkdfExpandLabel(client, ver.labelKey, 16), iv: hkdfExpandLabel(client, ver.labelIv, 12), hp: hkdfExpandLabel(client, ver.labelHp, 16), };
}
hkdfExpandLabel — это HkdfLabel из RFC 8446, §7.1 с префиксом tls13 и пустым контекстом, двадцать строк вокруг HMAC-SHA256.
QUICv2 (RFC 9369) функционально тот же протокол. Его придумали в том числе против окостенения: чтобы middlebox’ы не привыкали к одной версии. Отличий для Initial четыре:
|
|
QUICv1 |
QUICv2 |
|---|---|---|
|
version |
|
|
|
initial salt |
|
|
|
метки HKDF |
|
|
|
тип пакета Initial |
|
|
Если парсер цензора знает только первую колонку, вторая для него — мусор, и пакет проходит. Это и описали Heitmann и соавторы.
Как я мерил
Инструмент на Node 18+ без зависимостей: HMAC, AES-GCM, AES-ECB для защиты заголовка и UDP есть в стандартной библиотеке. Пакет собирается руками, поэтому первым делом сверка с тестовыми векторами из приложения A RFC 9001: пока ключи и защищённый заголовок не сходятся байт в байт, мерить нечего.
Хендшейк я до конца не довожу. Нужен только факт, что пакет дошёл: любой ответ сервера — Initial, Retry или Version Negotiation — это «прошёл», тишина — «дроп». Правил, без которых результат ничего не значит, набралось четыре:
-
Адресат один на все пробы, меняется только SNI. Иначе не понять, что стало причиной: домен, маршрут или сам сервер. Я слала всё на
quic.aiortc.org. -
Свежий исходный порт на каждую пробу. По данным работы, после срабатывания ТСПУ держит блокировку по 4-tuple ещё 420 секунд. Переиспользуешь порт — следующая проба наследует чужой бан.
-
Повторы вперемешку. UDP теряется сам по себе. В самом первом прогоне одиночная проба
discord.comне получила ответа и записалась в блокировки — на пяти повторах ответ пришёл все пять раз. В работе по той же причине делали по 10 повторов на домен. -
Контроль для v2. Главная ловушка: если сервер не умеет QUICv2, его молчание неотличимо от блокировки. Поэтому адресат должен отвечать и на v1, и на v2 либо хотя бы на пробу с несуществующей GREASE-версией: на неё сервер по RFC 9000 обязан ответить Version Negotiation.
quic.aiortc.orgотвечает на всё, 3 из 3.
Читается результат так:
|
v1 |
v2 |
вывод |
|---|---|---|
|
дроп |
прошёл |
обход работает, как в работе |
|
дроп |
дроп |
режутся обе версии, парсер обновили |
|
прошёл |
прошёл |
домен на этой сети не блокируется |
|
прошёл |
дроп |
почти наверняка дело в сервере, а не в цензуре |
Дальше скан: 300 первых доменов из Tranco (у авторов было 100 000). Фаза 1 — быстрый триаж по одной пробе, фаза 2 — подозрительные домены по пять проб на версию плюс контрольная выборка заведомо доступных, фаза 3 — тот же домен по TLS поверх TCP.
Что получилось
Первый прогон с машины, на которой я писал инструмент, показал, что QUIC там не фильтруется вообще: четыре проверочных домена прошли 5 из 5 в обеих версиях. Контроли зелёные, векторы RFC сходятся, просто цензуры на этом канале нет. Подтвердить или опровергнуть работу такой прогон не может, поэтому дальше я мерил с российских адресов в пяти разных автономных системах, включая Ростелеком.
На всех пяти сетях 15 доменов из топ-300 режутся в обеих версиях:
bbc.com facebook.com medium.com
cdninstagram.com fbcdn.net soundcloud.com
discord.com godaddy.com t-online.de
doubleclick.net googleadservices.com t.co
linkedin.com googlesyndication.com twitter.com
Ещё несколько добавляются на отдельных сетях: bbc.co.uk, instagram.com, linktr.ee, pages.dev, whatsapp.com, snapchat.com, dell.com. Выходит 18–19 доменов на точку при общем ядре в 15. Провалов контрольной выборки ноль.
Итог скана по Ростелекому для примера:
проверено доменов 300
блокируется QUICv1 21
блокируется QUICv2 18
блокируется обе версии 18
только v1 (v2 проходит) 3
нестабильных 3
провалов контроля 0
Три домена, где v2 прошёл, — snapchat.com, pages.dev и bbc.co.uk. На других сетях те же домены режутся в обеих версиях. Остаток старой дыры это или шум пяти проб, по одному дню сказать не могу. Ядро в 15 доменов от этого не зависит.
Ещё одно расхождение: пара доменов режется по QUIC, а по TLS открывается, например nih.gov. В работе блоклисты QUIC и TLS совпадали, у меня совпадают почти полностью, но не до конца.
Как инструмент мне врал
Эту часть я считаю главной. Ни один из дефектов ниже не ронял измерение — каждый выдавал аккуратный правдоподобный результат.
ClientHello на два байта длиннее. Я ошибся в длине расширения key_share: записал 38 вместо 36. Серверы отвечали TLS alert вместо ServerHello. Для «дошёл или нет» это даже годится, ответ ведь пришёл, но заметил я ошибку только потому, что три независимых хоста ответили одинаковым alert’ом.
r в списке доменов. Список Tranco пришёл с CRLF, и SNI уходил как "discord.comr". Такого имени нет ни в одном блоклисте, поэтому три скана по 300 доменов дали ровно ноль блокировок. Вывод «QUIC в России не фильтруется» выглядел вполне правдоподобно. Спасло противоречие с прицельными пробами в тех же прогонах: там discord.com резался стабильно.
Порядок проб. Самый неприятный случай. Два прогона показали 72 и 77 доменов, где v1 режется, а v2 проходит, — то есть ровно обход из работы, только в масштабе, о котором авторы и не писали. В этих списках оказались google.com, github.com, cloudflare.com, apache.org, apple.com, gstatic.com.
Причина нашлась в фазе 2: для каждого домена я сначала слал пять проб v1, потом пять проб v2. Любой эффект, который бьёт по первой серии пакетов, — всплеск потерь, рейт-лимит на канале или у адресата — выглядит при таком порядке как «v1 заблокирован, v2 работает». Инструмент систематически производил ту самую картину, которую искал. Косвенный признак был в цифрах сразу: нестабильных доменов в этих прогонах 24 и 41, в остальных трёх — от 3 до 7. Артефакт вылез ровно на шумных каналах.
Исправил двумя способами. Пробы v1 и v2 стали отдельными задачами в общем перемешанном списке, так что версия больше не связана с моментом отправки. И появились «канарейки»: google.com, cloudflare.com, apache.org, microsoft.com, gstatic.com, akamai.net, amazonaws.com, wikipedia.org. Блокировка любой из них объявляет весь прогон недействительным. Контрольная выборка этого не ловила: она набирается из доменов, прошедших фазу 1, а значит заранее смещена в сторону исправных.
Важно, что этот дефект не затрагивает главный вывод. Он умеет выдумать «v1 режется, v2 проходит», но не «режутся обе»: вторая серия проб в таком случае восстановилась бы.
Системный DNS. Это уже TLS-проба. Она брала адрес у резолвера провайдера, а тот для части доменов отдавал заглушку: четыре несвязанных домена на одном адресе. TLS с заглушкой успешно поднимался, и проба записывала домен в рабочие. Теперь домен резолвится и системно, и через DoH, при расхождении проверяется адрес от DoH, а отказ системного резолвера при живом DoH записывается как блокировка DNS.
Общий урок у всех четырёх один. Ловились они только сопоставлением независимых источников: прицельной пробы со сканом, QUIC с TLS, вывода со здравым смыслом о том, что google.com не может быть заблокирован. Если проба не проверяет, до того ли сервера дошла и то ли имя отправила, она измеряет не то, что вы думаете.
TLS: молчание против сброса
Заодно видно, как по-разному устроены фильтры. В России TLS-соединение с запрещённым SNI ведёт себя так: TCP поднимается, ClientHello уходит, а ответа нет. Через 6 секунд срабатывает таймаут. Контрольная проба с SNI=example.com на тот же IP получает ответ за 320–380 мс. Это молчаливый дроп, устройство стоит в разрыве (in-path).
С белорусской сети (A1, AS42772) я проверил пять заблокированных там новостных сайтов. После ClientHello через 20–30 мс прилетает RST, контроль с example.com проходит за то же время. Мгновенный ответ — признак инъекции с устройства на ответвлении (on-path), такого устройства, которое само пакет не держит, а только видит его копию. По устройству это ближе к китайскому GFW, чем к ТСПУ. Разница во времени отклика в два порядка — прямое следствие архитектуры: инъектор отвечает сразу, устройство в разрыве просто молчит.
QUIC по SNI в той белорусской сети не фильтровался вовсе: 80 проб из 80 прошли, ни v1, ни v2 не тронуты. Работа Heitmann и соавторов заканчивается призывом посмотреть на незамеченную QUIC-цензуру в других странах. Про Беларусь я в литературе ничего не нашел, так что пусть будет хотя бы одна точка.
Ограничения
-
Российские точки — адреса в датацентрах, не домашний интернет. Фильтрация на резидентских линиях может отличаться.
-
300 доменов против 100 000 у авторов.
-
Один день, без повторов во времени.
-
Только IPv4.
-
QUIC-пробы шли на сторонний адресат, а не на серверы самих доменов. Строго измерено только «Initial с таким SNI не доходит по этому пути».
-
Два прогона из пяти забракованы, повтор на тех же сетях с исправленным порядком я пока не делала.
Сам вывод «QUICv2 режется» держится на пяти независимых автономных системах, пяти пробах на ячейку и перемешанном порядке. Через пять месяцев после измерений в работе дыра в QUICv2, похоже, закрыта: парсер ТСПУ выучил вторую версию.
Источники
Автор: kinomig
