脆弱性

インサイドWin32kエクスプロイト: Win32kの実装の背景とエクスプロイトの方法論

Clock Icon 4 min read
Related Products

This post is also available in: English (英語)

概要

2022年1月下旬、SNS上で「Microsoft Windowsの新たな特権昇格の脆弱性(CVE-2022-21882)が野生(in the wild)で悪用されている」という報告が複数あがってきました。これらの報告を受け、CVE-2022-21882の解析を行ったところ、これはWin32k.sysのユーザーモード コールバック関数xxxClientAllocWindowClassExtraBytesの脆弱性であることがわかりました。

2021年にはこれと非常によく似た脆弱性(CVE-2021-1732)がMicrosoftに報告され、同社により修正されています。私たちは、これら2つの脆弱性を詳しく調べ、それぞれの脆弱性に関わるコードの把握につとめました。最初の分析で私たちは「CVE-2021-1732に対する修正プログラムがCVE-2022-21882の防止には不十分であった理由」を明らかにしたいと考えました。

本稿は連載の第1部です。本連載ではこれら2つの脆弱性とそれに関連する概念実証(PoC)エクスプロイトを例にとり、Win32kの内部とエクスプロイト全般について解説する予定です。

第1部では、かなりの量の背景情報を取り上げます。これには何年もかけて優秀なリサーチャーの皆さんが行ってきた背景研究も含まれます。こうした背景情報を取り上げることで、読者の皆さんに、Win32kの最新の実装やそれに関連するエクスプロイト手法をキャッチ アップしていただければと思います。さらに理解を深めたいかたは、本稿末尾のリンク先参考文献の通読をお勧めします。

本連載で取り上げる脆弱性は、いずれもCortex XDRのAnti-LPE保護モジュールにより検出・ブロックされます。いずれの脆弱性も、NT AUTHORITY\SYSTEMの特権トークンを現在の(エクスプロイトの)プロセスのそれにコピーすることで特権昇格を図る、データオンリー型の(システム上のデータ変更にコード実行を必要としない種類の)エクスプロイトです。Cortex XDRのAnti-LPEモジュールは、この特定の種類の特権昇格技術を監視します。

関連するUnit 42のトピック Microsoft Windows, CVE-2021-1732, CVE-2022-21882

CVE-2022-1732とCVE-2022-21882の概要

Win32 APIによるWindows開発やWindowsの内部構造については類書が多数ありますが、グラフィカル ユーザー インターフェイス(GUI)を使った開発やそうした開発の基礎となる内部構造をカバーしているセキュリティ関連リソースは私たちの経験上はあまりないように思われます。このインターフェイスはWin32k.sysWin32kbase.sysWin32kfull.sys内に実装されています。

そこで私たちはWindowsのGUIの内部構造とそれに関連するAPIに慣れ親しむため、少し調査をしてみることにしました。ここ10年ほどの間に書かれたWin32kのエクスプロイトに関するホワイトペーパーやWin32 APIに関するMicrosoft Developer Network (MSDN)のドキュメントをいくつか読みました。

本稿で取り上げる基盤コードについて、読者の皆さんがあらかじめ専門知識を持っていることを前提にはしたくないので、この第1部で関連APIやオブジェクト、データ構造に関する背景を説明することで、今回分析する2つの脆弱性や、ほかのWin32k.sysの脆弱性・エクスプロイトに対する理解を深めていただければと思います。

これら脆弱性のエクスプロイトや修正プログラムの迂回方法はそこまで難しくないので、最近の2事例を選んで、Win32kの内部構造をひととおり見ていくことで、read/writeプリミティブの取得によく使われる方法を理解していただくことにしました。いい機会なので、Win32k.sysコードベース内でWin32kのエクスプロイト対象にされやすいもの(ユーザーモードのコールバック)についても解説していきます。

Win32kの歴史と背景

Microsoftは、Windows NT 4.0より前は、Win32 APIのGUI機能をClient-Server Runtime SubSystem (CSRSS.exe)と呼ばれるユーザーモード プロセス内に実装していました。しかし、ユーザーモード カーネルモード間のコンテキスト スイッチは計算量が大きく、メモリー オーバーヘッドが大きくなりがちでしました。

そこで、この問題を解消してWindowsオペレーティング システム全体の高速化を図るために、MicrosoftはWindowsサブシステム(Window Manager、GDI、グラフィックス ドライバー)をカーネルに移すことにしました。この移動が始まったのが、1996年のWindows NT 4.0からです。

この変更は、現在はカーネルモードのWindowsサブシステムとして知られているWin32k.sysというカーネルモード ドライバーを通じて実装されました。Windowsサブシステムのユーザーモード コンポーネントは、今もCSRSS内に存在しています。

