アジョブジ星通信

進捗が出た頃に更新されるブログ。

はじめての仮想HID

半年ぶりです。崇高な計画を遂行するために、仮想 HID が欲しくなったので、ドライバーから作ってみようと思い、いじってみた記録を書いておきます。

HID ドライバーのサンプル

Microsoft 公式のドキュメントで HID ドライバーのサンプルが公開されています。

これをパクれば簡単ですね!

……ミニマルサンプルにしてはでかすぎて理解できない。

というわけで、これをすべて理解する前に、もっと簡単そうな方法を見つけたので、それを試してみました。

Virtual HID Framework

Windows 10 から Virtual HID Framework(略して VHF)が登場しました。今までの HID ドライバーでは、 Windows が用意してくれるのは「これは HID ですよ」と宣言してくれる機能くらいで、多数の I/O リクエストがパススルーされてくるので、それに対応する処理をすべて書く必要がありました。対して VHF を使用したドライバーは、それ自身が HID として振舞うのではなく、 HID の作成、削除、 I/O 処理を管理するドライバーとして振舞います。このことから、ドキュメントでは「HID source driver」と呼ばれています。

VHF を使った HID ソースドライバーの作り方の公式ドキュメントはここにあります。

これを読めば満足という方は、このブログを閉じても大丈夫です。

ドキュメントの「Virtual HID device tree」の図を見てもらうと、 HID ソースの働きがわかると思いますが、 HID ソースは、自身が 0 個以上の子 HID を持つデバイスとして振舞います。 Windows のドライバー業界でバスと呼ばれているやつです。子 HID には Windows に組み込まれている VHF 用の仮想 HID ドライバーが使用されます。

バイスマネージャーの「接続別」で表示するとこんな感じになっています。

f:id:azyobuzin:20180725025411p:plain

HID ソース → 仮想 HID と接続され、 HID の種類からキーボードであることを認識してキーボードのドライバが読み込まれています。

はじめてのカーネルモードドライバー

Vista からユーザーモードドライバーが導入され、現在では、 Windows のドライバーはカーネルモードドライバーとユーザーモードドライバーに分けられます。ユーザーモードドライバーのほうが、いくらクソプログラムを書いても落ちるのはドライバーが走っているプロセスだけだし、自己署名証明書で署名しただけでもテストモードでないマシンに入れられるという利点があるので、できればユーザーモードドライバーとして作成したかったのですが、今のところ VHF はカーネルモードにしか対応していません。残念。ユースケース的にこれこそユーザーモードでできるべき機能だろというお気持ちがあるのですが。。なお、 VHF を使わず HID ミニドライバーを作る場合はユーザーモードにできます。

現在開発できるカーネルモードドライバーは 2 種類あり、 Windows 2000 あたりから使われている WDM(Windows Driver Model) と、 Vista から登場した KMDF(Kernel Mode Driver Framework) です。 KMDF は WDM のラッパーで、 Microsoft 曰くオブジェクト指向らしく書けるようにしたもので、ユーザーモードドライバーを書くときに使う UMDF 2 と共通のヘッダーファイルを使います。 VHF は汎用化のため WDM のオブジェクトを受け取りますが、 KMDF のオブジェクトと WDM のオブジェクトは相互に変換することができるので、ここでは KMDF で開発していくことにします。

まずは、開発環境を作っていきます。 Visual Studio 2017 は既にインストールされているとして、ドライバー開発のための SDK をインストールします。公式サイトから落として入れるだけです。

インストールが完了すると、プロジェクトテンプレートからドライバープロジェクトが作成できるようになっているので、「Kernel Mode Driver, Empty (KMDF)」プロジェクトを作成します。 Empty じゃないほうは、最初からいろいろ設定してあって、テンプレートを理解するほうが大変だったので……。

f:id:azyobuzin:20180725032524p:plain

最初に、何もしないドライバーを作って、デプロイできることを確認してみましょう。適当に driver.cpp とでも名前をつけて、デバイスを作成して放置するだけのプログラムを書きます。

#include <ntddk.h>
#include <wdf.h>

extern "C" {
    DRIVER_INITIALIZE DriverEntry;
    EVT_WDF_DRIVER_DEVICE_ADD VirtualHid1DriverEvtDeciceAdd;
}

#ifdef ALLOC_PRAGMA
#pragma alloc_text (INIT, DriverEntry)
#pragma alloc_text (PAGE, VirtualHid1DriverEvtDeciceAdd)
#endif

NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) {
    WDF_DRIVER_CONFIG config;
    // デバイス作成しろ通知を受け取る関数を指定
    // 強制的に 1 デバイス作成させるので、最初に 1 回だけ呼ばれるはず
    WDF_DRIVER_CONFIG_INIT(&config, VirtualHid1DriverEvtDeciceAdd);

    NTSTATUS status = WdfDriverCreate(
        DriverObject, RegistryPath,
        WDF_NO_OBJECT_ATTRIBUTES, // DriverAttributes 指定なし
        &config,
        WDF_NO_HANDLE // 作成した WDFDRIVER は使わないので受け取らない
    );

    return status;
}

NTSTATUS VirtualHid1DriverEvtDeciceAdd(_In_ WDFDRIVER Driver, _Inout_ PWDFDEVICE_INIT DeviceInit) {
    UNREFERENCED_PARAMETER(Driver);
    PAGED_CODE();

    // 何もしないデバイスを作成
    WDFDEVICE device;
    NTSTATUS status = WdfDeviceCreate(&DeviceInit, WDF_NO_OBJECT_ATTRIBUTES, &device);

    return status;
}

最初に include しているのが、 Windowsカーネルのヘッダーファイルと、 KMDF, UMDF 用のヘッダーファイルです。 UMDF では ntddk.h が windows.h になります。

ドライバーが読み込まれると、 DriverEntry という名前の関数が呼び出されるので、 C の関数としてエクスポートしておきます。そんなことするなら最初から C++ ではなく C で書けという気もしますが、 Better C として C++ の構文が使いたくなったときに不便だなと思って……ね。

次に、#pragma alloc_text、これはコードが常にメモリ上にあるか、それとも必要に応じてディスクから読み出してくるかを指定します。できるだけ、ディスクに追い出せる PAGE を使いたいところですが、一部の処理は必ずメモリ上にコードがなければいけません。ドキュメントを読みながら、これは必ず IRQL が PASSIVE_LEVEL で呼ばれるぞ(便利な用語集)、とわかったら PAGE にしておき、その関数の最初に「PAGED_CODE();」を入れてアサーションしておきます。なお、 alloc_text に指定する関数名も C の関数名なので、マングリングされていると適用されないらしいです。

それでは、デプロイするための準備をします。まず、ビルドを通すためにプロジェクトプロパティから Inf2Cat の設定を開き、 Use Local Time を「はい」にしておきます。これは inf ファイルのバージョン欄を編集していないので、現在の時刻が使われるのですが、 UTC から見て JST が未来なのでエラーになるためです。

f:id:azyobuzin:20180725040145p:plain

次に、デプロイ先マシンを、オレオレ証明書で署名されたドライバーを受け入れ、カーネルデバッグを許可するように設定していきます。がっつり Windows の設定がいじられるので、普段使っていない PC か仮想マシンを用意します。やりかたはここに書いてありますが、ちゃんと読まないとミスりがち。

まずデプロイ先マシンと開発マシンがプライベートネットワーク内にあり、ファイル共有を有効にします(これで必要なポートは全部開くはず)。そして、 WDK に入っている Remote\x64\WDK Test Target Setup x64-x64_en-us.msi をデプロイ先マシンにコピーしてインストールします。ここまでできたら、ドキュメントに従って Visual Studio の「Configure target devices」を開いて、デプロイ先マシンのホスト名を突っ込んで放置すると、勝手に「WDKRemoteUser」というユーザーが作成され、 Windows がテストモードになります。おめでとうございます、これで逸般の Windows です。

プロビジョニングは「failed」と表示されると思いますが、デバイス一覧で「Status: Configured for driver testing」と表示されていればデプロイを行うことができます。

最後に、今設定したマシンにデプロイできるように設定します。プロジェクトプロパティの Driver Install → Deployment を開いて、コンボボックスから今設定したマシンの名前を選びます。するとさらに設定項目が出てくるので、今回は強制的に 1 つのデバイスとして認識させたいので、「Hardware ID Driver Update」を選択し、そこに適当なハードウェア ID(inf ファイルに書いてあるやつとかで良さそう)を入力しておきます。

f:id:azyobuzin:20180725041602p:plain

以上で設定は完了です。プロジェクトを右クリックして「配置」をクリックすると、デプロイ先マシンに勝手にコマンドプロンプトが表示されて作業が始まります。 WDKRemoteUser 以外でサインインしているときは、勝手にサインアウト(ユーザー切り替えではありません!)されて WDKRemoteUser に切り替わるので気を付けてください。デプロイが完了すると、作成したプロジェクト名の名前をしたデバイスを、デバイスマネージャーで確認することができるはずです。

