



デモ
1. はじめに
Windowsは、USBデバイスが接続されたときにUSB記述子を解釈して、適切なカーネルドライバをロードします。デバイスから送信されたデータが正しく検証されない場合、カーネルレベルの脆弱性につながる可能性があります。
物理的なアクセスが可能な環境では、USBを介したローカル権限昇格(LPE)が可能です。
Active Directory環境では、攻撃者はワークステーション上でSYSTEM整合性を持つマシンアカウントの資格情報を盗み出し、ドメイン内での横展開(ラテラルムーブメント)に悪用することができます。さらに、キオスクやPOSデバイスなどの制限されたユーザー環境によって課される一部の制限をバイパスすることができ、同様の条件が公共のPCや会議室のデバイスにも適用されます。
この記事では、Windows USBドライバの脆弱性について説明し、不正な形式のUSBデバイスとユーザーランドプログラムを使用して攻撃者がSYSTEM権限を取得する方法を実証します。脆弱性の発見から根本原因分析、そしてエクスプロイト開発に至るまでの全プロセスを共有します。
私たちはENKI WhiteHatのセキュリティ研究者であり、WindowsのバグハンターであるDongjun KimとJongseong Kimです。以前、過去の投稿 [link] でWindowsの脆弱性に関する研究を共有しました。
2. 背景
2.1 ACEとはどういう意味ですか?
Windowsの脆弱性に関心がある方なら、それらがPatch Tuesdayを通じてMicrosoft Security Response Center(MSRC)から公開されていることをご存知かもしれません。
私たちが知る限りにおいて、2024年以降、Microsoft Security Response CenterはCWE分類を含む脆弱性の根本原因(root cause)情報の公開を開始しています。
CVE情報を確認していると、時折以下のような記述を目にすることがあります。
CVSSメトリクスによると、攻撃元区分(攻撃ベクトル)は「ローカル(AV:L)」です。なぜCVEのタイトルにはこれが「リモートコード実行」であると示されているのでしょうか?
タイトルにある「Remote(リモート)」という言葉は、攻撃者の位置を指しています。このタイプの悪用は、「任意コード実行(Arbitrary Code Execution: ACE)」と呼ばれることもあります。攻撃自体はローカルで実行されます。つまり、脆弱性を悪用するには、攻撃者または被害者がローカルマシンからコードを実行する必要があります。
では、任意コード実行(ACE)とは何でしょうか?ACEとは、被害者のローカルマシン上で攻撃者のコードが実行されることで発生する脆弱性を指します。ちなみに、これと一般的なタイプの脆弱性との違いは何でしょうか?
このケースでは、特別に細工されたデバイスが被害者のローカルマシンに接続されたときに、脆弱性が誘発される可能性があります。
私たちが提示する脆弱性も、このACE脆弱性にあたります。

2.2 WindowsにおけるUSBスタック
Windowsは、広く普及しているUSBデバイスをサポートするために、どのようにUSB仕様を実装しているのでしょうか?Windowsは、広範囲かつ高度なUSBスタック構造を持っています。
まず、USBデバイスをWindows PCに接続すると、PCIバスドライバーがPCIスロットをチェックして新しく接続されたデバイスを検出します。このとき、PCIバスドライバーはUSBホストコントローラーを発見し、それをWindowsのPnPマネージャーに報告して、物理デバイスオブジェクト(PDO)を作成します。
PnPマネージャーはこのデバイスがUSBホストコントローラーであることを認識し、適切なドライバーをロードします。新しく接続されたUSBデバイスがUSB 3.0仕様を使用している場合、機能デバイスオブジェクト(FDO)を作成し、C:\Windows\System32\drivers から usbxhci.sys をロードします。
USBホストコントローラードライバーがロードされた後、コントローラー内にルートハブが作成されます。このルートハブはバスドライバーの役割を果たし、USBエニュメレーション(列挙)フェーズを開始します。USBエニュメレーションフェーズでは、USBデバイスに固有のアドレスを割り当て、USBデバイスから記述子(ディスクリプタ)を受信してデバイス情報を取得します。PnPマネージャーはこの情報を読み取り、どの追加ドライバーをロードすべきかを決定します。
たとえば、キーボードに関連する記述子がPnPマネージャーに渡されると、USBデバイス用の kbdclass.sys ドライバーがロードされます。
これらから、USBスタックが非常に高度な構造を持っていることがわかります。

