Чем 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 к произвольному узлу, то есть
чужим инструментом.
Остаточные риски, названные прямо#
- Повторных передач у нашего поддельного TCP нет. Потерянный сегмент рукопожатия TLS означает для прибора зависшее соединение. На обычном пути потерь нет; на плохом прибор увидит обрыв — подозрительно, но неотличимо от плохой связи. Настоящая точка TCP в пользовательском пространстве (с окном и повторами) — отдельная работа, и её цену надо мерить, а не предполагать.
- Прикрытие отвечает через нас, а не напрямую. Круг задержки до сайта-прикрытия складывается с нашим: прибор, который умеет мерить время до ServerHello и сравнивать с прямым обращением к тому же сайту, увидит разницу. Лечится выбором прикрытия рядом с хабом.
- Своих мы узнаём по рукопожатию, а не по адресу. Значит любой прибор доходит до дорогой арифметики (один X25519 на аутентификатор). Ограничитель частоты на неопознанных есть, но защиты от распределённого потока рукопожатий у хаба нет — как и у Reality.
- Сессия создаётся на первом же поддельном 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% быстрее, а при трёх процентах потерь проваливается в пять раз.
Что дальше#
- Мерить облик на рваном пути: сейчас все числа сняты на канале без потерь, а самое интересное (обратная связь по сборке, отсутствие повторов) видно именно там.
- Набивка и сокрытие тайминга — если однажды окажется, что распределение размеров важнее полосы.