Назад к блогу

Уязвимость в Telegram Desktop: как одна ссылка могла привести к краже файлов и захвату аккаунта

Уязвимость в Telegram Desktop: как одна ссылка могла привести к краже файлов и захвату аккаунта

В Telegram Desktop до версии 7.2.9 обнаружилась цепочка уязвимостей, позволявшая через специально сформированную ссылку читать файлы авторизации и захватывать чужую учётную запись. Разбор показывает, как безобидные на первый взгляд механизмы — передача URL между процессами, схема `interpret:` и автоматическое скачивание файлов — складываются в эксплойт. Материал будет полезен тем, кто интересуется практической безопасностью десктопных приложений и логикой поиска подобных многошаговых атак.

Разбираем цепочку дефектов, из-за которой клиент Telegram Desktop до версии 7.2.9 позволял чужой ссылке превратиться в набор команд, прочитать файлы авторизации и восстановить чужую сессию. Нетривиальность здесь в том, что каждый отдельный шаг выглядит безобидно: передача URL между процессами, поддержка схемы interpret: для публикации релизов, автоматическое скачивание файлов из группы. Опасность возникает, когда эти механизмы складываются в одну цепочку.

Как ссылка попадает от нового процесса к уже запущенному

Когда операционная система запускает второй экземпляр Telegram для открытия ссылки, этот новый процесс не может передать объект URL напрямую: сокет переносит байты, а не объекты. Объект URL из памяти нового процесса не может пройти через этот канал, поэтому его приходится разворачивать в строку текста.

Разворачивание устроено как набор инструкций: каждый элемент начинается с ключевого слова, за ним идёт аргумент и завершающая точка с запятой. Ссылка для открытия кодируется как OPEN: плюс URL в кодировке FullyEncoded:

commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';';

Инициатор соединения — второй процесс, запущенный операционной системой; он пересылает URL уже работающему экземпляру по сокету. На принимающей стороне запущенный экземпляр читает полученные байты, разрезает их по каждой точке с запятой и трактует каждый кусок как отдельную инструкцию. Для кусков, начинающихся с OPEN:, берётся остаток и восстанавливается как URL — ровно так, как если бы он только что пришёл в командной строке.

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

Дефект №1: неэкранированный разделитель

Первый дефект в том, что символ ; внутри query-части URL не экранируется при сериализации. Новый процесс воспринимает всю строку как единый URL, потому что для него точка с запятой — просто символ внутри query. Он выравнивает строку и записывает её в сокет без изменений.

Принимающая сторона, напротив, режет вход по каждому символу ;. Поэтому одна ссылка tg://x?a=1;CMD:quit превращается в две инструкции: OPEN:tg://x?a=1 и CMD:quit. Если в URL окажется несколько вложенных разделителей, каждый из них разрежет строку на отдельную инструкцию — вместо одной получится несколько.

Помимо OPEN: принимающий экземпляр распознаёт и другие команды, начинающиеся с ключевого слова. Обход строки выполняется циклом, который ищет следующую ; начиная с позиции from; внутри цикла кусок выделяется между разделителями, а для OPEN: URL берётся остаток после префикса с ограничением длины 8192.

Дефект №2: схема interpret:

Второй дефект — обработчик схемы interpret:. Когда URL имеет эту схему, его путь добавляется в массив interprets, и обработка прекращается:

if (url.scheme() == u"interpret"_q) {
    interprets.append(url.path());
    return false;
}

Этот обработчик достижим через команду OPEN:, потому что для каждого стартового URL формируется команда вида OPEN:<url>;, а затем такая команда может быть передана в составе ссылки, например tg://x?a=1;OPEN:interpret:instructions.txt.

Схема interpret: изначально использовалась самим Telegram для публикации собственных релизов. При выходе новой версии архив сборки нужно было выложить в канал, а в подпись (caption) поместить список изменений. Вместо ручной работы скрипт писал небольшой текстовый файл, где указывал канал, отправляемый файл и текст подписи, после чего запускал Telegram с путём к этому файлу. В build/updates.py:206 это делается вызовом subprocess.call(... 'Telegram -sendpath interpret://' + scriptPath + '/.../command.txt', shell=True).

Файл-инструкция содержит поля from, channel, file и caption — например, from: 1234567890, channel: 1987654321, file: out/Release/deploy/6.9.3/tsetup.6.9.3.exe и caption: с текстом изменений.

Функция InterpretSendPath принимает окно и путь, открывает файл по этому пути. Если открыть не удалось, возвращается строка "App Error: Could not open interpret file: " + path, иначе содержимое целиком читается как UTF-8.

Поле from: содержит идентификатор аккаунта и сравнивается с id текущего авторизованного аккаунта — чтобы оператор не опубликовал релиз не из-под того аккаунта. Но проверка выполняется только при наличии строки from:, поэтому если её опустить, проверка пропускается.

От чтения файла к захвату аккаунта

Чтобы понять, зачем атакующему читать файлы, нужно разобрать, как устроено хранилище авторизации. Каталог tdata содержит три файла: key_datas (соль и зашифрованный DEK), файл авторизации MTProto, зашифрованный DEK, и подкаталог с индексом хранимых данных аккаунта.

