Categories: 脅威リサーチ

Barbervisor:Intel VT-x を用いたスナップショット ファジング ツールの開発談話

脆弱性を検査する方法の 1 つはファジングです。大まかに言うとファジングとは、ランダムな入力データを生成して変更を加え、それを所定の検査対象に入力してクラッシュさせるというプロセスです。2017 年にベアメタルハイパーバイザの開発に着手しました。スナップショットのファジング、つまり、既知の静的な状態にあるプログラムのサブセットに対してファジングするためです。ベアメタルで起動できるカスタムカーネルも開発が必要でした。それまではオペレーティングシステムの開発経験がなかったので、新たな分野を学びつつ Talos のツールも増やせる一石二鳥だと思ったのです。この経験を皆さんに参考にしていただけるよう、プロジェクトの顛末をまとめました。

Barbervisor のソースコードはこちらで公開しています。

目標

プロジェクトの目標はシンプルで、Intel の仮想化テクノロジーである Intel VT-x を使用して、x86 メモリのスナップショットを仮想マシン(VM)で実行することです。ここで言うスナップショットとは、任意の時点における実行中のシステムレジスタとメモリを収集したものです。VM の主目的がファジングだとすれば、正確な場所でスナップショットを作成することは極めて重要です。たとえば大規模な GUI 駆動型アプリケーションにファイルパーサーがある場合、GUI のロード後と入力バッファのロード後にスナップショットを取得することが想定されます。検査時にスナップショットが手元にあれば、こうした場所にすばやくリセットできるので、パーサーを呼び出す前に正確な計算時間を全面的に設定する必要がありません。

スナップショットの利点はたくさんありますが、その 1 つは、実行を再開する場所の内部状態がクリーンである必要がないことです。各スナップショットは同じ状態のメモリとレジスタから開始されるため、VM はいつでも停止できます。検査対象でクリーンなループを探す必要はなく、スナップショットを作成する場所を簡単に見つけることができます。

これが達成しようとした必要最低限のことです。開発の第一段階として、以下を目指しました。

  • メモリとレジスタのスナップショットを用意し、このスナップショットを使用して新しい VM を作成する。
  • あらかじめ決めておいた停止ポイントまで VM でスナップショットを実行する。
  • 終了状態を、他のハイパーバイザ(今回は VirtualBox)で正常とされている状態と比較する。
  • VM をリセットする。
  • VM メモリを変更して既知の別のパスを作成する。
  • 新たに決めた停止ポイントまで VM でスナップショットを実行する。

全体的な目標を念頭に置きつつ、目標達成までの道のりを見ていきましょう。

(情報開示:このブログでは新しいバグについては説明しません。主に、アイデアを実現させる過程で試した手順について述べていきます)

カーネル

Intel VT-x での作業を開始する前に、ベアメタルで起動できるカーネルが必要でした。そこで、Rust の学習チャンスとしてカーネルを開発することにしました。基本カーネル全体をゼロから作成したわけではありません。先駆者である Philipp Oppermann 氏から学べたことは、OS 開発の第一歩として大いに役立ちました。初期段階では同氏のブログ記事を読み、OSDev Wiki で概念を照らし合わせながら、カーネルを再実装して学習しました。

Philipp 氏のブログに掲載されている内容と今回のプロジェクトとでは、テスト環境に重要な違いがあります。Qemu ではなく Bochs でテストする必要があったのです。Bochs はよくできた x86 エミュレータで、以下の理由から必要でした。

  • オープンソースなのでカスタムロギングを追加できる。
  • Intel VT-x をエミュレートできる。

Bochs でカーネルを実行するのは驚くほど簡単でした。Bochs を使用するには、以下のとおり必ず vmx を有効にしてビルドする必要があります。

Bochs をビルドしたら、どのタイプのシステムをエミュレートするかを指示するために、設定ファイルの bochsrc が必要となります。bochsrc では、カーネルをビルドした際に生成される .iso ファイル を cdrom に挿入してカーネルを実行できます。

ata0-master: type=cdrom, path=”build/os-x86_64.iso”, status=inserted

boot: cdrom, floppy, disk

Bochs の作業を行いつつも、いずれはこのプロジェクトをベアメタルで実行したいと考えていました。そこで、当時手元にあって唯一利用できた予備のマシン Lenovo T460p を使いました。Bochs でしか実行できない(本番環境では動作しない)代物ができあがることを防ぐうえで、ラップトップでカーネルを実行することは極めて重要でした。PXE を使用したことで、ラップトップでのカーネル実行も非常に簡単でした。ラップトップと同じネットワーク上に TFTP サーバをセットアップし、カーネルを TFTP ディレクトリにドロップすれば、ラップトップでカーネルを起動できます。カーネルを頻繁にラップトップにロードすることで、Bochs とベアメタルの開発環境もほぼ同等になるようにしました。

この時点でカーネルは以下のような状態です。

  • Bochs とベアメタルで起動
  • Rust を実行(64 ビットモード)
  • 画面にログテキストを表示(詳細は「統計情報」セクションを参照)
  • バンプアロケータでメモリを割り当て
  • メモリの管理にはページテーブルを使用
  • global_allocator を設定してから Vec などの Rust の core コレクションを使用

この初期カーネルを使って、Intel VT-x の学習を実際に始めていきます。

