Telegram 桌面端本地会话复用
导读:这个复现结果超出了我原本的认知。Telegram 的设备认证机制——手机号验证、短信验证码、二次验证密码——在我的理解里是设计完备的。但当 tdata 被复制到另一台 Mac 后启动客户端,登录界面没有出现。这不是 2FA 被破解了,而是整个复用过程根本没有触发需要验证的环节。
这不该发生
7 月 15 日,同事发来了自己写的一篇分析稿:一款 macOS 窃密木马不仅收集钱包数据库、Keychain 和浏览器凭据,还会把 Telegram Desktop 的 tdata 会话目录一并复制外传。稿子里提到一个让我格外关注的测试结论——将 tdata 恢复到另一台 Mac 后,Telegram 直接恢复了登录状态,全程未触发任何认证环节。
按照我的感觉是:这不应该发生。
Telegram 的设备认证机制并非没有考量。新设备登录时,服务端会要求手机号验证、短信验证码,如果开启了 2FA 还需要输入二次验证密码。这套流程在设计上是完备的——一台未授权的设备不应该能绕过它。
所以看到这个结论时,我的直觉是怀疑的。按照我对 Telegram 认证流程的理解,复制 tdata 至多能绕过本地缓存,服务端在会话恢复时应该会检测到设备环境变化并触发重新认证。如果只是一个结论而没有验证过程,我会倾向于认为测试环节遗漏了某些关键变量。
但测试是做了的。不仅做了,还在 Telegram 的两个 macOS 客户端上——Qt 跨平台版(Telegram Desktop)和 Swift 原生版(Telegram for macOS)——分别做了复现。
我决定自己梳理一遍这个过程。不是审稿,而是尝试理解:一个在我看来不该出现的结果,为什么确实出现了。
tdata 目录:被复制的会话数据
Telegram Desktop 把登录状态持久化在 ~/Library/Application Support/Telegram Desktop/tdata/ 中。这个目录是客户端为了维持登录状态而在本机维护的一组数据文件,包含密钥材料、会话状态和映射结构。
木马复制该目录时的操作逻辑相当明确:
std::string src = app_support + "Telegram Desktop/tdata/";
std::string dst = staging + "tg/";
copy_file(src + "key_datas", dst + "key_datas");
std::vector<std::string> names = list_directory_names(src);
for (const std::string& name : names) {
if (contains(names, name + "s")) {
copy_file(src + name + "s", dst + name + "s");
copy_file(src + name + "/maps", dst + name + "/maps");
}
}
这不是随手搜几个文件名的操作。代码中先复制 key_datas,再遍历目录寻找成对的会话文件和对应的 maps 映射目录。这些文件的集合就是客户端重建登录状态时所需的完整数据。
复现:认证全线绕过
Telegram Desktop
同事在隔离环境(macOS 12.7 / Telegram Desktop 4.16)中,将木马从受害者设备上复制出的 tdata 文件恢复到了对应路径。
启动客户端。登录界面没有出现。
没有要求输入手机号。没有发送验证码。没有要求输入 Telegram 二次验证密码。客户端直接恢复了原有账号的登录状态,随后开始同步聊天记录。
到这里,我必须诚实地记录:看到这个结果时,我仍然觉得需要确认一遍是否有遗漏。因为在我的认知里,Telegram 的设备认证不应该允许这种路径存在。但复现环境是干净的——没有缓存、没有残留的授权 token、没有预先配置的任何东西。只有一份原样复制过来的 tdata。
一个关键区分需要在这里说清:会话复用不等于破解 2FA。
Telegram 的二次验证保护的是「新设备授权」流程——当一台从未获得过授权的设备发起登录请求时,服务端验证手机号、短信验证码和 2FA 密码。但当攻击者直接恢复本地已完成授权的会话数据时,客户端处于"已登录"状态,它不会重新触发完整的认证流程。整个复用过程根本没有进入需要验证密码的环节。
也就是说:2FA 没有失效,而是被绕过了——不是因为攻击者猜到了密码,而是因为攻击者走了一条不经过密码验证的路径。
Telegram for macOS
Telegram 在 macOS 上还提供了另一个独立发行的原生客户端——Telegram for macOS(可通过 App Store 或官网下载的 Swift 原生版本)。对该版本的测试表明,本地会话文件同样可以被复制并在另一台 Mac 上恢复登录状态,认证流程同样被绕过。
但 macOS 原生客户端有一项与 Telegram Desktop 不同的行为,这让我不得不额外标注出来。
在同事的测试中,当服务端检测到异常登录行为并触发安全机制后,Telegram Desktop 会被强制退出登录并清除本地数据。而 Telegram for macOS 的表现不同——被安全机制标记后的客户端虽然无法发送新消息或接收新消息,但界面保持打开状态,本地缓存的历史聊天记录仍然可以被翻阅。
这意味着 macOS 原生客户端在这一攻击场景下存在两重盲区:不仅无法通过「新增登录设备」这一常规维度被用户感知,且在服务端检测到异常之后,历史聊天记录仍然暴露在外。
设备列表没有新增记录
复现过程中还有一个容易忽略的细节:复制后的会话在设备列表中没有稳定表现为一条清晰、独立的新增设备授权记录。
这意味着即使受害者主动检查 Telegram 的已登录设备列表,也未必能马上意识到本地会话数据已经被复制。
当原设备和复现设备长时间并发使用时,服务端可能因为 AuthKey 冲突或异常行为模式使其中一端的会话失效并要求重新登录。但在较短、间歇性的访问测试中,会话没有立即被强制终止。攻击者使用与原设备相同的 AuthKey,行为模式上与「正常用户从新 IP 继续使用」的差异极小,自动检测的区分难度较高。
从 tdata 到可编程 API 会话
除了直接恢复桌面客户端,还存在另一种利用路径。拿到 tdata 后,攻击者可以结合开源库(opentele、Telethon 等)、已授权的 AuthKey 和官方客户端 API 参数,将本地登录态转换为可编程的 Telegram API 会话。
转换后的 API 会话可用于读取对话、获取历史消息和发送消息。由于复用的是原有授权,不会在设备列表中产生新记录。脚本可以采用短时、间歇性连接的方式工作,不必保持长连接,从而降低立即触发异常登出的风险。
Passcode
Telegram Desktop 提供了一个本地保护机制——Passcode。如果用户在客户端设置中启用了 Passcode,恢复会话后攻击者仍可能被要求输入这个密码。
但这道保护需要放在完整攻击链中看待。这款木马同时收集 macOS Keychain、Apple Notes 和浏览器密码管理器的数据。这些位置可能恰好包含用户记录的 Telegram Passcode 或与其他服务复用的密码。
诚实记录
这个复现结果打破了我对 Telegram 设备认证机制的既有认知。在分析之前,如果有人问我「Telegram 的 tdata 被复制到另一台电脑会怎样」,我的回答大概率是:服务端会在发现 AuthKey 与设备环境不匹配时要求重新认证。这个回答不对。
但不对的地方不在于我对认证流程的技术理解——手机号、短信验证码、2FA 这些环节的技术描述没有错。错在我默认了一个不成立的前提:客户端在启动时一定会检测环境变化并触发重新认证。实际上,Telegram 桌面客户端把「本地文件完整」等价于「用户本人使用」,没有内置针对文件被复制到另一设备的检测机制。
这让我重新审视一个问题:当一个安全结论和自己的直觉冲突时,如何处理。
我的做法是:不因为直觉说「这不该发生」就忽略测试结果。直觉是认知的起点而非终点。如果测试设计和复现环境没有问题,那需要修正的是直觉背后的假设,而非测试结论。
Telegram 的本地会话复用不是设计漏洞——桌面客户端的设计前提从来都是「本地文件系统是安全的」。问题在于,这个前提在恶意代码获得文件访问权限后就不再成立,而客户端没有在这个前提下被打破时做出对应响应。这是一致性缝隙的一个典型表现:实施层的安全假设(文件系统访问受控)与信任层的实际状态(攻击者已获得文件访问)之间存在不一致。
对 Telegram Desktop 和 Telegram for macOS 双端都做测试后,可以确认这个风险不是特定客户端的实现问题,而是桌面端本地会话持久化模型固有的特性。macOS 原生客户端在异常响应上的差异——不会强制登出——进一步放大了风险面积。
对于长时间不打开的 Telegram 桌面客户端,这种问题尤其隐蔽:用户可能在某台设备上登录一次后就很少使用。攻击者有充足的时间窗口读取聊天记录,而用户下一次打开客户端时,不会收到任何异常提示——因为从服务端视角来看,这仍然是「同一台设备」。