splify2
xsteer

Чем xsteer отличается от настоящего TLS — и что с этим сделано#

Разбор по измерению, а не по намерению. Утверждение «выглядит как TLS» проверяется одним способом: взять запись своего трафика, запись трафика настоящего транспорта в той же обстановке и посчитать по обеим одни и те же признаки. Всё, где числа расходятся, — это то, по чему нас можно отличить.

Как измеряли#

tests/xhttp-compare.sh поднимает в двух пространствах имён, соединённых veth, два туннеля и качает через каждый одинаковые 8 МБ:

  • эталон — настоящий Xray 26.3 с транспортом xhttp поверх TLS (свой сертификат, отпечаток Chrome от uTLS); скачивание идёт через socks5 → vless → xhttp → HTTP;
  • xsteer — наш хаб и наш клиент, одно соединение (чтобы сравнивать форму одного потока с формой одного потока), скачивание через туннель.

Обе записи разбирает tests/wireshape.py — свой разбор pcap без зависимостей.

Две оговорки, без которых числа врут:

  • Разгрузку устройства выключаем (ethtool -K … tso off gso off). С ней ядро отдаёт в veth «суперпакеты» по 8–64 КБ, и tcpdump записывает их, а не то, что будет на проводе: у xhttp появлялись сегменты по 8223 байта, каких не бывает. Наши пакеты идут из сырого сокета готовыми, и сравнение «границы записей против границ сегментов» сравнивало бы разное с разным.
  • Скорость ограничена 12 Мбит/с (RATE=). На предельной скорости пространств имён (полтора гигабита) форма потока другая, чем на любом настоящем канале: ядро отдаёт пакеты плотнее, чем это бывает у человека, и часть признаков относится к обстановке, а не к протоколу.

Ниже три столбца: было — формат, совместимый с реализацией на C (ключ --no-batch), стало — текущий, xhttp — эталон. Признаки профиля TCP (окно, PSH, опции SYN, размер ClientHello) исправлены сразу для обоих форматов, поэтому в столбце «было» для них стоят числа, снятые до правок.

Направление загрузки (хаб → клиент), ~6000 сегментов#

признак было стало xhttp
сегментов, начинающихся с заголовка записи 100% 26% 16%
записей, кончающихся ровно на границе сегмента 100% 1% 0%
записей, не влезших в сегмент (то есть поток) 0 1575 1026
медиана длины записи 1455 5781 8218
флаг PSH 99% 16% 16%
полноразмерных сегментов 99% 73% 83%
разных значений объявленного окна 1 23 36
голых подтверждений 0,03% 0,8% 4%

Обратное направление (клиент → хаб)#

признак было стало xhttp
голых подтверждений от числа пакетов 0,2% 64% 59%
опции в SYN MSS, NOP, WS MSS, NOP, WS, NOP, NOP, SACK_PERM MSS, SACK_PERM, TS, NOP, WS
ClientHello 537 байт, один сегмент 1759 байт, два сегмента (1460+299) 1759 байт, два сегмента (1448+375)
записей на границе сегмента 100% 99% 99%
флаг PSH 99% 8% 40%
повторных передач 0 0 0

Что было исправлено и чем заплатили#

1. Запись всегда кончалась на границе сегмента (самый дешёвый признак)#

Проверяется одной строкой разбора, без расшифровки и без статистики: у настоящего TLS запись живёт своей длиной, и границы сегментов ей безразличны, а у нас совпадали всегда.

Сделано: несколько кадров едут одной записью (контейнер wire.BatchBuild), запись становится больше сегмента и разрезается между ними; получатель собирает (wire.Reasm). Пачка набирается только из того, что уже прочитано — чтение устройства неблокирующее, поэтому ни одного ожидания не добавилось: при загрузке очередь не пуста и кадры набираются сами, при интерактивном трафике в пачке один кадр.

Заплатили: разрезанная запись гибнет целиком при потере любого сегмента. Поэтому размером пачки управляет обратная связь — получатель сообщает о несобравшихся записях (CtlReasmLoss), и отправитель немедленно возвращается к одному кадру на десять секунд. Именно поэтому пачка не собирает одиннадцать сегментов, как позволяет 16-килобайтная запись TLS: превратить один процент потерь в одиннадцать было бы дороже любого облика.