カーネルへの移動により、求められるオーバーヘッドは大幅に削減されましたが、Microsoftはクライアントのアドレス空間のユーザーモード部分に管理用のデータ構造をキャッシュするなど、いくつか古い技法に頼らざるを得ませんでしました。実際、コンテキスト スイッチをさらに回避するため、管理用構造体の一部は歴史的にユーザーモード部分にのみ保存されています。ただし、カーネル アドレスの漏えいを防ぐため、Microsoftはこれら構造体のユーザーモードとカーネルモードのコピーを使い、ユーザーモードの構造体内にカーネル アドレスが格納されないようにするメソッドを実装しはじめました。

さらに、Win32kには、これらユーザーモード構造体へのアクセスを提供したり、ウィンドウ フックなどの既存ユーザーモード機能のサポートが求められたことから、それらのタスクを実現するユーザーモード コールバックが実装されることになりました。

Tarjei Mandt氏はその詳細なホワイトペーパーのなかで、「ユーザーモード コールバックがあるおかげで、Win32kがユーザーモードにコールバックすれば、アプリケーション定義のフックを呼び出したり、イベント通知を提供したり、ユーザーモードとの間でのデータのコピーなどのタスクを実行できるようになる」と説明しています。Mandt氏は2011年のBlack Hat USAでこのリサーチを発表し、そのさいユーザーモード コールバックの実装やデータ整合性の確保がMicrosoftにもたらした課題についても提示しました。

Mandt氏は、多くのオブジェクトがユーザーモード コールバック実行前に適切にロックされていないこと、それが原因でユーザーモード コールバック中のユーザーモード コードがこれらオブジェクトを破壊しうること、そこからUAF (Use-After-Free: メモリー解放後の使用)の脆弱性につながることを実証しました。Microsoftは2011年に同氏の指摘した問題の多くに対処しましたが、ユーザーモード コールバックは今日でも悪用されています。

Mandt氏のリサーチに触発され、2019年にGil Dabah氏が同氏のリサーチを下地にした論文を書きました。Dabah氏はユーザーモード コールバック中、ユーザーモード コードが適切にロックされたオブジェクトを破壊した場合にも、その破壊されたオブジェクトが適切にロックされていないほかのオブジェクトに二次的な影響を与えうることを発見しました。この動作は二次的なオブジェクト破壊とさらなるUAF脆弱性につながります。

Windows GUI APIの基本的背景

Win32kの内部構造の説明にうつる前に、Win32 APIを使ってウィンドウの作成と破棄を行うシンプルなCプログラムについて簡単に説明しておきます。そうすればグラフィックス ウィンドウがプログラム的にどのように作成・操作されているのかがわかってくるでしょうし、ウィンドウやそのメニューを定義している基礎となる構造を調べられます。

以下、図1~3のサンプル コードを参照しながら、ウィンドウ作成の基本や、ウィンドウやメニューの定義の基礎となる構造を説明します。サンプル コードには、なるべくわかりやすいよう、コメントを付けてあります。

図1に示したように、このサンプル プログラムは最初にウィンドウ クラスを定義しています。プロセスは、ウィンドウ クラスを登録した後で、WNDCLASSEX構造体内に定義されている型のウィンドウを作成する必要があります。まずウィンドウ クラスのオブジェクトをWNDCLASSEX wcx = { }として宣言し、次にウィンドウ クラスの構造体にパラメーターを指定します。

画像1は、ウィンドウ クラスを定義しているコード行です。オブジェクトの宣言の後、構造体へのパラメーターの指定が続いています。
図1. ウィンドウ クラスの定義

ウィンドウ クラスの構成要素は以下の通りです。

  • cbSize: この構造体のサイズ(単位はバイト)。このメンバーはsizeof(WNDCLASSEX)に設定します。
  • style: ウィンドウ クラスのスタイル。このメンバーにはクラス スタイルを任意に組み合わせてよい。
  • lpfnWndProc: そのクラス内のウィンドウに送られるすべてのメッセージを処理し、ウィンドウの動作を定義する関数へのポインター。通常、少なくとも一部のメッセージについては、デフォルトのウィンドウ プロシージャが使用されます。ただし、カスタム ウィンドウ プロシージャを使って独自のウィンドウ体験が提供されることも多い。詳しくはWindowProcを参照のこと。
  • cbClsExtra: ウィンドウ クラスの構造体の後ろに続けて割り当てる追加バイト数。このシステムはこの追加バイト数を0で初期化しています。
  • cbWndExtra: ウィンドウ インスタンスの後ろに続けて割り当てる追加バイト数。このシステムはこの追加バイト数を0で初期化しています。このcbWndExtraとさきのcbClsExtraを混同しないように注意。さきほどのcbClsExtraはこのウィンドウ クラスの全ウィンドウに共通の追加バイト数。cbWndExtraは0に設定することが多いが、0ではない場合、その追加メモリーは通常、全ウィンドウ共通ではないデータを保存するのに使う。これがPoCで使われるようすは後述します。
  • hInstance: そのクラスのウィンドウ プロシージャを持つインスタンスへのハンドル。そのクラスを登録したアプリケーションまたは.DLLを識別します。ここではWinMainへのhinstance引数が割り当てられています。
  • hIcon: クラスへのハンドル。LoadIcon(NULL, IDI_APPLICATION)はデフォルト アイコンをロードします。
  • hCursor: クラス カーソルへのハンドル。LoadCursor(NULL, IDC_ARROW)はデフォルト カーソルをロードします。
  • hbrBackground: クラスの背景ブラシへのハンドル。GetStockObject (WHITE_BRUSH)は白いブラシへのハンドルを返す。GetStockObjectは汎用オブジェクトを返すので戻り値には型キャストが必要。
  • lpszMenuName: クラス メニューのリソース名を指定する、null文字で終端された文字列へのポインターを指定する(この名前がリソース ファイルに表示される)。メニュー バーがいらなければ、このフィールドはNULLでもよい。
  • lpszClassName: このウィンドウ クラスの構造体を識別するクラス名。
  • hIconSm: 小さいクラス アイコンへのハンドル。