デバッグ実行をすると本来なら「12. デバッグが開始され、デバッガーのコマンドも実行できます。」とあるように、リモートの WinDbg に接続されてカーネルデバッグが開始されるはずなのですが、僕の環境では WinDbg との対話欄にデプロイのログが表示されるだけで、デバッグできる気配がありませんでした。詳しい方、助けてください。

追記: デバッグ手段について書きました。

HID Report Descriptor

HID ソースになるためには、そもそも HID とは何かについて知っておく必要があります。 HID は Human Interface Device の略であるように、入出力装置なら何でもアリみたいな規格です。そこで、 HID は USB の仕様に従って、自分がどのような入出力を持ち、それをどのようにビット表現するのかについて宣言します。その宣言のうち、 USB のための伝送情報を除いて、入出力情報だけの部分を Report Descriptor と呼びます。 Windows の HID ミニドライバーは、この Report Descriptor を取得する I/O リクエストを処理する必要があり、 VHF では仮想 HID 作成時に、 Report Descriptor を VHF に渡します。

HID の仕様については以下のページにあり、ここに HID Descriptor Tool という GUI で Report Descriptor を組み立てることができるツールがあるので、これを使って Report Descriptor を作成していきましょう。

構造としては、機能のコレクションという形で意味的な階層構造を持ち、その中で入出力するデータの定義をレポートとして定義します。例えば「A」のキーだけを持つキーボードを定義してみましょう。まず、この HID はキーボードという機能を持ちます。そしてキーボードは、キーという機能を持っています。したがって、このような階層構造になっています。(コレクションの種類は Physical とか Application とかありますが、仕様から具体的な使い分けを理解することができなかったです。 Physical は 1 つのセンサーから読み取ることができる内容、 Application はデバイスについている機能をまとめたもの、みたいな理解をしています。)

最上位 Application コレクション

  • キーボード(Application コレクション)

    • Aキー

バイスの階層構造ができたら、入出力するデータを定義していきます。このキーボードは A キーが押されているかどうかを(ユーザーがコンピュータに)入力します。これは、 0 または 1 の情報が 1 ビットで表されていると定義すると良さそうです。これで入出力データの定義は完成しましたが、 Windows が扱えるデータの最小単位はバイトなので、 7 ビットの無意味なデータを含むということも定義しておきます。

このデータを HID Descriptor Tool に入力するとこのようになります。

f:id:azyobuzin:20180725232332p:plain

定義した Input レポートに対応するデータは、定義順に下位ビットから詰めていきます。したがって、この場合は、 A キーが押されているなら 1 バイトの「1」、押されていないなら 1 バイトの「0」をレポートすれば良いことになります。

HID Descriptor Tool で「Header Fille (*.h)」として保存することで、 C 言語の配列として Report Descriptor を出力することができます。今定義した Report Descriptor をバイト配列として出力するとこのようになります。

char ReportDescriptor[27] = {
    0x05, 0x01,                    // USAGE_PAGE (Generic Desktop)
    0x09, 0x06,                    // USAGE (Keyboard)
    0xa1, 0x01,                    // COLLECTION (Application)
    0x05, 0x07,                    //   USAGE_PAGE (Keyboard)
    0x09, 0x04,                    //   USAGE (Keyboard a and A)
    0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
    0x25, 0x01,                    //   LOGICAL_MAXIMUM (1)
    0x75, 0x01,                    //   REPORT_SIZE (1)
    0x95, 0x01,                    //   REPORT_COUNT (1)
    0x81, 0x02,                    //   INPUT (Data,Var,Abs)
    0x75, 0x01,                    //   REPORT_SIZE (1)
    0x95, 0x07,                    //   REPORT_COUNT (7)
    0x81, 0x01,                    //   INPUT (Cnst,Ary,Abs)
    0xc0                           // END_COLLECTION
};

char の部分を UCHAR に書き換えると、 VHF で使える形式になります。

VHF の導入

バイスの定義を準備できたので、実際に VHF をドライバーに組み込んで、子 HID を作成してみましょう。

まず、 VHF のライブラリと静的リンクするようにします。 WDK のライブラリディレクトリは $(DDK_LIB_PATH) で取得できるようなので、リンクするファイルとして「$(DDK_LIB_PATH)vhfkm.lib」を追加します。

f:id:azyobuzin:20180725235918p:plain

次に、 inf ファイルに VHF を下位(自分自身のデバイスとPDO(デバイスの接続を表す、バスが作成するデバイスオブジェクト)の間)のフィルタードライバーとして登録するために、次のセクションを追加します。