Intel VT-x

Intel VT-x の学習は Barbervisor 用カーネルの再実装と並行して行いました。Intel の仮想化ソリューションには幅広い概念が関係します。それらを把握できるよう、『Intel Software Developer’s Manual』(特に第 3 巻の第 23 〜 28 章)を毎晩数ページずつ読み進めました。内容は非常にわかりやすく、後で参照するリファレンスガイドとしてもピッタリです(一読で習得できるような内容ではないため、幾度も開くことになります)。毎晩数ページずつ読んで実際に仮想化のためのコードを考え記述するまでに約 1 ヵ月かかったと思います。さらに C++ の概念を確認するため、Bareflank プロジェクトと SimpleVisor プロジェクトも参照しました。

今回の仮想マシン(VM)は概念的には同じ物理プロセッサ上で実行されている独立したコードであり、独自の環境に分離されています。VM をテストする最初のフェーズではアセンブリ命令をいくつか VM にロードします。VM を起動すると、VM のコンテキストでこれらの命令が実行されます。命令が実行されると VM は終了し、カーネルに実行が戻されます。マニュアルによれば、このプロセスは以下のようになるはずです。

VMXON -> Init VMCS -> VMLAUNCH -> VMEXIT

  • VMXON:仮想マシンの使用をプロセッサに指示する命令
  • Init VMCS:VM の制御構造を初期化(このプロジェクト全体の最大の問題)
  • VMLAUNCH:VMCS が有効であると想定し、終了条件まで VM を実行
  • VMEXIT:何らかの VMExit の理由により、VM の実行を終了

最初のフェーズの目標は VM で以下の命令を実行することでした。

すべてが正しく設定されているとすれば、vmcall 命令によって VM が終了し、eax の値は 0x4141 になるはずです。

VMCS

仮想マシン制御構造には、所定の VM を実行するうえで必要となるカスタマイズ可能なフィールドがすべて含まれています。VM の制御構造の初期化は、開発初期で最も時間のかかった作業でした。以下にフィールドの説明を示します。

 

出典:https://rayanfam.com/topics/hypervisor-from-scratch-part-1/

VMCS 自体を初期化した後、vmread および vmwrite 命令を使用してこれらのフィールドの読み取りと書き込みを行います。この基本的な部分では、セグメントやモデル固有レジスタ(MSR)のような Guest フィールドの大部分を、別のハイパーバイザの VM から複製した方が簡単でした。

まず、WinDbg で見つかった情報を「Guest State Area」に上書きコピーしました。MSR など、情報のないフィールドには 0x11111111 のようなダミー値を書き込みました。必要性が不明なフィールドにこうしたダミー値を書き込んでおくことで、実際に必要であれば何らかのエラーが発生して判明すると考えたのです。

カーネルの「Host State Area」については、VM のホストのレジスタとセグメントを物理マシンのホストと同じ状態に設定しました。以下のようなシンプルなラッパーを使用して、すべてのホスト情報を収集しました(注:このコードはレガシーであり、現在の Rust では動作しません)。

次は制御フィールドです。制御フィールドごとに読み取ることができる MSR があり、0 と 1 どちらの設定も許可するビットマスクになっています。特定のフィールドでは 0 または 1 のどちらかだけが許可されます。逆の値の場合、VM エントリは失敗します。

最終的に、各制御フィールドについては以下のコードを実行することにしました。

最後に、EPT が必要でした。EPT(拡張ページテーブル)は、ゲストの物理アドレスからホストの物理アドレス変換するためにホストが使用する独立したページテーブルです。EPT の章を読んだ時点で、EPT の形式について、カーネル用に実装済みの 4 レベルのページテーブルの形式と非常に似ていることに気づきました。唯一の違いはページテーブル自体の各エントリのフラグです。とりあえず元のページテーブルのコード全体をコピーしてから、すべての PageTable 参照を ExtendedPageTable 参照に変更し、エントリのフラグを適切な EPT エントリのフラグ(READ、WRITE、EXECUTE)に変更しました。すると、驚いたことにうまく動作しました。最後に、VM がアドレス変換時にどのページテーブルを使用するかを認識できるよう、EPT を VMCS に設定します。

ここでの注意点ですが、私が知っている限り、これらのフィールドの有効性を短時間でチェックする方法はありません。つまり、全チェックしか方法がないのです。次は VM の起動です。

この時点の極めて原始的な API でも、メモリを EPT にマッピングし、EPT アドレスを変換してホストの物理アドレスを取得できるはずでした。先述のテストケースを以下のコードで実行できるはず、そう考えたのです。

ついに無事に動作するか確認する瞬間がやってきました。しかし VM を起動したところ VMEntry エラーが発生しました。幸いにも第 26 章には 100 以上のチェック項目があります。照らし合わせていけば、適切に初期化が行われたか確認できます。その日は残りの時間をデバッグに費やしてエラーを見つけようとしましたが、結局わかりませんでした。次の週末、同じプロセスを 1 ステップずつチェックして間違っている箇所を探しましたが、それでもだめでした。こうなった原因の 1 つとして、VMCS のフィールドに設定した内容の大半に自信がなかったことが挙げられます。ここに来て、プロジェクトを丸々 2 ヵ月間棚上げしました。その先の進め方の可能性が無数にあるように思えて、どう進めたらよいかわからなかったのです。

