todo/mtproxy/faketls-relay-diagnosis.md
loop-uh 482ef21393
All checks were successful
Published content check / validate (push) Successful in 3s
Сохранить локальные статьи о FIDO и VLESS
Добавить новый раздел о стандартах FIDO, статьи о VLESS Encryption и слоях VLESS, а также несжатую иллюстрацию диагностики MTProxy. Обновить связанные материалы и направить ссылки на исходники новых статей в Forgejo.
2026-08-07 15:52:30 +03:00

809 lines
144 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
date: 2026-07-26
tags:
- mtproto
- mtproxy
- faketls
- clienthello
- диагностика
- tdesktop
aliases:
- Инцидент MTProxy 26 июля 2026
- Релей или клиент
- Почему рабочий ключ не подключается
- Диагностика FakeTLS без сборки клиента
---
# 🔬 Релей или клиент: диагностика MTProxy, когда рабочий ключ не подключается
![[faketls-relay-diagnosis-header.png]]
> [!info] О чём заметка
> Ключ `tg://proxy?...` работает в официальном Telegram, но не работает в другом клиенте (собственной сборке, форке, экспериментальном приложении). Заметка показывает, как за несколько минут и без пересборки клиента установить, кто именно виноват — релей или клиент, — и приводит измеренные требования, которые боевые FakeTLS-релеи предъявляют к первому пакету клиента. Про то, кто вообще формирует TLS-почерк и почему обход MTProxy делается на стороне клиента, — в [[mtproxy/ja4-sni-client-side|JA4/SNI задаёт клиент]].
> [!warning] Статус данных
> Все числа ниже — измерения 26 июля 2026 на боевых FakeTLS-релеях, поднятых через vpnbot: пять адресов и четыре разных порта (443, 442, 8445, 45633), с двух машин одновременно — одна на сети с активной фильтрацией, другая без неё. Каждый результат повторён минимум дважды со свежим ClientHello, а ключевые — раундами по четыре с чередованием проверяемых вариантов. Совпадение поведения разных релеев делает выводы правдоподобными для распространённых сборок, но это не спецификация: другая реализация может вести себя иначе, и проверять свой релей стоит своей пробой.
---
## Задача, ход и результат
**Что было.** Форк Telegram Desktop с переработанным контуром MTProxy. Ключи, которые в официальном приложении работают, в форке не подключались: «Подключение…» по кругу, в логе — рукопожатие ушло, ответа нет. Причём не на одном релее, а на всех сразу, и к вечеру становилось хуже. Со стороны это выглядело как «форк сломан».
**Чего хотелось.** Понять, где именно ломается, не гадая. Проблема в том, что клиент по своим логам этого узнать не может: FakeTLS устроен так, что отвергнутый клиент не получает отказа, а получает правдоподобный ответ камуфляжного сайта. Значит нужен взгляд снаружи — проба, которая воспроизводит путь клиента байт в байт, но запускается за минуты и без пересборки.
**Что сделали.** Написали такую пробу и прошли ею весь путь: TCP → FakeTLS → obfuscated2 → `resPQ`. Дальше — серия контролируемых опытов: одна переменная за раз, руки чередуются в раундах, всё запускается с обеих машин, чтобы сеть с фильтрацией сравнивалась с сетью без неё в те же минуты.
**К чему пришли.** Релеи были исправны почти всегда — ломалось четыре разных вещи, и путать их нельзя, потому что лечатся они по-разному:
| механизм | как выглядит | чем лечится |
|---|---|---|
| **TCP до адреса не проходит** | таймаут на всех открытых портах, десятки секунд | только другой адрес |
| **Имя в SNI не пропускают** | адрес отвечает (даже на мусор), но TLS с этим именем — тишина | ключ с другим маскируемым доменом |
| **Отпечаток ClientHello отвергают** | часть профилей проходит 12/12, другая 3/12, на том же релее в те же минуты | профиль, который не совпадает с эталоном браузера |
| **Релей теряет плечо до Telegram** | рукопожатие проходит, `resPQ` не приходит либо обратка замолкает | чинить сервер |
Отдельно выяснилось, что задержка, которую пользователи ощущали как «медленный прокси», сетью вообще не создавалась: на одном и том же релее сеть с фильтрацией и сеть без неё дали одинаковые цифры. Секунды добавлял сам клиент — этому посвящена [[#Задержка где она на самом деле|отдельная глава]].
**Почему это стоит читать целиком.** Половина текста — не находки, а отвергнутые версии: размер пакета, незафлашенная запись, пост-квантовый ключ, порядок расширений, JA4, накопительный порог. Каждая выглядела убедительно и каждая закрыта измерением. Ход рассуждений тут важнее выводов: те же грабли лежат в любом расследовании такого рода.
---
## Термины (чтобы заметка читалась без контекста)
- **MTProxy** — прокси-сервер, встроенный в клиенты Telegram; подключается по ссылке `tg://proxy?server=…&port=…&secret=…`. Обзор реализаций — в [[Zapret/mtproto/02-implementations|обзоре реализаций MTProto Proxy]].
- **FakeTLS (`ee`-секрет)** — режим маскировки, где первый пакет клиента выглядит как обычное TLS-рукопожатие к сайту. Секрет начинается с байта `0xEE`, за ним 16 байт ключа, а дальше — имя домена, которое клиент подставит в SNI.
- **ClientHello** — первый, ещё не зашифрованный пакет TLS: список шифров, расширения, SNI. В FakeTLS в его поле `random` (32 байта на позиции 11) кладётся HMAC-SHA256 от всего пакета с занулённым этим полем.
- **Камуфляж (доппельгангер)** — сайт, на который релей молча проксирует всё, что не опознал как своего клиента. Именно поэтому неудача выглядит не как отказ, а как «странный ответ».
- **obfuscated2** — внутренний слой Telegram поверх FakeTLS: 64 байта инициализации, из которых выводятся ключи AES-CTR, и тег протокола в байтах 5659.
- **resPQ** — первый ответ самого Telegram (не релея) на запрос `req_pq_multi`. Дошёл resPQ — значит работает вся цепочка, а не только рукопожатие.
---
## TL;DR
- Релей отвечает валидным `ServerHello` **и** когда принял клиента, и когда отправил его на камуфляж — по префиксу `16 03 03` эти случаи неразличимы, отличать нужно по HMAC ответа.
- Контракт релея к ClientHello состоит из девяти требований: длина **≥ 517** и **≤ 4096** байт, SNI **строго из секрета**, `session_id` ровно **32 байта**, **первый же шифр после GREASE** из набора `1301`/`1302`/`1303`, совпадение **первых 28 байт** digest, метка времени **не в будущем** (запас ровно 3 секунды), метка времени **не слишком старая**, и **неповторяемость** client random.
- Порог 517 закреплён в коде: ветка FakeTLS входит только при `(packet_len >> 24) >= 2`, то есть при длине TLS-записи не меньше 512 байт.
- Сверху предел тоже есть, но далеко: 517, 600, 1200, 2000 и 3000 байт принимаются одинаково, 4096 — последняя принимаемая длина, 4097 уже уводится на камуфляж.
- Внутри FakeTLS релеи принимают **только padded intermediate** (`0xdddddddd`); теги `0xeeeeeeee` и `0xefefefef` обрываются без единого байта в ответ.
- Ограничение на параллельные подключения, которое часто закладывают в клиенты, у этих релеев не подтвердилось: 24 одновременных сессии дошли до resPQ за 210520 мс суммарно.
- Номер дата-центра в obfuscated2 по resPQ не проверяется: этот запрос не привязан к дата-центру, поэтому ответ приходит и при `dc = 0`, `9`, `1`, `2`. Считать отсюда, что релей номер игнорирует, нельзя — а ошибка в нём всплывёт позже, на настоящей сессии.
- Практический вывод для клиента: часы, спешащие больше чем на 3 секунды, ClientHello короче 517 байт и повторная отправка того же самого рукопожатия ломают подключение к полностью исправному релею.
- Отдельно от контракта релея есть **фильтрация по отпечатку на стороне сети**: из шести профилей ClientHello клиента два отвергаются молча (3 попытки из 12), четыре проходят всегда — один релей, один секрет, одни минуты. Клиент, по умолчанию посылающий отвергаемый профиль, выглядит как «ни один прокси не работает», хотя релеи исправны.
- Блокировка отпечатка **включается со второго-третьего соединения** и дальше держится, поэтому «первые несколько соединений проходят» — это её форма, а не признак чего-то другого.
- Отличать отпечатки по JA4 нельзя: он сортирует расширения и выбрасывает GREASE, и у отвергаемого профиля JA4 совпадает с проходящим буква в букву.
- Задержка через прокси раскладывается на три слоя (до релея, ответ релея, релей → Telegram); измерено, что фильтрация к ней ничего не добавляет, а секунды создавал сам клиент — см. главу про задержку.
- Правильный способ найти такое — **перебрать все профили клиента с проблемной машины**, собирая байты из правил самого клиента, а не строить пробу «по мотивам».
- Различие двух шаблонов сузилось до **одного байта в теле GREASE-расширения**: отбираешь байт — отвергнутый шаблон проходит 6/6, добавляешь его проходящему — тот замолкает 1/6. Инверсия в обе стороны на одной сети в одни минуты.
- Селектор — **не одно поле, а точное совпадение с эталоном**: значение байта (`ff` вместо `00`), его позиция (первый GREASE вместо последнего) и длина ECH payload (143 вместо 144) — каждое по отдельности снимает блокировку при неизменном остальном. Сломай любой признак, и hello проходит.
- Одного хвоста недостаточно: `android_okhttp` заканчивается тем же байтом и проходит 12/12, потому что не несёт остального (ни ECH payload, ни ALPS, ни пост-квантового key_share). Отвергаются ровно те два профиля, у которых **и** хвост, **и** полный набор признаков Chromium.
- Правило покрывает **весь** набор ECH-длин, из которого клиент выбирает случайно (144, 176, 208, 240): все проверенные отвергаются одинаково, так что «повезёт с длиной» не работало никогда.
- Что это значит на практике: **копия должна оставаться неточной**. Любое отклонение — лишнее расширение в два байта, байт, срезанный с `enc` внутри ECH, длина ECH payload вне набора, переставленный порядок ALPN, переименованная группа key_share — снимает блокировку целиком. Проверено восемью правками по одному полю на трёх релеях.
- Правило **не привязано к адресу**: та же картина воспроизведена на другом IP и на нестандартном порту 45633. Отдельно от этого бывает блокировка самого адреса — там молчит всё, включая проходящий профиль и не-TLS мусор.
- Блокировка **непостоянна**: в одних прогонах эталонные hello замолкают со второго-третьего раза, в другом двадцать пять подряд прошли целиком. Значит режут выборочно и эпизодически; измерять состояние надо в момент, когда клиент уже видит тишину.
- Бывает и третий случай: **адрес заблокирован, но с белым списком имён**. Из четырнадцати маскируемых доменов к такому адресу прошли только поддомены `microsoft.com` — лечится ключом с таким доменом, адрес менять не нужно.
- Настоящий, побайтово браузерный ClientHello (дамп Яндекс.Браузера) эта сеть **тоже отвергает** — 1 из 6, — а наша упрощённая копия проходит 6 из 6. Значит фильтр не ищет «не-браузер», и цель «сделать отпечаток максимально похожим на настоящий браузер» неверна: похожесть тут вредит.
- **Привилегированное имя в SNI отключает фильтр отпечатка целиком.** Один адрес, одни минуты: отвергаемый отпечаток с именем из секрета — 0 из 4, тот же отпечаток с `update.microsoft.com` — 4 из 4. Поэтому для своего релея правильный маскируемый домен даёт больше, чем любая работа с отпечатком.
---
## Почему «прокси не работает» — это ещё не диагноз
Симптом, с которого начинается любое такое расследование, выглядит одинаково: ключ раздали пользователям, в официальном приложении он работает, а в другом клиенте — «Подключение…» по кругу. Дальше обычно начинается перебор гипотез: сервер блокируют, порт режут, секрет неправильный, DPI мешает. Перебор дорогой, потому что каждая проверка требует пересобрать клиент, а пересборка Telegram Desktop — это часы.
Проблема глубже, чем медленный цикл проверки. Клиент физически не может отличить «релей меня отверг» от «релей меня принял, но что-то сломалось дальше», потому что **отвергнутый клиент не получает отказа**. Релей, не опознавший своего, не разрывает соединение и не шлёт ошибку — он проксирует байты на камуфляжный сайт, а тот отвечает своим настоящим TLS-рукопожатием. Клиент видит корректный по форме `ServerHello`, не находит в нём ожидаемого HMAC и пишет в лог что-то вроде «плохой digest» — то есть жалуется на релей, до которого его пакет даже не дошёл в качестве клиентского.
Проще говоря: FakeTLS устроен так, что провал маскируется под успех. Поэтому первым делом нужно не читать код клиента, а выяснить снаружи, что именно релей считает приемлемым.
---
## Проба: четыре уровня, каждый отвечает на свой вопрос
Проверка снаружи занимает минуты, потому что весь путь до Telegram воспроизводится обычным скриптом без единой строчки клиентского кода. Уровни идут по нарастанию и каждый следующий имеет смысл только после того, как прошёл предыдущий.
**Уровень 1 — TCP.** Просто соединение с хостом и портом. Отсекает случаи «сервер лежит», «порт закрыт файрволом», «домен не резолвится».
**Уровень 2 — FakeTLS.** Собрать канонический 517-байтный ClientHello, отправить, дождаться `ServerHello` и **проверить его HMAC**. Проверка HMAC здесь не формальность, а единственное, что отличает релей от камуфляжа.
**Уровень 3 — весь путь до Telegram.** После рукопожатия отправить obfuscated2-инициализацию и запрос `req_pq_multi`, дождаться `resPQ`. Дошёл `resPQ` — релей не просто ответил, а реально пронёс запрос до дата-центра Telegram и вернул ответ.
**Уровень 4 — параллелизм.** Открыть N сессий одновременно и посмотреть, сколько из них доходят до `resPQ`. Это проверка предположений, которые часто зашивают в клиент («релей отвечает только на пару рукопожатий, остальное глотает») — предположений, которые почти никогда не измеряют. Важная деталь постановки: сессии нужно **оставлять открытыми и с трафиком**, потому что клиент их держит, а проба, закрывающая соединение сразу после ответа, проверяет совсем другой режим.
> [!important] Запускать пробу нужно с той машины, где симптом
> Проба, прошедшая с другого хоста, не говорит о сети пользователя ничего. В разобранном случае это стоило целого ложного вывода: с внешнего сервера релей отвечал на 24 параллельных сессии, из чего был сделан вывод «клиент чист, виновата сеть или лимит на IP пользователя» — а запуск той же пробы с проблемной машины дал 17 успешных рукопожатий подряд за 2126 мс. Сеть оказалась ни при чём, и вывод пришлось отозвать.
>
> Правило простое: пока проба не запущена **оттуда**, где воспроизводится симптом, гипотеза «виновата сеть» не проверена, а лишь не опровергнута.
Четырёх уровней хватает, чтобы разделить «релей или клиент». Когда выяснилось, что сеть умеет отвергать выборочно, к ним добавились ещё два — и их порядок важен, потому что каждый отсекает предыдущие версии целиком:
**Уровень 0 — TCP до всех открытых портов.** Если рукопожатие не встаёт вовсе, содержимое пакета уже ни при чём, и дальше идти незачем. Отличать таймаут (фильтр глотает SYN) от мгновенного отказа (порт закрыт) обязательно: это разные диагнозы.
**Уровень 5 — перебор маскируемых имён.** Тот же hello к тому же адресу, меняется только имя в SNI. Критерий здесь особый — **пришёл ли хоть какой-то ответ**, а не сошёлся ли HMAC: релей сверяет имя со своей настройкой и отправляет чужое на камуфляж, поэтому по HMAC такие руки «неуспешны» всегда, и различие имён измерить было бы нельзя. Ответ камуфляжа означает ровно то, что нужно: пакет прошёл сеть.
### Минимальная проба второго уровня
Скрипт ниже самодостаточен: он строит канонический ClientHello (тот, что клиенты Telegram слали до перехода на постквантовый шаблон), отправляет его и проверяет цифровую подпись ответа. `RULES` — это тот же язык блоков, которым шаблон описан в исходниках клиента: `S` — литерал, `Z` — нули под digest, `G` — пара GREASE, `R` — случайные байты, `K` — 32-байтный key share, `D` — домен из секрета, `Open`/`Close` — двухбайтовый префикс длины.
```python
RULES = [
('S', '1603010200010001fc0303'), ('Z', 32), ('S', '20'), ('R', 32),
('S', '0020'), ('G', 0),
('S', '130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f003501000193'),
('G', 2), ('S', '00000000'),
('Open', 0), ('Open', 0), ('S', '00'), ('Open', 0), ('D', 0),
('Close', 0), ('Close', 0), ('Close', 0),
('S', '00170000ff01000100000a000a0008'), ('G', 4),
('S', '001d00170018000b00020100002300000010000e000c02683208687474702f312e31'
'000500050100000000000d0012001004030804040105030805050108060601'
'001200000033002b0029'),
('G', 4), ('S', '000100001d0020'), ('K', 0),
('S', '002d00020101002b000b0a'), ('G', 6),
('S', '0304030303020301001b0003020002'), ('G', 3), ('S', '0001000015'),
]
```
Сборка: пройти список, дописывая байты; на месте `Z` запомнить позицию digest; добить нулями до 517 байт через расширение padding (`00 15` + длина); посчитать `HMAC-SHA256(ключ, весь пакет)` и положить в digest; наконец подмешать метку времени — **XOR последних четырёх байт digest с текущим unixtime** (little-endian).
Проверка ответа: прочитать `16 03 03` + длина + тело, затем 11 байт `14 03 03 00 01 01 17 03 03` + длина и остаток. Склеить `клиентский digest + весь ответ`, занулить 32 байта на позиции 43 и сверить их с `HMAC-SHA256(ключ, склейка)`. Сошлось — ответил релей. Не сошлось (или вместо `14 03 03…` пришло что-то другое) — ответил камуфляж.
### Форматы, которые нужны, чтобы собрать пробу с нуля
**ClientHello.** Байты 02: `16 03 01`. Байты 34: длина TLS-записи (big-endian). Байт 5: `01` — тип handshake. Байты 68: длина handshake. Байты 910: `03 03`. **Байты 1142: digest.** Байт 43: длина session id (`0x20`), следом 32 байта. Затем двухбайтовая длина списка шифров и сам список, методы сжатия (`01 00`), двухбайтовая длина расширений и расширения. Порядок сборки: собрать пакет с нулями на месте digest → добить до нужной длины расширением padding (`00 15` + длина + нули) → посчитать `HMAC-SHA256(ключ, весь пакет)` → положить результат в digest → подмешать время в байты 3942 операцией XOR с unixtime (little-endian).
**ServerHello.** Ответ релея состоит из четырёх частей подряд: `16 03 03` + двухбайтовая длина + тело; `14 03 03 00 01 01`; `17 03 03` + двухбайтовая длина + тело. Подпись проверяется по склейке «клиентский digest ‖ весь ответ», в которой занулены 32 байта на позиции 43 (то есть байты 1142 самого ServerHello). Тело последней части — случайные байты (`RAND_bytes`), и они входят в подпись наравне с остальным, поэтому проверять её можно только собрав все четыре части целиком: подпись считается по всему, что релей прислал, а не по одному ServerHello.
**obfuscated2.** 64 байта случайных данных со служебными ограничениями: первый байт не `0xef`; первые четыре не равны `HEAD`, `POST`, `GET `, `OPTI`, `0xdddddddd`, `0xeeeeeeee` и `16 03 01 02`; байты 47 не нулевые. Ключ шифрования — байты 839, IV — 4055; для расшифровки берутся те же байты 855, развёрнутые задом наперёд. При работе через прокси оба ключа дополнительно прогоняются как `SHA-256(ключ ‖ 16-байтовый секрет из ee-секрета)`. Байты 5659 — тег протокола, 6061 — `dc_id`. В сеть уходят первые 56 байт открытым текстом и байты 5663 в зашифрованном виде, после чего поток продолжается тем же шифром.
**Обёртка данных.** Перед первыми данными клиент шлёт `14 03 03 00 01 01`, дальше каждый кусок оборачивается в `17 03 03` + двухбайтовая длина. Telegram Desktop режет записи по 2878 байт.
**Первый запрос.** `req_pq_multi` — конструктор `0xbe7e8ef1` и 16 байт nonce, завёрнутые в незашифрованное MTProto-сообщение: восемь нулевых байт `auth_key_id`, восемь байт `message_id`, четыре байта длины. В padded intermediate сверху добавляется четырёхбайтовая длина и случайный хвост выравнивания. Ответ Telegram — конструктор `0x05162463` (resPQ) с тем же nonce.
### Четыре ловушки, на которых легко получить ложный вывод
**Ловушка первая: судить по префиксу.** `16 03 03` в начале ответа означает лишь «на том конце какой-то TLS-сервер». Камуфляжный nginx отвечает точно так же. Если считать такой ответ успехом, получится вывод вида «релей принимает ClientHello любой длины» — и он будет неверным: короткие пакеты принимал не релей, а сайт за ним. Единственный честный критерий — HMAC ответа.
**Ловушка вторая: считать digest до подмешивания времени.** Клиентский digest участвует дважды: он лежит в `random` отправленного ClientHello и он же — первые 32 байта склейки, по которой проверяется `ServerHello`. Между этими двумя применениями в него подмешивается время (XOR последних четырёх байт). Если проба возьмёт digest до подмешивания, а в пакет положит после, ClientHello релей примет, а проверка ответа не сойдётся — и получится ровно тот же ложный вывод «релей отвечает мусором». Порядок один: сначала HMAC, потом XOR со временем, и только потом брать копию для проверки ответа.
**Ловушка третья: писать в лог расхождение часов без точки отсчёта.** Клиент, решивший записывать, насколько его часы разошлись с истинным временем, считает разницу между системными часами и тем, что ушло в метку. Если поправки нет, в метку уходят те же системные часы, и разница выходит нулевой — при часах, спешащих на час. Ноль читается как «с часами всё хорошо», а означает «не с чем сравнить», то есть ровно тот случай, ради которого запись и заводилась. Число имеет смысл только рядом с признаком, была ли поправка вообще; и опасно не большое расхождение, а его отсутствие при отсутствующем отсчёте.
**Ловушка четвёртая: переносить шаблон клиента вручную.** Если проба должна повторить именно тот ClientHello, который шлёт конкретный клиент, шаблон удобно вытащить прямо из его исходников. Регулярное выражение, не учитывающее суффиксы строковых литералов конкретного языка (в C++ это, например, `"…"_q`), молча выбросит все литералы — проба соберёт мусор, релей его не признает, и родится ложный вывод «наш клиент шлёт неправильный ClientHello». Страховка простая: перед отправкой разобрать собранный пакет как TLS и сверить, что заявленная длина записи равна `len 5`, длина handshake равна `len 9`, а обход расширений заканчивается ровно на конце пакета.
---
## Что релеи требуют от ClientHello
Требования ниже воспроизвелись на обоих релеях одинаково, причём каждое проверялось при остальных заведомо выполненных. Порядок изложения совпадает с порядком проверок в коде официального MTProxy (`net/net-tcp-rpc-ext-server.c`), так что список можно читать и как контракт, и как маршрут отладки.
### Длина: не меньше 517 байт
Порог оказался ровным и жёстким. Пакеты 450, 512, 515 и 516 байт уводятся на камуфляж, а 517 байт и всё, что длиннее, принимаются релеем.
| Длина ClientHello | Релей А | Релей Б |
|---|---|---|
| 450 / 512 / 515 / 516 | камуфляж (alert или обрыв) | камуфляж |
| **517** | **релей** | **релей** |
| 518 / 600 / 1200 / 2000 / 3000 | релей | релей |
Число 517 не случайно: это размер канонического FakeTLS-рукопожатия, при котором в заголовке TLS-записи оказывается длина `0x0200`. Верхняя граница тоже есть, но далеко — 4096 байт, и постквантовые шаблоны современных клиентов в 17002100 байт до неё не достают.
> [!check] Порог виден прямо в исходниках официального MTProxy
> Вся ветка FakeTLS входит по одному условию:
>
> ```c
> } else if ((packet_len & 0xFFFFFF) == 0x010316 && (packet_len >> 24) >= 2
> && ext_secret_cnt > 0 && allow_only_tls) {
> ```
>
> Первая половина — это префикс `16 03 01`, вторая — старший байт длины TLS-записи, и он обязан быть не меньше двух. Значит запись ≥ `0x0200` = 512 байт, а весь ClientHello ≥ 517. Ниже этого релей в разбор рукопожатия даже не заходит.
>
> Ещё два ограничения из той же ветки, о которых легко забыть:
>
> - `if (len > min_len)` — клиент не имеет права дослать ни байта поверх ClientHello, пока не пришёл `ServerHello`. Отправка данных одним залпом с рукопожатием уводит на камуфляж.
> - `int read_len = len <= 4096 ? len : 4096;` и следом `if (len != read_len)` — ClientHello длиннее 4096 байт тоже уходит на камуфляж. Верхняя граница всё-таки есть, просто далеко: постквантовые шаблоны в 17002100 байт до неё не достают.
Проще говоря: релею нужно, чтобы первый пакет был не короче эталонного и не длиннее четырёх килобайт; всё за этими границами он за своего клиента не считает.
> [!warning] Частая ошибка чтения этого кода
> Внутри ветки действительно нет никакой константы 517 — там только самосогласованность: `min_len = 5 + 256 * header[3] + header[4]`, ожидание остатка при нехватке байт и отказ при избытке. Отсюда рождается вывод «официальный MTProxy примет хелло любой длины, значит порог 517 — свойство чужой сборки». Вывод неверен: порог стоит **на входе в ветку**, а не внутри неё. `packet_len` — это первые четыре байта соединения, прочитанные как little-endian: для `16 03 01 XX` получается `0xXX010316`, поэтому `packet_len >> 24` и есть старший байт длины записи. Требование `>= 2` отсекает всё короче 517 байт ещё до того, как начнётся разбор.
>
> Проверяется это за минуту и снаружи: 516 байт уводятся на камуфляж, 517 принимаются, граница ровная и одинаковая на двух независимых релеях.
>
> Побочная деталь того же выражения: `packet_len` объявлен знаковым `int`, так что при старшем байте `0x80` и выше сдвиг даёт отрицательное число и условие не выполняется. Практического значения это не имеет — такие длины давно за лимитом 4096.
>
> Релей, принимающий короткое рукопожатие (наблюдался случай с 270 байтами), — это не опровержение, а признак **другой сборки**. У telemt порог задан константой `MIN_TLS_CLIENT_HELLO_SIZE = 100` с комментарием «структурный минимум для валидного TLS 1.3 ClientHello с SNI, намеренно консервативный», а собственный тест проекта закрепляет её в диапазоне между 64 и 512 — то есть 512 там сознательно исключено, а не совпало.
### Метка времени: окно от минус двух часов до «сейчас»
Последние четыре байта digest несут время клиента, подмешанное операцией XOR. Релей его извлекает и сверяет с собственными часами — окно оказалось узким и асимметричным.
| Сдвиг часов клиента | Результат на обоих релеях |
|---|---|
| +0 / +1 / +2 / +3 с | релей |
| +5 / +10 / +20 / +30 с, +1 мин, +1 ч, +1 сутки | камуфляж |
| 1 ч, 2 ч (в удачный момент — и до 2 ч 24 мин) | релей |
| 2 ч 30 мин и старше | камуфляж |
Верхняя граница попадает в код официального MTProxy до секунды: `if (timestamp > now + 3)` — три секунды принимаются, пять уже нет. Совпадение настолько точное, что служит и признаком сборки: релей, прощающий ровно +3 с, — это MTProxy или совместимая с ним реализация.
Нижняя граница ведёт себя иначе: она не фиксирована и **дрейфует**. Один и тот же возраст рукопожатия (2 ч 24 мин) в одном прогоне принимался, а через четверть часа — уже нет; в один и тот же момент один релей принимал возраст до 585 секунд, а второй — до 9042. Это не капризы сети, а прямое следствие второй ветки проверки:
```c
if (first_client_random != NULL && timestamp > first_client_random->time + 3) return 1;
const int MAX_ALLOWED_TIMESTAMP_ERROR = 10 * 60;
if (timestamp > now - MAX_ALLOWED_TIMESTAMP_ERROR) return 1;
```
Проще говоря: релей принимает старое рукопожатие, только если способен проверить его на повтор, то есть если оно новее самой старой записи в кеше client random. Кеш пуст или молод — работает запасное правило «не старше десяти минут». Кеш накопил записи за пару часов — окно расширяется до них. А когда у релея несколько воркеров (`-M`), кеш у каждого процесса свой, и результат зависит от того, кому досталось соединение. Сам MTProxy об этом предупреждает — но не в том файле, где живёт разбор рукопожатия, а в `mtproto/mtproto-proxy.c`, в описании опции: «spawn several slave workers; not recommended for TLS-transport mode for better replay protection».
Для клиента отсюда следовало бы, что отставание часов до десяти минут безопасно всегда. На официальном MTProxy это так, но как общее правило — неверно, и опровергается это не измерением, а исходниками других сборок: у mtg допуск по умолчанию `DefaultTolerateTimeSkewness = 3 * time.Second` и проверяется он симметрично, через `time.Since(createdAt).Abs()`. То есть на mtg часы, отставшие на четыре секунды, уже отбиваются — там, где MTProxy простил бы десять минут.
Пересечение трёх реализаций даёт единственное правило, верное везде: **метка должна отличаться от истинного времени не больше чем на три секунды в любую сторону**. Всё, что шире, работает у одних пользователей и не работает у других, и разница будет не в сети, а в том, какую сборку поставил владелец релея.
И обратное следствие, про которое легко забыть: **успешное рукопожатие само по себе является измерением**. Оно доказывает, что метка попала в окно, то есть часы клиента отстоят от часов релея не больше чем на десять минут и не спешат больше чем на три секунды. Одной такой записи в логе достаточно, чтобы снять с часов подозрение до конца сессии.
Отсюда практическое следствие, объясняющее часть жалоб «у меня прокси не работает, а у соседа работает»: **спешащие часы на устройстве ломают FakeTLS**, и внешне это выглядит как неисправный прокси.
> [!danger] На цензурируемой сети часы чинить нечем
> Telegram Desktop подставляет в метку не сырые системные часы, а поправленные дважды: сдвигом по времени серверов MTProto и сдвигом по заголовку `Date` из HTTP-ответа. Обе поправки на холодном старте равны нулю, и обе требуют выйти наружу **напрямую**: время MTProto берётся из установленной сессии, а HTTP-запрос за `Date` явным образом ставит `setProxy(QNetworkProxy::NoProxy)` и мимо прокси идёт всегда.
>
> Ровно на той сети, где прокси и нужен, оба канала могут быть закрыты. Тогда поправка не приезжает никогда, и в FakeTLS уходят локальные часы — навсегда, а не «до первой синхронизации». Сверять часы перед тем, как винить прокси, стоит именно поэтому: у клиента может не быть способа их починить.
>
> Две детали той же механики, которые видно только в исходнике `lib_base/base/unixtime.cpp`. Первая: поправка по серверам MTProto не применяется, если отличается от текущей меньше чем на `kIgnoreTimeDifference = 3` секунды — а три секунды это ровно то, что релей прощает в будущее. То есть штатно отработавшая синхронизация может оставить клиента на три секунды впереди, и этого уже достаточно. Вторая: применение поправки MTProto обнуляет поправку по `Date` и сбрасывает признак её достоверности, так что точка отсчёта не только может отсутствовать изначально — она может пропасть после успешной синхронизации.
### Повторы: тот же ClientHello второй раз не принимается
Отправка байт в байт того же самого пакета даёт релей на первой попытке и камуфляж на всех последующих. Это защита от повторов (replay protection), и в диагностике она важна практически: пробу нужно каждый раз генерировать заново. Скрипт, который собрал пакет один раз и шлёт его в цикле, покажет «первый раз работает, дальше сломалось» и уведёт расследование в сторону мнимого rate-limit.
Ключом кеша служат **первые 16 байт** digest, а живёт запись `MAX_CLIENT_RANDOM_CACHE_TIME = 2 * 86400`, то есть двое суток. Измерение это подтверждает с запасом: повтор через 5, 30 и 60 секунд отбивался одинаково, а свежий пакет на каждую попытку принимался всегда.
### Ещё четыре проверки, о которых легко забыть
**SNI обязан совпадать с доменом из секрета.** Релей достаёт имя из расширения SNI и ищет его среди разрешённых (у официального MTProxy они задаются ключом `-D`); не нашёл — камуфляж. Изменения **одного байта** в домене при полностью пересчитанной подписи достаточно, чтобы уйти на маскировочный сайт. Домен в секрете — часть контракта, а не подсказка: клиент, который подставляет собственный SNI ради обхода DPI, теряет доступ к релею.
**Первым шифром после GREASE обязан идти `13 01`, `13 02` или `13 03`.** Формулировка «в списке должен быть TLS 1.3» тут неверна и упускает главное — проверяется именно первая позиция:
```c
while (cipher_suites_length >= 2 && (client_hello[pos] & 0x0F) == 0x0A
&& (client_hello[pos + 1] & 0x0F) == 0x0A) {
cipher_suites_length -= 2;
pos += 2;
}
if (cipher_suites_length <= 1 || client_hello[pos] != 0x13
|| client_hello[pos + 1] < 0x01 || client_hello[pos + 1] > 0x03) { /* камуфляж */ }
```
Измерения на обоих релеях подтверждают каждую деталь этого фрагмента:
| Что подставлено в начало списка шифров | Результат |
|---|---|
| GREASE, затем `1301` (эталон) | релей |
| GREASE, затем `1302` или `1303` | релей |
| GREASE, затем `1300`, `1304` или `13ff` | камуфляж |
| GREASE, затем `c02b`, а `1301` дальше по списку | камуфляж |
| Две пары GREASE подряд, затем `1302` | релей |
| Без GREASE вовсе, сразу `1301` | релей |
| Вместо GREASE значение `1a1b` (младшие полубайты `A` и `B`) | камуфляж |
Отсюда два требования к клиенту, которые из общей формулировки не следуют. Первое: **GREASE обязан иметь форму `?A ?A`** — младший полубайт обоих байт равен `0xA`. Это проверка формы, а не таблица разрешённых значений: пара `0a 1a` из разных байтов пропускается штатно, а произвольная заглушка вроде `1a 1b` не пропускается и занимает место «первого настоящего шифра», после чего рукопожатие уходит на камуфляж. Второе: **важна именно первая позиция**, а не наличие TLS 1.3 где-то в списке. Профиль, у которого после GREASE идёт `c0 2b`, будет отвергнут, даже если `13 01` есть третьим или пятым.
Практический смысл второго требования выходит за рамки отладки: оно определяет, какие браузерные почерки вообще можно эмулировать в FakeTLS. Годятся только те, где TLS 1.3 стоит в списке первым — а это ровно то, как список строят современные Chrome, Firefox и производные от них.
**Сравниваются только первые 28 байт digest.** Порча одного бита внутри них — камуфляж; последние четыре байта в сравнении не участвуют, потому что несут время. Отсюда, кстати, и способ передать время, не ломая подпись.
**Верхний предел длины измеряется, а не выводится.** 3517 и 4096 байт принимаются, 4097 и 5000 — уже нет, ровно как предсказывает `read_len = len <= 4096 ? len : 4096` со следующим за ним `if (len != read_len)`.
Отдельно, не про клиент, но полезно при разборе чужой конфигурации: рукопожатие, пришедшее на **порт 80**, релей отправляет на камуфляж сразу, ничего не проверяя.
---
## Что установлено дальше по цепочке
**Внутренний протокол — только padded intermediate.** После того как FakeTLS установлен, клиент открывает obfuscated2 и объявляет режим тегом в **байтах 5659 инициализации**. Оба релея принимают лишь `0xdddddddd` (padded intermediate); при `0xeeeeeeee` (intermediate) и `0xefefefef` (abridged) соединение закрывается без ответа.
> [!note] Одинаковые константы в двух разных местах
> Те же четыре значения — `0xef…`, `0xeeeeeeee`, `0xdddddddd` и HTTP-методы — встречаются в исходнике MTProxy ещё раз, в распознавании типа **внешнего** соединения (строки 10411069). Читая код, эти места очень легко перепутать: они выглядят одинаково и лежат рядом. Но внешний блок целиком обёрнут в `#if __ALLOW_UNOBFS__`, а в обычной сборке этот макрос не определён, так что все четыре ветки вырезаются препроцессором и снаружи остаются только FakeTLS и obfuscated2. Измеренное требование padded intermediate относится к внутреннему слою — к тем самым байтам 5659 внутри уже установленного FakeTLS, а не к вырезанному распознаванию. Для `ee`-секрета клиенты Telegram и должны выбирать padded intermediate, так что это не сюрприз, но проверять стоит: ошибка в выборе тега даёт ровно ту же картину «рукопожатие прошло, дальше тишина».
**По resPQ нельзя проверить `dc_id`.** Байты 6061 инициализации obfuscated2 несут номер дата-центра. Штатные значения 15, а вместе с ними 0, 9, 1 и 2 одинаково приводят к получению resPQ на обоих релеях — но вывод отсюда ровно один и он узкий: `req_pq_multi` не привязан к конкретному дата-центру, поэтому ответ на него приходит при любом номере. Из этого **не** следует, что релей номер игнорирует: он может приводить неизвестное значение к своему умолчанию. И дальше по цепочке номер критичен — ключ авторизации привязан к конкретному дата-центру, так что клиент с неверным `dc_id` получит resPQ и сломается на первой же настоящей сессии. Проверять корректность номера этим запросом просто нечем.
**Параллельные подключения — ограничения не обнаружено.** Восемь одновременных сессий дошли до `resPQ` за 91182 мс каждая; двадцать четыре — все успешно, суммарно 210 мс на одном релее и 517 мс на другом. Это стоит подчеркнуть, потому что клиенты нередко строят внутренний ограничитель на предпосылке «публичный MTProxy отвечает на пару рукопожатий, остальное глотает». Предпосылка верна не для всех сборок, а цена ошибки высокая: ограничитель растягивает старт, а при накоплении мнимых промахов может отложить дозвон на десятки секунд.
**Камуфляж иногда виден невооружённым глазом.** Обычный HTTP-запрос на порт одного из релеев вернул ответ nginx `400 The plain HTTP request was sent to HTTPS port` — то есть за релеем стоит настоящий веб-сервер, и именно он отвечает всем неопознанным клиентам. Второй релей на такой запрос промолчал и закрыл соединение, так что признак работает не всегда. Про то, как такой фронт устраивают намеренно, — в [[Zapret/mtproto/07-nginx-haproxy|связке MTProxy с nginx и HAProxy]].
---
## Что этот метод вскрыл в клиенте
Расследование проводилось для стороннего форка Telegram Desktop с переработанным контуром MTProxy, и все четыре находки — клиентские, релеи были исправны с самого начала.
**Padding до канонической длины не выполнялся.** В генераторе ClientHello добивание пакета до 517 байт оказалось привязано к флагу, который включался только при синтетическом PSK-расширении, то есть практически никогда. Для шаблонов, которые и без padding длиннее 513 байт (Chrome, Firefox, Yandex — у всех крупный постквантовый key share), это ничего не меняло. Но шаблон, имитирующий Android-библиотеку OkHttp, давал 264 байта — и релей уводил такого клиента на камуфляж, а клиент рапортовал о плохом digest. После восстановления padding пакет стал ровно 517 байт и проходит до `resPQ` на обоих релеях.
**Ограничитель параллельных дозвонов исходил из непроверенной предпосылки.** В коде было зашито «релей отвечает на два рукопожатия одновременно», тогда как измерение даёт минимум 24. Такой ограничитель не ломает подключение сразу, но добавляет задержки на старте и штрафует релей за собственные же промахи клиента.
Цена ошибки здесь несимметрична, и это стоит разобрать, потому что ловушка типовая. Третий дозвон и все следующие ждали освобождения слота — до целого бюджета рукопожатия; пара промахов поднимала интервал шагами по 2 с до 30 с; очередь клампилась двумя минутами; состояние было общим на весь процесс и все аккаунты. Одного неудачного старта — сон, смена сети, переезд между дата-центрами — хватало, чтобы исправный релей попал за полминуты интервала, и клиент выглядел «не подключается» минутами. Лимит, взятый из чужого наблюдения, обошёлся дороже, чем отсутствие лимита.
Итог после разбора: лимит параллелизма убран целиком, остался интервал между дозвонами и штрафной интервал по фактическим промахам — начиная с четвёртого, шагами по 0,5 с, с потолком 4 с. Дальше собственный таймер переподключения сессии всё равно медленнее и берёт роль тормоза на себя, а рост сверх того только задерживает возвращение ожившего релея.
Интервал между дозвонами сначала поставили в 250 мс — из соображения «приходить как отдельные клиенты, а не залпом». Позже выяснилось, что и это предположение не выдерживает проверки, и его пришлось снизить до 50 мс; разбор — в главе про задержку.
**Фазовый расчёт таймаута оказался мёртвым.** Общий бюджет попытки для mtproxy складывается из фаз: гонка маршрутов (4000 + 300 × 2), одиночный маршрут (8000), ожидание ServerHello (5000), ещё один маршрут (8000) и запас 500 — итого 26 100 мс, которые затем клампятся потолком в 12 600 мс. Сумма никогда не проходит клампа, то есть весь расчёт всегда возвращает потолок. Вреда в наблюдаемых логах это не даёт (4 с на DNS плюс 8 с на маршрут укладываются в 12,6 с), но фазовая арифметика существует только на бумаге, и первое же увеличение любой фазы пройдёт незамеченным.
**Успешное соединение засчитывалось как промах.** Ветка, где клиент принимает подключившийся маршрут, не дождавшись более приоритетного, не помечала аренду слота как успешную — и ограничитель воспринимал исправный релей как проблемный.
Общее у всех четырёх: ни одна не следует из логов клиента напрямую, потому что в логах видно только «digest не сошёлся» или «нет ответа», а причина находится на стороне, о которой клиент ничего не знает.
### Улика, которая видна в логе
Оговорка «не диагностируется по логам» верна не до конца: одна улика в логе есть, и она про то, что клиент **отправил**, а не про то, что ему пришло.
**Длина ClientHello, зависящая от домена.** Канонический пакет всегда 517 байт, каким бы ни был SNI: padding домен и поглощает. Если в логе `длина длина_SNI` даёт одну и ту же константу на разных прокси — padding не работает. В разобранном случае константа была `247` на пяти прокси подряд, и этого одного достаточно, чтобы найти баг, не подходя к серверу.
### Почему размер ответа ничего не решает
Соблазн отличить релей от камуфляжа по объёму ответа возникает у всех, кто видит рабочее соединение в двести байт рядом с неудачным в четыре тысячи. Поддаваться не стоит: измерения на двух релеях разваливают критерий сразу в обе стороны. Релей А на валидное рукопожатие отвечает 3381 байтом, а на короткое — 184; релей Б отвечает 196 байтами в обоих случаях, побайтово одинаково.
Причина в самом релее, и она видна в коде. Принимая клиента, он собирает ServerHello сам и заполняет первую запись данных случайными байтами, длину которых берёт под маскируемый домен — `RAND_bytes(response_buffer + pos, encrypted_size)`, где `encrypted_size` приходит из `get_domain_server_hello_encrypted_size(info)`, то есть из величины, измеренной у домена при старте. За тяжёлым `www.microsoft.com` ответ тяжёлый, за лёгким `*.sslip.io` — лёгкий. Отвергая клиента, релей проксирует соединение на настоящий адрес домена, и объём определяет уже тот сервер.
Есть и вторая причина, не зависящая от домена вовсе. Если зондирование домена при старте не удалось, размер берётся случайным из фиксированного диапазона: `info->server_hello_encrypted_size = 2500 + rand() % 1120`, то есть от 2500 до 3619 байт. Замеренные 3381 у релея А ложатся ровно в него. Иными словами, штатный ответ исправного релея бывает трёхкилобайтным просто потому, что маскируемый домен в тот момент не отозвался, — и размерный критерий хоронит не только измерение, но и константа в исходнике.
Вдобавок наблюдаемая величина — не «ответ» целиком, а первая запись application data: разбор фиксированного шаблона FakeTLS доходит ровно до её конца. Настоящий сервер TLS 1.3 кладёт туда EncryptedExtensions, а сертификат отправляет следующей записью, которую клиент к этому моменту ещё не читал. Отсюда и 46 байт там, где ожидался килобайт. Совпадение «двести против тысяч» в первых логах было свойством конкретных площадок, а не признаком.
> [!warning] 122 байта ServerHello не доказывают ничего
> Размер тела ServerHello, равный 122, встречается и у релея, и у настоящего сервера, поэтому по нему нельзя судить, кто ответил. Это естественная длина ответа TLS 1.3 с ключом x25519: 2 байта версии + 32 случайных + 33 на эхо session_id + 2 на шифр + 1 на сжатие + 2 на длину расширений + 6 на supported_versions + 40 на key_share = 118, плюс четыре байта заголовка handshake. MTProxy зашивает `\x16\x03\x03\x00\x7a\x02\x00\x00\x76\x03\x03` именно потому, что так выглядит любой современный сервер.
### Различать причины по ответу нельзя, по своему же пакету — можно
Раз объём ответа не голосует, а подпись при отказе не сходится в обоих случаях, единственный детерминированный признак лежит в том, что клиент отправил сам. Хелло, нарушивший контракт — короче 517 байт, длиннее 4096, с не-TLS 1.3 шифром первым после GREASE или с SNI, отличным от домена в секрете, — до разбора рукопожатия не доходит, и расхождение подписи в этом случае принадлежит клиенту, а не секрету. Хелло, контракту удовлетворяющий, оставляет ровно одну причину — секрет.
Отсюда практическое правило для авторов клиентов: **проверку контракта делать перед отправкой, а код ошибки выбирать по её вердикту**, а не по тому, что пришло в ответ. Заодно это единственный способ превратить бесполезное «bad Server Hello digest» в конкретную причину, названную в тот момент, когда она возникла.
### Контракт, который клиент может проверить сам
Всё, что релей требует от первого пакета, проверяется до отправки, и это дешевле любой внешней пробы.
Длина обязана лежать в границах от 517 до 4096 байт. Заявленные длины обязаны сходиться с фактической: длина TLS-записи равна размеру пакета минус пять, длина handshake — минус девять, а блок расширений обязан упираться ровно в конец пакета. Последнее стоит проверять именно обходом: парсер, который просто читает расширения подряд и выходит, когда осталось меньше четырёх байт, пропустит хвост в один-три байта внутри объявленного блока — то есть ровно ту ошибку в арифметике вложенных длин, ради которой проверка и нужна.
Первый набор шифров после отброшенных GREASE обязан быть одним из `1301`, `1302`, `1303`, причём GREASE отбрасывается по форме — младший полубайт `0x0A` в обоих байтах, — а не по таблице значений, так что заглушка другой формы займёт место первого настоящего набора и провалит проверку. SNI обязан байт в байт совпадать с доменом из секрета.
Проверку стоит делать предупреждающей, а не запрещающей: релей с другим набором требований всё равно заслуживает попытки, а ценность проверки в том, что причина называется в момент своего возникновения, а не через вечность в виде неподписанного ServerHello.
---
## Чек-лист требований к клиенту
- [ ] ClientHello не короче 517 байт — при необходимости добить расширением padding.
- [ ] ClientHello не длиннее 4096 байт.
- [ ] SNI берётся из секрета и не подменяется.
- [ ] `session_id` ровно 32 байта. Измерено: hello с 31 и с 30 байтами релей отправляет на камуфляж (ответ приходит, digest чужой) — он читает пакет по смещению, рассчитанному на 32 байта, как у любого настоящего браузера.
- [ ] Первым шифром после GREASE идёт `13 01`, `13 02` или `13 03` — не «где-то в списке», а именно первым.
- [ ] GREASE в списке шифров имеет форму `?A ?A`; произвольные заглушки релей за GREASE не считает.
- [ ] Digest считается по всему пакету с занулённым полем и кладётся на позицию 11.
- [ ] В последние четыре байта digest подмешано время; часы не должны спешить (запас всего 3 секунды) и желательно не отставать больше десяти минут.
- [ ] Соблазн вычесть из метки запас в полминуты «раз в прошлое можно на десять минут» — не следовать ему: асимметрия окна есть только у официального MTProxy, а у mtg допуск по умолчанию симметричный и равен трём секундам, так что такой сдвиг сломает mtg-релеи полностью.
- [ ] Каждое новое соединение — новое рукопожатие; повторная отправка того же пакета недопустима.
- [ ] Ничего не досылается поверх ClientHello, пока не пришёл ServerHello.
- [ ] Подпись ответа проверяется по всем четырём его частям сразу — хвост из случайных байт входит в неё, и его длина ничего не значит.
- [ ] Внутри — padded intermediate (`0xdddddddd`).
- [ ] Лимит параллельных дозвонов, если он вообще нужен, измеряется на своих релеях, а не берётся из чужих наблюдений.
---
## Чем отличаются другие реализации
Всё измеренное выше объясняется кодом официального MTProxy, но парк релеев неоднороден: vpnbot, например, умеет ставить три разные сборки. Различия, которые видно прямо в исходниках:
| Что | Официальный MTProxy | telemt | mtg |
|---|---|---|---|
| Минимальная длина ClientHello | 517 — через условие «старший байт длины записи ≥ 2» | `MIN_TLS_CLIENT_HELLO_SIZE = 100`, и собственный тест закрепляет диапазон `> 64` и `< 512` | явного порога в разборе нет: `client_side.go` читает структуру и проверяет её, а не длину |
| Будущее в метке времени | не дальше `now + 3` | `TIME_SKEW_MAX = +120 с` | `DefaultTolerateTimeSkewness = 3 с` |
| Прошлое в метке времени | до старейшей записи кеша, при пустом кеше — 10 минут | `TIME_SKEW_MIN = 120 с` | те же 3 с — проверка через `.Abs()`, окно симметрично |
| Защита от повторов | 16 байт client random, кеш 2 суток | первые 16 байт digest, окно настраивается | есть |
| Сравнение digest | первые 28 байт | первые 28 байт | первые 28 байт |
Отдельная деталь telemt, которой нет у остальных: метка, меньшая `BOOT_TIME_MAX_SECS`, трактуется как время с момента загрузки, а не как unix-время, и проверку перекоса тогда обходит — с потолком `BOOT_TIME_COMPAT_MAX_SECS = 120 с` и настроенного окна повторов. Это совместимость с клиентами, которые кладут в метку аптайм; полагаться на неё нельзя, но при разборе чужого поведения о ней стоит помнить.
Практический вывод: **писать клиент нужно по самому строгому контракту**. Рукопожатие в 517 байт с меткой, отличающейся от истинного времени не больше чем на три секунды, примут все три реализации; рукопожатие в 300 байт или с меткой минутной давности пройдёт только там, где проверки мягче, — и именно такой клиент будет «случайно» работать у одних пользователей и не работать у других.
---
## Симптом «первые соединения проходят, дальше тишина» и чем он оказался
Самый дорогой разбор за всю историю этой заметки: пять правдоподобных объяснений подряд, каждое отвергнуто измерением, и только шестое попадание. Записываю целиком — и находку, и все пять промахов, потому что промахи здесь полезнее находки.
Симптом в логе клиента: на каждом релее **первые три-четыре соединения проходят целиком** (`server_hello_ok`, ключ создан, первый mtproto-пакет получен), а все последующие получают ровно ноль байт в ответ и умирают по пятисекундному таймауту — `rx_after_ch=0`, `rx_class=zero`, `close_origin=local_timeout`. Локально пакет уходит полностью (`ch_accepted == ch_bytes`). Повторяется на трёх разных релеях подряд. Со временем становится хуже: к вечеру не проходит уже ни одно соединение.
### Причина: сеть отвергает конкретный отпечаток клиента
Клиент умеет шесть профилей ClientHello. Проба, которая строит **байты этих же шести профилей** из правил самого клиента и посылает их одному релею, дала на фильтрованной сети такое (двенадцать попыток на профиль, три прогона по четыре):
| профиль | фильтрованная сеть | чистая сеть |
|---|---|---|
| firefox | 12 / 12 | 6 / 6 |
| firefox_android | 12 / 12 | 6 / 6 |
| android_okhttp | 12 / 12 | 6 / 6 |
| yandex | 12 / 12 | 6 / 6 |
| **chrome_modern** | **3 / 12** | 6 / 6 |
| **android_chrome** | **3 / 12** | 6 / 6 |
Один релей, один секрет, одни минуты, одна машина. Клиент по умолчанию посылал `chrome_modern` — то есть ровно тот отпечаток, который эта сеть не пропускает. Отсюда и «у нас все прокси не работают»: на такой сети клиент не мог поднять ни одного соединения ни к одному релею, пока Python с той же машины в ту же минуту получал `ServerHello` за 810 мс.
Три успеха из двенадцати — не случайность, а форма отказа: **блокировка включается после первых одного-двух соединений с этим отпечатком и дальше держится**. В прогоне по раундам видно прямо: раунды 12 проходят, с третьего тишина до конца. Это же и объясняет исходное «первые три-четыре соединения работают, дальше ноль» — и объясняет, почему симптом усиливался к вечеру.
### Пять гипотез, отвергнутых измерением
Все пять выглядели убедительно, и каждая казалась «той самой». Порядок — как их проверяли:
1. **Размер пакета.** Проба шлёт 517 байт (один TCP-сегмент), клиент 17181823 (гарантированно два). Тридцать пар структурно идентичных hello, где растут все declared lengths и padding, так что меняется только длина: **60 из 60** получили `ServerHello` с верным digest, времена одинаковые.
2. **Незафлашенная запись.** `QTcpSocket::write()` только ставит байты в очередь, а клиент сразу запускает пятисекундный отсчёт; фрагментированный путь делал `flush()`, обычный — нет. Отвергнуто дважды: по 21 отказу интервал до таймаута равен 4.9995.012 с, то есть event loop того же потока жив; и замолчали **уже установленные** соединения, где данные до этого шли в обе стороны. Незафлашенный hello ни того, ни другого объяснить не может.
3. **Пост-квантовый key_share (ML-KEM).** Отвергнуто составом: блок `M()` на 1216 байт несут три из четырёх проходящих профилей.
4. **Порядок расширений.** Chromium перемешивает расширения на каждом hello, и оба отвергнутых профиля — единственные, кто это делает. Контролируемый опыт: один профиль, две руки, различие только в порядке, длина внутри пары совпадает побайтово. Результат: `chrome_modern` молчит **и** упорядоченный (2 из 6 в первом прогоне, 1 из 6 во втором), `yandex` проходит **и** перемешанный (6 из 6 в обоих). Опыт повторён дважды с тем же выводом: порядок ни при чём.
5. **JA4.** Отвергнуто вычислением: у проходящего `yandex` и у отвергаемого `chrome_modern` JA4 **совпадает буква в букву**`t13d1514h2_8daaf6152771_d8a2da3f94cd`, и он же у настоящего Яндекс.Браузера. JA4 сортирует расширения и выбрасывает GREASE, поэтому всё, чем эти два шаблона различаются, для него невидимо.
### Один байт переворачивает результат в обе стороны
Порядок расширений опровергнут тем же прогоном, который проверил следующее подозрение — единственное измеренное различие двух шаблонов: у `chrome_modern` одно GREASE-расширение несёт один нулевой байт, у `yandex` оба пустые. Две дополнительные руки к тому же опыту, всё остальное неизменно:
| рука | размер | фильтрованная сеть |
|---|---|---|
| `chrome_modern` как есть | 1718 | 1 / 6 |
| `chrome_modern`, тело GREASE опустошено | 1717 | **6 / 6** |
| `yandex` как есть | 1717 | 6 / 6 |
| `yandex`, в GREASE положен тот же байт | 1718 | **1 / 6** |
Инверсия работает в обе стороны: отбираешь байт — отвергнутый шаблон начинает проходить; добавляешь байт — проходящий начинает молчать. Одна переменная, четыре руки, раунды по кругу.
### Настоящий браузер эта сеть тоже отвергает
Дамп Яндекс.Браузера, снятый на той же машине, с единственными изменениями «SNI на домен релея» и «digest в поле random» — то есть все шифры, все расширения, все длины и все GREASE-значения браузерные:
| что послано | размер | фильтрованная сеть |
|---|---|---|
| настоящие байты Яндекс.Браузера | 1814 | **1 / 6** |
| наш шаблон `yandex` | 1717 | 6 / 6 |
| наш `chrome_modern` (контроль) | 1718 | 2 / 6 |
Это снимает предыдущее возражение, а не подтверждает его: в дампе GREASE-расширение `6a6a` несёт ровно один нулевой байт — то же, что у `chrome_modern`. То есть браузер отвергается **вместе со** своим байтом, а наша копия проходит **без** него, и обе картины согласуются.
Отсюда важный вывод, независимый от механизма: эта сеть **режет подлинный браузерный ClientHello и пропускает нашу подделку**. Значит фильтр не занят поиском «не-браузера», и вся рамка «сделать отпечаток похожим на настоящий браузер» — неверная. Похожесть тут не помогает, а мешает.
### Селектор — не одно поле, а точное совпадение
Тринадцать рук, размеры выровнены до байта там, где это нужно, чередование по раундам, один релей. На чистой сети — 13 из 13 в каждом раунде; на фильтрованной отвергнуты ровно три:
| рука | размер | ECH payload | последнее расширение | фильтр. сеть |
|---|---|---|---|---|
| `chrome_modern` как есть | 1718 | 144 | GREASE, тело `00` | **1 / 6** |
| `yandex` + тот же байт | 1718 | 144 | GREASE, тело `00` | **1 / 6** |
| настоящий Яндекс.Браузер | 1814 | 240 | GREASE, тело `00` | **1 / 6** |
| `chrome_modern`, тело опустошено, размер выровнен | 1718 | 145 | GREASE, пусто | 6 / 6 |
| `yandex` + байт, размер выровнен | 1717 | 143 | GREASE, тело `00` | 6 / 6 |
| `chrome_modern`, тело 8 байт | 1718 | 137 | GREASE, тело 8×`00` | 6 / 6 |
| `chrome_modern`, байт в **первом** GREASE | 1718 | 144 | GREASE, пусто | 6 / 6 |
| `chrome_modern`, байт `ff` вместо `00` | 1718 | 144 | GREASE, тело `ff` | 6 / 6 |
| `chrome_modern`, оба GREASE удалены | 1718 | 153 | не GREASE | 6 / 6 |
| дамп браузера с опустошённым GREASE | 1813 | 240 | GREASE, пусто | 6 / 6 |
| `yandex` как есть | 1717 | 144 | GREASE, пусто | 6 / 6 |
Три сравнения в этой таблице меняют **ровно одну** переменную:
- **значение байта.** `ff` вместо `00`, длина и ECH те же — блок исчезает.
- **позиция байта.** Тот же байт в первом GREASE-расширении, последнее пустое, длина и ECH те же — блок исчезает.
- **длина ECH payload.** Тело GREASE одинаковое, 144 против 143 — блок исчезает.
Значит правило — **конъюнкция**: hello отвергается, только когда все признаки одновременно в «браузерном» виде. Сломай любой — проходит. Это не эвристика «похоже на прокси», а сверка с эталоном.
Соответствие правилам клиента точное — и оно же показывает, что хвоста самого по себе недостаточно:
| профиль | ECH payload | ML-KEM | ALPS | хвост `G(3); S("00 01 00")` | фильтр. сеть |
|---|---|---|---|---|---|
| `firefox`, `firefox_android` | нет | да | нет | нет | 12 / 12 |
| `android_okhttp` | нет | нет | нет | **да** | 12 / 12 |
| `yandex` | да | да | да | нет (`S("00 00")`) | 12 / 12 |
| `chrome_modern`, `android_chrome` | да | да | да | **да** | **3 / 12** |
`android_okhttp` — контрпример, без которого правило звучало бы как «дело в хвосте»: тот же байт, и 12 из 12. Отвергаются ровно те два профиля, у которых хвост стоит **внутри** полного Chromium-набора. ECH payload клиент берёт из набора `{144, 176, 208, 240}` (`client_hello_builder.cpp`), и все проверенные длины этого набора отвергаются одинаково.
### Проверка конъюнкции: тринадцать рук, две сети
Отдельный прогон против той же цели, где все руки сохраняют отвергаемую форму хвоста и меняют по одной длине. На чистой сети — 13 из 13; на фильтрованной:
| рука | размер | чётность | ECH payload | фильтр. сеть |
|---|---|---|---|---|
| `chrome_modern` как есть | 1718 | чёт | 144 (в наборе) | **1 / 4** |
| ECH payload 176 | 1750 | чёт | 176 (в наборе) | **1 / 4** |
| ECH payload 240, как у браузера | 1814 | чёт | 240 (в наборе) | **1 / 4** |
| настоящие байты Яндекс.Браузера | 1814 | чёт | 240 | **0 / 4** |
| ECH payload 142 | 1716 | **чёт** | 142 (вне набора) | 4 / 4 |
| ECH payload 143 | 1717 | нечёт | 143 (вне набора) | 4 / 4 |
| + padding-расширение 1 байт перед последним | 1723 | нечёт | 144 | 4 / 4 |
| + padding-расширение 2 байта перед последним | 1724 | чёт | 144 | 4 / 4 |
| то же расширение в начале списка (1 и 2 байта) | 1723 / 1724 | нечёт / чёт | 144 | 4 / 4 |
| поле `enc` внутри ECH: 31 и 30 байт вместо 32 | 1717 / 1716 | нечёт / чёт | 144 | 4 / 4 |
| `yandex` как есть | 1717 | нечёт | 144 | 4 / 4 |
Что из этого следует:
- **Чётность общей длины опровергнута напрямую.** Рука с ECH 142 даёт 1716 байт — чётное, — и проходит, тогда как отвергаются 1718, 1750 и 1814, тоже чётные.
- **Правило покрывает весь набор ECH-длин клиента.** 144, 176 и 240 отвергаются одинаково, значит случайный выбор длины на каждое hello клиента не спасал никогда.
- **Любая правка выводит из-под подписи.** Лишнее расширение в два байта, где угодно в списке; байт, срезанный с `enc` внутри ECH; смена длины ECH payload — каждой хватает.
- **Точное совпадение отвергают жёстче.** Настоящий браузер получил 0 из 4 и был отвергнут уже в первом раунде, тогда как наши шаблоны первый раунд проходят и замолкают со второго.
### Правило не про адрес: четыре релея, один порядок
Тот же опыт — девять рук одинаковой длины, где меняется по одному полю, — прогнан против четырёх разных релеев с проблемной машины. Отвергаемая форма это `chrome_modern` как есть, контроль-позитив `yandex`, контроль на чужих байтах — дамп браузера.
| релей | порт | `chrome_modern` | восемь правок по одному полю | `yandex` | дамп браузера |
|---|---|---|---|---|---|
| 159.194.198.73 | 443 | **2 / 4** | 4 / 4 каждая | 4 / 4 | **1 / 4** |
| 155.212.137.130 | **45633** | **2 / 4** | 4 / 4 каждая | 4 / 4 | **1 / 4** |
| 82.26.171.54 | 442 | **3 / 4** | 4 / 4 каждая | 4 / 4 | **3 / 4** |
| 194.39.110.183 | 443 | 0 | 0 | **0** | 0 |
Что из этого следует:
- **Правило читает содержимое, а не адрес.** Одинаковая картина на двух разных IP, причём на втором — нестандартный порт 45633. Ни порт 443, ни конкретный адрес тут ни при чём.
- **Любое отклонение снимает блокировку.** Порядок ALPN; `h3` вместо `h2` в ALPS; переименованная группа пост-квантового key_share; переименованный x25519; порядок двух последних шифров; порядок в `supported_versions`; номер расширения `0017``0018`; номер `0023``0024`. Каждая рука той же длины, что контроль, и каждая проходит четыре раза из четырёх на всех релеях.
- **Блокировка накопительная, и порог разный.** На 159.194.198.73 сначала замолкает дамп браузера (со второго раунда), затем `chrome_modern` (с третьего). На 82.26.171.54 обе эталонные руки проходят три раунда и замолкают в четвёртом — **одновременно**. Синхронность указывает на общее состояние для эталонных hello к этому адресу, а не на отдельный счётчик у каждого отпечатка. Порог зависит от маршрута.
- **Порог не связан с повторением как таковым.** Правленые руки повторяются столько же раз, что и контроль, и не блокируются никогда. Значит считается не «часто встречающийся отпечаток», а именно совпадение с эталоном.
- **Четвёртый релей — другой случай.** Там молчит всё, включая `yandex` и не-TLS мусор, а с чистой сети этот же релей отвечает 11 из 11. Это блокировка адреса или маскируемого домена (`www.apple.com`), отдельный слой, к отпечатку отношения не имеющий.
> [!note] Как это формулировать точно
> «Правка поля X снимает блокировку» — измерено. «Фильтр читает поле X» — уже интерпретация: из данных следует лишь, что hello перестал совпадать с эталоном, а какие поля входят в сравнение и с каким весом, отсюда не выводится. На практике разницы нет — лечится любой правкой, — но в выводах это разные утверждения.
### Привилегированное имя в SNI отключает фильтр отпечатка
Самый практичный результат за всё расследование, и он получен одной переменной на одном адресе. На релее, где отпечаток режется прямо в эти минуты (проверено `test10` непосредственно перед: `chrome_modern` 2/4, дамп браузера 1/4), посланы четыре руки по кругу:
| рука | размер | фильтр. сеть |
|---|---|---|
| отвергаемый отпечаток + имя из секрета (`espn.com`) | 1718 | **0 / 4** |
| **тот же отпечаток** + `update.microsoft.com` | 1730 | **4 / 4** |
| проходящий отпечаток + `espn.com` (контроль) | 1717 | 4 / 4, HMAC сходится |
| проходящий отпечаток + `update.microsoft.com` | 1729 | 4 / 4 |
Один адрес, один порт, одни минуты, те же байты hello — меняется только имя, и вердикт переворачивается. Значит **фильтр отпечатка применяется избирательно, по имени в SNI**: привилегированное имя выводит hello из правила целиком.
Собирается связная трёхслойная картина, объясняющая все наблюдения:
1. **Блокировка по имени.** Некоторые имена к некоторым адресам не пропускаются вовсе — там молчит всё, включая проходящий профиль (случай `www.apple.com`).
2. **Белый список имён.** Поддомены `microsoft.com` пропускаются всегда, независимо от отпечатка. `login.microsoftonline.com` в список не входит — он ведётся по домену второго уровня.
3. **Фильтр отпечатка** — только для имён, не попавших ни в один список: точное совпадение с эталонными браузерными hello режется, неточная копия проходит.
> [!tip] Что это значит для настройки релея
> Маскируемый домен важнее любой работы с отпечатком. Ключ, выпущенный на привилегированное имя, проходит с **любым** отпечатком, включая побайтовый дамп настоящего браузера, который иначе режется. Правка в клиенте (`yandex` по умолчанию) остаётся нужной — клиент не выбирает домен, он приходит из секрета, — но для своих релеев дешевле поставить правильное имя, чем подбирать отпечаток.
Методологически здесь важен критерий: **«пришёл ли хоть какой-то ответ»**, а не «сошёлся ли HMAC». Релей проверяет SNI и отправляет чужое имя в камуфляж, поэтому по HMAC руки 2 и 4 «неуспешны» всегда — и эксперимент был бы невозможен. Ответ камуфляжа означает ровно то, что нужно измерить: пакет прошёл сеть.
### Заблокированный адрес с белым списком по имени
Четвёртый релей из таблицы выше молчал на всё — но не потому, что недоступен. Не-TLS мусор в 1700 байт получил в ответ `HTTP/1.1 …`, то есть адрес отвечает. Перебор четырнадцати маскируемых имён к тому же адресу и порту (hello один и тот же, меняется только имя в SNI; критерий — пришёл ли хоть какой-то ответ, потому что digest для чужого имени и не должен сойтись):
| имя в SNI | ответ |
|---|---|
| `update.microsoft.com` | **3 / 3** |
| `azure.microsoft.com` | **3 / 3** |
| `www.microsoft.com` | **2 / 3** |
| `login.microsoftonline.com` | 0 / 3 |
| `www.apple.com` (имя из секрета) | 0 / 3 |
| `www.icloud.com`, `ya.ru`, `yandex.ru`, `www.google.com`, `www.cloudflare.com`, `cdn.jsdelivr.net`, `www.gosuslugi.ru`, `vk.com`, `www.tinkoff.ru` | 0 / 3 |
Проходят ровно три имени, и все три — поддомены `microsoft.com`. При этом `login.microsoftonline.com` **не** проходит: список ведётся по домену второго уровня, а не по подстроке «microsoft».
Это не блокировка отпечатка и не блокировка адреса в чистом виде, а **заблокированный адрес с белым списком имён**: TLS к нему пропускают, только если SNI из привилегированного набора — очевидно, чтобы не ломать обновления Windows. Отсюда прямое решение: ключ с маскируемым домеником из `microsoft.com` на том же адресе, менять адрес не нужно.
Проверять релеем важно с обеих сторон: с чистой сети тот же релей ответил на **все четырнадцать** имён, включая `ya.ru` и `vk.com`. Значит различие создаёт сеть, а не релей — версия «релей не отвечает на чужой SNI» опровергнута измерением.
> [!warning] Подменить имя на стороне клиента нельзя
> Соблазнительная мысль: если сеть режет по имени, пусть клиент пошлёт другое имя с тем же ключом — и ключ менять не надо. Не работает. На живом релее с секретом `espn.com`: hello с его собственным именем верифицируется, а `update.microsoft.com`, `www.google.com` и даже трёхбайтовое `a.b` — с тем же ключом и корректно посчитанным digest — уходят в камуфляж. Релей сравнивает SNI с настроенным доменом отдельно от подписи.
>
> Отсюда же читается и предыдущий абзац: «пришёл TLS-ответ» в переборе имён означал ответ **камуфляжного сайта**, то есть «пакет дошёл», а не «релей принял». Поэтому имя, прошедшее перебор, годится только как кандидат: ключ с ним должен выпустить сервер, и проверять надо по HMAC.
> [!warning] Блокировка непостоянна во времени
> На релее, где `chrome_modern` устойчиво отвергался (2/4 и 1/6 в нескольких прогонах), отдельный прогон **двадцати пяти подряд идентичных** hello этого профиля прошёл целиком: 25 из 25 приняты. То есть отпечаток режут эпизодически, а не всегда.
>
> Что из этого следует для прежних выводов. Сравнения «одна переменная, руки чередуются в раунде» остаются в силе: обе руки в паре получали одинаковые условия, и разделение 1/6 против 6/6 не объясняется дрейфом. А вот версия про **накопительный порог** («блок включается после первого-второго эталонного hello») этим прогоном не подтверждается — 25 попыток подряд не включили ничего. Правдоподобнее выборочная проверка части соединений, состояние которой меняется само.
>
> Практический смысл `withheld` от этого не меняется: посылать отпечаток, который на части соединений режут, незачем. Но измерять состояние блокировки надо в тот момент, когда она активна — то есть когда клиент уже показывает тишину, а не в произвольное время.
### Ловушка при подборе ручек
**Менять длину `session_id` нельзя** — не из-за фильтра, а из-за самого релея: hello с 31 и 30 байтами уходит на камуфляж и возвращает чужой digest, потому что релей читает пакет по смещению, рассчитанному на 32 байта, как у любого настоящего браузера. Обнаружено прогоном на **чистой** сети, где такая рука обязана проходить: она единственная дала `digest MISMATCH`, пока остальные двенадцать верифицировались.
Отсюда правило для таких опытов: **сначала прогнать весь набор рук там, где блокировок нет**. Рука, которую не принимает релей, на фильтрованной сети выглядит точно как отвергнутый отпечаток, и вывод из неё будет ложным.
### Насколько это доказано
Разделять стоит три вещи, у них разная надёжность.
**Измерено и воспроизведено** (на это можно опираться):
- Какие отпечатки отвергаются, а какие проходят: шесть профилей × двенадцать попыток на двух сетях, плюс тринадцать рук × шесть и тринадцать × четыре в двух отдельных прогонах. Разделение всегда одно и то же.
- Что снимает блокировку при неизменном остальном: значение байта, его позиция, длина ECH payload, лишнее двухбайтовое расширение, укороченный `enc`. Каждое — отдельным однопеременным сравнением.
- Что не является причиной: размер пакета, незафлашенная запись, пост-квантовый key_share, порядок расширений, JA4, чётность длины. Каждое закрыто своим измерением, а не рассуждением.
- Что подделка проходит, а подлинный браузерный дамп нет.
**Модель, согласованная со всеми данными, но не наблюдавшаяся напрямую**: «фильтр сверяет hello с эталонными отпечатками браузеров и режет точные совпадения». Внутренностей фильтра никто не видел; любое описание, дающее те же предсказания, подходит не хуже. Это объяснение полезно как рабочая гипотеза и не должно подаваться как факт.
**Границы, за которые данные не выходят**:
- Один провайдер, одна точка доступа, один релей (два IP). Про другие сети это ничего не говорит.
- Одни сутки. Блокировка имеет состояние (включается со второго-третьего соединения), а значит и правила могут меняться.
- Проверены четыре поля из многих. Что ещё входит в совпадение — неизвестно; известно лишь, что каждого из проверенных достаточно, чтобы из него выйти.
Практический вывод от модели не зависит: клиент посылает шаблон, который на обеих сетях отвечали всегда, и это измерение, а не объяснение.
> Пока это не измерено, причина в заметке стоит как «отпечаток такой-то отвергается», а не как объяснение. Для дела это не критично: правка в клиенте стоит на измерении, а не на объяснении.
### Метод, который сработал
Все пять промахов родились из попытки объяснить механизм. Попадание пришло от перебора: **послать с проблемной машины байты каждого варианта, который умеет сам клиент, и посмотреть, какие проходят**. Три правила, которые из этого следуют:
- **Строить пробу из правил клиента, а не «похоже на клиент».** Проба, собранная по своему разумению, случайно оказалась старым 517-байтным шаблоном и потому проходила всюду — именно она и увела в сторону размера пакета. Правила надо разбирать из исходника клиента, тогда сравнение честное.
- **Перебор вариантов дешевле объяснения.** Шесть профилей × двенадцать попыток — двадцать минут. Пять гипотез о механизме — полдня.
- **Одна переменная за раз, руки чередовать.** Когда пришло время проверять механизм, сработала схема «один профиль, две руки, различие в одном поле, раунды по кругу»: сеть, которая режет пачками, не может подыграть одной руке. Ей же и опровергнут порядок расширений.
### Как снять эталонный дамп браузера
Проверять копию не с чем, пока нет оригинала. Достаточно двадцати строк: слушатель на порту, первый ClientHello целиком, hex в файл. Открывать надо `https://localhost:<порт>/`**по имени, не по IP**, иначе браузер не пошлёт SNI и дамп будет непригоден. Браузер покажет ошибку, это и значит, что дамп снят.
Три ловушки, каждая из которых стоила отдельной отладки:
- **В SNI-расширении две длины в двух байтах друг от друга** — длина расширения и длина списка имён, они различаются на 2. Записать одно значение в оба — релей ответит TLS-алертом.
- **Поле random нужно занулить перед подсчётом HMAC.** В шаблоне оно нулевое по построению, в захвате там байты браузера, и digest не сойдётся.
- **Защита от повторов отбивает побайтово одинаковый hello.** Захват неизменен, поэтому вторая и третья попытки уходят в камуфляж — выглядит в точности как отвергнутый отпечаток. Настоящий браузер каждый раз ставит новый `session_id`; в пробе надо делать то же.
Что дал дамп: наш шаблон `yandex` оказался очень близкой копией. Совпадает набор шифров, совпадает набор расширений (шестнадцать плюс два GREASE по краям), совпадают три key_share (GREASE 1 байт, `0x11ec` 1216 байт, `0x001d` 32 байта), совпадает JA4. Расходятся две вещи: payload ECH (у браузера 240 байт, у нас случайный из 144/176/208/240) и тот самый GREASE-байт — у браузера он **есть**.
И главное, чего от дампа не ждали: он оказался не эталоном для подгонки, а независимой проверкой правила — см. [[#Настоящий браузер эта сеть тоже отвергает]]. Копия проходит, оригинал нет.
### Что из этого сделано в клиенте
Отпечаток:
- `Auto` больше не разворачивается в `chrome_modern`, дефолт — `yandex` (`b334f9c137`).
- Оба отвергаемых шаблона помечены `withheld` и отводятся к дефолту **в обоих местах**, где настройка превращается в hello, так что выбранный руками `chrome_modern` тоже не уйдёт в сеть. Сами шаблоны остались в таблице со своими метаданными о захвате — JA4-гард на них продолжает работать.
- Рядом с шаблоном `yandex` стоит предупреждение не «улучшать» его до точной копии браузера, и это закреплено тестом: измерено, что точная копия отвергается, а неточная проходит.
Задержка:
- Интервал между дозвонами к одному релею снижен с 250 до 50 мс. Основание — измерение: двадцать четыре дозвона вообще без интервала дошли до `resPQ` все двадцать четыре, вся пачка за 170 мс, тогда как через очередь те же двадцать четыре стоили около шести секунд. Рост интервала при промахах не тронут — именно он защищает релей, который действительно не тянет пачку.
- Выключен алгоритм Нейгла (`TCP_NODELAY`) на обоих транспортах. Тонкость: Qt применяет опции сокета через движок, который появляется только после установления соединения, поэтому ставить их в конструкторе бесполезно — нужно в обработчике подключения.
Диагностика:
- При «hello ушёл, ответа нет» в лог пишутся состояние сокета, число непрочитанных байт и число уведомлений о чтении (`fb0c74e0d4`) — это отличает «ответ не пришёл» от «ответ пришёл и не был прочитан».
- Хеш маскируемого домена в логе теперь солится случайным значением, взятым один раз за запуск. Причина прозаична: восемь байт SHA-256 от короткого доменного имени подбираются по словарю мгновенно — именно так `www.google.com` был восстановлен из настоящего лога за доли секунды. Внутри одного лога поле по-прежнему позволяет сопоставлять соединения, но имя из него больше не достать.
- `flush()` в обоих путях записи hello (`6a059504db`) оставлен, хотя причиной не был: полагаться на проход event loop там, где сразу за записью включается таймер, всё равно нельзя.
### Отвергнутая гипотеза: незафлашенная запись
Дальше в клиенте нашлась асимметрия, которая выглядела как объяснение: `QTcpSocket::write()` не отправляет данные, а ставит их в буфер, и уходят они, когда поток в следующий раз вернётся в event loop. Фрагментированный путь записи hello всегда делал `flush()`, а нефрагментированный — тот, которым идёт каждое соединение к mtproxy, — не делал ни разу. Клиент при этом сразу после записи запускает пятисекундный отсчёт ожидания ответа, и в логе честно писал `mtproxy client hello queued locally`. Форма отказа совпадала до буквы: `ch_accepted == ch_bytes`, `rx_after_ch=0`, `close_origin=local_timeout`.
Гипотеза **опровергнута тем же логом**, до того как собрался билд с исправлением. Два независимых замера:
1. **Таймаут срабатывает вовремя.** По 21 отказу интервал от `client_hello_sent` до `failed` равен 4.9995.012 с. Таймер ожидания ServerHello живёт на том же потоке, что и сокет, значит его event loop исправно крутился и в конце пятисекундного окна. Чтобы незафлашенная запись пролежала все пять секунд, поток должен был не возвращаться в цикл ни разу — и тогда таймер не мог бы отработать с точностью до 12 мс, причём двадцать один раз подряд.
2. **Молчат и уже установленные соединения.** Четыре первых соединения к релею полностью прошли рукопожатие, два получили ключ, одно — первые данные Telegram. Затем создание ключа на третьем и четвёртом **не завершилось никогда**, а все четыре получили `mtp_receive_timeout` через 89 секунд. Незафлашенный hello физически не может остановить приём на соединении, которое уже обменивалось данными в обе стороны.
Правку (`6a059504db`, флаш в обоих путях) оставили: полагаться на проход event loop там, где сразу за записью включается таймер, всё равно неправильно. Но причиной наблюдаемого отказа она не была, и записывать её в «исправлено» нельзя.
### Что на самом деле показывает лог
Симптом сформулирован неверно с самого начала. Это не «новые соединения не получают ответа», а **вся обратка от релея замолкает разом**, примерно через 1.1 с после первого успешного соединения: и новые сокеты, и уже работавшие. При этом TCP-рукопожатие к тому же адресу продолжает проходить (`tcp_connected` спустя семь секунд после начала тишины). Повторяется на трёх релеях подряд с одинаковым профилем: 34 полностью успешных соединения, дальше ноль.
Отсюда следует главное: **отказ находится за пределами формата пакета и за пределами кода отправки**. Так выглядит либо обрыв у релея на его пути к Telegram, либо фильтрация обратного потока на маршруте после того, как поток классифицирован.
Первое подтверждение второй природы получено 26 июля в 12:30 UTC на обоих присланных релеях: FakeTLS-фронт по-прежнему отвечает `ServerHello` с верным digest за 1546 мс, но сразу после 64-байтного obfuscated2-init релей закрывает соединение (FIN), не дожидаясь даже `req_pq`. То есть первый уровень жив, второго за ним в этот момент нет. Форма отличается от клиентской (там была тишина без FIN), поэтому это не то же самое событие — но это прямое доказательство, что второй уровень у этих релеев ломается независимо от клиента.
### Проверка сделана: виноват релей, а не клиент
26 июля проба, повторяющая схему дозвона клиента (соединение каждые 370 мс по восьми дата-центрам, затем `req_pq` каждые 500 мс на каждом), была запущена **с двух разных машин**с сервера в другой стране и с той самой машины, где симптом. Результат идентичный на обеих:
- `ServerHello` с верным digest приходит за 2028 мс на всех восьми соединениях;
- сразу после 64-байтного obfuscated2-init релей закрывает соединение (FIN), **не дожидаясь `req_pq`**;
- `resPQ` не приходит ни разу; одно соединение прожило 20 секунд и получило 38 запросов без единого ответа.
Первый уровень (FakeTLS-фронт) полностью исправен, второго за ним нет. Клиент в этой картине не участвует вообще — воспроизводится восьмьюдесятью строками Python. Совпадение результата с двух разных сетей исключает и маршрут пользователя.
### Как выглядит релей, который «падает», в логе клиента
Второй лог того же дня — другой прокси (`telegram.monkeyvillage.pro:443`, два адреса, **обычный obfuscated2 без FakeTLS**) и другая форма отказа. Сессии там работают: ключи создаются, файлы скачиваются. Но за 31 секунду — 73 события `error=remote_closed`, и распределены они не случайно:
- закрытия приходят **синхронными пачками**: 16 кластеров, крупнейший — **27 сокетов за 156 мс**, второй — 9 за 129 мс;
- все кластеры приходятся на **одну и ту же долю секунды** (.87.93). То есть на релее раз в секунду срабатывает какая-то уборка, а не индивидуальные таймауты соединений;
- медианный срок жизни сокета после `connected` — 3 секунды.
Контрольный замер: тридцать **простаивающих** TCP-соединений к тому же адресу, удерживаемые 25 секунд, не закрылись ни одно. Значит это не лимит на число соединений и не таймаут простоя — уборка выкашивает именно те сокеты, по которым идёт трафик. Сходится с первым случаем: ломается плечо **релей → Telegram**, а не клиентская сторона.
Что смотреть на сервере в таком случае: свежесть `proxy-secret` и `proxy-multi.conf` (устаревшие — классическая причина «рукопожатие прошло, дальше ничего»), перезапуски и OOM воркеров прокси, переполнение таблицы `nf_conntrack`.
### Чем клиент делает хуже
Отказ серверный, но клиент подносит спичку. По тому же логу: **88 дозвонов за 31 секунду**, до **28 одновременных установленных соединений** к одному прокси, из них **14 сокетов — чистые потери**: клиент на каждое соединение гонку между двумя IP прокси и проигравшего закрывает. Плюс каждая медиа-полоса поднимает свою сессию — к одному DC 2 их в логе три (`160002`, `170002`, `180002`). Любой серверный лимит при таком профиле срабатывает в разы раньше, чем при одном сокете на сессию.
---
## Задержка: где она на самом деле
Жалоба «через прокси всё медленно и пинг большой» почти всегда приписывается сети. Померив, оказалось наоборот: сеть добавляет ноль, а секунды создаёт сам клиент. Разбирать надо по слоям, потому что каждый слой чинится по-своему.
### Три слоя, которые надо мерить отдельно
Одно соединение раскладывается на три измеримых отрезка:
- **tcp** — от SYN до SYN/ACK. Это путь до релея: география и оператор.
- **faketls** — от ClientHello до ServerHello. Релей отвечает сам, никуда не ходя.
- **respq** — от `req_pq_multi` до `resPQ`. Этот пакет идёт релей → Telegram → релей, поэтому **`respq` минус `tcp`** — примерно та дорога, которую релей добавляет позади себя.
Измерения с двух машин, одна на фильтрованной сети, одна на чистой:
| откуда | релей | tcp | faketls | respq | «за релеем» |
|---|---|---|---|---|---|
| фильтрованная | `194.39.110.183` | 70 | 71 | 94 | ~24 |
| фильтрованная | `159.194.198.73` | 17 | 9 | 55 | ~38 |
| чистая | `159.194.198.73` | 11 | 11 | 56 | ~45 |
Две вещи читаются сразу. Первая: на одном и том же релее фильтрованная и чистая сеть дают **одинаковые** цифры (17/55 против 11/56) — значит фильтрация не добавляет задержки, она либо пропускает, либо режет. Вторая: релеи различаются вдвое по суммарному RTT (55 против 94 мс), причём по разным причинам — первый дальше от Telegram, второй дальше от клиента. Выбирать релей по одному лишь пингу до него неправильно: `tcp` у `194.39.110.183` вчетверо хуже, зато позади него дорога короче.
Отдельно проверено, что установленное соединение живёт: пятьдесят запросов подряд за 245 секунд, все отвечены за ~90 мс без единого пропуска. Симптом «релей замолкает» на этом релее не воспроизводится — значит он относится к конкретным релеям, а не к схеме.
### Что создаёт задержку на самом деле
Раз сеть даёт 5594 мс, а в клиенте ощущается заметно больше, разница делается внутри клиента. Нашлось два источника.
**Очередь дозвонов.** Клиент разносил каждую попытку к одному релею на четверть секунды. Холодный старт открывает десятки сокетов, поэтому последние ждали секундами: в логе видно прямо — `tcp_ms` доходит до 8666 мс там, где сетевой round trip 17 мс. Обоснование у этого разноса было такое: приходить как отдельные клиенты, а не одной пачкой, чтобы не выделяться. Две вещи это обоснование сняли — во-первых, стало известно, на что фильтр реально смотрит (форма hello и имя в SNI, не тайминг), во-вторых, измерена цена: **двадцать четыре совершенно непейсеных дозвона дошли до resPQ 24 из 24, вся пачка за 170 мс**. Те же двадцать четыре через очередь стоили около шести секунд.
**Nagle.** Отключение (`TCP_NODELAY`) не выставлялось нигде в дереве. Алгоритм придерживает короткую запись, пока не подтверждена предыдущая — а mtproto именно так и устроен: короткий запрос, потом ожидание. Тонкость реализации: Qt применяет опции сокета через движок, который появляется только после установления соединения, поэтому ставить их в конструкторе бесполезно, нужно в обработчике `connected`.
Оба исправлены (`7b3bd451f5`): разнос снижен до 50 мс, `LowDelayOption` выставляется на обоих транспортах после подключения. Эскалация разноса при неудачах не тронута — именно она защищает релей, который действительно глотает пачку.
> [!tip] Как выбирать релей по скорости
> Сравнивать надо не пинг до релея, а сумму: `tcp` плюс то, что позади. Релей с отличным пингом, но далёкий от дата-центра Telegram, окажется медленнее скромного, стоящего рядом с ним. Меряется одной командой на каждый релей, за минуту.
---
## Чего проба не показывает
- **Какая именно сборка стоит на площадке.** vpnbot умеет ставить mtg, официальный MTProxy и telemt ([[Zapret/mtproto/03-telemt|разбор telemt]], [[Zapret/mtproto/04-mtg|разбор mtg]]); версию у релеев напрямую не спрашивали. Косвенных признаков хватает: порог 517, потолок 4096, запас будущего ровно +3 секунды и дрейфующая нижняя граница — всё это поведение официального кода, и ни одна измеренная величина ему не противоречит. Но «ведёт себя как MTProxy» — не то же самое, что «это MTProxy».
- **Поведение под DPI.** Проба идёт из сети без активной фильтрации, поэтому она ничего не говорит про блокировки на маршруте — этим занимается [[Zapret/mtproto/05-censorship|разбор каскада детекции ТСПУ]].
- **Долгую работу соединения.** Проверяется путь до первого ответа Telegram; деградация на длинных сессиях, потеря соединений и скорость передачи остаются за рамками.
- **Мобильные сети.** Часть операторов ограничивает доступ к прокси по IP-диапазонам, и это видно только с самого устройства.
---
## Чек-лист диагностики
- [ ] Проверить TCP-доступность хоста, и не на одном порту, а на **всех открытых** — это отсекает сразу три остальных механизма. Если рукопожатие не встаёт, содержимое hello уже ни при чём. Различать исходы: таймаут в десятки секунд — фильтр глотает SYN; мгновенный RST — скорее закрытый порт. Измерено: адрес, доступный извне на 8445, 443 и 8443, с фильтрованной сети дал `False` на всех трёх с задержками 3037 с, тогда как снаружи — 12 с. Такой адрес не лечится ни доменом, ни отпечатком, нужен другой.
- [ ] Отправить канонический 517-байтный ClientHello и **сверить HMAC ответа**, а не только префикс `16 03 03`.
- [ ] Каждую попытку генерировать заново — повтор идентичного пакета отбивается защитой от повторов.
- [ ] Сверить часы устройства: спешка даже на минуту уводит соединение на камуфляж, а на закрытой сети клиенту нечем их поправить.
- [ ] Если есть лог клиента — посмотреть, зависит ли длина ClientHello от длины SNI: одинаковая разница `длина длина_SNI` на разных прокси означает, что padding не работает. Размер ответа при этом ни о чём не говорит — см. раздел про то, почему он не решает.
- [ ] Если рукопожатие проходит, довести пробу до `resPQ` — так проверяется тег протокола и весь путь до Telegram.
- [ ] Если и `resPQ` приходит, релей исправен: дальше искать в клиенте (длина и форма ClientHello, выбор тега протокола, внутренние ограничители дозвона).
- [ ] Прежде чем закладывать в клиент лимит параллельных подключений — измерить его на своих релеях, а не брать из чужих наблюдений.
- [ ] Пробу запускать **с той машины, где симптом**; результат с другого хоста ничего не говорит о сети пользователя.
- [ ] Сессии в пробе держать открытыми и с трафиком — клиент их держит, а проба, закрывающая соединение сразу, проверяет другой режим.
- [ ] Если проба проходит, а клиент нет — сравнить размеры первого пакета: 517 байт укладываются в один TCP-сегмент, 17001800 нет. На проверенных релеях разницы нет, но это единственное отличие, которое видно на проводе, поэтому проверять дёшево.
- [ ] Если и это совпало — искать не на проводе, а в клиенте: отправлена ли запись фактически (`write()` в Qt только ставит в очередь) и не занят ли поток, который должен прочитать ответ. Пробу для этого запускать **в момент зависания** клиента.
- [ ] Если клиент умеет несколько профилей ClientHello — **перебрать их все с проблемной машины**, собирая байты из правил самого клиента. Это дешевле любой гипотезы о механизме и находит «сеть режет наш отпечаток» за двадцать минут.
- [ ] Проверять отпечаток нельзя по JA4: он сортирует расширения и выбрасывает GREASE, поэтому два шаблона с одинаковым JA4 могут иметь разную судьбу на фильтрованной сети.
- [ ] Считать успехом только верный HMAC ответа. Первые попытки могут проходить и после включения блокировки — она включается со второго-третьего соединения.
- [ ] Не подгонять отпечаток «под настоящий браузер» без измерения: на фильтрованной сети побайтовый дамп браузера может отвергаться, а упрощённая копия проходить. Проверять надо тот отпечаток, который реально уйдёт в сеть, на той сети, где симптом.
- [ ] Когда сравниваете две руки, различающиеся одним полем, **следить за размером**: правка тела расширения меняет ещё и длину hello, и без компенсации нельзя сказать, что именно измерено. Компенсировать удобно внутри GREASE key_share — его длина произвольна.
---
## 📚 См. также
- [[mtproxy/ja4-sni-client-side|Кто может менять JA4 и SNI]] — почему форма ClientHello целиком на стороне клиента
- [[mtproxy/tdlib-obf-client-side-stealth|Клиентская маскировка в TDLib]] — что можно менять в клиенте, не ломая совместимость
- [[mtproxy/mtproto-zig|MTProxy и mtproto.zig]] — как устроен FakeTLS-релей изнутри
- [[Zapret/mtproto/02-implementations|Реализации MTProto Proxy]] — mtg, официальный MTProxy, telemt и их различия
- [[Zapret/mtproto/07-nginx-haproxy|MTProxy за nginx и HAProxy]] — как устроен камуфляжный фронт
- [[Zapret/mtproto/08-best-practices|Практики эксплуатации MTProxy]] — что делать после того, как релей признан исправным
- 🔗 [MTProxy: net/net-tcp-rpc-ext-server.c](https://github.com/telegramMessenger/MTProxy/blob/master/net/net-tcp-rpc-ext-server.c) — разбор ClientHello, `is_allowed_timestamp`, кеш client random и все константы из этой заметки
- 🔗 [telemt: src/protocol/tls.rs](https://github.com/telemt/telemt/blob/master/src/protocol/tls.rs) — те же проверки в реализации на Rust, с другими порогами
- 🔗 [mtg: mtglib/internal/tls/fake/client_side.go](https://github.com/9seconds/mtg/blob/master/mtglib/internal/tls/fake/client_side.go) — разбор рукопожатия в реализации на Go
---
> [!quote] 🤖 Эти статьи открыты — можно обучать на них ИИ
> При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование и скачивание всего репозитория одним zip-архивом доступны в Forgejo: [исходник этой заметки](https://git.zapret.moe/zapretdiscordyoutube/todo/src/branch/main/mtproxy/faketls-relay-diagnosis.md) · [весь репозиторий](https://git.zapret.moe/zapretdiscordyoutube/todo/src/branch/main).