Имя каталога tdata не случайно и не зависит от конкретной установки: оно выводится из строки data — имени данных по умолчанию, поэтому одинаково на каждой установке.

При отсутствии локального пароля пароль, подаваемый на вход KDF, пустой, поэтому KEK получается из пустой строки и соли, а соль хранится в открытом виде в tdata/key_datas — в том же файле, что и зашифрованный DEK. Чтения одного этого файла достаточно, чтобы пересчитать KEK и развернуть DEK. А с DEK расшифровывается всё остальное, включая авторизацию сессии.

Файл D877F783D5D3EF8Cs — это MTProto-авторизация, зашифрованная с помощью DEK. Файл D877F783D5D3EF8C/maps — индекс сохранённых данных аккаунта, секретов он не хранит. Владение всеми тремя файлами даёт доступ к аккаунту: их помещают в свежий tdata, запускают Telegram, и сессия жертвы открывается.

Доставка ссылки

Сервер отвечает на запрос редиректом на составную ссылку со схемой tg://:

HTTP/1.1 302 Found
Location: tg://x?a=1;OPEN:interpret:instructions.txt

Браузер, получив такой Location, передаёт ссылку обработчику схемы tg://. Перед запуском Telegram система может показать подтверждение — в зависимости от браузера и от того, использовала ли жертва обработчик ранее.

Внутри Telegram ссылка разбирается: часть после OPEN: попадает в список стартовых URL, который собирается в команды вида OPEN: + url + ;, а схема interpret распознаётся отдельно — её путь добавляется в список interprets и не считается стартовым URL.

Как устроен proof of concept

Атакующий создаёт супергруппу и добавляет в неё жертву — настройки приватности Telegram по умолчанию позволяют это без подтверждения со стороны приглашённого. Затем публикует три текстовых файла-инструкции, по одному на каждый похищаемый файл, все с указанием собственной группы атакующего как получателя. Пропуск строки from: полностью обходит проверку аккаунта.

Файл-инструкция задаётся полями channel: 2001234567, file: tdata/key_datas, caption: poc. К кодировке предъявляются требования: обычный текст с окончаниями строк LF и без метки порядка байтов (BOM).

Два других файла указывают на tdata/D877F783D5D3EF8Cs и tdata/D877F783D5D3EF8C/maps. Автоматическое скачивание сохраняет все три на диск жертвы, когда жертва открывает группу — что она всё равно сделает, потому что там её ждёт ссылка.

Путь сохранения предсказуем. Отправляя файл в группу, атакующий точно знает, куда тот будет сохранён. Относительный путь разрешается от рабочего каталога Telegram — его папки данных. На Windows это %APPDATA%\Telegram Desktop, на три уровня ниже домашнего каталога пользователя, а Downloads лежит прямо в домашнем каталоге. Поэтому путь вида C:\Users\<user>\Downloads\Telegram Desktop\<file name> детерминирован, а единственная неизвестная — имя пользователя — обходится относительным путём interpret:../../../Downloads/Telegram%20Desktop/instructions.txt, который поднимается на три уровня вверх и попадает в Downloads.

Затем атакующий отправляет в чат безобидную ссылку. Жертва кликает, браузер выполняет редирект, который несёт по одной команде на цель, отправленной одной строкой:

tg://x?a=1
;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions1.txt
;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions2.txt
;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions3.txt

Каждая команда OPEN:interpret:... открывает соответствующий файл инструкции, что и приводит к извлечению сразу key_datas и D877F783D5D3EF8Cs.

Смягчения и что они меняют

В источнике перечислены три меры снижения риска, но они не устраняют сами дефекты.

Включение настройки «ask where to save each file» отменяет автоматическую загрузку — файл-инструкция не попадает на диск. Ограничение круга тех, кто может добавлять вас в группы, только контактами убирает место доставки: украденные файлы можно отправить лишь в канал или супергруппу. Локальный пароль не мешает краже файлов, но делает украденную сессию непригодной — при условии, что он выбран как настоящий пароль.

Настоящее исправление — обновление до 7.2.9 или новее. Коммит удаляет схему interpret:// и Support::InterpretSendPath целиком, а разделитель записей на сокете экранирует percent-префиксным hex-кодированием до записи и декодирует после разделения, так что точка с запятой в данных больше не может стать границей. Дополнительно записи CMD: и CTRL: пропускаются, если по тому же соединению идёт OPEN:, а локальные пути к файлам отбрасываются после появления нелокального URL на этом соединении.

Контекст спецификации deep links

Спецификация описывает два вида ссылок: HTTPS-ссылки вида t.me/path?query (домен t.me может быть также telegram.me, telegram.dog и домен из поля me_url_prefix глобальной конфигурации, получаемой через help.getConfig) и URI вида tg:path?query или tg://path?query. Фрагмент #fragment всегда игнорируется при разборе.