スナップショット

VM のプロセスは進めていましたが、スナップショットの作成方法や、それがこのプロジェクトにおいてどのような意味を持つかについてすら、まだわからないままでした。ある日、シャワーを浴びていた時に(プロジェクトの問題の数々はシャワールームで解決されるような気がします)、単純なアプリケーションを複製するには、マシンの物理ダンプと現在のレジスタの状態さえあれば良いのだと気が付きました。そして、既存のハイパーバイザではどのように状態が維持され、復元されるのかということについて思いを巡らせました。

VirtualBox について調べてみると、–debug-command-line を使用してコマンドラインで有効にできるデバッガがあることがわかりました。デバッガのコマンドを見ると、writecore を使用して VirtualBox のコアダンプをディスクに書き込むことができます。また、(Volatility のおかげで)生の物理メモリをディスクにダンプする .pgmphystofile コマンドもあります。メモリはコアダンプでも入手できますが、生の物理ダンプを使用すると、物理メモリへのインデックス作成がずっと簡単になります。

VirtualBox はオープンソースなので、ソースで簡単にコアダンプのフォーマットをたどり、パーサーを実装できます。VMCS で必要なレジスタがすべてコアダンプにあるように見えたので、VirtualBox を使用してスナップショットを作成しました。具体的に何をコピーすべきかを把握できたおかげで、既存のハイパーバイザであれば(ほぼ種類を選ばずに)どれからでも同じ情報を取得できるとわかったのです。

次の目標は、ファジング対象にする正確な場所で小型アプリケーションのスナップショットを作成することです。

VirtualBox でのスナップショット作成

ここで目標としたのは、既知のアプリケーションのスナップショットを作成してメモリを変更し、元の VM で実行したのとは異なる結果を得ることでした。以下のアプリケーションを対象にしました。

 

デフォルトでは、実行パスは常に FAIL ケースに入ります。スナップショットの目標は、VM にロードされた後でバッファに文字列「B」が挿入され、実行が SUCCESS パスに入ることを確認することです。

アプリケーションのスナップショットを作成するためにプログラムをコンパイルし、VirtualBox の Windows VM にインストールした WinDbg で実行します。次に、check() を呼び出すところまでアプリケーションをステップ実行します。この時点で 1 つの妙案が浮かびましたが、実際にうまくいくとは思えませんでした。

vmcall を使用してゲストを強制終了できることは元々知っていたのですが、VirtualBox で vmcall を使用するとどうなるか興味がわいたのです。現在の call 命令を vmcall で上書きして実行すると、VirtualBox の エラーメッセージが表示されました。

(注:このテストは VirtualBox 5.2 で実行しました)

驚いたことに、ここで [Ignore] をクリックするとデバッガにフォーカスが戻り、コマンドを使用してコアダンプと物理メモリダンプを生成できるのです。現在の問題は、コアダンプをこのまま実行すると元の命令が上書きされてしまうことです。これではすべてが同じになってしまいますが、所定のユーザランドからシステム全体のスナップショットを作成する方法としては非常に有望に思えました。

少し手を加えた後の最終的なステップの例がこちらです。

次の例では Windows 10 VM で notepad.exe のスナップショットを作成しています。

スナップショットのメカニズムがわかったので、今度はそれを利用して自分の VM にスナップショットを取り込む方法を考えていきます。

スナップショットの復元

この時点で、メモリをマッピングするための API は以下のようになっていました。ゲストの物理アドレス 0x12340000 には 0x41、0x42、0x43、0x44 のバイトが含まれています。

実行にあたっては、VM のメモリ全体をロードする必要はないことがわかりました。EPTViolation VMExit を使用すればメモリページをオンザフライでロードできます。VM を終了させることができる条件としては、実行されたページやアクセスされたページが見つからない場合など、EPT にエラーがある場合が考えられます。これは、VM を完全に空の EPT で起動し、エラーが起きる場所で VMExit を発生させた後、エラーが発生したページをロードして VM の実行を再開するという形で活用できます。この方法を使えば、ファジングを実行するたびに 4GB の VM 全体をロードするのではなく、実行に必要なメモリのみをロードすればよくなります。

コードでも、これは非常にシンプルに見えました。VM が終了した理由に基づいて match(select と同様)を実行し、EPT Violation の原因となったゲストの物理アドレスをプロセッサから読み取ります。読み取ったアドレスを使用してゲストの物理アドレスのバイトを取得(詳しくは後述する興味深い状況を参照)し、必要なページにそれらのバイトを書き込みます。

カーネルでのスナップショットの実行

注意深い読者は、上記の snapshot::get_page の呼び出しに気付いたかもしれません。スナップショットがあっても、バイトをカーネル自体に取り込む方法はありませんでした。カーネルにはデータを送受信するためのネットワークがなかったのです。カーネルは PXE ブートしましたが、いったんカーネルを導入すると、画面越しに見るほかは情報をカーネルに入出力できませんでした。解決策として、スナップショットをカーネルにコンパイルします(一応うまくいきましたが、このやり方はまったくお勧めしません)。

スナップショットをカーネルにコンパイルするために、コアダンプ用のパーサーを作成しました。このパーサーは、保存されている cr3 で見つかったページテーブル全体を snapshot.rs ファイルにダンプするもので、以下の形式になっています。