[VirtualHid1_Device.NT.HW]
AddReg = VirtualHid1_Device.NT.AddReg

[VirtualHid1_Device.NT.AddReg]
HKR,,"LowerFilters",0x00010000,"vhf"

「VirtualHid1_Device」の部分は、各自の inf ファイルに合わせて書き換えてください。また、「.NT.HW」セクションが既にある場合は、うまいこと統合してください。

参考: AddReg ディレクティブの説明

それでは、 VHF を使って仮想 HID を作成するコードを追加していきましょう。まず、ヘッダーファイルとして「vhf.h」をインクルードします。

次に、仮想 HID の作成ですが、 VhfCreate 関数を呼び出すことで行います。 VhfCreate 関数は、 WdfDeviceCreate を呼び出した後かつ、 IRQL が PASSIVE_LEVEL のときに呼び出すことができます。引数には、 VHF_CONFIG 構造体へのポインタを取ります。 VHF_CONFIG は、 VHF_CONFIG_INIT 関数を使って初期化することができます。このときに、 Report Descriptor を指定します。受け取りたいコールバックがあったり、他のデバイスになりすましたかったりする場合は、 VHF_CONFIG_INIT で初期化した後に、 VHF_CONFIG のフィールドに代入していきます。

VhfCreate を呼び出して、ハンドルを取得した後、 VhfStart 関数を呼び出すことで、 HID として動作を開始します。

以上を踏まえて、何もしない仮想 HID を作成するプログラムを示します。このプログラムは前に示した何もしないドライバーからの増分(DriverEntry を省略)です。

#include <vhf.h>

NTSTATUS VirtualHid1DriverEvtDeciceAdd(_In_ WDFDRIVER Driver, _Inout_ PWDFDEVICE_INIT DeviceInit) {
    UNREFERENCED_PARAMETER(Driver);
    PAGED_CODE();

    // デバイスを作成
    WDFDEVICE device;
    NTSTATUS status = WdfDeviceCreate(&DeviceInit, WDF_NO_OBJECT_ATTRIBUTES, &device);
    if (!NT_SUCCESS(status)) return status;

    VHF_CONFIG vhfConfig;
    VHF_CONFIG_INIT(
        &vhfConfig,
        WdfDeviceWdmGetDeviceObject(device), // WDM のデバイスオブジェクトに変換
        sizeof(ReportDescriptor), ReportDescriptor // ReportDescriptor を指定
    );

    // ハンドルの作成
    VHFHANDLE hVhf;
    status = VhfCreate(&vhfConfig, &hVhf);
    if (!NT_SUCCESS(status)) return status;

    // イベント処理を開始
    status = VhfStart(hVhf);
    if (!NT_SUCCESS(status)) return status;

    return STATUS_SUCCESS;
}

これをデプロイすれば、最初に示したスクリーンショットのように、デバイスマネージャーで仮想 HID を確認することができます。なお、このデバイスがインストールされた状態で、再度デプロイを行おうとすると、子 HID を削除できずエラーになるので、再デプロイを行う前にはデバイスマネージャーから子 HID を削除しておいてください。

簡単なデバイスを作ってみる

最後に、せっかくキーボードに成りすますことができたので、実際に文字を入力してみましょう。次のような動作をするキーボードを作ってみることにします。

  1. バイスを作成して 10 秒待つ(デプロイ処理のコマンドプロンプトが消えるまでの時間)
  2. 1 秒ごとに 0.2 秒間 A キーを押した状態にする
  3. 2 を 10 回繰り返したら仮想 HID を削除

ブログに貼り付けるには、ちょっと長めのコードになったので、完成品は GitHub に置いておきます

というわけで、各種小技というか要素を説明していきます。

まず、 VHF の各種コールバックの引数にある VhfClientContext ですが、これは VHF_CONFIG の VhfClientContext で設定したポインタが入ります。 WDFDEVICE ハンドルとかを入れておくと便利かと思います。

次に、実際にキー操作を出力する方法ですが、 Report Descriptor で定義した通りにデータを作成し、それを VhfReadReportSubmit 関数に渡します。 VHF はそのデータを保持し続け、レポートを取得する I/O リクエストがやってくると、最後に VhfReadReportSubmit を呼び出したときに指定された値を返します(EvtVhfReadyForNextReadReport コールバックを指定するとこの挙動を変えることができるようです)。今回作成する HID がレポートする値は、 1 バイトの「0」または「1」と定義したので、このように書くと出力を更新することができます。