Побочно накладные расходы упали: шесть полноразмерных пакетов в одной записи стоят 46 байт на пакет против 61 у одиночных.

2. Объявленное окно не менялось никогда#

Одно значение на все 6000 пакетов — это не «похоже на TLS», это «вообще не похоже на стек TCP». Сделано: окно блуждает, но только у принимающей стороны и не чаще, чем раз в восемь принятых сегментов (у настоящей загрузки принимающая сторона показала 169 разных значений, отдающая — 36). Первая версия шевелила его у обеих сторон на каждом пакете и дала 2015 значений — перестараться тут так же заметно, как не сделать вовсе. Блуждание идёт в верхней части диапазона: при масштабе 7 даже нижняя граница означает восемь мегабайт в полёте, иначе conntrack по дороге начнёт метить наши же сегменты недействительными.

3. PSH стоял на каждом пакете#

Настоящий стек ставит PSH на последнем сегменте записи приложения — при потоковой загрузке это примерно каждый шестой сегмент. Сделано: считаем сегменты, шесть без PSH, седьмой с ним, и отдельно ставим его после паузы. Стало 16% против 16% у эталона.

4. В SYN не было разрешения SACK#

Набор и порядок опций в SYN — это отпечаток стека, и его отсутствие видно первым же пакетом соединения. Сделано: MSS, NOP, WS, NOP, NOP, SACK_PERM — ровно виндовый профиль.

Почему именно виндовый: метки времени (TS) мы посылать не можем — они стоят 12 байт в каждом сегменте, и 61 байт накладных превратился бы в 73, то есть протокол потерял бы смысл. Linux и macOS метки посылают, Windows по умолчанию нет; значит профиль обязан быть виндовым целиком, а не наполовину. SACK при этом объявляем и не используем — у нас нет повторных передач, а объявленный и неиспользуемый SACK нормален и для настоящих стеков.

5. Обратное направление состояло из данных, а не из подтверждений#

У настоящей загрузки обратное направление — это в основном голые подтверждения (59% пакетов). У нас каждое внутреннее подтверждение TCP едет как данные и заодно подтверждает принятое, поэтому голых ACK почти не было (0,2%). Разница считается одной строкой и говорит «это туннель».

Сделано: подтверждаем и данными, и отдельно — голое подтверждение каждые три принятых сегмента, но не чаще пяти тысяч в секунду. Ограничение по частоте, а не по числу: каждое такое подтверждение — системный вызов на горячем пути приёма, и без ограничения на полутора гигабитах выходило восемнадцать тысяч лишних вызовов в секунду и минус 20% скорости (замерено). С ограничением на реальных скоростях соотношение то же, что у эталона (64% против 59%), а на предельных ухудшается — но там форму потока пакет за пакетом всё равно никто не разглядывает.

6. ClientHello был вчерашним#

537 байт в одном сегменте против 1759 в двух у настоящего Chrome. Причина у эталона — постквантовый обмен X25519MLKEM768: его ключ занимает 1216 байт, из-за него браузерный Hello и вырос. «Chrome, который не предлагает постквантовый обмен» — это Chrome позапрошлого года, и это видно по размеру, по числу сегментов и по составу supported_groups.

Сделано: в key_share первым идёт X25519MLKEM768 со случайными байтами (мы им не пользуемся, хаб его игнорирует, а от настоящего ключа шум не отличить), группа добавлена и в supported_groups. Hello стал 1759 байт — столько же, сколько у эталона, — и уезжает двумя сегментами; хаб собирает его из сегментов с пределом на накопленное.

7. Keepalive шёл ровно по расписанию#

Пакет одного и того же размера ровно каждые 15 секунд не встречается ни в одном браузерном соединении и находится подсчётом пауз между мелкими пакетами. Сделано: интервал с разбросом ±20% на обеих сторонах. Стоит это ничего.

Что осталось, и почему#

Медиана длины записи 5781 против 8218. Наша запись ограничена wire.MaxRecord (8192) и числом кадров, доступных в очереди; эталон пишет в запись поток целиком. Разница есть, но она уже внутри разброса самих реализаций TLS: 8218 — это буфер Xray, а не свойство протокола.

Полноразмерных сегментов 73% против 83%. Следствие того же: у нас в записи 4–5 кадров, значит чаще случается «хвостовой» сегмент. Лечится большей пачкой, то есть большей ценой потери.

PSH на обратном пути 8% против 40%. У эталона обратное направление — это редкие куски POST, каждый со своим PSH; у нас — редкие пачки подтверждений. Признак слабый (обратный поток мал), но он есть.

Профиль TCP виндовый, а сервер — линуксовый. Наш клиент представляется стеком Windows, потому что не может позволить себе метки времени. Для настольного клиента на Windows это ровно правда. Для роутера на OpenWrt — нет: там линуксовый стек, и хост, у которого TLS-отпечаток Chrome, а TCP-профиль Windows, при сопоставлении признаков выделяется. Устранимо только ценой 12 байт на сегмент.

Туннель остаётся туннелем. Распределение размеров записей повторяет распределение внутренних IP-пакетов; двусторонний непрерывный обмен без периодов тишины не похож на загрузку страницы; несколько соединений к одному адресу с постоянными портами живут часами. Всё это устраняется только набивкой и сокрытием тайминга, то есть ценой полосы и задержки, и в этом протоколе такого решения пока нет — сказано прямо, а не спрятано.

Повторных передач не бывает никогда. На пути с потерями настоящий TLS-поток покажет повторы, а наш — нет: у нас датаграммная семантика. Это неустранимо по построению; на чистом пути признак не наблюдаем, потому что повторов нет ни у кого.

Активное зондирование#

Модель угрозы#

Прибор соединяется с нашим портом и присылает подлинный ClientHello, иногда — мусор, иногда записанный ранее чужой Hello. Он смотрит не на форму трафика, а на ответ. Порт :443, который ведёт себя не как сервер HTTPS, — это готовый приговор, и никакая маскировка потока тут не помогает.

Что было#

Ответ был один: фатальное оповещение TLS (handshake_failure). Это лучше молчания, но отличимо: настоящий сервер с сертификатом для запрошенного имени присылает ServerHello и сертификат, а мы — семь байт отказа. Проверяется одним openssl s_client.

Что сделано#

Четыре режима (--decoy), и главный из них — proxy: хаб открывает настоящее соединение к сайту-прикрытию, отдаёт ему присланный ClientHello без единой правки и переливает байты в обе стороны через своё поддельное соединение. Прибор получает подлинный ServerHello, подлинный сертификат и подлинную страницу — то есть видит ровно то, что увидел бы на сайте-прикрытии.

    alert   sslv3 alert handshake failure — прибор узнаёт, что здесь не сервер
    silent  ничего — отличимо сильнее отказа: открытый порт, замолчавший на ClientHello
    reset   RST — выглядит как «сервис упал между SYN и данными»
    proxy   CONNECTION ESTABLISHED, TLSv1.3, подлинный сертификат сайта-прикрытия

Три вещи, которых на этой дорожке не хватало и которые нашлись тем же стендом:

  • Мусор вместо TLS отвечается сразу. Прибор первым делом пробует не только ClientHello, но и «GET / HTTP/1.1» и просто случайные байты. Пока проверки не было, такие байты копились в ожидании заявленной длины записи, и открытый порт МОЛЧАЛ в ответ на запрос HTTP — признак не хуже молчания на Hello. Теперь: первые два байта не похожи на запись рукопожатия — дорожка неопознанных начинается немедленно.
  • Закрытие прикрытия закрывает и нас. Настоящий сервер HTTPS на запрос HTTP отвечает отказом или закрывает соединение, но делает это БЫСТРО. Прежде прикрытие закрывалось, а наше поддельное соединение висело — снаружи опять «порт молчит». Теперь уходит FIN, и сессию убирает владелец. Стенд поймал это пятисекундным ожиданием curl.
  • Предел на число одновременно проксируемых (32). Каждое такое соединение — настоящий сокет к прикрытию и горутина; без предела поток зондирования превращался бы в нашу же атаку на прикрытие.

tests/probe.sh проверяет все четыре настоящим openssl s_client и заодно — что защита не мешает своим: пир поднимается на том же хабе в том же режиме и несёт трафик.

В плане реализации на C записано, что проксирование невозможно: «оно требует настоящей точки TCP, а слушающий сокет на том же порту отвечал бы SYN-ACK нашим же пирам». Это верно про слушающий сокет ядра и неверно про нас: поддельным TCP владеем мы сами, в пользовательском пространстве, и сокета ядра на порту нет вовсе.

