Rust で Windows API のグローバルフック

Pocket

本記事は、 Rust Advent Calendar 2024 シリーズ3 の 20日目 の記事だ。
ついでに、 Windows Advent Calendar 2024 20日目 の記事としても登録させてもらっている。
遅刻です。申し訳ない。

今回は、やや実践的な例として、 Rust で Windows API の グローバル フック を使って、各ウィンドウに届くマウスやキーボード・ウィンドウメッセージをダーッとリアルタイムで表示するコードを書いてみよう。

フックDLLでメモリリークやセグメント違反を引き起こすとシステム丸ごと死にかねないし、フックした情報を表示するためにプロセス間通信やマルチスレッドの処理が必要になるなど、割と Rust 向きな例ではなかろうか。

フックとは

Win32 アプリケーションでは、各ウィンドウやコントロールがそれぞれイベントハンドラ(プロシージャ)を持ち、これが届いたメッセージを処理することで様々な機能を振る舞わせる。
「フック」とは、本来のプロシージャに届くはずの処理を先に引っ掛けて(フックして)、処理を間に挟んでから本来のプロシージャに返したり、あるいは処理を奪ってしまう機能だ。

グローバル フック は、同じ PC 内の権限的に許されるあらゆるプロセスのスレッドに対して、フックをねじ込む機能だ。
キーロガーなどのウイルスや、クリップボード監視のソフトなどでよく使われている(と思う)。

グローバルフックした場合、フック対象それぞれのプロセス毎に、フックプロシージャを定義した DLL をロードさせ、そのプロシージャを呼び出させる。
つまり、フックされたプロセス毎に DllMain が呼ばれるし、メモリ空間も独立する。
標準出力も、フックされたプロセスのものが利用されるので、例えばフックプロシージャで println! しても、フックを登録したプロセスではなくて、フックされたプロセス側の標準出力に書き込まれるわけだ。

graph LR
    subgraph caller[呼び出し元 exe]
        D0[DLL]
    end
    subgraph sub1[フック対象プログラム1]
        D1[DLL]
    end
    subgraph sub2[フック対象プログラム2]
        D2[DLL]
    end
    D0 -->|"登録"| D1
    D0 -->|"登録"| D2
    D1 -->|"println!"| cfor1@{ shape: f-circ }
    D2 -->|"println!"| cfor2@{ shape: f-circ }
    cfor1 --> sub1
    cfor2 --> sub2

このため、フックしたメッセージを登録元プロセスにて表示するためには、スレッド間参照などは使えず、プロセス間通信などを使う必要がある。
今回は、名前付きパイプで登録元プロセスに情報を渡してみる。

graph LR
    subgraph caller[呼び出し元 exe]
        D0[DLL]
    end
    subgraph sub1[フック対象プログラム1]
        D1[DLL]
    end
    subgraph sub2[フック対象プログラム2]
        D2[DLL]
    end
    D0 -->|"登録"| D1
    D0 -->|"登録"| D2
    D1 -->|"名前付きパイプ等"| caller
    D2 -->|"名前付きパイプ等"| caller

名前付きパイプによるプロセス間通信

Windows の Win32 API の場合、 CreateFileMapping で 名前付き共有メモリ や、 CreateNamedPipe で 名前付きパイプ などが使える。
それぞれ、 OS 毎の機能を隠蔽したラッパとなる shared_memory クレートinterprocess クレート などもある。

今回の用途では、フックした DLL 元プロセスに、フックされた多数のプロセスから FIFO でメッセージを送るのが目的なので、名前付きパイプあたりが適当そうだ。

しかし、 フック DLL からフックしたメッセージを受け取る登録元プロセスでは、 (フックを解除する終了処理を安全に行うために終了メッセージを待機する目的で) ウィンドウメッセージループを回しているので、パイプ周りの API を非同期的に呼ぶ必要がある。
また、 Windows の名前付きパイプには、パイプを閉じたことをクライアント側からサーバー側に通知する API がないため、自前で処理を書く必要がある。
さらに、サーバー側で任意のプロセスから名前付きパイプを接続されたら、次のプロセスが名前付きパイプにアクセスするには別途新たにサーバーを立てる必要がある。

これらの処理は、非同期でマルチスレッドに処理する必要があるのでなかなか面倒そうだ。

幸いにも、 Rust の非同期フレームワークのデファクトスタンダードになっている tokio が、名前付きパイプ用の named_pipe モジュール を提供しているので、これを使ってしまおう。

コード

コードの全体像は、 GitHub のリポジトリを見ていただくとして。

コードを少し見ていこう。

登録元exe

DLL のロードは、14日目の記事の Rust で Win32 API ことはじめ #DLL の呼び出し で紹介したとおり。

登録元exe の方は、 tokio で名前付きパイプサーバーを開けて待機する。

NamedPipeServer in tokio::net::windows::named_pipe - Rust にサンプルがあるが、 pipe_server.connect().await でクライアント接続を待機して接続が来たら、即座に同じ名前で名前付きパイプサーバーを立ち上げ直すループを回している。
名前付きパイプで繋いでくるプロセスが複数あるので、前のパイプから情報を読んでいる間に次のプロセスが繋いでくる可能性があるためだ。

