Telegram Desktop runs as a single instance. A second launch forwards its URL to the
first over a local socket, and both sides treat ; as the command separator.
The sender never escapes it:
commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';';
QUrl::FullyEncoded leaves ; alone — RFC 3986 calls it a
legal sub-delimiter. So a ; inside the URL opens a second command. A second
flaw compounds it: the OPEN: handler applies no scheme filter, so an injected
record reaches the internal interpret: scheme, which reads a file named in an
instruction file and posts it to a channel — with no authorization check and no
confirmation dialog.
| Affected | Telegram Desktop through 7.2.8 |
|---|---|
| Fixed | 7.2.9 (2026-09-16) |
| Still vulnerable | materialgram v7.0.5.1 — the fork has not taken the fix |
Paste a tg:// URL and see how the receiver splits it. This is arithmetic
on a string — nothing is launched.
%3B does not workOnly a raw ; splits. QUrl::FullyEncoded does not
decode an already-encoded sequence, so %3B stays %3B and the
receiver never sees a separator. Try both forms above.
The path after interpret: points at the instruction file, not at
the file being stolen. The target is named inside it.
channel: 4316121815
file: C:/Users/you/Documents/target.txt
caption: lab
| Key | Meaning |
|---|---|
channel: | destination — bare ID, no -100 prefix. Must be a channel or supergroup the victim is already in. |
file: | what gets stolen — any path the process can open |
caption: | message text |
topic: | forum topic id, optional |
from: | owner check — omit it and no check runs at all |
The parser splits on \n only. A file saved by Notepad uses CRLF, so every
line keeps a trailing \r — and file: silently points at a
path that does not exist. channel: still works, because
toULongLong() stops at the \r. The result looks exactly like a
mistyped path. Save as LF.
This hands a tg:// URL to your OS, the same way a browser does for any
link. It only reaches the IPC socket if a Telegram instance is already running
— a link clicked inside Telegram is handled in-process and never touches
the socket, which is exactly why the delivery path has to be a browser.
Each failure stops at a different point, so the message tells you exactly how far the payload travelled.
| Dialog | Reached |
|---|---|
Could not open interpret file | Chain works. The instruction file itself is missing. |
Could not find file with path | Instruction file parsed. The file: target is missing — or CRLF. |
Could not find channel with id | File read. The channel is not loaded in this session. |
Invalid command: <line> | Unknown key. Only the five above are accepted. |
Wrong current user | from: was present and did not match. |
| no dialog, file in channel | Full chain. |
core/sandbox.cpp:529 OPEN: -> startUrls.append(...) no scheme filter
core/application.cpp:1233 scheme() == "interpret" extracted
mainwidget.cpp:3253 InterpretSendPath(...) unconditional
support/support_helper.cpp:673 QFile f(path) ... api().sendFiles() no dialog
The 7.2.9 fix has four independent layers. The decisive one is the third — the
interpret: branch was removed from checkStartUrls() entirely.
Escaping alone would still leave a local attacker able to write to the socket directly.
Local reproduction needs a Windows host and a pinned 7.2.8 build. Note that the
portable build updates itself: -noupdate stops it applying a staged
update but not downloading one, so assert the version on every run.