ページテーブルで見つかった各ページには大規模な match ステートメントがあり、そのアドレスのバイトを含めて 0x1000 バイトのベクタがありました。気になるのはコンパイルの所要時間だと思いますが、カーネルをビルドして各テストを実施するのに平均して約 30 分かかりました。本来なら止めるべきだったのでしょうが、ページを 1 つずつロードするプロセスが実際に機能していることをとにかく確かめたかったのです。

snapshot.rs 用のパーサーを get_page() 関数と合わせて作成する一方で、現在のページテーブルの解析はすでに進めていたため、静的なゲスト仮想ページをゲストの物理ページマップにダンプしました。このように、ハイパーバイザはゲストの仮想ページをゲストの物理ページに直接マッピングするので、自身を解析する必要はありません。この API は以下のようなものです。

幸いなことに、このプロセスは問題なく機能しました。このシンプルなアプリケーションが実際に使用するのはメモリ内の 10 ページ程度なので、最初の実行ではマッピングしたすべてのページを画面にダンプし、snapshot.rs にダンプした他のページはすべて削除しました。こうして、スナップショットの作成練習を重ねる間、短時間でイテレーションサイクルを繰り返すことができました。

小さなスナップショットは作成できたので、メモリを変更するというアイデアをテストする準備が整いました。

最初のブレークポイント

実際にスナップショットを始める前の最後の課題は、命令実行を特定時点で停止する方法を見つけることです。考えられる案としては、前述した vmcall 命令を使用し、vmcall ですべての VMExit を起こすというものがありました。命令でブレークポイントを設定する場所に 0xcc を記述し、Breakpoint VMExit を起こすという案もありました。どちらも素晴らしい案ですが、そのとき実際に選択した案は、ゼロによる除算を強制的に実行するというものでした。除算のエラーは VMExit と見なされ、その場所でプロセスの状態を分析できるようになります。

この時点でアドレスを見ると、ジェネリック型の u64 はやや曖昧であることに気づきました。そこで u64 を GuestVirtual(今回の実装では現在の cr3 にのみ適用可能)、GuestPhysical、KernelPhysical でラップすることにしました。これにより、Rust コンパイラでコードの健全性チェックを実行し、使用している raw の u64 数値が実際に想定している数値になるようにすることができます。

以下は対処前の API の例です。

これでは、ブレークポイントを設定する場所がゲストの仮想アドレスの 0xdead0000 なのか、ゲストの物理アドレスの 0xdead0000 なのかがはっきりしません。それがこの時点で以下のようになりました。

以下のとおり、この API はメモリを変換してメモリに書き込むという以前作成した機能をベースに構築しています。

元の VirtualBox スナップショットを復元して実行できるので、自分でスナップショットを取得した後で既存のシステム状態を再確認することもできます。このやり方であれば、スナップショットを取得する前にすべての関連情報を収集することを気にかける必要はありません。FAIL と SUCCESS の両方の printf 呼び出しにブレークポイントを設定して、どちらかがヒットした場合に VM の実行が停止するようにしようと考えました。VirtualBox で元のスナップショットを復元すれば、WinDbg でさらに詳しく調べて特定のアドレスを収集し、それらのアドレスをカーネルでハードコードできるようになります。

最終的にテストケースは以下のようになっています。

メモリを変更しなければ、FAIL ヒットケースが常にヒットします。当初の目標を確認するには、VM の作成後、入力バッファを変更した結果 SUCCESS ヒットケースがヒットすればよいのです。

バッファを変更したところ、SUCCESS のヒットケースがヒットしました。正直、こうなるとは思っていなかったので、テストケースが適切に実装されていることを確認するために何度もラップトップを再起動しました。たいしたことではないように思えるかもしれませんが、このテストケースはここまで数週間の努力に見合う成果でした。個人的な感想ですが、知識がないまま大規模プロジェクトを開始するのは非常に気が重いものの、学習しながらプロジェクトを遂行できるとこの上ない達成感を得られます。知識は手の届かないものではなく、時間と労力という投資の問題だということも改めて認識しました。

ここまでの結果を「成功」と言いたいところですが、その前に VM のリセットを実装する必要があります。

VM のリセット

VM をリセットするメリットは、VM 自体のすべてのページをリセットする必要がないことです(これはこの話から思いついたものです)。EPT には、特定の EPT エントリでページが変更された場合に設定される「ダーティビット」というものがあります。この情報を使用することで、単純なページテーブルウォークでどのページをリセットする必要があるかがカーネルに通知され、コストのかかるページコピーの多くが回避できるようになります。

以下は、Intel のマニュアルに記載されている EPT エントリ内のダーティビットの場所です。

リセットの実装例はこちらです。初期メモリ状態から開始し、最初の実行中に 0x5000 と 0x6000 ページのみが変更されています。リセットするために 0x5000 と 0x6000 ページのみが元のスナップショットからコピーされます。他のすべてのページは変更されていません。2 回目の実行後は 0x4000 ページのみが変更されています。2 回目のリセットでは、元のスナップショットから 0x4000 ページのみがコピーされています。

このリセットの元の実装は以下のとおりです。

.reset() を実装し、メモリをリセットしてランダムに変更しながら 1 つの VM をループするという最後のテストを実行しました。メモリを変更しないテストケースはすべて Fail 、メモリを変更した場合は Success となればテストは合格です。

これで、初期の「ファジング」ケースが成功しました。

実際のところ、この方法では検査対象のファジングは困難ですが、ネットワークドライバを使用すれば解決できます。このハードルを克服しなければならないので、数ヵ月間は開発ペースを落としました。ハードルを下げるにはネットワークドライバの実装が必要でした。大きなマイルストーンに到達したところではあったものの、独自のネットワークドライバを実装するなどという案に対しては、まだまったくの及び腰でした。この先まだまだ困難な作業がある(と思われた)ため、数ヵ月間プロジェクトを中断しました。

リベース

プロジェクトを中断している間に、Orange Slice プロジェクトが別のところで立ち上がりました。Orange Slice から PXE を基本的なネットワークとして使用するという着想を得たので、orange_slice をフォークし、その上で動作するように Barbervisor の大部分を作り直しました。メモリアロケータが異なっていたため、Barbervisor が orange_slice のアロケータを使用できるようにメモリ割り当て方法を変更する作業が大半でした。

リベースされた新たなプロジェクトで最後のテストケースが機能するようになった後、PXE を基本のネットワーク戦略として活用してみることにしました。数時間後、この戦略は可能ではあるものの長期的な解決策にはならないことに気づきました。もっとも、PXE について新しい知識が身に付いたので無駄な努力だったわけではありません。結果には結びつかなくても、得られた知識は無題にならないという良い例だと言えそうです。

ネットワークドライバ

しばらく検討してさらに研究を進めたうえで、ドライバを実装してカーネルでネットワークを有効にすることにしました。

このプロジェクトでネットワークを有効にする意義は主に以下の 2 つです。

  • スナップショットをカーネルにコンパイルするのではなく、VM に追加するページをカーネルがネットワーク経由で要求できるようになります。
  • データをカーネルとの間で任意に送受信できるようになります。これは、すべての実行トレースや成功したファジングケースなどのデータをカーネルから取得するうえで非常に重要です。

ネットワークドライバは複数のコアで使用できるため、ドライバを Mutex でラップすると、各コアが必要に応じて NIC にアクセスできるようになります。lazy_static クレートを活用することで、実行時に NIC を一度だけ静的に初期化できます。このコードでは、使用可能な NIC が PCI 経由で 1 つも見つからなかった場合はパニックとなります。それ以外の場合は NIC を初期化し、Mutex で設定します。

このパケットは smoltop プロジェクトをフォークして TFTP レイヤを追加することで構成しました。

ネットワークドライバの実装は、このプロジェクトで最も重要な機能でした。カーネルとのデータのやり取りは、他のすべての機能を動かすうえで不可欠になります。プロジェクトの再実装に話を戻すと、ネットワークドライバは、カーネルの実行とメモリ割り当てを実装した後で真っ先に実装する機能の 1 つになります。今だからこそ言えることですが、それが研究の醍醐味です。

ネットワークドライバの実装により、ネットワーク経由でロードされたスナップショットを確認できるようになりました。

ネットワーク経由のスナップショット

サーバを完全に制御し、他の機能を追加できるように TFTP も実装しました。tftp64d は元々テスト用に使用していましたが、カスタムサーバ用に使用することにしました。

また、TFTP サーバに新しいコマンドを 2 つ追加しました。

  • SNAPSHOT_translate_0xc3000_0x120000:CR3(1 番目)を使用してゲスト仮想アドレス(2 番目)を変換します。
  • SNAPSHOT_page_0x2000:所定のゲストの物理アドレスにあるページアラインされた物理ページを返します。

これまでのところ、この形式は問題なく動作しています。ただし、これは明らかに最適な設計ではありませんでした。シングルバイトの命令コードの方が適切でしょう。

これら 2 つの新機能により、以前のスナップショット API を置き換えるのは非常に簡単でした。これで snapshot::guest_vaddr_to_guest_paddr 関数がネットワーク経由で機能するようになりました。

また、snapshot::get_page 関数もネットワーク経由で機能するようになりました。

この 2 つの修正により、スナップショットは前述したページのロード方法と同じやり方で、ネットワークを経由してオンザフライで取得できます。修正後も元のテストケースは計画どおりに機能しました。

基本的なマルチコア設計

1 つのコアで 1 つの VM のロード、実行、リセットが可能になったので、今度は作業を並列化するために複数のコアを起動していきます。このための API は Orange Slice のものを使いましたが、シンプルなものです。

この API を、カーネルの最初の実行時に ACPI を初期化する際と、別の時点で他のコアの一部またはすべてを起動する際に使用しました。現在のコアがブートシステムプロセッサ(BSP)であるかどうかを確認するだけで、最初のコアとして実行されているかどうかをカーネルが認識します。

マルチコアを正常に起動するためのテストケースは、まったく同じテストケースを実行し、いずれかのブレークポイントに到達した後、まだ起動するコアが残っている場合は次のコアを起動するというものでした。

このファジングテストのフローは以下のとおりです。

  • カーネルを起動し、それが BSP であれば ACPI を初期化する。
  • このコアの新しい VM を初期化する。
  • ネットワークから取得したすべてのページをキャッシュしながら、完了するまで VM を実行する。
  • 終了ブレークポイントに到達したら、コアが残っている場合は新しいコアを起動する。

ファジングされていない元の実行パスのすべてのページはすべてのコアで必ず必要となるため、最初のコアは元の実行パスを単独で実行します。この実行により、ネットワークから取得したすべてのページがメモリにキャッシュされます。これ以降は、ネットワーク経由でページが要求される前に、カーネルはそのページがすでに取得されているかどうかを確認します。この方法であれば、同じページをコア数と同じ回数繰り返してネットワーク経由で取得するのではなく、キャッシュされたページをコピーするだけで済みます。

トレース

シングルステップトレースを有効にするために、Intel VT-x には「Monitor Trap Flag」という便利な機能があります(Intel のマニュアルの第 25 章 5.2 を参照)。このフラグが VMCS の VM 実行制御(VM Execution Control)で設定されている場合、VM は MonitorTrapFlag の終了理由を受けてすべての命令を終了します。これにより、カーネルは VM の状態を記録し、その後実行を再開できます。

トレースを追跡するために、各コアは 2 つのトレースを保持します。1 つは検出されたアドレスのみを保存し、もう 1 つはより詳細にトレースするためにレジスタの状態を保存します。アドレスのトレースは一般的なトレースに有用です。詳細トレースは VirtualBox から実際のトレースをデバッグしたり相互参照したりするのに便利です。

以下に示すとおり、これらの実装は非常にシンプルです。

これで、任意の時点で各トレースを TFTP サーバに送信できます。主な目的は、カーネルでデータを収集した後、さらに解析するためにサーバに送信することでした。完全な物理メモリもあるので、デジタルフォレンジックの考え方を用いて、検出されたアドレスをよりクリーンな module!exported_function+offset 構文に変換できます。

こうして収集したアドレスをトレースパーサーに渡すことで、トレースは以下のようなより有用性の高い結果を返せるようになります。

[15738] USER32.dll+0x00027750, (0x7ffe4d3c7750) 0x7ffe4d3c7750: cmp dword ptr [rip + 0x82179], 5

[15739] USER32.dll+0x00027757, (0x7ffe4d3c7757) 0x7ffe4d3c7757: je 0x7ffe4d3c7760

[15740] USER32.dll+0x00027759, (0x7ffe4d3c7759) 0x7ffe4d3c7759: jmp qword ptr [rip + 0x629d8]

[15741] win32u.dll+0x000011d0, (0x7ffe4a5c11d0) 0x7ffe4a5c11d0: mov r10, rcx

[15742] win32u.dll+0x000011d3, (0x7ffe4a5c11d3) 0x7ffe4a5c11d3: mov eax, 0x100d

[15743] win32u.dll+0x000011d8, (0x7ffe4a5c11d8) 0x7ffe4a5c11d8: test byte ptr [0x7ffe0308], 1

[15744] win32u.dll+0x000011e0, (0x7ffe4a5c11e0) 0x7ffe4a5c11e0: jne 0x7ffe4a5c11e5

[15745] win32u.dll+0x000011e2, (0x7ffe4a5c11e2) 0x7ffe4a5c11e2: syscall

[15746] ntoskrnl.exe!KiSystemCall64Shadow+0x0, (ntoskrnl.exe+0x330140) 0xfffff803461e1140: swapgs

[15747] ntoskrnl.exe!KiSystemCall64Shadow+0x3, (ntoskrnl.exe+0x330143) 0xfffff803461e1143: mov qword ptr gs:[0x7010], rsp

[15748] ntoskrnl.exe!KiSystemCall64Shadow+0xc, (ntoskrnl.exe+0x33014c) 0xfffff803461e114c: mov rsp, qword ptr gs:[0x7000]

[15749] ntoskrnl.exe!KiSystemCall64Shadow+0x15, (ntoskrnl.exe+0x330155) 0xfffff803461e1155: bt dword ptr gs:[0x7018], 1

[15750] ntoskrnl.exe!KiSystemCall64Shadow+0x1f, (ntoskrnl.exe+0x33015f) 0xfffff803461e115f: jb 0xfffff803461e1164

[15751] ntoskrnl.exe!KiSystemCall64Shadow+0x24, (ntoskrnl.exe+0x330164) 0xfffff803461e1164: mov rsp, qword ptr gs:[0x7008]

[15752] ntoskrnl.exe!KiSystemCall64Shadow+0x2d, (ntoskrnl.exe+0x33016d) 0xfffff803461e116d: push 0x2b

[15753] ntoskrnl.exe!KiSystemCall64Shadow+0x2f, (ntoskrnl.exe+0x33016f) 0xfffff803461e116f: push qword ptr gs:[0x7010]

[15754] ntoskrnl.exe!KiSystemCall64Shadow+0x37, (ntoskrnl.exe+0x330177) 0xfffff803461e1177: push r11

[15755] ntoskrnl.exe!KiSystemCall64Shadow+0x39, (ntoskrnl.exe+0x330179) 0xfffff803461e1179: push 0x33

[15756] ntoskrnl.exe!KiSystemCall64Shadow+0x3b, (ntoskrnl.exe+0x33017b) 0xfffff803461e117b: push rcx

[15757] ntoskrnl.exe!KiSystemCall64Shadow+0x3c, (ntoskrnl.exe+0x33017c) 0xfffff803461e117c: mov rcx, r10

[15758] ntoskrnl.exe!KiSystemCall64Shadow+0x3f, (ntoskrnl.exe+0x33017f) 0xfffff803461e117f: sub rsp, 8

[15759] ntoskrnl.exe!KiSystemCall64Shadow+0x43, (ntoskrnl.exe+0x330183) 0xfffff803461e1183: push rbp

また、収集したトレースを解析して module+offset 形式にし、に入力するというのも便利な活用案です。これは、ファジングツールの現在の進行状況を確認するのに役立ちます。

完全なトレースも素晴らしいのですが、カバレッジ型のファジングを目標とするのであれば、実際のところカバレッジは便利です。トレースは検査対象の完全な実行パスですが、カバレッジは実行中にヒットした命令のまばらなサブセットであり、必ずしも順序どおりに記録されるわけではありません。トレースを収集した後の次の有用なステップは、何らかの形のカバレッジメカニズムを実装することです。

カバレッジ

プロセッサでカバレッジイベントをトリガーする方法はいくつかあります。このプロジェクトで実装した方法では「VMX-Preemption Timer」(Intel のマニュアルの第 25 章 5.1 を参照)を使用しています。プリエンプションタイマーは、タイムスタンプカウンタに基づいて指定した値からカウントダウンします。タイマーがゼロになると、PreemptionTimerExpired VMExit がトリガーされます。タイマーのカウントダウン速度は IA32_VMX_MISC MSR(Intel のマニュアルの付録 A.6 を参照)で調整できます。

Monitor Trap によるトレースと同様に、プリエンプションタイマーによるカバレッジの収集は非常に簡単です。[VMX-preemption timer-value] フィールドに乱数を設定し、VMCS の VM 実行制御でプリエンプションタイマーをアクティブにすると、タイマーが有効になります。タイマーが切れると、VMExit をトリガーして処理できます。

各カバレッジイベントが VMExit をトリガーするので、各 VM のパフォーマンスが影響を受ける可能性があります。これを大まかに計算するために、まずはファジングを行わず、VMExit を発生させない場合の指定した実行の所要時間と、固有の命令の総数を測定する必要がありました。そこから、カバレッジを調整して 1 秒あたりの VMExit を増やしていき、60 秒間ループで実行しました。実行後は、完全なトレースの命令の数はすでに把握していたので、1 秒あたりの現在の VMExit 数で出るカバレッジの割合を記録しました。このステップを 1 秒あたりの VMExit のテスト数ごとに 3 回繰り返して、小規模のサンプルサイズの平均を求めました。これで、特定のスナップショットのカバレッジを収集するコストを大まかに把握できます。

トレースと 1 つの基本的なカバレッジメカニズムが準備できたので、最後に、ファジングツールを作成するうえで気になる実用的なファジングツールのインターフェイスが欲しいところです。

ファジングツールの実装

必要に応じてハイパーバイザの内部をすべて変更できることはわかっていますが、ファジングツールを抽象化してそれぞれをハイパーバイザの構造自体から分離させたいと思いました。ハイパーバイザがこのインターフェイスを認識しているので、これらの方法の一部またはすべてを実装するとファジングツールが有効になります。以下が全体のトレイトです。

スナップショットとファジングツールの不一致によってビットが複数回発生すると、各ファジングツールは開始アドレスを指定して自身を検証する必要があります。このチェックは、スナップショットがネットワーク経由で送信されると実行されます。

実行中に所定の場所が検出され、ファジングツールの実行を停止してリセットしようとする場合は、exit_breakpoints でこれらの場所を指定できます。

ファジングツールは、数マイクロ秒が経過した後やリタイアした命令が多数検出された後にリセットすることもできます(IA32_PERF_FIXED_CTR0 を使用、Intel のマニュアルの第 19 章 13 を参照)。

さまざまなカバレッジも指定できます。ユーザモードのみ、カーネルモードのみ、指定したアドレス範囲のみ、検出されたすべての命令、カバレッジなし、などです。

この関数は、所定のファジングケースに対し、所定の VM の入力バイトを返す関数を返します。これを使用することで、レジスタからバイトを読み取ったり、ハードコードされているアドレスからバイト数を返すことができます。生成されたテストケースを実行した結果カバレッジが増えた場合は、テストケースを保存するために使用します。

ファジングツールを使って、所定のスナップショットにパッチを適用することもできます。指定したバイトが VM の現在のページに書き込まれるだけでなく、ページキャッシュ内のバイトが恒久的に変更されます。

ファジングツールを使って、所定のアドレスにフックを実装することもできます。事実上、これらのフックはクロージャを呼び出すブレークポイントとなります。

また、ファジングサイクルのさまざまな場所で呼び出される各種のクロージャもあります。

最後に、fuzz_fn は各ファジングケースの開始時に呼び出されます。この関数では、ファジングケースごとのメモリ変更が実装されています。この関数は実装されておらず、トレースまたはデバッグのカバレッジメカニズムとしてのみ使用されます。

こちらはダムバイトフリッパーの例です。

シンプルにするために、この例ではコーパスではなく単に 1 つの入力ファイルを変更しています。このスナップショットでは入力ファイルが rcx であらかじめ指定されています。この関数を実行すると、サイズが IMAGE_LEN の既知の入力ファイル内で最大 32 のランダムバイトが破損します。