UCHAR data = 1;
HID_XFER_PACKET packet;
packet.reportBuffer = &data;
packet.reportBufferLen = 1;
packet.reportId = 0; // REPORT ID を指定しない場合は 0
NTSTATUS status = VhfReadReportSubmit(hVhf, &packet);

また、今回は繰り返すタイマーと、 1 度だけコールバックが呼ばれるタイマーの 2 種類のタイマーを使うので、それぞれについて説明しておきます。

まずは、 1 度だけ呼ばれるタイマーの作成と開始です。 WdfTimerCreate 関数を呼び出すことでタイマーを作成することができます。指定するオプション類については、以下のサンプルコードを見てください。タイマーを作成したら、 WdfTimerStart 関数で待ち時間を指定して、タイマーを開始します。このとき DueTime 引数には、時刻または現在からどのくらい後かを指定します。どのくらい後かを指定するには負の値を指定するのですが、 WDF_REL_TIMEOUT_IN_MSWDF_REL_TIMEOUT_IN_SEC といったマクロを使うと読みやすくなります。

// タイマーのコールバックを指定
WDF_TIMER_CONFIG timerConfig;
WDF_TIMER_CONFIG_INIT(&timerConfig, FooEvtTimerFunc);

WDF_OBJECT_ATTRIBUTES timerAttributes;
WDF_OBJECT_ATTRIBUTES_INIT(&timerAttributes);
// 寿命を共にするオブジェクトを指定しておくと、自動に破棄してくれる
timerAttributes.ParentObject = device;
// 繰り返さないタイマーでは、コールバックを PASSIVE_LEVEL で呼び出すことができる
timerAttributes.ExecutionLevel = WdfExecutionLevelPassive;

WDFTIMER timer;
status = WdfTimerCreate(&timerConfig, &timerAttributes, &timer);

// 0.2 秒後に FooEvtTimerFunc が呼ばれる
WdfTimerStart(timer, WDF_REL_TIMEOUT_IN_MS(200));

// PASSIVE_LEVEL なのでページを使用できる
#ifdef ALLOC_PRAGMA
#pragma alloc_text (PAGE, FooEvtTimerFunc)
#endif

VOID FooEvtTimerFunc(_In_ WDFTIMER Timer) {
    PAGED_CODE();
    // 時間が来たときの処理
}

一方、繰り返し実行するタイマーでは、 WDF_TIMER_CONFIG の初期化に WDF_TIMER_CONFIG_INIT_PERIODIC 関数を使用します。コールバックは DISPATCH_LEVEL で呼び出されることに注意します。

WDF_TIMER_CONFIG timerConfig;
WDF_TIMER_CONFIG_INIT_PERIODIC(
    &timerConfig,
    FooEvtTimerFunc,
    1000 // ミリ秒単位で
);

WDF_OBJECT_ATTRIBUTES timerAttributes;
WDF_OBJECT_ATTRIBUTES_INIT(&timerAttributes);
timerAttributes.ParentObject = device;

WDFTIMER timer;
NTSTATUS status = WdfTimerCreate(&timerConfig, &timerAttributes, &timer);

// 10 秒後に初めて FooEvtTimerFunc が呼ばれる
WdfTimerStart(timer, WDF_REL_TIMEOUT_IN_SEC(10));

VOID FooEvtTimerFunc(_In_ WDFTIMER Timer) { /* 時間が来たときの処理 */ }

タイマーの停止には WdfTimerStop 関数、仮想 HID の削除には VhfDelete 関数を使用します。どちらも第 1 引数にハンドルを取り、第 2 引数に、完了するまでブロッキングするかを指定します。ブロッキングする場合は PASSIVE_LEVEL である必要があり、制約も多いわりに特にメリットもないので、基本的には FALSE を指定しておけば良いと思います。

最後に、完成したものが実際に動いている様子を載せておきます。


まとめと今後の展望

Virtual HID Framework を使うことで、面倒な I/O リクエスト処理を一切書かずに仮想 HID を作成することができました。さらに VhfCreate を複数回呼び出せば、子 HID を複数作成することもできます。捗りますね。

お気持ちとしては、早くユーザーモードにも対応してほしいです。このブログを書きながら BSoD させてしまったので……。

今後については、デバッガーが接続できない問題(追記: 多少改善しました)について誰か助けてくれるのを待っている間に、 WPP を使ったログの吐き出しについて調査していきたいです。一度試したのですが、プリプロセッサの仕様が全然わからん(詳細な説明もサンプルもないってお前)なのと、 ETW の読み出しに苦戦したので、再挑戦していきたいところです。

以上です。疲れた。