https://learn.microsoft.com/en-us/windows-hardware/drivers/gettingstarted/driver-stacks
2.3 USBエニュメレーション
ここで、少し立ち止まってみましょう。USBエニュメレーション(列挙)フェーズとは何でしょうか?USBエニュメレーションフェーズとは、先ほど少し触れたように、デバイスがポートに新しく接続された際に、そのデバイスが何であるかを特定し、固有のアドレスを割り当て、適切なドライバーをロードしてホストとUSBデバイス間の通信を可能にする初期化プロセスのことです。
USBエニュメレーションの手順を簡単に説明すると、以下のようになります。
USBデバイスが新しく接続されると、USBハブが電圧の変化を検知し、ホストに通知します。
ホストはバスにリセット信号を送信し、USBデバイスはアドレス0を使用して通信を開始します。
ホストはアドレス0でデバイス記述子を要求し、USBデバイスの最大パケットサイズを確認します。
ホストはUSBデバイスに1から127の間の固有アドレスを割り当て、デバイスは割り当てられたアドレスを使用して応答を開始します。
ホストは割り当てられたアドレスを介して再度記述子を要求し、製造元ID、消費電力、インターフェースの数、およびエンドポイントのタイプを確認します。
PnPマネージャーは適切なドライバーをロードし、デバイスにコマンドを送信してUSBデバイスの状態を「構成済み(Configured)」に変更します。
このUSBエニュメレーションフェーズは、1つのドライバー内だけで実行されるのではなく、複数のドライバーにまたがって実行されます。
セキュリティの観点から見ると、USBエニュメレーションフェーズは非常に重要なプロセスです。このフェーズに脆弱性が存在し、攻撃者によって悪用に成功した場合、特別に細工されたUSBデバイスを接続するだけで、PCが完全に制御されてしまう可能性があります。
2.4 プラグアンドプレイ (PnP)
背景(Background)のセクションを終える前に、プラグアンドプレイ(PnP)とは何かについて簡単に説明します。PnPは、システムハードウェア構成の変化を自動的に検出するWindowsアーキテクチャの一部です。PnPは、ユーザーモード/カーネルPnPマネージャー、I/Oマネージャー、バスドライバー、ファンクションドライバーなどと連携することで、システムを再起動することなく、新しいデバイスをすぐに使用できるようにする便利な技術です。

https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/pnp-components
3. CVE-2026-32223
3.1. 脆弱性の根本原因
USBデバイスに関する背景知識を説明しましたので、ここからは私たちが発見した脆弱性、CVE-2026-32223について説明します。
CVE-2026-32223は、WindowsのUSB印刷ドライバー(usbprint.sys)で発生するヒープベースのバッファオーバーフローの脆弱性です。この脆弱性は、デバイス構成の不適切な検証に起因しており、usbprint.sysが不正な形式のデバイス構成を使用するときに引き起こされます。
usbprint.sysは、USBPRINT_ProcessIOCTL関数でIRP_MJ_DEVICE_CONTROLリクエストを処理します。脆弱性は、IOCTL 0x220064によって呼び出されるMake1284IdStringFromUsbStrings関数で発生します。この関数が実行されるためには、v8[1064]が0であり、かつv8[1065]が0以外である必要があります。
v8はDeviceObject内のDeviceExtensionフィールドです。WindowsデバイスドライバーはDeviceExtensionフィールドを定義して、DeviceObject構造体内にオプションで追加データを格納することができます。DeviceExtensionは、通常DeviceObject作成ルーチンの近くで作成されます。
同様に、usbprint.sysにおいて、DeviceExtension (v8)はUSBPRINT_StartDevice関数内で初期化されます。この関数は、PnPマネージャーからのリクエストによってデバイスが実際に起動されるポイントです。この関数では、[1]においてUSBPRINT_ConfigureDevice関数が呼び出されます。
USBPRINT_ConfigureDevice関数内では、[1]においてUSBPRINT_GETUSBConfigs関数が呼び出され、USB記述子の1つである構成記述子(Configuration Descriptor)をPに格納します。記述子の取得に成功した場合、Pを第2引数として[2]でUSBPRINT_CheckConfigs関数を呼び出します。
USBPRINT_CheckConfigs関数は、[1]および[2]においてUSBD_ParseConfigurationDescriptorEx関数を呼び出し、構成記述子の中から一致するInterfaceClassおよびInterfaceProtocolを持つ記述子配列を検索します。その結果は、それぞれDeviceExtension[1064]とDeviceExtension[1065]に格納されます。
これは、IOCTL 0x220064を呼び出して実行させるために、構成記述子を適切に設定する必要があることを意味します。
USBPRINT_ProcessIOCTL関数に戻り、Make1284IdStringFromUsbStrings関数の引数を確認すると、第1引数はDeviceObject、第2引数はユーザーが提供する出力バッファ、第3引数は出力バッファの長さです。
この脆弱性はMake1284IdStringFromUsbStrings関数内で発生します。まず、[1]と[2]でUSBPRINT_GetUsbStringDescriptor関数を呼び出してデバイスのMFGとMDLを取得します。次に、それぞれ[3]と[4]でMFGとMDLの長さを計算し、2つの長さの合計に11を加えた値をv13に格納します。
v13が0x20A未満の場合、[7]において出力バッファのサイズの非ページプール(NonPaged Pool)メモリを割り当てます。プールメモリの割り当てに成功すると、[8]でRtlStringCbPrintfAを使用してプール内にフォーマットされた文字列をコピーします。文字列のコピーに成功した場合、[9]においてプールメモリの内容を出力バッファにプログラム経由でコピーします。
このプロセスにおいて、出力バッファのサイズが割り当てられたプールサイズよりも小さい場合、ヒープベースのバッファオーバーフローが発生する可能性があります。
3.2. 脆弱性のトリガー
この脆弱性をトリガーするには、不正な記述子を持つUSBデバイスと、IOCTL 0x220064を呼び出すユーザーランドプログラムが必要です。
3.2.1 USBデバイスのエミュレーション
不正なUSBデバイスを構築するために、私たちはOdroid C4ボードとArmbianを使用しました。Odroid C4はUSB OTGポートをサポートしており、ボード自体をUSBデバイスとして動作させることができます。Armbian上でLinux Raw Gadgetを使用することで、バイトレベルで自由にUSB記述子を構築できます。
Raw GadgetはUSB Gadgetサブシステムの低レベルインターフェースであり、ユーザー空間が/dev/raw-gadgetデバイスを介してUSBエニュメレーション応答を直接制御できるようにします。GadgetFSやConfigFSに基づく従来のアプローチとは異なり、ホストからの各コントロール転送リクエストを直接処理できるため、異常な記述子の組み合わせを柔軟に構築できます。
3.2.2 記述子の構造
先ほど分析したUSBPRINT_CheckConfigs関数の条件を満たすために、インターフェース記述子のbInterfaceClassを0x07(プリンター)に設定し、bInterfaceProtocolを0x04(IPP over USB)に設定します。この場合、bInterfaceProtocol = 0x02(双方向)を持つインターフェースは含めてはなりません。
この設定により、USBPRINT_CheckConfigs内でDeviceExtension[1065] = 1かつDeviceExtension[1064] = 0になり、Make1284IdStringFromUsbStrings関数に到達できるようになります。
3.2.3 不正な形式の文字列記述子の設定
オーバーフローのサイズは、MFGとMDL文字列記述子の大きさによって決まります。デバイス記述子内でiManufacturerおよびiProductによって参照される文字列記述子は、Raw GadgetのEP0ハンドラーで直接処理されます。
ASCII文字列をUTF-16LEに変換して、USB文字列記述子を構築します。Make1284IdStringFromUsbStrings関数では、このワイド文字列の文字数がカウントされてv13 = len(MFG) + len(MDL) + 11が計算されるため、MFG文字列の長さを長くすることで、オーバーフローのサイズを制御できます。
3.2.4 EP0コントロール転送の処理
Raw Gadgetの重要な点は、EP0ループ内でホストからのすべてのコントロール転送リクエストを直接処理することです。ホストが文字列記述子を要求すると、上記で構成された不正な形式のMFG/MDL文字列を応答として返します。
3.2.5 脆弱性トリガーのシナリオ

4. 脆弱性の悪用 (エクスプロイト)
4.1 制限事項の分析
エクスプロイトを開発する前に、まずこの脆弱性の制限について説明します。
プールタイプ: [7] の ExAllocatePool2 呼び出しにおいて、フラグ値は 0x40 (POOL_FLAG_NON_PAGED) です。オーバーフローは NonPagedPoolNx プールタイプで発生します。
データの制限: [8] の RtlStringCbPrintfA によって作成される、NULL 終端の ASCII 文字列がコピー対象です。ペイロードの途中に NULL バイト (0x00) を挿入することはできず、NULL 終端文字は末尾にのみ存在します。ただし、オーバーフローデータは MDL 文字列記述子の最後の部分に対応するため、USB デバイスは任意データを配置できます。実際の値はエクスプロイト戦略に依存します。
オーバーフローサイズ: この脆弱性において、オーバーフローサイズを決定する変数は 2 つあります。
v13 =
MFG_len + MDL_len + 11: この値は、USB 文字列記述子における MFG と MDL の長さによって決定されます。それぞれ最大 253 文字まで可能であるため、v13 の最大値は253 + 253 + 11 = 517 = 0x205となり、条件v13 ≤ 0x209を満たす必要があります。OutputBufferLength: ユーザーランドプロセスが
DeviceIoControlを呼び出す際に決定されます。IopXxxControlFile関数内では、ExAllocatePool2(0x69, max(InputBufferLength, OutputBufferLength), 'IoSB')を使用して SystemBuffer が割り当てられるため、この値が割り当てサイズを決定します。IOCTL 0x220064 は入力バッファを使用しないため、サイズは OutputBufferLength と等しくなります。USBPRINT_ProcessIOCTL関数では、長さが 0 の場合はエラーが返されるため、長さは少なくとも 1 以上でなければなりません。
実際のコピーは、[9] の memmove(a2 + 2, v16, v13) によって実行されます。USB 文字列記述子の bLength フィールドは uint8 であるため、その最大値は 0xFF (255) です。WCHAR データの長さは (bLength - 2) / 2 ですが、bLength が奇数(例: 0xFF)の場合、残りの 1 バイトはゼロ初期化されたバッファの次のバイトと結合され、非ゼロの WCHAR としてカウントされる可能性があります。したがって、文字列ごとに最大 127 文字の WCHAR が可能であり、v13 の最大値は 127 + 127 + 11 = 265 = 0x109 になります。その結果、memmove は最大 0x109 バイト までコピーします。オーバーフローのサイズは (v13 + 2) - OutputBufferLength として計算でき、v13 と OutputBufferLength の両方が攻撃者によって制御可能であるため、オーバーフローのサイズは自由に制御できます。
CACHE_ALIGNED 割当て: もう 1 点考慮すべき点があります。IOCTL 0x220064 は METHOD_BUFFERED を使用し、DeviceIoControl のパスにおいて、I/O マネージャは CACHE_ALIGNED フラグを設定して SystemBuffer を割り当てます。
CACHE_ALIGNED 割当てでは、返されるポインタはキャッシュライン (0x40) の境界にアラインされます。アロケータはプールヘッダのために要求サイズを 0x10 切り上げ、ブロック内の 0x40 アラインされた位置にユーザーデータを配置できるように、さらに 0x40 のスラックを追加します。POOL_HEADER とアラインされたポインタの間のスペースが、アライメントパディング (meta) です。切り上げ自体は確実に行われますが、結果として生じる meta は、選択されたブロックがその LFH サブセグメント内のどこから開始されるかに依存します。LFH サブセグメントはページアラインされており、各ブロックは固定の block_size ストライドで配置されるため、block_size が 0x40 の倍数ではないバケットの場合、ブロックの開始位置は 0x40 で法をとると {0x00, 0x10, 0x20, 0x30} を巡回します。そして、LFH が異なるフリースキップを選択するにつれて、meta も同じ 4 つの値を巡回することになります。したがって、正確なオーバーフローサイズは meta に依存します。
要約すると、v13 と OutputBufferLength の両方が攻撃者によって制御可能であるため、オーバーフローのサイズを望ましい範囲内に制御することができます。唯一の注意点として、block_size が 0x40 の倍数ではないバケットの場合、meta は LFH スロット抽選によってランダム化されたかのように動作します。これを完全に回避する綺麗な方法は、割り当てが block_size が 0x40 の倍数のバケットに収まるように OutputBufferLength を選択することです。そのようなバケットでは、すべてのスロットがすでに 0x40 アラインされているため、meta はコンパイル時の定数に収束します。次のセクションでは、その選択プロセスについて説明します。
4.2 プール風水 (Pool Feng Shui)
このオーバーフロー脆弱性を安定してエクスプロイトするためには、オーバーフローが発生するチャンクが攻撃者の制御するオブジェクトの隣に配置される必要があるため、Named Pipe のプールスプレーを使用してヒープのレイアウトを操作します。
4.2.1 オーバーフローサイズの選択
セクション 4.1 で要約したように、v13(コピーするデータのサイズ)と OutputBufferLength(宛先バッファのサイズ)は攻撃者が選択できます。ここでは以下の値を使用しました。
MFG 110, MDL 75 →
v13 = 110 + 75 + 11 = 196 = 0xC4OutputBufferLength = 0xB0
プールヘッダ (0x10) と CACHE_ALIGNED オーバーヘッド (0x40) を OutputBufferLength に追加すると、実質的な割り当てサイズは正確に 0x100 になり、これは LFH の 0x100 バケットに配置されます。0x100 mod 0x40 == 0 であるため、選択されたサブセグメント内のすべてのブロック開始位置はすでに 0x40 アラインされており、すべての試行において meta は 0x30 に固定されます。オーバーフローは、毎回実行するたびに (v13 + 2) - (0x100 - 0x10 - 0x30) = 0xC6 - 0xC0 = 6 バイトとなり、これは隣接する POOL_HEADER を書き換えるのに過不足のない正確なサイズです。
一般的な Named Pipe エクスプロイトでは、オーバーフローによって隣接する DATA_QUEUE_ENTRY (DQE) フィールドを直接改ざんすることでプリミティブを取得します。v13 と OutputBufferLength を調整することでオーバーフローサイズを任意に増やすことができるため、DQE に到達させることは可能です。しかし問題は、セクション 4.1 で述べた NULL バイトの制限です。オーバーフローデータは RtlStringCbPrintfA によって ASCII 出力として生成されるため、途中に NULL バイト (0x00) を挿入することができません。それに対し、DQE 内のポインタフィールド (Flink, Irp) を意味のある形で操作するには NULL バイトが必要です。
一方で、POOL_HEADER は 1 バイトのフィールド (PreviousSize, PoolIndex, BlockSize, PoolType) で構成されているため、NULL バイトを使用せずに精密な制御が可能です。したがって、我々は POOL_HEADER を改ざんして ExFreePool の動作を操作することを選択しました。具体的な変更内容については、次のセクションで説明します。
4.2.2 LFH バケットのアライメント
IOCTL の SystemBuffer (IoSB) は、以下のように割り当てられます。
Named Pipe に対して WriteFile が呼び出されると、npfs は DQE を割り当てます。
以下のようにして Named Pipe のスプレーを実行できます。WriteFile を通じて 0xC0 バイトが書き込まれると、カーネル内でサイズ 0xF0 の NpFr 割り当てが発生し(NpAddDataQueueEntry が 0x30 バイトの DQE ヘッダを先頭に追加するため)、これは IoSB と同じ LFH 0x100 バケットに配置されます。
4.2.3 CPU アフィニティ固定によるスプレーの安定化
LFH は CPU ごとのアフィニティスロット構造を使用しています。RtlpHpLfhBucketActivate 関数を分析すると、LFH バケットが有効化される際に、各 CPU 用のオーナースロットが作成されることが確認できます。
[1] において、RtlpHpLfhPerfFlags のビット 5 をチェックしています。このビットがセットされている場合、CPU ごとにオーナーが作成されますが、セットされていない場合は単一のオーナーのみが作成されます。[2] では、各オーナーが初期化されます。各オーナーは独立したサブセグメントチェーンを管理するため、異なる CPU 上で同じバケットに割り当てが行われた場合、それらは異なるサブセグメントに配置されます。
この問題に対処するため、SetThreadAffinityMask を使用してスレードを特定の単一 CPU に固定(ピン留め)します。これにより、すべてのスプレー割り当てが同じオーナーのサブセグメントチェーンに集中し、隣接して配置される確率が高くなります。
4.2.4 スプレーのレイアウト
スプレー成功後の理想的なプールレイアウトは以下のようになります。IoSB の直後にあるブロックが、NpFr パイプでなければなりません。
オーバーフローが発生すると、IoSB の直後にあるブロック (NpFr) の POOL_HEADER のうち 6 バイトが、MDL 文字列記述子の最後の数バイトで上書きされます。以下は、オーバーフロー後の変更された POOL_HEADER を示しています。
各フィールドの役割は以下の通りです。
PreviousSize = 0x0C: この値は、
ExFreePoolWithTagのキャッシュアラインされた後退ステップで使用され、0x0C × 0x10 = 0xC0バイトの後退運動を引き起こします。PoolType = 0x06: ビット 2 (CacheAligned) がセットされており、これにより後退ステップがトリガーされます。この際、ビット 3 (PoolQuota) はクリアされている必要があります。これについてはセクション 4.3.1 で詳しく説明します。
これら 2 つの値は、USB デバイスからの MDL 文字列記述子の末尾によって制御されます。
4.3 ゴーストチャンク (Ghost Chunk)
セクション 4.2 では、オーバーフローによって隣接するブロックの POOL_HEADER を改ざんしました。ここでは、この改ざんされた POOL_HEADER を使用して「ゴーストチャンク」を作成する方法について説明します。このゴーストチャンク手法は、Synacktiv の論文 "Scoop the Windows 10 pool!" で導入された Aligned Chunk Confusion 攻撃に基づいています。
4.3.1 CacheAligned の後退ステップ
Windows のセグメントヒープでは、CACHE_ALIGNED フラグ付きで割り当てられたチャンクは、解放時に特別な処理が行われます。ExFreePoolWithTag 関数を分析すると、PoolType のビット 2 (CacheAligned) がセットされている場合、PreviousSize を使用して元の割り当てベースが計算されることがわかります。
[1] において、PoolType のビット 3 (PoolQuota) をチェックしています。このビットがセットされている場合、[2] で ExpPoolQuotaCookie を用いて ProcessBilled ポインタがデコードされ、[3] でそれが有効な EPROCESS であるか検証されます。改ざんされた POOL_HEADER 内の ProcessBilled フィールドは有効な値を含んでいないため、ビット 3 がセットされていると BSOD (ブルースクリーン) が発生します。したがって、PoolType は 0x06 (ビット 2 のみセット) でなければならず、0x0E (ビット 2 + ビット 3) は使用してはなりません。
[1] を通過した後、[4] で PoolType のビット 2 (CacheAligned) をチェックします。改ざんされた PoolType (0x06) ではこのビットがセットされているため、[5] で PreviousSize × 0x10 の後退ステップが実行されます。改ざんされた PreviousSize = 0x0C により、後方に 0x0C × 0x10 = 0xC0 バイト移動します。[6] では、新たな位置にある POOL_HEADER から BlockSize を読み取り、解放サイズを計算します。
この後退ステップで「着地」する位置が、このエクスプロイトの極めて重要なポイントです。オーバーフロー発生時のブロックレイアウトを再確認してみましょう。
IOCTL が返るとカーネルは IoSB を解放し、Block N+1 のスロットは空になります。このスロットに再スプレー (respray) パイプを配置することで、パイプデータの最初のバイト (data[0x00]) に偽の POOL_HEADER を挿入できます。そして Block N+2 が解放される際、後退ステップは N+2_hdr (slot + 0x200) - 0xC0 = slot + 0x140 = N+1 block + 0x40 に着地します。これは再スプレーパイプの偽の POOL_HEADER がある位置に対応します。
4.3.2 ダイナミックルックアサイドによるゴースト解放
後退ステップの後、[6] で偽の POOL_HEADER から BlockSize (0x21) を読み取り、解放サイズを 0x21 × 0x10 = 0x210 と計算します。これにより、0x210 バイトの「ゴーストチャンク」が解放されることになります。
このチャンクがどこへ解放されるかが重要です。ExFreePoolWithTag の最後の部分に、Dynamic Lookaside (ダイナミックルックアサイド) の分岐があります。
[1] において、_SEGMENT_HEAP の UserContext フィールドを読み取ります。これは Dynamic Lookaside 構造体を指しています。[2] では、解放サイズが [0x201, 0xF80] の範囲内に収まっているかをチェックします。0x210 - 0x201 = 0x0F ≤ 0xD7F であるため、この条件は満たされます。[3] で、Dynamic Lookaside が存在し、利用可能な深さ (depth) があれば、[4] において SList にプッシュされます。
ルックアサイド SList にプッシュされると、それ以降に同じサイズ (0x210) の割り当て要求があった場合、ポップされて全く同じアドレスが返されます。もしこのチャンクが代わりに VS FreeChunkTree に解放されてしまった場合、隣接するチャンクとマージされてしまい、ゴースト再請求に失敗する可能性があります。そのため、ルックアサイドのルートに入ることは必須です。
Dynamic Lookaside を有効化するためには、Balance Set Manager (BSM) が該当バケットに対する頻繁な割り当てと解放を検出する必要があります。このメカニズムは、RtlpDynamicLookasideRebalance 関数を分析することで確認できます。
[1] では各バケットの割り当て/解放頻度を計算し、[2] でそれを頻度順にソートします。[3] で頻度が少なくとも 25 (0x19) 回以上のバケットのみが選択され、[4] で EnabledBucketBitmap にセットされます。BSM はこの再バランス処理を約 1 秒に 1 回実行します。
したがって、エクスプロイトの開始時には、あらかじめ該当バケットを有効化するために、サイズ 0x210 の割り当て・解放操作を十分に行う必要があります。我々は、vp777 氏の CVE-2020-17087 エクスプロイトで使用されている EnableLookaside パターンに従い、0x1000 回のパイプ割り当て・解放処理を 2 ラウンド行い、その後 BSM の再バランスを待ちました。
4.3.3 ゴースト再請求と重複 (Overlap)
ゴーストチャンク (0x210) がルックアサイド SList にプッシュされた後、同じサイズの Named Pipe を作成すると、正確に同じアドレスでそれがポップされます。この挙動は、ExAllocateHeapPool 関数のルックアサイドポップパスで確認できます。
[1] で SList が空でないかチェックし、[2] でエントリーをポップします。ゴーストパイプの WritePipe(0x1D0) を呼び出すと、NpAddDataQueueEntry → ExAllocatePool2(0x200) → サイズ 0x210 のブロック割り当てが発生し、[2] で以前にプッシュされたゴーストチャンクのアドレスが返されます。
この時点で、ゴーストパイプの NpFr ブロックは再スプレーパイプのデータ領域と物理的に重複しています。
再スプレーパイプに対して PeekNamedPipe が呼び出されると、カーネルは再スプレーの DQE が指すデータ領域 (+0x40) からデータを読み取ります。この領域はゴーストパイプの NpFr ブロックと重複しているため、カーネルによってゴーストパイプの DQE に書き込まれた Flink 値をユーザーモードから読み取ることができます。この Flink 値はゴーストパイプの CCB にある OutQueue リストヘッドのカーネルアドレスであり、任意のアドレス読み書き(Arbitrary Read/Write)の起点になります。
4.4 任意の読み取り (Arbitrary Read)
カーネルポインタをリークさせた後、ゴーストパイプの DQE を操作することで任意読み取りプリミティブを構築します。
4.4.1 ゴースト DQE の書き換え
再スプレーパイプが ReadFile を介して解放されると、対応する LFH スロットが解放されます。このスロットに新たなパイプ(書き換えパイプ)を割り当て、ゴースト DQE を望ましい値で上書きします。
4.4.2 偽の IRP を用いた任意アドレスの読み取り
偽の IRP は、VirtualAlloc で割り当てられたユーザー空間のページ上に構築されます。読み取りたいカーネルアドレスを AssociatedIrp.SystemBuffer (+0x18) に設定します。
ゴーストパイプに対して PeekNamedPipe が呼び出されると、npfs!NpReadDataQueue は EntryType=1 のパスに入り、IRP の SystemBuffer からデータを読み取ってユーザーバッファへコピーします。
PeekNamedPipe は非破壊的(DQE を削除しない)であるため、SystemBuffer のみを更新して繰り返し任意読み取りを実行できます。
4.5 カーネル構造体の探索 (Walk)
任意読み取りプリミティブは取得できましたが、権限昇格には ntoskrnl.exe のベースアドレスや PsInitialSystemProcess などのカーネルアドレスが必要です。既知のカーネルアドレスは、セクション 4.3 でリークした g_queue_addr (ゴーストパイプの CCB 内にある OutQueue リストヘッド) のみなので、この値から始めてカーネル構造体を探索します。
4.5.1 CCB → npfs.sys DRIVER_OBJECT
g_queue_addr は、ゴーストパイプの NP_CCB 構造体の内部にある OutQueue (+0xA8) リストヘッドを指しています。ここから、CCB アドレスを次のように導出できます: CCB_addr = g_queue_addr - 0xA8。
Named Pipe の CCB は、サーバー側の FILE_OBJECT へのポインタを保持しています。この FILE_OBJECT は対応する DEVICE_OBJECT を指し、それはさらに管理対象の DRIVER_OBJECT を指しています。ゴーストパイプは npfs.sys が管理する Named Pipe であるため、このチェーンをたどることで npfs.sys の DRIVER_OBJECT に到達します。
4.5.2 DRIVER_OBJECT → ntoskrnl.exe ベースアドレス
ロードされたすべてのカーネルモジュールは、KLDR_DATA_TABLE_ENTRY 構造体の双方向連結リストを通じて管理されています。DRIVER_OBJECT の DriverSection (+0x28) フィールドはドライバの KLDR_DATA_TABLE_ENTRY を指しているため、npfs.sys のエントリーから出発して InLoadOrderLinks リストを走査することで、システム内のロードされたすべてのモジュールを列挙できます。
4.6 任意の書き込み (Arbitrary Write)
任意読み取りプリミティブを用いてカーネル構造体を自由に探索できるようになったので、次は権限昇格を達成するために任意書き込みプリミティブを構築します。
4.6.1 メカニズム
Named Pipe に対して ReadFile が呼び出されると、カーネルは DQE に関連付けられた IRP を完了処理します。IRP に BUFFERED_IO がセットされている場合、IopProcessBufferedIoCompletion は memmove(UserBuffer, SystemBuffer, Information) を実行します。これを悪用することで、任意のカーネルアドレスにデータを書き込むことができます。
4.6.2 偽の IRP の構造
4.6.3 IRP 完了のルート
IRP が完了処理される際、IopProcessBufferedIoCompletion は Flags をチェックして memmove を実行します。これが任意書き込みの核心です。その直後、IopCompleteRequest は IRP を解放しようとします。しかし、偽造された IRP はユーザー空間に存在するため、IoFreeIrp が呼び出されるとカーネルはユーザーメモリをプールに返そうとしてクラッシュを引き起こします。そのため、Flags に 0x8000 を追加し、AllocationSize を 0 に設定することで IoFreeIrp の呼び出しをバイパスします。
4.6.4 IRP 完了処理の存続
偽の IRP はユーザー空間に存在するため、IRP 完了パスにおいてカーネルがアクセスするすべてのフィールドが有効でなければなりません。以下の 3 点を適切に処理する必要があります。
ETHREAD: IRP の Tail.Overlay.Thread (+0x98) フィールドには、有効な ETHREAD ポインタが含まれている必要があります。これは、IopCompleteRequest がこのフィールドを読み取ってスレッドの IRP リストを操作するためです。セクション 4.4 で得た任意読み取りプリミティブを使用して、PsInitialSystemProcess から ActiveProcessLinks をたどり、現在の PID の EPROCESS を探します。次に、その EPROCESS の ThreadListHead を走査して現在の TID の ETHREAD を見つけ、このフィールドに格納します。
ThreadListEntry の自己参照(セルフリンク): IopDequeueIrpFromThread は、スレッドの IRP リストからその IRP を削除します。この操作は、双方向に連結されたリスト上で RemoveEntryList を行うことに相当します。偽造された IRP は実際にはスレッドリストにリンクされていないため、Flink/Blink が不正な場所を指しているとクラッシュの原因となります。ThreadListEntry (+0x20) の Flink と Blink の両方に自身のアドレス(&IRP + 0x20)を設定することで、RemoveEntryList の検証条件(Flink->Blink == &entry && Blink->Flink == &entry)が満たされ、リスト構造の整合性を維持したまま動作させることができます。
NpRemoveDataQueueEntry: ゴーストパイプに対して ReadFile が呼び出されると、NpReadDataQueue が DQE を処理し、その後 NpRemoveDataQueueEntry を呼び出します。この関数は、InterlockedExchange64 を用いて DQE に紐づく IRP の CancelRoutine (+0x68) を 0 にクリアし、SecurityContext が存在すればそれを解放します。その後、ExFreePoolWithTag で DQE 自体を解放しますが、IRP 自体は解放しません。IofCompleteRequest は、呼び出し元である NpReadDataQueue によって呼び出されます。したがって、InterlockedExchange64 が正しく動作するように、偽造 IRP の CancelRoutine (+0x68) に非ゼロの値を設定しておく必要があります。
4.6.5 権限昇格 (Privilege Escalation)
任意読み取りプリミティブを一度構築すると、非常に高い信頼性で使用できます。いくつかアプローチはありますが、ここでは SeDebugPrivilege を変更する DevCore ブログで解説されている手法を採用します。
我々は、任意書き込みプリミティブを用いてカーネルのグローバル変数 nt!SeDebugPrivilege を書き換えることで権限昇格を行います。
OpenProcess(PROCESS_ALL_ACCESS) が呼び出される際、カーネルの PsOpenProcess は呼び出し元のトークンに SeDebugPrivilege が含まれているかどうかを検証します。このチェックに使用される LUID は、グローバル変数 nt!SeDebugPrivilege に格納されています。
[1] において、nt!SeDebugPrivilege (LUID, 8バイト) が読み取られ、[2] において、SepPrivilegeCheck はこの LUID を呼び出し元トークン内の特権(特権リスト)と比較します。[3] で一致が検出されると、要求されたすべてのアクセス権限が許可されます。
SeDebugPrivilege のデフォルトの LUID は {LowPart=0x14, HighPart=0x0} です。通常、Medium 整合性レベルのプロセス(一般的なユーザー権限のプロセス)のトークンはこの特権を含んでおらず、そのため SepPrivilegeCheck は FALSE を返します。
しかし、この LowPart を、Medium 整合性プロセスのトークンにあらかじめ存在する他の特権の LUID に置き換えた場合、状況が変わります。たとえば、SeChangeNotifyPrivilege (LUID=0x17) は、すべての標準的なプロセストークンに含まれています。nt!SeDebugPrivilege の 1 バイトのみを変更して(LowPart を 0x14 から 0x17 に書き換えて)おくと、[2] で SepPrivilegeCheck は SeChangeNotifyPrivilege を見つけ出し、TRUE を返します。結果として [3] が PROCESS_ALL_ACCESS を付与し、SYSTEM 権限のプロセス(例: winlogon.exe)へのフルアクセスが可能になります。
最後に、OpenProcess(winlogon.exe, PROCESS_ALL_ACCESS) を呼び出した後、CreateRemoteThread を実行することで、SYSTEM レベルの cmd.exe を起動することができます。
4.7 まとめ
これまでに説明した手順に基づき、USB デバイスの接続から SYSTEM 権限昇格に至るまでのエクスプロイトフローの全容をまとめます。
1. USB デバイスの切断とドライバのロード
特別に細工された USB プリンタデバイスを標的システムに接続します。PnP マネージャにより usbprint.sys がロードされ、InterfaceProtocol = 0x04 が指定された構成記述子によって DeviceExtension[1065] = 1 および DeviceExtension[1064] = 0 がセットされます。
2. ダイナミックルックアサイドの事前有効化
サイズ 0x210 の Named Pipe 割り当て・解放操作を 0x1000 回、合計 2 ラウンド実行し、Balance Set Manager による再バランス(Rebalance)を待ちます。これにより、カーネルの Dynamic Lookaside 内で 0x210 バケットが有効化され、以降に発生するゴーストチャンクを SList から確実に再利用できるようになります。
3. LFH スプレー
0x100 LFH バケット内に多数の Named Pipe を作成(スプレー)します。各パイプのデータ領域の先端に、偽の POOL_HEADER (BlockSize = 0x21, PoolType = 0x0A) を配置します。すべての割り当てが同じ LFH オーナーのサブセグメントに集中するように、CPU アフィニティを固定します。
4. 穴あけ (Hole Creation) と IOCTL トリガー
スプレー配列の末尾近くにあるパイプを解放して LFH 内に空きスロットを作成し、直ちに IOCTL 0x220064 を呼び出します。Make1284IdStringFromUsbStrings が IoSB を割り当て、その空きスロットに配置します。meta = 0x30 の時、6 バイトのオーバーフローが発生し、隣接する NpFr ブロックの POOL_HEADER が PreviousSize = 0x0D、PoolType = 0x06 という値で上書きされます。IOCTL が返ると、カーネルは IoSB を解放します。
5. 再スプレー (Respray)
解放された IoSB のスロットに、新たな Named Pipe (respray) を配置します。偽の POOL_HEADER (BlockSize = 0x21) は再スプレーパイプのデータ領域の最初のバイトに位置するように調整されており、そこが後退ステップの着地点となります。
6. スプレーの解放とゴーストチャンクの作成
スプレーされたすべてのパイプを解放します。POOL_HEADER が改ざんされたブロックが解放されると、ExFreePoolWithTag 内で CacheAligned の後退処理がトリガーされ、再スプレーパイプの偽の POOL_HEADER へと着地します。偽の BlockSize = 0x21 の設定によって、0x210 バイトのゴーストチャンクが Dynamic Lookaside の SList にプッシュされます。
7. ゴーストチャンクの再獲得と重複の検出
0x1D0 バイトのデータを格納する Named Pipe を作成すると 0x210 のブロックが必要となるため、ゴーストチャンクが Dynamic Lookaside からポップされて返されます。このゴーストパイプの DQE は、再スプレーパイプのデータ領域と物理的に重複しています。再スプレーパイプに対して PeekNamedPipe を呼び出すと、ゴースト DQE の Flink 値(カーネルアドレス)がユーザーモードにリークします。
8. 任意読み取り構造の構築
再スプレーパイプを読み取って対応する LFH スロットを解放し、書き換えパイプを用いてゴースト DQE を上書きします。EntryType = 1 に設定し、Irp がユーザー空間に用意した偽の IRP を指すようにします。これにより、ゴーストパイプに対して PeekNamedPipe を呼び出すたびに、偽 IRP の SystemBuffer に指定された任意のカーネルアドレスからデータを読み出すことができます。
9. ntoskrnl.exe のベースアドレス特定
リークした Flink 値を開始点としてカーネル構造体をたどります。DRIVER_OBJECT の DriverSection から KLDR_DATA_TABLE_ENTRY を取得し、InLoadOrderLinks リストを走査してロードされているモジュールを列挙することで、ntoskrnl.exe の DllBase を特定します。
10. 任意書き込みと権限昇格
ゴースト DQE に偽造した IRP を連結します。IRP の Flags に BUFFERED_IO | INPUT_OPERATION | 0x8000 を設定し、書き込み元のカーネルアドレスを SystemBuffer に、書き込み対象のアドレスを UserBuffer に格納します。ゴーストパイプに対して ReadFile を呼び出すと、IopProcessBufferedIoCompletion が memmove(UserBuffer, SystemBuffer, size) を実行し、任意のカーネルメモリへの書き込みが完了します。これを利用して、nt!SeDebugPrivilegeの LUID の 1 バイトを、Medium 整合性プロセスですでに保持されている特権の LUID (0x17, SeChangeNotifyPrivilege) に書き換えます。
11. SYSTEM シェルの取得
SeDebugPrivilegeを変更した後、OpenProcess(winlogon.exe, PROCESS_ALL_ACCESS) の呼び出しが成功を収めます。CreateRemoteThread を用いて WinExec("cmd.exe") を呼び出すシェルコードを実行(インジェクション)することで、SYSTEM 権限の cmd.exe が起動します。
5. パッチ
この脆弱性は、コピー操作を行う前に境界チェックを追加することで修正されました。コードは、バッファサイズを検証せずにデータをコピーする代わりに、データを書き込む前に出力バッファが十分に大きいことを確認するようになりました。
6. 結果
私たちは、usbprint.sysにおいてヒープベースのバッファオーバーフローを特定し、不正なUSBデバイスからSYSTEM権限への昇格に至る完全なエクスプロイトチェーンを実証しました。
制御されたUSB記述子、IOCTL操作、および高度なヒープ操作技術を組み合わせることで、最新の緩和策が存在するにもかかわらず、信頼性の高いエクスプロイトを実現しました。
私たちの結果は、入力値検証が不十分な場合、USB列挙のような十分に確立されたサブシステムであっても、重大な攻撃対象領域を露呈する可能性があることを浮き彫りにしています。
USBデバイスエミュレータとエクスプロイトの完全なソースコードは、私たちのGitHubリポジトリで公開されています。

人気の記事