これでウィンドウ クラスの属性の定義は終えたので、以下の図2に示したRegisterClassEx()を使ってこのアプリケーションに登録する必要があります。失敗した場合、RegisterClassEX()は0を返します。成功した場合、登録されるクラスを一意に識別するクラス アトム(atom)を返します。ウィンドウ クラスを登録することで、そのクラスとそのクラスに関連する構造体メンバーをWindowsに定義します。

画像2はウィンドウ クラスの登録を行っているコード行です。
図2. ウィンドウ クラスの登録

ウィンドウの作成

ウィンドウが登録されると、以下の図3に示したCreateWindowExA()を呼び出して、そのウィンドウ クラスのインスタンスを作成できるようになります。

画像3はメイン ウィンドウを作成する何行ものコードです。
図3. ウィンドウを作成するコード

CreateWindowExA()への引数は図4の通りです。

画像4は、CreateWindowExA()の関数プロトタイプを構成する引数を示しています。
図4. CreateWindowExA()の関数プロトタイプ

各項目を以下に簡単に説明します。

  • dwExStyle: 作成するウィンドウの拡張ウィンドウ スタイル。ここではデフォルトのウィンドウ定数であるWS_EX_LEFTに設定することでこのウィンドウに汎用の左寄せのプロパティを付与しています。
  • lpClassName: クラス名。RegisterClassEXの呼び出し内で宣言されたwcx.lpszClassNameから取得します。
  • lpWindowName: ウィンドウ名。
  • dwStyle: 作成するウィンドウのスタイル。ここではトップレベルの(親の)ウィンドウを作成するWS_OVERLAPPEDWINDOWを使っています。
  • X: ウィンドウの水平方向の初期位置。オーバーラップ ウィンドウまたはポップアップ ウィンドウの場合、xパラメーターはウィンドウ左上隅の初期X座標(スクリーン座標)になる。子ウィンドウの場合、xは親ウィンドウのクライアント領域の左上隅を基準としたウィンドウ左上隅のx座標になる。xCW_USEDEFAULTに設定されている場合、システムがウィンドウ左上隅のデフォルト位置を選び、yパラメーターは無視されます。
  • Y: 上記のXと同じだが方向は垂直方向。
  • nWidth: ウィンドウの幅。
  • nHeight: ウィンドウの高さ。
  • hWndParent: 作成されるウィンドウの親ウィンドウないしオーナー ウィンドウへのハンドル。
  • hMenu: ウィンドウ スタイルに応じてメニューへのハンドルか子ウィンドウの識別子を指定。オーバーラップ ウィンドウまたはポップアップ ウィンドウの場合、hMenuはそのウィンドウに使われるメニューを識別します。クラス メニューを使う場合はこれにNULLを指定してもよい。
  • hInstance: ウィンドウに関連付けられるモジュールのインスタンスへのハンドル。
  • lpParam: そのウィンドウのウィンドウ プロシージャに渡される追加情報。追加情報を渡さない場合はNULLを渡す。

CreateWindowEx()を呼び出してウィンドウが作成されると、そのウィンドウは内部的に作成されます。つまり、メモリーが割り当てられて構造体には値が入りますがまだ表示はされません。ウィンドウを表示するにはShowWindow()関数を呼び出します。

ShowWindow()CreateWindowExW()の呼び出しで得られたハンドルと、WinMain()から得られた状態変数のnCmdShowを受け取ります。この状態変数 nCmdShowは、通常の状態、最大化された状態、最小化された状態のように、ウィンドウが画面上にどのように表示されるかを決定します。

ShowWindow()はアプリケーション ウィンドウの表示方法のみを制御します。この制御対象には、タイトル バー、メニュー バー、ウィンドウ メニュー、最小化ボタンなどの要素が含まれます。クライアント領域とは、テキストエディタで文字を入力する場所など、アプリケーションがデータを表示する領域のことです。クライアント領域はUpdateWindow()関数を呼び出すことで描画されます。

CreateWindowExW()関数へのdwStyleパラメーターにWS_VISIBLEウィンドウ スタイルを指定した場合はShowWindow()関数を呼び出す必要はありません。その場合、暗黙的にWindowsが呼び出しを代行してくれます。同様に、WS_VISIBLEスタイルを指定せず、ShowWindow()関数も呼び出さなかった場合、ウィンドウは表示されないままになります。

ウィンドウ メッセージとウィンドウ プロシージャ

UpdateWindow()を呼び出すとウィンドウが完全に表示されて使用可能な状態になります。Windows用のもっとシンプルなコンソール アプリケーションを書く場合、コンソールからのユーザー入力に応じてアプリケーションが明示的に関数を呼び出すことになります。

ウィンドウをもつアプリケーションの場合、通常はユーザーがアプリケーションとやりとりします。たとえばテキストを入力したり、ボタンやメニューをクリックしたり、マウスを動かしたりします。これらのアクションには、それぞれ特別な機能があります。これを実現するため、Microsoftはユーザー入力(キーボード、マウス、タッチなど)からのメッセージを各アプリケーションのさまざまなウィンドウにリレーするイベント駆動型システムを実装しました。これらのメッセージは、「ウィンドウ プロシージャ」と呼ばれる各ウィンドウ内の関数によって処理されます。

Windowsはスレッドごとに1つメッセージ キューを保持しています。このメッセージ キューはウィンドウの状態に影響するようなユーザー入力イベントがあればそれを中継します。そしてWindowsがこれらのイベントをメッセージに変換し、メッセージ キューに配置します。アプリケーションは以下の図5のようなコードを実行することでこれらのメッセージを処理します。

画像5は、whileから始まるウィンドウ メッセージ キューのループです。
図5. ウィンドウ メッセージ キューのループ

GetMessage()関数は、メッセージ キューから次のメッセージを取得します。パラメーター&msgはMSG構造体へのポインターです。このMSG構造体内には、割り当てられたウィンドウ プロシージャがそのメッセージを適切に処理するために必要とするメッセージ情報が保持されています。

MSG構造体のメンバーには、hwndmessageなどがあります。hwndにはウィンドウ プロシージャがメッセージを受信するウィンドウへのハンドルが入っていて、messageには、ウィンドウ プロシージャへの要求内容を決定するメッセージIDが入っています。たとえばこのmessageにWM_PAINTというメッセージが含まれている場合、それは「ウィンドウのクライアント領域が変更されたので再描画が必要」とウィンドウ プロシージャに伝えています。

TranslateMessage()関数は仮想キー メッセージを文字メッセージに変換するものですが、今回の解説では重要ではありません。DispatchMessage()関数はMSG構造体のウィンドウ ハンドルで識別したウィンドウにメッセージを送り、そのウィンドウ クラスで定義されているウィンドウ プロシージャに処理させます。

この時点までに、このサンプル コードは以下の動作を行うことで、ウィンドウ クラスの定義というオーバーヘッドを完了しています。

  • ウィンドウを登録する
  • ウィンドウ クラスで定義されたウィンドウ インスタンスを作成する
  • ウィンドウを画面に表示する
  • メッセージ ループに入る

何を表示するか、ユーザーの入力にどう反応するかを決めるのはウィンドウ プロシージャです。Windowsはアプリケーションが処理しないウィンドウ メッセージを処理するためのデフォルト ウィンドウ プロシージャを用意しています。このデフォルト ウィンドウ プロシージャはあらゆるウィンドウが適切に機能するように最小限の機能を提供しています。

ウィンドウのすべての機能がそのなかに定義されていることからも推測されるように、ウィンドウ プロシージャはかなり複雑になることがあります。本稿ではMicrosoftのデフォルト ウィンドウ プロシージャであるDefWindowProc()にのみ注目します。

ウィンドウの構造体

前述のように、WindowsはメニューやウィンドウなどのGUIオブジェクトをWin32k.sysを介してカーネル内で管理するようになりました。ウィンドウ オブジェクトが作成されると、そのプロパティはtagWNDという名前のデータ構造で追跡されます。

残念ながらMicrosoftはWin32kのデバッグ シンボルの多くを削除してしまったので、これら構造体の内容は確認しづらくなっています。リバース エンジニアリングにもとづいて、Windows 10 バージョン 21H1ではこの構造体かどのように見えるかを図6に示します。

画像6は何行にもわたるコードです。この画像は親のtagWND構造体を表しています。
図6. 親のtagWND構造体