もう 1 つの例として、入力ファイルをコーパスから選択して変更を加えたファイルに置き換えるものがあります。

ここではグローバルコーパスからファイルを選択し、ni の実装を使用して変更しています。この例では、元のイメージがクリアされた後、変更した入力で上書きされます。

統計情報

ファジングの仕組みに加えて、システムに関する統計情報を収集すれば、ファジングのどこで時間がかかっているかを発見し、さまざまなボトルネックがどこに存在するかを把握するのに役立ちます。この実装では、統計情報を追跡するのにいくつかのグローバルアトミック変数を使用しています。テスト環境はわずか 4 コアのマシンだったため問題ありませんでした。コアの数がもっと多かったなら、数十コアものアトミックをロックするには非常に時間がかかるので、おそらくアトミック自体がボトルネックになったでしょう。ですが、この研究ではそれほど問題になりませんでした。

以下は、これらのアトミック変数で収集される統計情報の例です。

以下に示すとおり、初期化すると、カーネル内のどの場所でもさまざまな統計情報を簡単に調整できます。

これらの統計情報の出力はラップトップの画面上に表形式で表示していました。

当時気になったのは、各タイプの VMExit とハイパーバイザ自体のオーバーヘッドに関わる各種関数のタイミング、特に VM のリセットにかかった時間でした。

制限事項

このプロジェクトを始めて何年も経ちますが、ここまで達成できたのは我ながら感心します。いくつかの既知のバグに対処しましたが、まだ制限事項は残っています。

デバイス

現在、デバイスはプロジェクトでまったくエミュレートされていないため、いずれかのデバイスがクエリされるとハードリセットのケースと見なされてしまいます。たとえば、これまでに試したすべての Windows 10 トレースは、デバイスをクエリする前に HalRequestSoftwareInterrupt を通過していました(完全なシステムトレースを収集していたので、見つけるのは非常に簡単でした)。このことを把握していれば、導入したページで HalRequestSoftwareInterrupt が確認された場合、TFTP サーバによりブレークポイントを挿入することができます。該当するページには、この方法で必ずブレークポイントを入れています。

メモリ内のモジュール

すべてがスナップショットを中心に展開されるため、要求されたコードがスナップショットに含まれていない場合(検査対象にまだロードされていないモジュールなど)、そのコードは実行できません。今のところ、ロードされているモジュールの先にスナップショットを進め、再スナップショットすることで対処しています。この方法はすべてのケースでうまくいくわけではないでしょうが、これまでに行ったテストの一部では成功しています。

遅いイテレーションサイクル

カーネルの変更をテストするには、カーネルを再構築し、ハードウェアに再導入する必要があります。これでは、新しいアイデアをテストするイテレーションサイクルに途方もなく時間がかかってしまいます。この問題を回避するには、カーネルの状態をクリーンにして 16 ビットモードに戻し、ネットワーク経由で新しいスナップショットをリロードします。このようにして、カーネルはハードウェアを再起動することなく自身のリセットをトリガーできます。最も効率的な方法ではないものの、現在の実装は研究アイデアをテストするうえでは少なくとも有効でした。

RUST の最新情報

ハイパーバイザを作成するというプロジェクトの性質上、コードの各箇所では安定していない Rust のナイトリー版を活用しています。そのため、Rust チームが実施した変更が原因でコードが勝手に壊れてしまう可能性があります。これは問題の一種ですが、言語の違った側面を学ぶチャンスだとも言えそうです。私の場合も、Rust の各部が進化する中でチャレンジを繰り返したことで、最新機能について詳しくなっていたようです。

まとめ

以上で、Barbervisor の開発と研究がどれほどのものだったか、概要をよくおわかりいただけたと思います。プロジェクトはまだ進行中ですが、重要なポイントをいくつかお伝えしておきます。

  • ハイパーバイザテクノロジーと同時に Rust を学習することは問題ありませんでした。
  • Intel VT-x を習得するために Intel のマニュアルを読むのも、たいして面倒ではありませんでした。
  • Bochs は Intel VT-x のデバッグに不可欠なツールです。
  • VMCS のような大規模な設定ブロックの初期化は 1 ステップずつ実行できます。
  • VirtualBox でのスナップショットはうまくいきましたが、VirtualBox が必須ということではありません(他のハイパーバイザでも問題ないはずです)。
  • ネットワークドライバは必須ですが、思っていたほど難しくありません。
  • ダーティビット VM リセットは非常に便利でシンプルです。
  • ネットワーク経由のスナップショットのロードは非常に便利です。
  • 情熱をかけたプロジェクトでモチベーションが下がることがあっても問題ありません。

ここで述べたことは、今回の研究という氷山のほんの一角にすぎません。このプロジェクトの主な目標は、こうした新テクノロジーがファジングの別の方法として使えるかどうかを理解することでした。

 

本稿は 2020 年 8 月 10 日に Talos Group のブログに投稿された「Barbervisor: Journey developing a snapshot fuzzer with Intel VT-x」の抄訳です。

 

TALOS Japan

Talos は、ネットワーク脅威の専門家集団です。Talos が提供する脅威インテリジェンスの情報は、既知および未知の脅威からお客様のネットワークを保護するためにシスコのセキュリティ製品によって活用されています。