著者:Dronex
はじめに
この記事は、Fuzzing Farmシリーズ全4章のパート3で、パート2の記事「Fuzzing Farm #2: ファザーの性能評価の考え方」の続きです。
Fuzzing Farmチームでは、OSS製品を主に対象とした興味深い1-dayの検証や、チーム内で見つけた0-dayの攻撃コードの開発など、エクスプロイト開発にも力を入れています。この記事を含めたFuzzing Farmシリーズの残りの2パートでは、1-day/0-dayのエクスプロイト開発に関する活動を紹介いたします。
攻撃者にとって、Proof-of-Concept (PoC)は非常に強力なツールです。PoCを読み、実際に動かしてみることで、攻撃者はバグの攻撃可能性を比較的容易に調査できます。また、RCE(リモートコード実行; Remote Code Execution)やLPE(権限昇格; Local Privilege Escalation)などの、より強力なエクスプロイトの実現にもPoCは役立ちます。
しかし、バグ報告にはPoCが含まれていない場合もあります。また、製品のセキュリティに関するバグ報告のほとんどは、非公開のまま管理されます。例えばCVE-2021-30633は、Google Chromeのバグに付与されたCVEで、2021年4月13日のものです。Fuzzing Farmチームは、このバグの調査を2021年の11月に実施しましたが、当時はもちろんのこと、2022年9月現在でも、このバグの報告は非公開のままです。
このようにPoCへのアクセスがない場合、攻撃者は自身の手でPoCを書く必要があります。Fuzzing Farmチームでは、このように非公開のセキュリティバグ報告をもとに、攻撃可能性を調査しています。
今回のブログ記事では、特定のCVEに対応するパッチを見つけ出し、それを解析し、そしてPoCを書くまでの流れについて説明します。ここでは、バグ修正から十分に時間が経ったプロダクトとして、Google ChromeのUse-after-Free脆弱性であるCVE-2021-30551を例に紹介します。
CVE-2021-30633
このCVEに関するGoogleの公式アナウンスによると、バグは次のように説明されています。
High CVE-2021-30633: Use after free in Indexed DB API. Reported by Anonymous on 2021-09-08
これによると脆弱性はChromium 93.0.4577.82までに修正されていますが、バグ報告はまだ公開されていません。バグ報告や既存のPoCへのアクセスがない場合、バグの修正パッチを調査して攻撃者自身でPoCを完成させる必要があります。
パッチの調査に入る前に、バグの概要を掴んでおきましょう。そもそも、アナウンスの文中にある”Indexed DB”とは何でしょうか。
IndexedDBとは、クライアント側(ブラウザ)にデータを保存するデータベーススキームで、Chromeをはじめとする主要ブラウザでサポートされている機能です。JavaScriptプログラムから、IndexedDB APIを通してIndexedDB上でデータを作成、編集、削除できます。この機能はWeb Storage APIに似ていますが、Web Storageは文字列のみを値として保持できる単純なkey-valueストアです。
一方で、IndexedDBには次のような特徴があります。
- Web Storageと比較して大きな容量を持つ。クライアント環境のストレージが十分あれば、GB単位のデータも保存可能。
- 文字列だけでなく、JavaScriptオブジェクトもそのまま格納できる。
- ほとんどの処理は非同期的である。操作の完了・エラーの通知はイベントハンドラまたはPromiseで受け取る。
- トランザクションを作成してデータにアクセスする。トランザクションでの操作はCommitによってすべて完了するか、Abortによって破棄・ロールバックされる。
ブラウザのRendererプロセスはローカルストレージにアクセスする権限がないため、IndexedDBに関するすべての主要な処理はBrowserプロセス側が実行します。
パッチと脆弱性の解析
CVE-2021-30633に該当する脆弱なコードを探すために、Chromiumのコードベースを検索し、Chromium 93.0.4577.82以前のIndexedDBに関連する複数のコミットを調査しました。その結果、次の2つのコミットが見つかりました。
[M93: [IndexedDB] Add browser-side checks for committing transactions.](<https://chromium.googlesource.com/chromium/src/+/7699615c0d3ca4b6231c426ad51710e5f2fc51aa%5E!/>)
[M93: [IndexedDB] Don't ReportBadMessage for Commit calls.](<https://chromium.googlesource.com/chromium/src/+/2f5740f50f1a94c9baf90903553a4c5af1d09b9a%5E!/>)
1つ目のコミットは、IndexedDBのトランザクションに関するパッチです。以下はこのパッチの一部です。
@@ -295,8 +295,8 @@
return;
if (!transaction_->IsAcceptingRequests()) {
- mojo::ReportBadMessage(
- "Commit was called after committing or aborting the transaction");
+ // This really shouldn't be happening, but seems to be happening anyway. So
+ // rather than killing the renderer, simply ignore the request.
return;
}
バグの原因を調査する前に、IndexedDBにおける通常のトランザクションの流れを図1に示します。
- トランザクションを作成
- Get, PutやDeleteなどの操作をリクエスト
- 一連の操作を実行するためにCommmitをリクエスト(この操作は通常自動で実行されるが、プログラマが明示的にCommitをリクエストすることも可能)
- トランザクションを終了
図1. IndexedDBにおける通常のトランザクションの流れ |
もしJavaScript APIからCommit後に操作をリクエストしようとすると、例外が発生します。しかし、JavaScript APIではなくMojo*1から直接リクエストを投げると、それが受理されるかはともかく、Browserプロセスに対して何度でもリクエストを送ることができます。
ここで、あらためて問題のパッチで追加されたコードを見てみましょう。
if (!transaction->IsAcceptingRequests()) {
mojo::ReportBadMessage(
"RenameObjectStore was called after committing or aborting the "
"transaction");
return;
}
メソッド名などから、トランザクションがリクエストを受け付けていない場合に処理を中断していることが分かります。
したがって、対象の脆弱性はトランザクションへのCommit後(トランザクションがリクエストを受け付けなくなるタイミング)に操作リクエストを送信することによって発生すると考えられます。
ブラウザ側の処理
データベース操作はリクエストの到着後すぐに実行されるのではなく、まずはタスクキューにプッシュされます。このタスクキューは、Commitリクエストまでデータベース処理を待機するのにも使われます。
図2はCommitが発生した際の処理の流れを示しています。なお、各ステップの途中にRendererプロセスは別のリクエストを送信できます。
- Commitリクエストが到着する。トランザクションの
is_commit_pending_
フラグがセットされる。 - Commitの処理が開始する。トランザクションによっては
CommitPhaseOne
が実行される。続くCommitPhaseTwo
が即座に実行されることもある。 CommitPhaseTwo
を実行する。トランザクションの状態がdeadに設定され、それ以上トランザクションが処理されることはない。
図2. Commitリクエストが到着してからの処理 |
脆弱性のパッチを適用すると、ステップ1以降のすべてのリクエストを拒否するようになります。調査中に発見したクラッシュ(後述)はステップ2と3の間に発生するため、これがCVE-2021-30633に該当するバグだと考えられます。
Use-after-Free: CommitPhaseOne
CVE-2021-30633に該当すると考えられるUse-after-Freeの原因について調査していきます。Commitリクエストが到着した際に呼ばれるIndexedDBBackingStore::Transaction::CommitPhaseOne
は、Commitの第一段階の処理にあたります。このメソッドは、external_object_change_map_
が空でなければデータを書き出すなどの処理をします。external_object_change_map_
は、トランザクションによって変更される外部オブジェクトを保持する変数です。外部オブジェクトは、外部ファイルが使われた際にファイルハンドルを格納するために使われます。これは、File System Access APIが呼ばれるか、データがBlobを使う必要があるほど大きい場合に発生します。
書き込み処理はIndexedDBBackingStore::Transaction::WriteNewBlobs
に以下のコードで実装されています。(content/browser/indexed_db/indexed_db_backing_store.cc
)
for (auto& iter : external_object_change_map_) {
for (auto& entry : iter.second->mutable_external_objects()) {
switch (entry.object_type()) {
case IndexedDBExternalObject::ObjectType::kFile:
case IndexedDBExternalObject::ObjectType::kBlob:
/* ... snipped ... */
case IndexedDBExternalObject::ObjectType::kFileSystemAccessHandle: {
if (!entry.file_system_access_token().empty())
continue;
// TODO(dmurph): Refactor IndexedDBExternalObject to not use a
// SharedRemote, so this code can just move the remote, instead of
// cloning.
mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken>
token_clone;
entry.file_system_access_token_remote()->Clone(
token_clone.InitWithNewPipeAndPassReceiver());
backing_store_->file_system_access_context_->SerializeHandle(
std::move(token_clone),
base::BindOnce(
[](base::WeakPtr<Transaction> transaction,
IndexedDBExternalObject* object,
base::OnceCallback<void(
storage::mojom::WriteBlobToFileResult)> callback,
const std::vector<uint8_t>& serialized_token) {
// |object| is owned by |transaction|, so make sure
// |transaction| is still valid before doing anything else.
if (!transaction)
return;
if (serialized_token.empty()) {
std::move(callback).Run(
storage::mojom::WriteBlobToFileResult::kError);
return;
}
object->set_file_system_access_token(serialized_token);
std::move(callback).Run(
storage::mojom::WriteBlobToFileResult::kSuccess);
},
weak_ptr_factory_.GetWeakPtr(), &entry,
write_result_callback));
break;
}
}
}
}
switch文のcase IndexedDBExternalObject::ObjectType::kFileSystemAccessHandle
に注目してみましょう。このブロックは、ファイルハンドルを持つ値がPutされた場合に実行されます。
ここではbacking_store_->file_system_access_context_->SerializeHandle
の呼出引数としてコールバック関数を与えており、コールバック関数には生ポインタ &entry
が引数として束縛されています。
entry
は、external_object_change_map_
のある要素のmutable_external_object()
が返す値の一要素です。登場する値を整理すると、次のようになります。
external_object_change_map_
:IndexedDBExternalObjectChangeRecord
クラスのインスタンスentry
:IndexedDBExternalObject
型変数への参照mutable_external_objects()
:メンバ変数extern_objets_
への参照を返す。extern_objects_
の型はstd::vector<IndexedDBExternalObject>
。
もしコールバック設定後にexternal_objects_
が参照するメモリ領域が変更された場合、ポインタ&entry
は無効になります。そのような場合、以下のコードにおいてUse-after-Freeが発生します。(変数object
は&entry
です。)
object->set_file_system_acceess_token(serialized_token);
この行が不正なentry
に対して実行されると、無効なメモリに書き込みを試み、結果としてUse-after-Free脆弱性が発生する可能性があります。
RaceによるUse-after-Freeの発生
Use-after-Freeを発生させるためには external_objects_
の指すメモリを解放する必要があります。解放処理を呼び出せるコードを探してみましょう。
空でない external_objects_
を持つ IndexedDBValue
がPutされた場合、最終的に IndexedDBBackingStore::Transaction::PutExternalObjects
メソッドが呼ばれます。
const auto& it = external_object_change_map_.find(object_store_data_key);
IndexedDBExternalObjectChangeRecord* record = nullptr;
if (it == external_object_change_map_.end()) {
std::unique_ptr<IndexedDBExternalObjectChangeRecord> new_record =
std::make_unique<IndexedDBExternalObjectChangeRecord>(
object_store_data_key);
record = new_record.get();
external_object_change_map_[object_store_data_key] = std::move(new_record);
} else {
record = it->second.get(); // [1]
}
record->SetExternalObjects(external_objects); // [2]
もしPutリクエストのキーがデータベース中にすでに存在する場合、else節[1]を通って既存のレコードrecord
を取得した後に、[2]に到達します。 SetExternalObjects
メソッドは単に external_objects_
の内容を置き換える処理、つまり既に存在するキーのデータを新しいデータで置き換えています。
void IndexedDBExternalObjectChangeRecord ::SetExternalObjects(
std::vector<IndexedDBExternalObject>* external_objects) {
external_objects_.clear();
if (external_objects)
external_objects_.swap(*external_objects);
}
clear
メソッド呼出は各 IndexedDBExternalObject
の要素に対してデストラクタを呼びます。また、続く swap
メソッドは、メンバ変数 external_objects_
と引数 external_objects
のポインタを入れ替えます。結果として、古いポインタは external_objects
が破棄されるタイミングで解放されます。もしこのタイミングで問題のコールバックを呼び出せれば、Use-after-Freeにつながることが分かります。
PoC: バグの再現
クラッシュを再現するためには、RendererとBrowserのプロセス間通信を直接操作するためのMojoをJavaScriptから使えるようにする必要があります。MojoJSという機能が有効化されているとJavaScriptからMojoを使えますが、これはデフォルトで無効化されています。通常、Rendererプロセス側のエクスプロイトでMojoJSを有効化しますが、今回はPoCを作るための実験なので、Chromeのコマンドライン引数に --enable-blink-features=MojoJS,MojoJSTest
を渡すことで有効化します。
次の手順でUse-after-Freeを起こすことが可能です。
- ファイルハンドルを持つIDBValueをセットしたPutリクエストを送信する。
- Commitリクエストを送信する。(遅延するためPutの前に送っても良い。)
WriteNewBlobs
が呼ばれた後でかつコールバックが呼ばれる前に、同じキーと異なるexternal_objects
を持つIDBValueをセットしたPutリクエストを送信する。
図3. Raceが成功する際の実行の流れ |
この処理をRaceが成功するまで繰り返すことで、いずれUse-after-Freeが発生します。
Use-after-Freeを確認するため、AddressSanitizerを付けてビルドしたChromium上でPoCを実行してみます。すると、図4のようにクラッシュが確認できました。
図4. Use-after-Freeによるクラッシュの様子 |
このクラッシュメッセージからも、これまで調査した箇所に該当するコードでUse-after-Freeが発生していることが分かります。PoCのコードは以下のgistリンクからからダウンロードできます。
https://github.com/RICSecLab/exploit-poc-public/tree/main/CVE-2021-30633
おわりに
この記事では、攻撃者がCVEを調査し、PoCを書くまでの流れについて説明しました。PoCを書き終えたら、バグの攻撃可能性を調べるフェーズに入ります。このケースは、事前にRendererプロセスをexploitしてMojoを有効化した上で、Chromeのサンドボックス回避などに使える脆弱性と考えられます。
このように、限られた脆弱性情報をもとにまずは実際にPoCを書くことで、攻撃可能性やエクスプロイトコードの方針を立てる際に役立ちます。
次回は、Fuzzing Farmチームで発見・検証した0-dayを取り上げる予定です。お楽しみに!
*1:RendererとBrowserがプロセス間通信する際に使われる低レイヤのAPI