これは網羅的なメンバーのリストではなく、本稿での説明上重要なものだけを示しています。構造体の割り当てが発生するxxxCreateWindowExの呼び出し中にHMAllocObjectを確認すると、この構造体のサイズは0x150 (336)バイトであることが確認できます。

HMAllocObjectを呼び出す直前のWinDbgの出力を図7に示します。割り当てサイズを表す第4引数がr9レジスタに格納され、これが0x150に相当することがわかります。

画像7はWinDbgの出力で、HMAllocObjectオブジェクトへの入力パラメーターを示しています。第4引数は割り当てサイズを表し、r9レジスタに格納されています。
図7 HMAllocObjectへの入力パラメーターを表示しているWinDbgの出力

図6に示すtagWND構造体は、以前はTEB (Thread Environment Block: スレッド環境ブロック)のWin32ClientInfoメンバー内で参照されていました。その後カーネルモードのアドレスが漏出しにくいよう、これは削除されました。

カーネルtagWND構造体の最初のメンバーはウィンドウ ハンドルです。それぞれのウィンドウはカーネル内に自身との紐づけをするtagWNDという構造体を持っています。

CVE-2022-21882の解析ではこの構造体が重要になりますが、今はオフセット0x28に注目します。Microsoftがシンボルを提供しなくなったので、筆者はこのオフセットに*pWNDというラベルをつけました。また同社は、この構造体名ももう提供していません。ただし、これはかつてstateやWWと呼ばれていました。Microsoftによればこれらの名前は廃止予定で内部的にはもう使われていないそうです。

このポインターの重要な点は、これがカーネル アドレスを含まないユーザーモード版のtagWND構造体で、その親であるtagWND構造体とは異なる構造になっていることです。この子の構造体はユーザーモード内だけでなくカーネル内にも存在します。Windowsはこのようにデータを管理することでカーネル アドレスの漏えいを避けようとしています。というのもユーザーモード アプリケーションはユーザーモードのデスクトップ ヒープ内にあるtagWND構造体のコピーを使って動作していて、カーネルモードのアドレスは見られないからです。

以下では引き続きこの子の構造体をtagWND構造体と呼ぶことにします。この子の構造体は、前述の親のtagWND構造体とは異なる(下記図8のような)構造になっていますが、他のリサーチ ブログや論文でもtagWND呼ばれることが多いので注意が必要です。

子であるtagWND構造体は図8に示すとおりで、リバース エンジニアリングによってそのメンバーとそのオフセットは確認済みです。ギャップは分析していませんがそれは今回の議論では重要ではありません。

「ウィンドウの作成」のセクションで説明したWNDCLASSEX構造体のメンバーの多くがこのtagWND構造体でも見うけられます。したがって、ウィンドウの作成時にWNDCLASSEX構造体を介して割り当てられたプロパティが、カーネルに渡された後、tagWND構造体内に格納されるということは明らかです。そして、そのプロパティはさらに、カーネルとユーザーモードの両デスクトップ ヒープ内にあるユーザー コピーに伝搬されます。

画像8には何行ものコードが示されています。これは子のtagWND構造体を表しています。BYTEのギャップは今回は関連性がないので分析していません。
図8. tagWND構造体のユーザーモード セーフ コピー

図9と図10はそれぞれ、親のtagWND構造体とカーネル版のユーザーモード セーフ コピーである子のtagWND構造体です。

画像9は親のtagWND構造体のWinDbgメモリー ダンプです。ff.ff8e82 ffff8e82 ff.ff8e82 ffff8e82 ff.ff8e82 ffff8e82 ff.ff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 kd> dq exffff8e82ee8437ee Lye eeeøøeee• eee5033e eeeoøeeøs 00038390 oeeeeeee• eeeeøeeø eøeøøeee• eøeeoøee eeeøeøøe• eeeøøeeø eeeeøeee• eeøeeeee oeeeeeøe• eeeeeeeø eøeoøeee• eoøeeeee oeeeeøøe• eeeeøeee eeeoeeees oooeeøee eeeøe15b• a4b69d8ø eø8437eø 008437fø 00843800 00843810 00843820 00843830 00843840 00843850 00843860 00843870 00843880 eeeøeøøe• eeeeøees ffffd40fS f34ea4bø eøeøøeeø eeeoøeeøs e2cceme 008437ee 0083354e 82 ffff8e82 ffff8e82 ffff8e82 01038390 ee844e70 ee83ø93e ee81887ø
図9. 親のtagWND構造体をWinDbgでメモリー ダンプしたところ

上の図は親のtagWND構造体ですが、そのハンドル(オフセット0x00)は、下の図のユーザー セーフ コピー版のtagWNDのハンドルと同じであることが見てとれます。また親の構造体はカーネル アドレスを持っていますがユーザーモード セーフ コピーの子である構造体にはユーザーモードのアドレスしかないことがわかります。最後に、親の構造体のtagWND+0x28は、子のtagWND構造体のコピーのアドレスへのポインターであることに注目してください。

