背景
Windows 10 (バージョン RS4) では、Microsoft は Windows ハイパーバイザー プラットフォーム (WHP) API を導入しました。この API は、Microsoft の組み込みハイパーバイザー機能をユーザー モードの Windows アプリケーションに公開します。2024年、著者はこのAPIを使用して、 DOSVisorと呼ばれる16ビットMS-DOSエミュレーターという個人プロジェクトを作成しました。リリースノートで述べたように、この概念をさらに進めて、Windowsアプリケーションをエミュレートするために使用する計画が常にありました。Elasticは、スタッフが個人的なプロジェクトに取り組むためのリサーチウィーク(ON Week)を年に2回設けており、このプロジェクトに取り組むための絶好の機会を提供しています。このプロジェクトは、その前身であるDOSVisorに触発されて、(想像力に欠けて)WinVisorと名付けられます。
ハイパーバイザーはハードウェアレベルの仮想化を提供するため、ソフトウェアを介してCPUをエミュレートする必要がありません。これにより、命令は物理 CPU とまったく同じように実行されますが、ソフトウェアベースのエミュレータはエッジケースで一貫性のない動作をすることがよくあります。
このプロジェクトは、Windows x64バイナリを実行するための仮想環境を構築し、システムコールをログに記録(またはフック)し、メモリイントロスペクションを可能にすることを目的としています。このプロジェクトの目標は、包括的で安全なサンドボックスを構築することではありません - デフォルトでは、すべてのシステムコールは単にログに記録され、ホストに直接転送されます。最初の形式では、仮想化ゲスト内で実行されるコードがホストに「エスケープ」するのは簡単でしょう。サンドボックスを安全に保護することは困難な作業であり、このプロジェクトの範囲を超えています。制限事項については、記事の最後に詳しく説明します。
WHP APIは 6 年前(執筆時点)利用可能だったにもかかわらず、 QEMU や VirtualBoxなどの複雑なコードベース以外の多くの公開プロジェクトでは使用されていないようです。もう 1 つの注目すべきプロジェクトは、Alex Ionescu の Simpleator で、これは軽量の Windows ユーザーモードエミュレータで、WHP API も利用しています。このプロジェクトには、WinVisor と同じ目標が多数ありますが、実装のアプローチは大きく異なります。WinVisorプロジェクトは、可能な限り自動化し、単純な実行可能ファイル(例:ping.exe
)箱から出してすぐに使えるもの。
この記事では、プロジェクトの一般的な設計、発生した問題の一部、およびそれらがどのように解決されたかについて説明します。開発時間の制約により一部の機能は制限されますが、最終製品は少なくとも使用可能な概念実証になります。GitHub でホストされているソース コードとバイナリへのリンクは、記事の最後にあります。
ハイパーバイザーの基本
ハイパーバイザーは、VT-x(Intel)およびAMD-V(AMD)拡張機能によって駆動されます。これらのハードウェア支援フレームワークは、1 つ以上の仮想マシンを 1 つの物理 CPU で実行できるようにすることで、仮想化を可能にします。これらの拡張機能は異なる命令セットを使用するため、本質的に相互に互換性はありません。それぞれに別々のコードを記述する必要があります。
内部的には、Hyper-V は Intel サポートに hvix64.exe
を使用し、AMD サポートに hvax64.exe
を使用します。Microsoft の WHP API は、これらのハードウェアの違いを抽象化し、基盤となる CPU タイプに関係なく、アプリケーションが仮想パーティションを作成および管理できるようにします。簡単にするために、次の説明ではVT-xのみに焦点を当てて説明します。
VT-x は、VMX (Virtual Machine Extensions) と呼ばれる命令セットを追加します。これには、VM の実行を初めて開始する VMLAUNCH
や、VM の終了後に VM に再入 VMRESUME
などの命令が含まれています。VM の終了は、特定の命令、I/O ポート アクセス、ページ フォールト、その他の例外など、特定の条件がゲストによってトリガーされたときに発生します。
VMX の中心となるのは、仮想マシン制御構造 (VMCS) です。これは、ゲスト コンテキストとホスト コンテキストの状態、および実行環境に関する情報を格納する VM ごとのデータ構造です。VMCS には、プロセッサの状態、制御構成、およびゲストからホストへの遷移をトリガーするオプションの条件を定義するフィールドが含まれています。VMCS フィールドは、 VMREAD
および VMWRITE
の指示を使用して読み取りまたは書き込みできます。
VM の終了時に、プロセッサはゲストの状態を VMCS に保存し、ハイパーバイザーの介入のためにホストの状態に戻ります。
WinVisor の概要
このプロジェクトでは、WHP API の高レベルな性質を利用しています。この API は、ハイパーバイザー機能をユーザー モードに公開し、アプリケーションがホスト プロセスからゲストの物理メモリに直接仮想メモリをマップできるようにします。
仮想CPUは、実行前にCPUの状態を初期化するためにCPL0(カーネルモード)で実行される小さなブートローダーを除いて、ほぼ独占的にCPL3(ユーザーモード)で動作します。これについては、仮想 CPU のセクションで詳しく説明します。
エミュレートされたゲスト環境のメモリ空間を構築するには、ターゲット実行可能ファイルとすべての DLL 依存関係をマッピングし、その後、プロセス環境ブロック (PEB)、スレッド環境ブロック (TEB)、 KUSER_SHARED_DATA
などの他の内部データ構造にデータを入力する必要があります。
EXE と DLL の依存関係のマッピングは簡単ですが、PEB などの内部構造を正確に維持するのはより複雑な作業です。これらの構造は大規模で、ほとんどが文書化されておらず、その内容は Windows のバージョンによって異なる場合があります。単純な「Hello World」アプリケーションを実行するために最小限のフィールドセットを設定するのは比較的簡単ですが、良好な互換性を提供するために改善されたアプローチを取る必要があります。
WinVisorは、仮想環境を手動で構築する代わりに、ターゲットプロセスの一時停止されたインスタンスを起動し、アドレス空間全体をゲストにクローンします。インポート アドレス テーブル (IAT) とスレッド ローカル ストレージ (TLS) のデータ ディレクトリは、DLL の依存関係の読み込みを停止し、エントリ ポイントに到達する前に TLS コールバックが実行されるのを防ぐために、メモリ内の PE ヘッダーから一時的に削除されます。その後、プロセスが再開され、通常のプロセスの初期化がターゲット実行可能ファイルのエントリポイントに到達するまで続行(LdrpInitializeProcess
)できます。その時点でハイパーバイザーが起動して制御を取得します。これは基本的に、Windows がすべてのハード ワークを行い、実行の準備ができているターゲット実行可能ファイルのユーザー モード アドレス空間が事前に設定されていることを意味します。
その後、新しいスレッドが中断状態で作成され、開始アドレスはカスタムローダー関数のアドレスを指します。この関数は、IATにデータを入力し、TLSコールバックを実行し、最後にターゲットアプリケーションの元のエントリポイントを実行します。これは基本的に、プロセスがネイティブに実行されている場合にメインスレッドが何をするかをシミュレートします。その後、このスレッドのコンテキストが仮想 CPU に "クローン" され、ハイパーバイザーの制御下で実行が開始されます。
メモリは必要に応じてゲストにページングされ、システムコールはインターセプトされ、ログに記録され、仮想化されたターゲットプロセスが終了するまでホストOSに転送されます。
WHP API では、現在のプロセスのメモリのみをゲストにマップできるため、メインのハイパーバイザー ロジックは、ターゲット プロセスに挿入される DLL 内にカプセル化されます。
仮想CPU
WHP API は、前述の VMX 機能に対する "わかりやすい" ラッパーを提供するため、 VMLAUNCH
を実行する前に VMCS を手動で入力するなどの通常の手順は不要になります。また、機能をユーザー モードに公開するため、カスタム ドライバーは必要ありません。ただし、仮想 CPU は、ターゲット コードを実行する前に、WHP を使用して適切に初期化する必要があります。重要な側面については、以下で説明します。
制御レジスタ
このプロジェクトには、 CR0
、 CR3
、および CR4
制御レジスタのみが関連します。CR0
と CR4
は、保護モード、ページング、PAE などの CPU 構成オプションを有効にするために使用されます。CR3
には、 PML4
ページングテーブルの物理アドレスが含まれています。これについては、「メモリページング」セクションで詳しく説明します。
モデル固有のレジスタ
モデル固有レジスタ (MSR) も初期化して、仮想 CPU が正しく動作するようにする必要があります。MSR_EFER
には、ロング モード (64 ビット) や SYSCALL
命令の有効化など、拡張機能のフラグが含まれています。MSR_LSTAR
には syscall ハンドラのアドレスが含まれ、 MSR_STAR
には syscalls 中に CPL0 に遷移する (および CPL3 に戻る) ためのセグメントセレクタが含まれます。MSR_KERNEL_GS_BASE
には、 GS
セレクターのシャドウ ベース アドレスが含まれています。
グローバル記述子テーブル
グローバル記述子テーブル (GDT) は、セグメント記述子を定義し、基本的には保護モードで使用するメモリ領域とそのプロパティを記述します。
ロングモードでは、GDTの使用は限られており、ほとんどが過去の遺物です - x64は常にフラットメモリモードで動作するため、すべてのセレクターは 0
に基づいています。これに対する唯一の例外は、スレッド固有の目的で使用される FS
レジスタと GS
レジスタです。そのような場合でも、ベースアドレスはGDTによって定義されません。代わりに、MSR (上記の MSR_KERNEL_GS_BASE
など) を使用してベース アドレスを格納します。
この陳腐化にもかかわらず、GDTは依然としてx64モデルの重要な部分です。たとえば、現在の特権レベルは、 CS
(Code Segment) セレクターによって定義されます。
タスクの状態セグメント
ロングモードでは、タスク状態セグメント (TSS) は、下位の特権レベルから上位の特権レベルに移行するときにスタック ポインターを読み込むために使用されます。このエミュレータは、最初のブートローダと割り込みハンドラを除いて、ほぼCPL3でのみ動作するため、CPL0スタックには1ページしか割り当てられません。TSS は、GDT 内の特別なシステム エントリとして格納され、2 つのスロットを占有します。
割り込み記述子テーブル
割り込み記述子テーブル (IDT) には、ハンドラ アドレスなど、各タイプの割り込みに関する情報が含まれています。これについては、割り込み処理のセクションで詳しく説明します。
ブートローダー
上記の CPU フィールドのほとんどは WHP ラッパー関数を使用して初期化できますが、特定のフィールド (例:XCR0
) は、WHP API (Windows 10 RS5) の新しいバージョンでのみ登場しました。完全を期すために、プロジェクトには小さな「ブートローダー」が含まれており、起動時にCPL0で実行され、ターゲットコードを実行する前にCPUの最終部分を手動で初期化します。16 ビットのリアルモードで起動する物理 CPU とは異なり、仮想 CPU はすでにロングモード (64 ビット) で動作するように初期化されているため、ブートプロセスが少し簡単になります。
次の手順は、ブートローダーによって実行されます。
-
LGDT
命令を使用して GDT をロードします。この命令のソース・オペランドは、前にデータが取り込まれたテーブルの基本アドレスと制限 (サイズ) を含む 10 バイトのメモリー・ブロックを指定します。 -
LIDT
命令を使用して IDT をロードします。この命令のソース オペランドは、上記の LGDT と同じ形式を使用します。 -
LTR
命令を使用して、TSS セレクタ インデックスをタスク レジスタに設定します。前述のように、TSS 記述子は GDT 内の特別なエントリ (この場合は at の0x40
) として存在します。 -
XCR0 レジスタは、
XSETBV
命令を使用して設定できます。これは、AVX などのオプション機能に使用される追加の制御レジスタです。ネイティブプロセスは XGETBV を実行してホスト値を取得し、その値はブートローダーのXSETBV
を介してゲストにコピーされます。
既に読み込まれている DLL 依存関係は、初期化プロセス中にグローバル フラグを設定している可能性があるため、これは重要な手順です。たとえば、 ucrtbase.dll
は、起動時に CPU が CPUID
命令を介して AVX をサポートしているかどうかを確認し、サポートしている場合は、最適化の理由で CRT が AVX 命令を使用できるようにするグローバル フラグを設定します。仮想 CPU がこれらの AVX 命令を最初に明示的に有効にせずに実行しようとすると XCR0
未定義の命令例外が発生します。
-
DS
、ES
、およびGS
データ セグメント セレクターを CPL3 と同等のもの (0x2B
) に手動で更新します。SWAPGS
命令を実行して、MSR_KERNEL_GS_BASE
からTEBベースアドレスをロードします。 -
最後に、
SYSRET
命令を使用してCPL3に移行します。SYSRET
命令の前に、RCX
はプレースホルダーアドレス(CPL3エントリポイント)に設定され、R11
は初期CPL3RFLAGS値(0x202
)に設定されます。SYSRET
命令は、CS
およびSS
セグメントセレクターをMSR_STAR
からのCPL3相当物に自動的に切り替えます。
SYSRET
命令が実行されると、RIP
の無効なプレースホルダー アドレスが原因でページ フォールトが発生します。エミュレータはこのページの障害をキャッチし、「特別な」アドレスとして認識します。その後、最初のCPL3レジスタ値が仮想CPUにコピーされ、カスタム RIP
ユーザーモードローダー関数を指すように更新され、実行が再開されます。この関数は、ターゲット実行可能ファイルのすべての DLL 依存関係を読み込み、IAT テーブルを設定し、TLS コールバックを実行してから、元のエントリ ポイントを実行します。インポートテーブルとTLSコールバックは、そのコードが仮想化環境内で実行されるように、早い段階ではなく、この段階で処理されます。
メモリ ページング
ゲストのすべてのメモリ管理は手動で処理する必要があります。つまり、ページング テーブルを作成して保守し、仮想 CPU が仮想アドレスを物理アドレスに変換できるようにする必要があります。
仮想アドレス変換
x64 でのページングに詳しくない方のために説明すると、ページング テーブルには PML4
、 PDPT
、 PD
、 PT
の 4 つのレベルがあります。任意の仮想アドレスについて、CPU はテーブルの各レイヤーをウォークスルーし、最終的にターゲットの物理アドレスに到達します。最新のCPUは5レベルのページングもサポートしていますが(4レベルのページングによって提供される256TBのアドレス可能なメモリでは不十分な場合)、これはこのプロジェクトの目的には関係ありません。
次の図は、サンプルの仮想アドレスの形式を示しています。
上記の例を使用すると、CPU は、PML4[0xFF]
-> PDPT[0x1ED]
-> PD[0x1E8]
-> PT[0x30]
のテーブル エントリを使用して、仮想アドレス 0x7FFB7D030D10
に対応する物理ページを計算します。最後に、オフセット(0xD10
)がこの物理ページに追加され、正確なアドレスが計算されます。
仮想アドレス内のビット 48
- 63
は 4 レベル ページングでは使用されず、基本的にビット 47
に一致するように符号拡張されます。
CR3
制御レジスタには、ベースPML4
テーブルの物理アドレスが入っています。ページングが使用可能 (ロング・モードでは必須) の場合、CPU のコンテキスト内の他のすべてのアドレスは仮想アドレスを参照します。
ページフォールト
ゲストがメモリにアクセスしようとすると、要求されたページがまだページング テーブルに存在しない場合、仮想 CPU はページ フォールト例外を発生させます。これにより、VM Exitイベントがトリガーされ、制御がホストに戻されます。これが発生すると、 CR2
制御レジスタには要求された仮想アドレスが含まれますが、WHP API では VM 終了コンテキスト データ内で既にこの値が提供されます。その後、ホストは要求されたページをメモリにマップし (可能な場合)、実行を再開するか、ターゲット アドレスが無効な場合はエラーをスローできます。
ホスト/ゲスト・メモリー・ミラーリング
前述のように、エミュレータは子プロセスを作成し、そのプロセス内のすべての仮想メモリは、同じアドレスレイアウトを使用してゲストに直接マップされます。ハイパーバイザープラットフォームAPIを使用すると、ホストユーザーモードプロセスの仮想メモリをゲストの物理メモリに直接マップできます。その後、ページング テーブルは、仮想アドレスを対応する物理ページにマップします。
プロセスのアドレス空間全体を事前にマッピングする代わりに、固定数の物理ページがゲストに割り当てられます。エミュレータには、非常に基本的なメモリ マネージャが含まれており、ページは "オンデマンド" でマップされます。ページフォールトが発生すると、要求されたページがページインされ、実行が再開されます。すべてのページの「スロット」がいっぱいになると、最も古いエントリが交換され、新しいエントリ用のスペースが確保されます。
エミュレータでは、現在マップされている固定数のページを使用するだけでなく、固定サイズのページテーブルも使用します。ページ・テーブルのサイズは、マップされたページ・エントリの量に対して可能な最大テーブル数を計算することによって決定されます。このモデルでは、シンプルで一貫性のある物理メモリレイアウトが実現しますが、効率が犠牲になります。実際、ページング・テーブルは、実際のページ・エントリーよりも多くのスペースを占有します。
PML4 テーブルは 1 つあり、最悪のシナリオでは、マップされた各ページ項目は一意の PDPT/PD/PT テーブルを参照します。各テーブルは 4096
バイトであるため、ページ テーブルの合計サイズは次の式を使用して計算できます。
PAGE_TABLE_SIZE = 4096 + (MAXIMUM_MAPPED_PAGES * 4096 * 3)
デフォルトでは、エミュレータでは一度に 256
ページ(合計1024KB
)をマッピングできます。上記の式を使用すると、次に示すように、ページング テーブルに 3076KB
が必要になると計算できます。
実際には、ページ・テーブル・エントリーの多くが共有され、ページング・テーブルに割り当てられたスペースの多くが未使用のままになります。ただし、このエミュレータはページ数が少ない場合でも適切に機能するため、このレベルのオーバーヘッドは大きな問題ではありません。
CPU は、変換ルックアサイド バッファー (TLB) と呼ばれるページング テーブルのハードウェア レベルのキャッシュを保持します。仮想アドレスを物理アドレスに変換する場合、CPU は最初に TLB をチェックします。一致するエントリがキャッシュに見つからない場合 (「TLB ミス」と呼ばれます)、代わりにページング テーブルが読み取られます。このため、ページング テーブルが再構築されるたびに TLB キャッシュをフラッシュして、同期しなくなるのを防ぐことが重要です。TLB 全体をフラッシュする最も簡単な方法は、 CR3
レジスタの値をリセットすることです。
システムコール処理
ターゲット・プログラムの実行時に、ゲスト内で発生するシステム・コールは、ホストによって処理される必要があります。このエミュレータは、 SYSCALL
命令と従来の(割り込みベースの)システムコールの両方を処理します。SYSENTER
はロング モードでは使用されないため、WinVisor ではサポートされていません。
高速システムコール (SYSCALL)
SYSCALL
命令が実行されると、CPUはCPL0に遷移し、MSR_LSTAR
からRIP
をロードします。Windows カーネルでは、これは KiSystemCall64
を指します。SYSCALL
命令は本質的に VM 終了イベントをトリガーしませんが、エミュレータは MSR_LSTAR
を予約済みのプレースホルダー アドレス (この場合は 0xFFFF800000000000
) に設定します。SYSCALL
命令が実行されると、RIP がこのアドレスに設定されているとページ フォールトが発生し、呼び出しをインターセプトできます。このプレースホルダーは Windows のカーネル アドレスであり、ユーザー モードのアドレス空間との競合は発生しません。
従来のシステムコールとは異なり、 SYSCALL
命令は CPL0 への移行中に RSP
値をスワップしないため、ユーザー モード スタック ポインターを RSP
から直接取得できます。
レガシーシステムコール(INT 2E)
従来の割り込みベースのシステムコールは、 SYSCALL
命令よりも遅く、オーバーヘッドが大きくなりますが、それにもかかわらず、Windowsでは引き続きサポートされています。エミュレータには割り込みを処理するためのフレームワークがすでに含まれているため、従来のシステムコールのサポートを追加するのは非常に簡単です。レガシーシステムコール割り込みがキャッチされると、いくつかのマイナーな変換の後、つまりCPL0スタックから保存されたユーザーモード RSP
値を取得するために、「一般的な」システムコールハンドラに転送できます。
システムコール転送
エミュレータが「メインスレッド」を作成し、そのコンテキストが仮想 CPU にクローンされると、このネイティブスレッドは、システムコールをホストに転送するためのプロキシとして再利用されます。同じスレッドを再利用すると、TEB の一貫性と、ゲストとホスト間のカーネル状態の一貫性が維持されます。特に Win32k は、エミュレータに反映されるべき多くのスレッド固有の状態に依存しています。
SYSCALL
命令または従来の割り込みによってシステムコールが発生すると、エミュレータはそれをインターセプトし、ユニバーサルハンドラ関数に転送します。syscall 番号は RAX
レジスタに格納され、最初の 4 つのパラメーター値はそれぞれ R10
、 RDX
、 R8
、および R9
に格納されます。R10
は、SYSCALL
命令がリターン アドレスで RCX
を上書きするため、通常の RCX
レジスタではなく最初のパラメーターに使用されます。Windows の従来の syscall ハンドラ (KiSystemService
) も互換性のために R10
を使用するため、エミュレータで異なる方法で処理する必要はありません。残りのパラメータはスタックから取得されます。
特定のシステムコール番号に必要なパラメータの正確な数はわかりませんが、幸いなことに、これは問題ではありません。固定量を使用するだけで、提供されたパラメータの数が実際の数以上である限り、システムコールは正しく機能します。単純なアセンブリ スタブが動的に作成され、すべてのパラメーターが入力され、ターゲットの syscall が実行され、クリーンに戻ります。
テストの結果、Windows のシステムコールで現在使用されているパラメータの最大数は 17
(NtAccessCheckByTypeResultListAndAuditAlarmByHandle
、 NtCreateTokenEx
、 NtUserCreateWindowEx
) であることが示されました。WinVisorは、将来の拡張を可能にするために、パラメータの最大数として 32
を使用します。
ホストで syscall を実行した後、戻り値がゲストの RAX
にコピーされます。その後、RIP
は SYSRET
命令 (またはレガシ システムコールの場合は IRETQ
) に転送され、仮想 CPU を再開してユーザー モードにシームレスに移行します。
システムコールのロギング
デフォルトでは、エミュレータは単にゲスト システムコールをホストに転送し、コンソールにログを記録します。ただし、生のシステムコールを読み取り可能な形式に変換するには、いくつかの追加の手順が必要です。
最初のステップは、システムコール番号を名前に変換することです。システムコール番号は、複数の部分から構成されています。ビット 12
から 13
にはシステムサービステーブルインデックス (ntoskrnl
は0
、win32k
1
) が含まれ、ビット 0
から 11
にはテーブル内のシステムコールインデックスが含まれます。この情報により、対応するユーザー モード モジュール (ntdll
/ win32u
) 内で逆引きを実行して、元の syscall 名を解決できます。
次のステップは、各システムコールに表示するパラメータ値の数を決定することです。前述のように、エミュレータは、ほとんどのパラメータが使用されていない場合でも、 32
パラメータ値を各システムコールに渡します。ただし、各システムコールのすべての 32
値をログに記録することは、読みやすさの理由から理想的ではありません。たとえば、単純な NtClose(0x100)
呼び出しは NtClose(0x100, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, ...)
と出力されます。前述のように、各システムコールのパラメーターの正確な数を自動的に決定する簡単な方法はありませんが、それを高精度で推定するために使用できるトリックがあります。
このトリックは、WoW64 が使用する 32 ビット システム ライブラリに依存しています。これらのライブラリは stdcall 呼び出し規約を使用します。つまり、呼び出し元はすべてのパラメーターをスタックにプッシュし、戻り値を返す前に呼び出し先によって内部的にクリーンアップされます。これに対し、ネイティブ x64 コードでは、最初の 4 個のパラメーターがレジスタに配置され、呼び出し元がスタックの管理を担当します。
たとえば、WoW64 バージョンの WoW64 バージョンの ntdll.dll
の NtClose
関数は、RET 4
命令で終わります。これにより、リターンアドレスの後にスタックからさらに 4 バイトがポップされ、関数が 1 つのパラメーターを取ることを意味します。関数が RET 8
を使用した場合、これは 2 パラメータなどを取ることを示唆しています。
エミュレーターは 64 ビット プロセスとして実行されますが、 ntdll.dll
と win32u.dll
の 32 ビット コピーをメモリにロードできます (手動または SEC_IMAGE
を使用してマップ)。WoW64 エクスポート アドレスを解決するには、 GetProcAddress
のカスタム バージョンを記述する必要がありますが、これは簡単な作業です。ここから、各システムコールに対応するWoW64エクスポートを自動的に見つけ、 RET
命令をスキャンしてパラメーターの数を計算し、値をルックアップテーブルに保存できます。
この方法は完全ではなく、失敗する可能性のあるいくつかの方法があります。
- WoW64 には、
NtUserSetWindowLongPtr
などの少数のネイティブ システムコールは存在しません。 - 32 ビット関数に 64 ビット パラメーターが含まれている場合、内部で 2x 32 ビット パラメーターに分割されますが、対応する 64 ビット関数では、同じ値に対して 1 つのパラメーターのみが必要です。
- Windows 内の WoW64 システムコール スタブ関数は、既存の WoW64 命令検索が失敗するような方法で変更
RET
可能性があります。
これらの落とし穴にもかかわらず、大部分のシステムコールでは、ハードコードされた値に頼ることなく、結果は正確になります。さらに、これらの値はログ記録の目的でのみ使用され、他のものには影響を与えないため、このコンテキストではわずかな不正確さが許容されます。エラーが検出されると、パラメータ値の最大数の表示に戻ります。
Syscall フッキング
このプロジェクトがサンドボックス化の目的で使用されていた場合、すべてのシステムコールをやみくもにホストに転送することは、明らかな理由から望ましくありません。エミュレータには、必要に応じて特定のシステムコールを簡単にフックできるフレームワークが含まれています。
デフォルトでは、 NtTerminateThread
と NtTerminateProcess
のみがフックされ、ゲストプロセスが終了するのをキャッチします。
割り込み処理
割り込みは IDT によって定義され、IDT は仮想 CPU の実行が開始される前に入力されます。割り込みが発生すると、現在のCPU状態がCPL0スタック(SS
、 RSP
、 RFLAGS
、 CS
、 RIP
)にプッシュされ、 RIP
がターゲットハンドラ関数に設定されます。
SYSCALL ハンドラの MSR_LSTAR
と同様に、エミュレータはすべての割り込みハンドラ・アドレスにプレースホルダ値 (0xFFFFA00000000000
- 0xFFFFA000000000FF
) を移入します。割り込みが発生すると、この範囲内でページフォールトが発生し、これをキャッチできます。割り込みインデックスは、対象アドレスの最下位8ビット(例: 0xFFFFA00000000003
が INT 3
)から抽出でき、ホストは必要に応じてそれを扱うことができます。
現在、エミュレータは INT 1
(シングルステップ)、 INT 3
(ブレークポイント)、 INT 2E
(レガシーシステムコール) のみを処理します。他の割り込みがキャッチされた場合、エミュレータはエラーで終了します。
割り込みが処理されると、 RIP
は IRETQ
命令に転送され、ユーザーモードにきれいに戻ります。一部のタイプの割り込みは、追加の「エラーコード」値をスタックにプッシュします - この場合、スタックの破損を避けるために、 IRETQ
命令の前にポップする必要があります。このエミュレータ内の割り込みハンドラ フレームワークには、これを透過的に処理するためのオプションのフラグが含まれています。
ハイパーバイザー共有ページのバグ
Windows 10 では、 KUSER_SHARED_DATA
の近くにある新しいタイプの共有ページが導入されました。このページは、 RtlQueryPerformanceCounter
や RtlGetMultiTimePrecise
などのタイミング関連機能で使用されます。
このページの正確なアドレスは、SystemHypervisorSharedPageInformation
情報クラスを使用して NtQuerySystemInformation
で取得できます。LdrpInitializeProcess
関数は、プロセスの起動時にこのページのアドレスをグローバル変数(RtlpHypervisorSharedUserVa
)に格納します。
WHP API には、この共有ページがゲストにマップされ、仮想 CPU がそこから読み取ろうとすると、 WHvRunVirtualProcessor
関数が無限ループに陥る原因となるバグが含まれているようです。
時間の制約により、これを完全に調査する能力は限られていました。ただし、簡単な回避策が実装されました。エミュレータは、ターゲット プロセス内の NtQuerySystemInformation
関数にパッチを適用し、SystemHypervisorSharedPageInformation
要求に対して STATUS_INVALID_INFO_CLASS
を返すように強制します。これにより、 ntdll
コードは従来のメソッドにフォールバックします。
デモ
この仮想化環境でエミュレートされる一般的な Windows 実行可能ファイルの例を次に示します。
制限
エミュレータにはいくつかの制限があり、現在の形式で安全なサンドボックスとして使用するのは安全ではありません。
安全性の問題
VMを「エスケープ」するには、単に新しいプロセス/スレッドを作成する、非同期プロシージャコール(APC)をスケジュールするなど、いくつかの方法があります。
Windows GUI 関連のシステム呼び出しでは、カーネルからユーザー モードに直接入れ子になった呼び出しを行うこともできますが、これは現在ハイパーバイザー レイヤーをバイパスします。このため、notepad.exe などの GUI 実行可能ファイルは、WinVisor で実行すると部分的にしか仮想化されません。
これを示すために、WinVisor にはエミュレータへの -nx
コマンドライン スイッチが含まれています。これにより、仮想 CPU を起動する前に、ターゲット EXE イメージ全体がメモリ内で実行不可としてマークされ、ホスト プロセスがコードをネイティブに実行しようとすると、プロセスがクラッシュします。しかし、これはまだ信頼するのは安全ではありません - ターゲットアプリケーションは、領域を再度実行可能にしたり、単に実行可能メモリを他の場所に割り当てることができます。
WinVisor DLL がターゲット プロセスに挿入されると、ターゲット実行可能ファイルと同じ仮想アドレス空間に存在します。これは、仮想 CPU で実行されているコードがホスト ハイパーバイザ モジュール内のメモリに直接アクセスできることを意味し、これによりメモリが破損する可能性があります。
実行不可能なゲストメモリ
仮想 CPU は NX をサポートするように設定されていますが、現在、すべてのメモリ領域はフル RWX アクセスでゲストにミラーリングされています。
シングルスレッドのみ
エミュレータは現在、1 つのスレッドの仮想化のみをサポートしています。ターゲット実行可能ファイルが追加のスレッドを作成した場合、それらはネイティブに実行されます。複数のスレッドをサポートするために、将来これを処理するための擬似スケジューラを開発できます。
Windows 並列ローダーは、すべてのモジュールの依存関係が 1 つのスレッドによって読み込まれるようにするために無効になっています。
ソフトウェアの例外
仮想化ソフトウェアの例外は、現在サポートされていません。例外が発生した場合、システムは通常どおり KiUserExceptionDispatcher
関数をネイティブに呼び出します。
まとめ
上記のように、エミュレータは、現在の形式のさまざまな実行可能ファイルで優れたパフォーマンスを発揮します。現在、システムコールと割り込みのログ記録には効果的ですが、マルウェア分析の目的で安全に使用できるようにするには、さらに多くの作業が必要になります。それにもかかわらず、このプロジェクトは将来の開発のための効果的なフレームワークを提供します。
プロジェクトリンク
https://github.com/x86matthew/WinVisor
著者はXの @x86matthewで見つけることができます。