パイプで渡すバイナリは、 Windows API の範囲外なので UTF-16 にしておく必要が無い。
このため、 UTF8 文字列にしておく。
また、前述の通りパイプを閉じたことをクライアント側からサーバー側に通知する API がないため、ゼロ終端 '\0' を見つけたらサーバー側でパイプを閉じるようにしている。 UTF-8 だと '\0' の検出が楽なのよね。

パイプ周りの面倒なハンドル管理は tokio がやってくれるので全部お任せ。

DLL の登録元プロセス側


DllMain 時に DLL の hInstance をグローバル変数に保存しておく。

StartHook が呼び出し元から呼び出されると、 SetWindowsHookExW 関数 を呼び出して、自分自身の DLL をウィンドウメッセージとマウスメッセージのグローバルフックチェーンに登録する。

ここまでは、登録元のプロセスで実行される部分だ。

DLL のフック先プロセス側

フック先プロセスでは、それぞれで改めて DllMain が呼び出されるため、それぞれのメモリ空間に DLL の hInstance をグローバル変数に保存される。

グローバルフックに登録したコールバック関数では、元プロセスに渡す文字列を整形する。

ウィンドウメッセージのフックは CallWndProc コールバック関数、 マウスメッセージのフックは MouseProc コールバック関数 のパラメーターで呼ばれる。

MouseProc の lParam は通常 MOUSEHOOKSTRUCT 構造体 のポインタなのだが、
WM_MOUSEWHEELWM_XBUTTONDOWN の場合は MOUSEHOOKSTRUCTEX 構造体 が入る。
違いは mouseData フィールド使われているかどうかなので、 上記以外のメッセージでも mouseData フィールドを参照しなければ MOUSEHOOKSTRUCTEX 構造体 にキャストしてしまっても問題は無い。

実際に元プロセスに名前付きパイプで渡しているのは parse_mouse_msg 関数だ。

parse_mouse_msg 関数では、整形した文字列を元プロセスに名前付きパイプする。

こちらは非同期である必要も無いし、 あまり不用意にフック先プロセスでスレッド増やすと不安定になりかねないので、 tokio は使わず 名前付きパイプ クライアント のドキュメントを参考に Win32 API を直接呼び出してクライアントとして接続する。

CreateFileW 関数 を、 書き込み専用の GENERIC_WRITE, FILE_SHARE_WRITE フラグをつけて呼び出してパイプに書き込みを始める。
パイプを開く処理が競合している可能性があるので、 ERROR_PIPE_BUSY でファイルが開けなければ、 WaitNamedPipeW 関数 で待機しながら10回ほどリトライする。

書き込みは WriteFile 。 UTF-8 文字列と ゼロ終端 '\0' を書き込み、最後に CloseHandle 関数 でパイプクライアントをクローズする。

まとめ

このように、あらゆるウィンドウメッセージやマウスメッセージをかすめ取るグローバルフックした情報を、特定のプロセスにパイプするような機能を、 Rust で比較的簡単に実装できる。

どういった操作をするとどのようなメッセージがどのウィンドウに届くか、簡単に調べられるようになるぞ。


余談

Win32 API でメッセージループすると、どうしても match 制御フロー演算子で定数を扱うことが多くなる。

私が Rust 慣れしてないだけかもしれないが、 match で定数とのマッチを意図した部分が、誤って変数と解釈されてうまく動かない…ということをしばしばやらかす。
例えば、定数を期待した以下のようなコードだと、 WM_CLOSE を use しそびれているので 変数 として扱われてコンパイルが通るため、全ての match をキャッチしてしまう(他、タイプミスでも同じ事が起こる)。
このため、 WM_CLOSE より後ろのメッセージが全て WM_CLOSE のブロックでハンドリングされてしまうのだ。

use windows::Win32::UI::WindowsAndMessaging::{CallNextHookEx, CWPSTRUCT, WM_ACTIVATEAPP, /*WM_CLOSE,*/ WM_MOVING};
match mcwp.message {
    WM_ACTIVATEAPP => {
        ;
    },
    WM_CLOSE => { // WM_CLOSE が変数扱いになって、 WM_MOVING を含むすべてのメッセージがキャッチされる
        ;
    },
    WM_MOVING => {
        ;
    },
}

このため、 use するのは一つ上の階層(トレイトや型)までに留めておいて、パス記法でアクセスさせる習慣をつけておいたほうがよい。

use windows::Win32::UI::WindowsAndMessaging as wm;
match mcwp.message {
    wm::WM_ACTIVATEAPP => {
        ;
    },
    wm::WM_CLOSE => { // もしタイプミスしていてもコンパイルエラーになる
        ;
    },
    wm::WM_MOVING => {
        ;
    },
}

windows-rs クレート内のサンプルでもこのような書き方を見かけるので、 Rust では常識的な書き方なのかもしれない。
でもさ、 IDE (rust-analyze) の支援で補間すると、勝手に use しちゃうじゃん?

コメントを残す

メールアドレスが公開されることはありません。

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください