Telegram Desktop vulnerability allowed any user's file to be stolen
https://beaksec.github.io/posts/telegram-desktop-one-click-account-takeover/📌 一個連結,偷走你的 Telegram 帳號:IPC 注入漏洞剖析(CVE-2026-107181)
TL;DR:Telegram Desktop 的跨進程通訊未過濾分隔字元,攻擊者靠一個惡意連結就能竊取受害者的登入檔案,進而接管帳號。
有人把你加進一個 Telegram 群組,群裡貼出一個連結。你點了它,帳號卻不再只屬於你一個人。這不是釣魚,而是一條貨真價實的程式碼漏洞鏈。
🤔 問題起點:為什麼兩個 Telegram 進程需要「通訊」
作業系統允許程式註冊自訂 URI scheme,Telegram Desktop 註冊的是 tg。點擊 tg://... 連結時,系統會啟動 Telegram 並把網址當成命令列參數傳入。但如果 Telegram 已經在執行中,系統並不知道,還是會啟動第二個進程——這個新進程必須自己判斷「我是多餘的」,判斷方式是嘗試連上一個本機 socket:已經在跑的那個實例是伺服器,新進程是客戶端,連線成功就把連結交出去,自己退出。
問題在於 socket 只能傳輸位元組流,不能傳物件,於是網址必須被序列化成一行文字。Telegram 的做法很簡單:每條指令是「關鍵字 + 參數 + 分號」,例如 OPEN:tg://x?a=1;。接收端收到後依分號切割字串,把每一段當成獨立指令處理。
🧩 注入:當參數裡剛好也有一個分號
如果傳入的網址本身就含有分號,例如 tg://x?a=1;CMD:quit,新進程仍把它當成一個完整 URL 序列化送出,變成 OPEN:tg://x?a=1;CMD:quit;。但接收端是依分號切割的,於是原本的一條指令,硬生生被拆成兩條:OPEN:tg://x?a=1 與 CMD:quit。這就是作者找到的第一個缺陷——未轉義的分隔字元造成指令注入。CMD: 本身只接受 show 與 quit,最多關掉程式,殺傷力有限;但 OPEN: 接受任意 URL,且不限制 scheme,這就打開了下一步的門。
🧩 第二個缺陷:interpret: scheme 不檢查是誰在發令
透過 OPEN:interpret:instructions.txt 這種組合,攻擊者可以把指令導向 Telegram 內部一個未對外公開、僅存在於程式碼中的 interpret: scheme。這原本是 Telegram 官方用來自動發布新版本的內部工具:一支腳本寫出一個指定頻道、檔案路徑與圖說文字的指令檔,再呼叫 Telegram 把建置檔案連同更新日誌發到頻道。負責執行的函式 InterpretSendPath 會讀取指令檔內指定的任意路徑的檔案,並送到指定的聊天視窗——但完全不檢查「這個請求究竟是誰發出的」,也不要求使用者確認。當這個指令只能從命令列呼叫時沒有風險,因為攻擊者得先有機器的立足點,有立足點的話直接讀檔案就好;但一旦這個指令能透過 socket、也就是透過剛才那個注入漏洞觸發,一個被害者點擊的連結就足以讀出任意本機檔案並外洩到攻擊者控制的頻道。
📊 影響範圍與修補狀態
受影響版本為 Telegram Desktop 7.2.8 及更早(在 Windows 6.9.3 上確認),漏洞編號 CVE-2026-107181,CVSS 3.1 評分 8.1(High),已在 7.2.9 版修補(commit db3405699f)。作者接著示範如何把這條漏洞鏈實際用於竊取受害者的登入檔案,達成帳號接管。
⚠️ 這種漏洞為什麼特別容易被忽視
這條鏈的兩個環節分開看都不嚴重:字串拼接的序列化格式本身沒有明顯「漏洞感」,interpret: scheme 也只是一個內部維運工具,原本只對擁有機器存取權的人開放。真正的風險是當這兩者被串接起來,一個原本假設「只能從受信任命令列呼叫」的特權操作,透過 IPC 通道變成任何人一個連結就能觸發的遠端功能。
🎯 對開發者的實務啟示
任何自訂的進程間通訊協定,只要用字串分隔符而非結構化序列化(如長度前綴或正規的 JSON/Protobuf),都必須對每個欄位值做轉義或跳脫處理,否則資料本身的內容就可能被解讀成協定的控制字元。更重要的是,任何執行「讀檔、發送、寫入」等特權動作的內部函式,都不該只因為「目前只有受信任的呼叫端會用到」而省略授權檢查——呼叫路徑一旦被擴大(例如從命令列擴大到 IPC socket),沒有做授權檢查的函式就會瞬間變成攻擊面。
🔗 來源
- 標題:Telegram Desktop vulnerability allowed any user’s file to be stolen
- 作者/機構:g-b-r(經 Hacker News 轉載)
- 連結:https://beaksec.github.io/posts/telegram-desktop-one-click-account-takeover/
#Telegram #CVE #SecurityResearch #IPC #VulnerabilityDisclosure #AccountTakeover #DesktopSecurity #ReverseEngineering #AppSec #ZeroDay
原始資料 Hacker News · 收集於 2026-10-10
摘要原文
Telegram Desktop: one-click account takeover via IPC injection Contents Telegram Desktop: one-click account takeover via IPC injection 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 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. 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 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: 1 OPEN:tg://x?a=1; tg://x?a=1 matches no handler inside Telegram, so on its own that link does nothing. It is only a carrier. That line is built here, one per URL to open: 1 2 3 4 // sandbox.cpp:295-297 for ( const auto & url : cRefStartUrls ()) { commands += u"OPEN:" _q + url . toString ( QUrl :: FullyEncoded ) + ';' ; } On the other side the running instance deserializes: it reads the received bytes, cuts them at every semicolon, and treats each piece as an instruction in its own right. For each piece starting with OPEN: it takes what follows and rebuilds it as a URL, exactly as if it had just arrived on the command line. 1 2 3 4 5 6 // sandbox.cpp:453-463 (abbreviated) for ( int32 to = cmds . indexOf ( QChar ( ';' ), from ); to >= from ; ...) { auto cmd = base :: StringViewMid ( cmds , from , to - from ); ... } else if ( cmd . startsWith ( u"OPEN:" _q )) { startUrls . append ( cmds . mid ( from + 5 , to - from - 5 ). mid ( 0 , 8192 )); The unescaped 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: 1 tg://x?a=1;CMD:quit The new process treats it as a single URL, because to it that semicolon is just a character inside the query. It flattens it and writes it to the socket: 1 OPEN:tg://x?a=1;CMD:quit; The running instance cuts at every semicolon and gets two instructions instead of one: 1 2 OPEN:tg://x?a=1 CMD:quit That is the injection, and it is the first of the two defects. The interpret: URI 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. 1 2 3 4 // application.cpp:1162-1164 if ( url . scheme () == u"interpret" _q ) { interprets . append ( url . path ()); return false ; Through OPEN: , then, it is reachable: 1 tg://x?a=1;OPEN:interpret:instructions.txt So what is interpret: for? It was the tool Telegram used to publish its own releases. When a new version shipped, the build archive had to be posted to a channel with the changelog as its caption. Rather than doing that by hand, a script wrote a small text file naming the channel, the file to send and the text to write, then launched Telegram with the path to that file. 1 2 3 # Telegram/build/updates.py:206 subprocess . call (... ' Telegram -sendpath interpret:// ' + scriptPath + ' /.../command.txt ' , shell = True ) The instruction file looks like this: 1 2 3 4 5 6 7 from: 1234567890 channel: 1987654321 file: out/Release/deploy/6.9.3/tsetup.6.9.3.exe caption: TDesktop at 12.06.26: - Fixed a crash in the media viewer. - Added a new sticker pack. The value of from: is compared against the id of the currently logged-in account: it keeps an operator from publishing a release from the wrong one. The check only runs if the line is present, so leaving it out skips it. The destination is set only by channel: , and has to be a channel or a supergroup. A function called InterpretSendPath does the work. So where is the bug? interpret: performs a privileged action, reading any file off the disk and sending it to a chat, without asking anyone for confirmation and without checking who asked for it. The function performs no authorization check. 1 2 3 4 5 6 7 8 9 // support_helper.cpp:673-680 QString InterpretSendPath ( not_null < Window :: SessionController *> window , const QString & path ) { QFile f ( path ); if ( ! f . open ( QIODevice :: ReadOnly )) { return "App Error: Could not open interpret file: " + path ; } const auto content = QString :: fromUtf8 ( f . readAll ()); When that comes from the command line, which is how the release script invokes it, it is not a problem: an attacker would need a foothold on the machine already, and with one they can read the files themselves. But once the same action is reachable through the socket, and therefore through the injection, a dangerous function becomes available from a link the victim clicks. That is a missing authorization, and it is the second of the two defects. Getting the instruction file onto 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. (368 points, 200 comments on Hacker News)
由 claude-code/sonnet 自動生成