画像10は親であるtagWND構造体のWinDbgメモリー ダンプです。ffff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 ffff8e82 dq poi(exffff8e82ee8437eo + ox28) eøeeeeee• 0005033e eeeeeøøø• eee3839ø gøeømeø• 40020019 ecceeøøe• egeømeø eøøe7ff7& ed32eeee eeeøeøøe• eeeøøeeø eqeeøeec oeøe10de eeeøeeee• øeeeeeeø eéeoøeee• ooeeeeee eeeøeøøø• eee384eø eeeeøeeø• eeø2ad3e eeeøeeee• øeeeeeeø oøeoøe27& ooøeoe88 eeeøeel€ eeeøøee8 eeeeeel€ eeøeee8e eeeø7ffT ed321edø oeeeeøøe• eeeeøeeø eeeoøeeø• eeø38fee eeeeeeeø• eeøeeeee eeeøe15b• a4b69d8Ø eeoeeeoe- oeeeoeoo eeeeoeee- eooeooee 01e3839ø 01083aø 01e383bø 010383cø 010383dø 010383eø 01e383fø 01038400 01e3841ø 01e3842ø 01038430
図10. 子のtagWND構造体をWinDbgでメモリー ダンプしたところ

歴史的に、ウィンドウ オブジェクトのカーネルモード アドレスを漏えいさせる方法はいくつか確認されています。Win32k内でユーザーモードのコード(たとえばウィンドウやメニューなど)が設定したプロパティを格納するオブジェクトはすべて一般にはユーザー オブジェクトと呼ばれます。

すべてのユーザー オブジェクト(tagWND構造体のユーザーモード コピーもその1つ)は、一般にUserHandleTableと呼ばれるセッションごとのハンドル テーブル内にインデックスされます。(ただし、tagWND構造体はつねにユーザーモードのセーフ コピーだったわけではなく、カーネル アドレスを含んでいたこともあります)。

以前は、User32.dll内のgSharedInfoと呼ばれるエクスポート可能な構造体を介し、UserHandleTableを経由してtagWNDオブジェクトの位置を特定できました。ただしこの方法はWindows 10 バージョン 1703からは使えなくなっています。Microsoftはカーネル アドレス漏えいをなくすための取り組みを続けており、デスクトップ ヒープ内のオブジェクトのカーネル アドレスはUserHandleTableから削除されています。

Window ManagerはUser32.dll内に存在するエクスポートされない関数HMValidateHandleによりハンドルの検証を行います。Windows 10 バージョン 1803より前は、Window Managerが検証対象のハンドルをもつオブジェクトへのカーネルモード ポインターを返していたため、アドレスの漏えいにはこれがよく使われていました。このカーネルのアドレス漏えいは修正されましたが、この方法は後述する2つの脆弱性をレビューするさいにも重要となります。

エクスプロイトの作成者がtagWND構造体の位置に興味を持つ理由は、歴史的に、tagWND.cbExtraを大きな値に変更してSetWindowLong関数を使えば、隣接するtagWND構造体に任意の書き込みを行えるからです。ただしWindows 10 バージョン1703からは、SetWindowLong関数が書き込むバイトがカーネルには書き込まれなくなりました。ただしそれはある特定条件下での話で、それについては後でエクスプロイトを解析するさいに説明します。この修正により任意の書き込みを行う技術は事実上封じられました。

ユーザー オブジェクトは、カーネル内の3種類のメモリー(デスクトップ ヒープ、共有ヒープ、セッション プール)のいずれかに格納されます。本稿の目的に照らして私たちの興味があるのはデスクトップ ヒープです。なぜならここにこれから扱うオブジェクトが格納されているからです。

各デスクトップは自身のデスクトップ ヒープを持っています。Windowsはカーネルに存在するデスクトップ ヒープに管理用構造体を保存するので、ユーザーモード アプリケーションからこれらの構造体にアクセスする方法が必要です。

歴史的にWindowsは、当該のデスクトップ ヒープをユーザーモードにマップしたコピーを作り、そこに関連する構造体へのカーネルモード ポインターを持たせていました。現在では、これらのポインターはユーザーモード ポインターに置き換えられています。つまり、Windowsはカーネルモード ポインター アドレスの漏えいを防ぐため、ユーザーモード内にデスクトップ ヒープの独立したコピーを作成し始めた、ということです。

通常、カーネルの脆弱性をエクスプロイトするにはユーザーモードからのエクスプロイトを可能にする多少の準備が必要です。その1つが、カーネル内で関心のあるオブジェクトがどこに位置しているかを判断することによりKASLR (Kernel Address Space Layout Randomization: カーネル仮想アドレス空間内でのレイアウトのランダム化)を回避する方法です。したがって、デスクトップ ヒープがどこに位置しているかを知り、そのデスクトップ ヒープのどこに関心の対象である特定オブジェクトが位置しているかを見つけられればしめたものです。

実際、Windows 10 バージョン1607以降、Microsoftはエクスプロイトの作成者がカーネル内のデスクトップ ヒープの場所とその関連オブジェクトの場所を特定するのを防ごうと緩和策を追加し始めました。こうした緩和策には、たとえば前述のUserHandleTableからのカーネル アドレスの削除や、各プロセスのスレッド環境ブロック(TEB)に存在するWin32ClientInfo構造体内のデスクトップ ヒープへのカーネル ポインター参照の削除が含まれます。また、HMValidateHandleは、自身に渡されたオブジェクト ハンドルのポインターを(カーネルモードではなく)ユーザーモードで返すようになりました。

MicrosoftによるWin32kカーネルの緩和の歴史についてはMorten Schenk氏が2017年のBlack Hat USAで行った発表を参照してください。

なおここでは低整合性プロセス (low-integrity processes) で動作していることを想定している点に注意してください。整合性レベル(IL)が「中」以上のプロセスであればEnumDeviceDriversNtQuerySystemInformationなどのAPI関数を使うのは非常に容易なので、それらを使って関心の対象となるカーネル ポインターを取得できます。

ユーザーモード コールバック

PoCの背景について論じる第1部で最後に紹介するのは、ユーザーモードのコールバックです。

Windowsサブシステムは主にWindowsカーネルに存在していますが、Windows自体はユーザーモードで動作しているため、Win32kはカーネルからユーザーモードへの呼び出し(call)を頻繁に行わねばなりません。ユーザーモード コールバックは、アプリケーション定義のフック、イベント通知、カーネルとユーザーモードとの間のデータのコピーなどの項目を実装するためのメカニズムを提供するものです。

Win32kはKeUserModeCallbackを呼び出してユーザーモード コールバックを行いますが、そのさいには呼び出したいユーザーモード関数に対応するApiNumberを指定します。このApiNumberUser32.dll (USER32!apfnDispatch)内にある関数テーブルへのインデックスです。このテーブルのアドレスは、各プロセス内でUser32.dllを初期化するさいにPEB(プロセス環境ブロック PEB.KernelCallbackTable)にコピーされます。

エクスプロイトの分析では、ユーザーモード コールバックがKernelCallbackテーブルによってどのようにフックされるのかを示します。またこのテーブルがWinDbg (図21図22)ではどのように見えるのかについても示します。KeUserModeCallbackの関数プロトタイプとそれに関連するパラメーターを下図11に示します。

画像11は、KeUserModeCallback関数とその関連パラメーターのスクリーンショットです。
図11 KeUserModeCallbackの関数プロトタイプ

ユーザーモード コールバックの入力パラメーターはInputBufferを介して渡され、コールバック関数の出力はOutputBuffer内に返されます。システム コールを呼び出すさいは、ntdll!KiSystemServiceまたはntdll!KiFastCallEntryがカーネル スレッド スタックにトラップ フレーム(TRAP_FRAME)を格納し、現在のスレッド コンテキストを保存することで、ユーザーモードに戻るときにレジスターを復元できるようにします。

ユーザーモード コールバックでユーザーモードへの移行を行うために、KeUserModeCallbackはまずスレッド オブジェクトが保持しているトラップ フレームの情報を使い、InputBufferをユーザーモード スタックにコピーします。次に、EIPntdll!KiUserCallbackDispatcherに設定し、新しいトラップ フレームを作成してそのスレッド オブジェクトのTrapFrameポインターを置き換えます。最後に、ntdll!KiServiceExitを呼び出して、ユーザーモード コールバックのディスパッチャーに実行を戻します。

画像12は、KiUserCallbackDispatcherの関数プロトタイプのスクリーンショットです。
図12. KiUserCallbackDispatcherの関数プロトタイプ

ユーザーモード コールバックが完了すると、NtCallbackReturnが呼び出されてカーネルでの実行が再開されます。この関数はコールバックの結果を元のカーネル スタックにコピーし、KERNEL_STACK_CONTROL構造体内に格納されているカーネル スタックと、保存済みのトラップ フレーム(PreviousTrapFrame)を復元します。以前中断した場所(ntdll!KiCallUserMode内)にジャンプする前に、カーネル コールバック スタックは削除されます。

画像13は、NtCallbackReturnの関数プロトタイプのスクリーンショットです。
図13. NtCallbackReturnの関数プロトタイプ

Window Managerは、Win32kの管理構造体上での操作に、排他ロックではなくエグゼクティブ リソース(ERESOURCE)の同期プリミティブを使用します。ERESOURCEプリミティブを使うと任意の数のスレッドが共有リソースにアクセスできるようになります。ただしその場合、各スレッドは対象リソースの読み取りだけを行います。ERESOURCEプリミティブは「single writer, multiple readers (書き込みは1名 読み取りは複数名)プリミティブ」とも呼ばれます。ERESOURCEが初期化されると、スレッドはExAcquireResourceExclusiveLiteを使って排他ロック(書き込み用)を取得したり、ExAcquireResourceSharedLiteを呼び出して共有ロック(読み込み用)を取得したりできるようになります。その後スレッドはExReleaseResourceLiteを呼び出してリソースを解放します。ここで説明する取得APIを使用するには通常のカーネルAPCを無効化する必要があります。それにはロック取得の呼び出し前にKeEnterCriticalRegionを呼び出し、ロック解放の呼び出し後で KeLeaveCriticalRegionを呼び出します。

Win32kがユーザーモード コールバックの呼び出し時にリソースを解放しなかったり、そのユーザーモード コールバックによってアプリケーションがGUIサブシステムをフリーズさせた場合、GUIサブシステムがフリーズしている間はWin32kはほかのタスクを実行できなくなります。そのため、Win32kはユーザーモード コールバックを呼び出すさいは必ずリソースを解放します。以下の図14のコードはそれがどのように行われるのかを示したものです。

画像14は疑似コードのスクリーンショットです。KeUserModeCallbackの呼び出し前に、Windowsがクリティカル リージョンに出入りするようすを示しています。
図14 KeUserModeCallbackの呼び出し前にWindowsがクリティカル リージョンに出入りするようすを示した疑似コード

このやりかたはジレンマを生みます。ユーザーモード コードは、オブジェクト プロパティの変更や配列の再割り当てなどを自由に行えるので、ユーザーモード コールバックから戻ったとき、Win32kは参照されたオブジェクトがまだ信頼できない状態にあることを確認しなければなりません。このようなオブジェクトに対して適切な検証やオブジェクトのロックを行わずに操作すれば、セキュリティ上の脆弱性が発生しかねませんし、実際に発生します。

実際、Tarjei Mandt氏が2011年に発表した論文「Kernel Attacks Through User-Mode Callbacks (ユーザーモード コールバックを通じたカーネル攻撃)」はこの種の脆弱性を複数例確認しており、長年にわたってWin32kのエクスプロイトに関するリサーチの基礎になってきました。その後、MicrosoftはWindowsのユーザーモード コールバック関数を見直し、オブジェクトの検証やロックが適切に行われるようにしたため、このクラスのバグのエクスプロイトは格段に難しくなりました。

Microsoftは「ユーザーモード コールバックへの直接呼び出しにより、変更されたオブジェクトを操作することによるWin32kのバグ」を効果的に排除したものの、「間接的にオブジェクトを変更して(たとえば子ウィンドウへの操作を実行中に親ウィンドウを破壊して)カーネルモードからユーザーモードへの状態を作り出すことにより、同様のバグを使う」方法があることを、Gil Dabah氏が2019年に実証しました。特定された脆弱性はさらに複雑なので、その特定は難しく、数もはるかに少ないと思われます。

Microsoftはユーザーモード コールバックを追跡すべき重要な対象であるとみなしたことから特別な接頭辞が付けられました。ユーザーモード コールバックの関数名の前には、xxxまたはzzzが付けられています。先頭にxxxがつけられた関数は、前述のように、クリティカル リージョンを離れ、ユーザーモード コールバックを呼び出すものです。先頭にzzzがつけられた関数は、非同期コールバックまたは遅延コールバックを呼び出すものです。本稿ではxxxが先頭についたコールバックにのみ注目します。

結論

本連載の第1部では、Win32 APIを使ってウィンドウやメニューなどのGUIオブジェクトを作成する方法を説明しました。また、これらオブジェクトの管理に使われるユーザーモードとカーネルモードのデータ構造を取り上げ、ユーザーモードとカーネルモードの行き来を最適化し、安全にするために、それらが長年をかけてどう変化してきたのかも説明しました。

第2部ではCVE-2022-21882のPoCを通しで見ていき、このコードが何を行っているかを解説します。最終部ではこの脆弱性と、この脆弱性がどのように特権昇格に利用されるのか、CVE-2021-1732の修正プログラムがCVE-2022021882の防止に不十分だった理由を説明します。

本連載で取り上げる脆弱性は、いずれもCortex XDRのAnti-LPE保護モジュールにより検出・ブロックされます。Cortex XDRのAnti-LPEモジュールは、NT AUTHORITY\SYSTEMの特権トークンを現在のプロセスのそれにコピーして特権を昇格させるような、データ オンリー型エクスプロイトなどの技術を監視しています。

追加リソース

Enlarged Image