Настройка#

    xsteer hub hub.conf --decoy proxy --decoy-dest www.microsoft.com:443
    xsteer hub hub.conf --decoy proxy --decoy-dest www.microsoft.com:443 \
                                            --decoy-sni .microsoft.com,.windowsupdate.com

Второй вид (--decoy-sni) соединяется с тем именем, которое назвал сам прибор, и потому отдаёт правильный сертификат на любое запрошенное имя. Постоянное назначение этого не даёт: прибор просит SNI, которого мы не обслуживаем, получает сертификат прикрытия и видит несовпадение. Список имён обязателен — без него порт хаба становится открытой пересылкой на :443 к произвольному узлу, то есть чужим инструментом.

Остаточные риски, названные прямо#

  1. Повторных передач у нашего поддельного TCP нет. Потерянный сегмент рукопожатия TLS означает для прибора зависшее соединение. На обычном пути потерь нет; на плохом прибор увидит обрыв — подозрительно, но неотличимо от плохой связи. Настоящая точка TCP в пользовательском пространстве (с окном и повторами) — отдельная работа, и её цену надо мерить, а не предполагать.
  2. Прикрытие отвечает через нас, а не напрямую. Круг задержки до сайта-прикрытия складывается с нашим: прибор, который умеет мерить время до ServerHello и сравнивать с прямым обращением к тому же сайту, увидит разницу. Лечится выбором прикрытия рядом с хабом.
  3. Своих мы узнаём по рукопожатию, а не по адресу. Значит любой прибор доходит до дорогой арифметики (один X25519 на аутентификатор). Ограничитель частоты на неопознанных есть, но защиты от распределённого потока рукопожатий у хаба нет — как и у Reality.
  4. Сессия создаётся на первом же поддельном SYN, то есть кем угодно. Вытеснение устроено так, что неподтверждённая сессия уходит первой: поток SYN с меняющихся портов не может выбить живые туннели. Но память под таблицу он занимает, и предел на воркера — единственное, что это держит.

Перенос в реализацию на C — сделан#

Всё перечисленное выше перенесено в steer (src/proto/xsteer/xs*.c, src/proto/obfs/obfs.c), и обе реализации снова говорят на одном проводе. Проверено tests/matrix.sh на НОВОМ формате: четыре сочетания половин, каждое с нуля, все зелёные (1,09–1,35 Гбит/с на четырёх потоках).

Форма трафика самой реализации на C снята тем же стендом (XS_IMPL=c sh tests/xhttp-compare.sh) — и она такая же:

признак xsteer на C xsteer на Go xhttp
сегментов, начинающихся с заголовка записи 21% 26% 16%
записей, кончающихся на границе сегмента 1% 1% 0%
медиана длины записи 7222 5781 8218
PSH 17% 16% 16%
разных значений окна (сторона хаба) 19 23 33
ClientHello 1759 в двух сегментах 1759 в двух 1759 в двух
голых подтверждений (обратный путь) 93% 64% 59%

Последняя строка — единственное, где реализации расходятся между собой: реализация на C собирает пачку агрессивнее (его цикл успевает вычитать из устройства больше), поэтому данных в обратном направлении у него ещё меньше, а доля голых подтверждений выше настоящей. Направление это маленькое, признак слабый, но он есть — и назван, а не спрятан.

Ключ --no-batch остался: им реализация на Go возвращается к старому формату для разговора с хабом предыдущей версии. XSM_COMPAT=1 sh tests/matrix.sh проверяет, что этот путь жив.

Режим потока#

Есть второй транспорт — записи по НАСТОЯЩЕМУ соединению TCP (--stream), сделанный ради Windows, где поддельный TCP невозможен без драйвера. Его облик и его цена измерены отдельно: docs/windows.md. Коротко: TCP-слой там не похож на настоящий, а является им (те же опции с метками времени, тот же MSS, те же значения окна, та же сегментация Hello — байт в байт), на чистом канале он на 40–65% быстрее, а при трёх процентах потерь проваливается в пять раз.

Что дальше#

  • Мерить облик на рваном пути: сейчас все числа сняты на канале без потерь, а самое интересное (обратная связь по сборке, отсутствие повторов) видно именно там.
  • Набивка и сокрытие тайминга — если однажды окажется, что распределение размеров важнее полосы.