Если встречается ссылка <username>.t.me, где username не односимвольный, не равен www, является валидным и не входит в список зарезервированных слов (addemoji, addlist, addstickers, addstyle, addtheme, auction, auth, boost, call, confirmphone, contact, giftcode, invoice, joinchat, login, m, nft, proxy, setlanguage, share, socks, web, a, k, z), она обрабатывается как t.me/<username>/ с добавлением остатка пути и строки запроса.

Для неизвестного типа tg:-ссылки клиент должен вызвать help.getDeepLinkInfo, передав только компонент path. Метод help.getDeepLinkInfo#3fedc75f path:string возвращает help.DeepLinkInfo, который может быть help.deepLinkInfoEmpty#66afa166 или help.deepLinkInfo#6a4ee832 с флагами update_app:flags.0?true и message:string, entities:flags.1?Vector<MessageEntity>. Возвращаемый текст может содержать описание того, что делает ссылка, объяснение, почему она не поддерживается приложением, и/или приглашение обновить клиент; в последнем случае устанавливается флаг update_app, и приложение должно перейти в магазин или попытаться обновиться. Для непонознанных t.me-ссылок этот метод вызывать не следует — вместо этого применяется обычная логика обработки HTTP-ссылок.

В локальном коде список LocalUrlHandlers задаёт регулярные выражения и обработчики для конкретных путей: ^join/?\?invite=([a-zA-Z0-9\.\_\-]+)(&|$) вызывает JoinGroupByHash, ^resolve/?\?(.+)(#|$) — ResolveUsernameOrPhone, ^login/?(\?code=([0-9]+))(&|$) — ResolveLoginCode, ^passport/?\?(.+)(#|$) — ShowPassport, а ^([^\?]+)(\?|#|$) — HandleUnknown.

Схемы авторизации и передачи данных

Среди deep links есть три схемы, связанные с авторизацией и передачей данных: tg://login?token=<base64encodedtoken>, tg://passport?params (и эквивалент tg://resolve?domain=telegrampassport&params), а также tg://confirmphone?phone=<phone>&hash=<hash>.

ResolveLoginCode извлекает токен из ссылки и передаёт его в handleLoginCode активного аккаунта — ссылка инициирует вход в аккаунт по коду. ShowPassport разбирает параметры ссылки (bot_id, scope, callback_url, public_key, nonce) и открывает форму Passport через showPassportForm — ссылка запускает передачу персональных данных боту.

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

Обработка локальных URL в коде

TryConvertUrlToLocal принимает строку URL, обрезает её до 8192 символов, затем по очереди проверяет шаблоны: tc://, .ton-сайты, поддомены вида <name>.t.me, и основной шаблон telegram.me/t.me/telegram.dog.

Для t.me-ссылок функция разбирает часть после домена и преобразует её в tg://-схему: телефон — в tg://resolve?phone=, joinchat/ или + — в tg://join?invite=, addstickers/addemoji — в tg://addstickers?set= или tg://addemoji?set=, username — в tg://resolve?domain= с добавлением параметров topic/post, story, album, collection, appname.

Ветка iv/?... закомментирована. Раньше она извлекала параметр url и, если он начинался с http:// или https://, возвращала его напрямую. Теперь вместо этого возвращается исходный url — с комментарием, что нужно показывать свою страницу t.me, а не URL напрямую.

Проверки активации и подтверждения

При переходе по ссылке с кодом входа система передаёт этот код текущему активному аккаунту, и окно активируется. Если кода нет или ни один аккаунт ещё не запущен, обработка не выполняется.

При переходе по OAuth-ссылке система разбирает её параметры и требует непустой token: без него обработка не идёт. С непустым токеном открывается окно авторизации по ссылке tg://oauth?token=... с url_encode.

При переходе по ссылке Passport система извлекает bot_id, scope, callback_url, public_key и nonce через Passport::NonceNameByScope(scope) и открывает форму Passport.

Перед запуском стартовой ссылки система требует активации, если стоит блокировка кодом-паролем или если ссылка не является внутренней ссылкой Passport. Внутренней ссылкой Passport считается oauth-ссылка, распознаваемая по regex ^oauth/?\?(.+)(#|$) или по username == "oauth".

Что из этого следует на практике

Цепочка работает потому, что три независимых механизма стыкуются без общей проверки. Передача URL между процессами считает точку с запятой частью данных, а принимающая сторона — границей инструкции. Схема interpret:, задуманная для внутренней публикации релизов, оказывается достижимой извне через команду OPEN:. Автоматическое скачивание кладёт файл в предсказуемое место, а относительный путь позволяет указать на него, не зная имени пользователя.

Ключевые условия эксплуатации: отсутствие локального пароля (иначе KEK не выводится из пустой строки), автоматическая загрузка файлов (иначе инструкция не попадает на диск), возможность добавить жертву в группу (иначе некуда доставлять файлы). Убрав любое из них, атакующий теряет звено цепочки.

Сами по себе меры вроде настройки сохранения файлов или ограничения приглашений в группы не устраняют дефекты — они лишь разрывают конкретный сценарий. Дефект в сериализации разделителя и в достижимости interpret: закрывается только исправлением, которое экранирует разделитель и удаляет схему целиком.

Где смотреть в коде

Источники

Похожее