LoginSignup
10
2

RustでWindowsのカーネルドライバーに触れてみる

Last updated at Posted at 2023-12-12

アドベントカレンダー 13日目

本記事はLabBaseテックカレンダー Advent Calendar 2023の 13日目になります!

はじめに

もうすぐ12月も終わりですね、早くこのくそ寒い時期が終わってほしいです

ところでみなさん、こちらのRedditのスレッドを知っていますか

microsoft/windows-drivers-rs: Platform that enables Windows driver development in Rust. Developed by Surface.

そうです、なんとあのMicrosoftから直々にWindowsのカーネルドライバー開発に関するものを出してくれたんです!
これはもう私にとってMicrosoftからのクリスマスプレゼントと言っても過言ではありません (結構前に出たけど!)

以前まではRustでWindowsのカーネルドライバーを開発するにはかなり仕込みだったりが必要で、本当に情報も少なくて大変だったんですが、これによりぐっと楽に開発できるようになりました

今回はこの windows-drivers-rs を実際に使ってみようと思います!

下準備

前提としてまずWindows、そしてRustが必要です
当たり前ですよね、RustでWindowsのカーネルドライバー作るって言ってるんですから
それと、wdkと呼ばれるWindows上でカーネルドライバーを開発するためのSDK群が必要です
先に楽な仮想環境から構築してしまいましょう

開発用仮想環境の構築

ここでは実際の環境ではなく、仮想環境のWindowsを用いて開発していこうと思います
理由は単純明快、カーネルに関するものでクラッシュするとWindowsくんはすぐに青い画面をげろげろと吐き出してクラッシュするため、自分のデバイスに影響がないようにするためです

仮想環境を構築するソフトはいくつかあると思いますが、今回は昔から使っている慣れ親しんだOracleのVirtual Boxを利用して新しく開発用のWindows環境を作ります

まずはVirtual Boxのダウンロードと、Windows11のISOをダウンロードしていきます

ISOは適当なところに保存しておいて、Virtual Boxをインストールしましょう。

起動したら新規ボタンを押し、仮想マシンを作成していきます
ISOイメージに保存しておいたWindowsのISOをセットし、セットアップもめんどくさいので自動インストールにチェックを入れて次へ行きましょう

スクリーンショット 2023-11-28 173229.png

ハードウェア設定と仮想ハードディスクは適当でいいので、そのまま次へ行きます
あ、EFIを有効化は必ずチェックしておきましょう(デフォルトでチェック済みですが)

image.png

image.png

これで完了を押して仮想環境の構築完了です

スクリーンショット 2023-11-28 173550.png

起動して動作確認と行きたいところですが、先に作成した仮想環境の設定を開き、セキュアブートを有効化のチェックを外しておきます
後でカーネルドライバーの読み込みで邪魔になるので

スクリーンショット 2023-11-28 173816.png

また、自分のマシンからできたドライバーを持ってくるために共有フォルダーを使用しますが、そのためGuest Additionというアドオンが必要です
仮想環境を起動して、上のタブからGuest AdditionsのCDイメージを挿入します

image.png

すると仮想環境内のWindows上にCDとして出てくるので、VBoxWindowsAdditionsを実行してインストール、再起動しておきます

image.png

一度仮想環境を落とし、仮想環境の設定を開いて共有フォルダーを設定します
ここでは自分のマシンのC直下に vbox というフォルダを作ったので、ここで自分のマシンと仮想環境とでファイルのやり取りをします
自動マウントはチェックしておきましょう

image.png

必要であればクリップボードの共有もしておくといいです

image.png

これで仮想環境の構築は終わりです

WDKのセットアップ

リポジトリを見ると、 ewdk というものを利用するといいとのことみたいなのでこちらを利用しましょう
ewdk とは、Visual StudioやWindowsのSDKおよびWDKなどのセットアップをより簡単に一括で入れてしまえるものみたいです
Enterpriseエディションみたいなことが書いてありますが、普通に個人でも利用可能みたいですね

MICROSOFT ENTERPRISE WINDOWS DRIVER KIT

↑ここの下の方にある1個目の Accept license terms をクリックして ewdk のISOをダウンロードします
適当なところにISOを保存して、ISOを右クリックしてマウントします

ドキュメントではC直下の ewdk フォルダに置くと書いてあったので、同じようにC直下にフォルダを作成し、ISOの中身を全部コピーします

ewdkはこれだけで完了です!
使うときはここにあるLaunch~みたいなバッチファイルをターミナルで実行してそこ経由でドライバーをビルドします
バッチファイルにはその場限りの変数設定などをして、WDKなどをターミナルが検出できるようにしてるみたいです

これでドライバーを開発してデバッグする環境の構築は終わったので、実際にドライバーのコードをリポジトリを参考にしながら書いてみましょう!

ドライバーの開発

開発するときは自分のマシンで行っていきます
windows-drivers-rs のリポジトリのReadmeを見つつ開発していくので、こちらも開いておきましょう

セットアップ

何やらLLVMと cargo-make が必要みたいなのですが、今回は cargo-make を使わずに進めていくのでLLVMだけをインストールしておきます

  • winget -i install LLVM.LLVM

次にプロジェクトディレクトリを作るので、適当な場所でドライバーのための cargo new をします
ここでは適当に kmdf というプロジェクトにしてしまいましょう
(Kernel Mode Driver Frameworkの略です、MSもよくサンプルにこの名前使ってますね!)

cargo new kmdf --lib

プロジェクトディレクトリに移動し、依存関係のクレートを追加していきます

cd driver
cargo add --build wdk-build
cargo add wdk wdk-sys wdk-alloc wdk-panic

そしたら Cargo.toml を開き、必要な設定を追加しておくみたいです

# こいつと
[lib]
crate-type = ["cdylib"]

# こいつ
[profile.dev]
panic = "abort"
lto = true # optional setting to enable Link Time Optimizations

[profile.release]
panic = "abort"
lto = true # optional setting to enable Link Time Optimizations

こんな感じの Cargo.toml ができてるはずです

Cargo.toml
[package]
name = "kmdf"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
crate-type = ["cdylib"]

[dependencies]
wdk = "0.1.0"
wdk-alloc = "0.1.0"
wdk-panic = "0.1.0"
wdk-sys = "0.1.0"

[build-dependencies]
wdk-build = "0.1.0"

[profile.dev]
panic = "abort"
lto = true # optional setting to enable Link Time Optimizations

[profile.release]
panic = "abort"
lto = true # optional setting to enable Link Time Optimizations

そしたら次に build.rs を同ディレクトリ内に作成し、ビルド用のコードをコピペします
これが出てくる以前はもっと大量にコードが必要だった記憶があるので、だいぶ楽になりましたね

build.rs
fn main() -> Result<(), wdk_build::ConfigError> {
   wdk_build::Config::from_env_auto()?.configure_binary_build();
   Ok(())
}

ここまで来たらもうほぼ完成です、あとは実際のドライバーのコードを書いていきます

最小限のドライバーの実装

Readmeによると、no_std の宣言やパニッククレートの extern 宣言、そしてアロケーターの設定が必要なようです
こちらを先に記述しておきます

lib.rs
#![no_std]

#[cfg(not(test))]
extern crate wdk_panic;

#[cfg(not(test))]
use wdk_alloc::WDKAllocator;
#[cfg(not(test))]
#[global_allocator]
static GLOBAL_ALLOCATOR: WDKAllocator = WDKAllocator;

次にmain関数...とはならず、ドライバーとしてのメイン関数は main ではなく DriverEntry という名前でエクスポートしなければなりません
それに従ってエントリー関数を書いていきましょう

lib.rs
use wdk::println;
use wdk_sys::{
    DRIVER_OBJECT, NTSTATUS, PCUNICODE_STRING,
};

#[export_name = "DriverEntry"]
pub unsafe extern "system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    _registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
    println!("Hello World !");
    return 0;
}

Readmeではこれで完成と書かれているのですが、ドライバーのアンロード処理を定義しておかないといけないことは意外と知られていません
無くても確かに動きはするのですが、アンロードの処理がうまく走らないので後々で行うドライバーの再ロードでのデバッグでいちいち再起動が必要になってめんどくさいので、適当に定義しておきます、こんな感じで、

lib.rs
    // ...
    driver.DriverUnload = Some(driver_exit);
    return 0;
}

unsafe extern "C" fn driver_exit(_driver: *mut DRIVER_OBJECT) {
    println!("Good Bye World !");
}

さっきの lib.rs をアンロードのとこまで定義して、完成です

lib.rs
#![no_std]

#[cfg(not(test))]
extern crate wdk_panic;

#[cfg(not(test))]
use wdk_alloc::WDKAllocator;
#[cfg(not(test))]
#[global_allocator]
static GLOBAL_ALLOCATOR: WDKAllocator = WDKAllocator;

use wdk::println;
use wdk_sys::{
    DRIVER_OBJECT, NTSTATUS, PCUNICODE_STRING,
};

#[export_name = "DriverEntry"]
pub unsafe extern "system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    _registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
    println!("Hello World !");
    driver.DriverUnload = Some(driver_exit);
    return 0;
}

unsafe extern "C" fn driver_exit(_driver: *mut DRIVER_OBJECT) {
    println!("Good Bye World !");
}

後はビルドして、仮想環境構築時に設定した共有フォルダーにぶち込んで動作テストできます

ビルド

さて、ドライバーのビルドですが、ここで ewdk の出番です
システムに直接SDK達をインストールする手順を踏んでいないので、 ewdk を通したターミナルでビルドすることになります

管理者としてWindowsターミナルを開き、 ewdk が置いてあるディレクトリに移動します
ここまで同じ手順を踏んでいれば C:\ewdk\ に置いてあるはずです

cd /ewdk

スクリーンショット 2023-11-29 154328.png

移動したら、 LaunchBuildEnv.cmd というcmdファイルを実行します

PS C:\ewdk> .\LaunchBuildEnv.cmd

実行すると、ターミナルのタブの名前が 管理者: Enterprise WDK... となるはずです

スクリーンショット 2023-11-29 154503.png

この状態でプロジェクトディレクトリに移動し、 cargo build --profile dev をします
すると target/debugkmdf.dll が出来上がるのですが、ドライバーファイルは .sys の拡張子が正しいので、dllを kmdf.sys にリネームしておきます

kmdf.sys を取り出して、共有フォルダーに移動しておきましょう
手順を踏んでいれば C:\VBox が共有フォルダーになっているはずです

ドライバーを動かしてみる

さて、ドライバーがビルド出来たので実際に動作確認をしていきましょう
ここからは仮想環境のWindowsに移り、実際に動かすところをやっていきます

下準備

まずVirtual Boxから作っておいた仮想環境を立ち上げます
デスクトップに行ったら早速動かしたいところですが、どうやってドライバー動かすのでしょうか?みなさん知ってますか?まぁ知らないですよね

色々やり方はありますが、今回は OSR Loader というツールを使用します
このツールは古いツールですが、まだ普通に動かせます
ただし署名確認を無効にする必要があるので、これは後でやります
また、ドライバーからの出力を見るために DebugView と呼ばれるデバッグビューアーツールも使用します
この二つをとりあえず用意してしまいましょう

ローダーもビューアーも適当な場所に解凍しておきましょう

そしたら次にドライバーのデバッグの有効化とドライバー署名強制の無効化のためのコマンドを実行します
本来は正しい署名をドライバーにしておかないとドライバーのロードがOSでブロックされてしまうのですが、それを強制的に無効化するものです
管理者でターミナルを立ち上げ、以下のコマンドを実行します

bcdedit /debug on
bcdedit /set testsigning on

早速再起動と行きたいところですが、スタートアップ設定でドライバーの署名を無効にしたいので、その画面に行くためのシャットダウンコマンドをうちます
設定の回復画面からの再起動でも同様のことができます

Win + R から shutdown /r /o /t 0 を実行するか、設定の回復画面から再起動します
どちらでも同様です

image.png

か、ここの 今すぐ再起動

スクリーンショット 2023-11-29 160815.png

これで再起動に入ると、このような画面が出てきます

スクリーンショット 2023-11-29 150413.png

トラブルシューティングから詳細オプション、スタートアップ設定と進み、もう一度再起動をかけます

スクリーンショット 2023-11-29 150431.png

スクリーンショット 2023-11-29 150438.png

スクリーンショット 2023-11-29 150448.png

するとスタートアップ設定の画面になるので、数字の7キーを押してドライバーの署名を強制しない、を選びます

そしたらやっとWindowsが起動します
起動時してデスクトップに行くと、デスクトップの右下の時計上辺りにはテストモードと書かれているはずです

image.png

ドライバーのロード

まずは共有フォルダーを開き、 kmdf.sys を適当な場所に移動します
私の環境では Z:\ として共有フォルダーが割り当てられていたので、ここから取り出し、今回はわかりやすく C:\ 直下に置きました

ローダーとビューアーをそれぞれ管理者権限で起動します

ビューアーのほうは、歯車がこの状態になってることを確認してください
これに赤い×がついていると、カーネルのキャプチャーをしてくれません

スクリーンショット 2023-11-29 161408.png

そして、ビューアーのオプションから Capture KernelEnable Verbose Kernel Output をチェック状態にしておきます

image.png

ローダーの画面に行き、C直下に置いたドライバーを選択します

image.png

基本的にな使い方として、
まずドライバーを Register Service でサービスとして登録し、 Start Service でドライバーをロードできます
Stop Service でアンロード処理に入り、ドライバーをビルドしなおしたりした際は一度 Unregister Service してから再度 Register Service を押します、これだけです

では実際に kmdf.sys をロード/アンロードさせてみましょう

スクリーンショット 2023-11-29 151223.png

Hello World !Good Bye World ! がデバッグビューアーに表示されていたら成功です!

ちなみに、ドライバーを実装しているときにアンロード処理まで実装しておいたと思うのですが、もしアンロードを実装していない場合、 Stop Service が正常に終わらず、エラーダイアログが出ます
また、 Unregister Service をして再度 Register Service をしようとすると、スケジュールされています...のようなダイアログが出て再起動するまでうまく進みません

アンロード処理をちゃんと実装している場合はこのようなことは起こりません

応用編

さて、Rustでカーネルドライバーを作成し、実際に動作するところまでできました
以前までは非常にめんどくさかったのですがこんなに簡単に作成出来て技術の発展は本当に素晴らしいものですね

ではここでまとめに...いやちょっと待ってください、せっかくカーネルレベルでいろいろ出来るのに 単純な Hello World ! の表示で満足してしまっていいのでしょうか?

否!!!!!!

ということでここからは応用編ですが、実際にカーネルレベルの処理を触ってみようとおもいます
どんなものにしようか迷ったのですが、昔からネットワーク系が好きだったのでパケット関係を触れたらな~と思い、パケットのフィルタリング処理なんかをやってみましょう

といってもそこまで難しいことはせず、Pingコマンドを打ったらビューアーで Pong! と表示させるくらいにしておきます
つまりピンポンドライバーです

Windowsでのパケットフィルタリング

ではWindowsでどうやってパケットをフィルタリングするのか、知らなかったのでぐぐってみたところ、どうやら Windows フィルタリング プラットフォーム なるものが存在しているらしいです
知らなかった~!!!!!!

出てきたMicrosoftのページを見てみると、

Windows フィルタリング プラットフォーム (WFP) は、ネットワーク フィルタリング アプリケーションを作成するためのプラットフォームを提供する一連の API およびシステム サービスです。 WFP API を使用すると、開発者は、オペレーティング システムのネットワーク スタック内の複数のレイヤーで行われるパケット処理と対話するコードを記述できます。 ネットワーク データをフィルター処理し、宛先に到達する前に変更することもできます。

と書かれているので、Pingコマンドの送信するIMCPパケットくらいはフィルタリングできそうですね

ドライバーのコードに戻る

事前に実際にWFPを利用しているドライバーのサンプルコードなどをGitHubで読み漁ってます

kmdf のコードにそのままフィルタリングの実装を行っていくので、エディターにいったん戻ります
今回利用するcrateを先にプロジェクトに追加してしまいましょう
windows-sysspinning_top、そしてカーネル用のバインドが独自に追加されたブランチの winapi-rs を今回使用します
winapi-rs についてははしょりますが、このブランチのcrateでしか利用できない関数があるので今回採用しています
追加するとこんな感じになります

Cargo.toml
# ...
[dependencies]
spinning_top = "0.3.0"
wdk = "0.1.0"
wdk-alloc = "0.1.0"
wdk-panic = "0.1.0"
wdk-sys = "0.1.0"
windows-sys = "0.52.0"

[dependencies.winapi]
git = "https://github.com/Trantect/winapi-rs.git"
branch = "feature/km"
features = [
  "wdm",
  "ntstatus",
  "fwp",
  "ndis",
  "rpcdce",
  "basetsd",
]
# ...

これで実際の処理を書いていく準備ができました
では処理を...といきたいのですがまず先に、後々利用するためのGUIDと呼ばれる変数と、ドライバーのアンロード処理の関係で後々グローバル変数が必要になってくるので、それを先に定義しちゃいます
GUIDはWFPのフィルターで利用するのですが、今回4種類用意するものがあります
ところでグローバルなものにはOnceCellなどを使うのが今時風ですが、フィールドに生ポインタを含んだ関係でうまくコンパイルできそうになかったので、今回は spinning_top というものを使用します
また、Sendを無理やり実装してコンパイルするために、 unsafe impl を使用して実装されたNewTypeでさらにwrapします

lib.rs
const CALLOUT_OUT_GUID: GUID = GUID {
    Data1: 0xd147e90a,
    Data2: 0xa6d4,
    Data3: 0x4ec3,
    Data4: [0x82, 0xa9, 0xc1, 0x60, 0x32, 0x72, 0x9f, 0x44],
};

const SUBLAYER_GUID: GUID = GUID {
    Data1: 0xd55044d0,
    Data2: 0x110b,
    Data3: 0x4819,
    Data4: [0xbe, 0xfe, 0x2e, 0x52, 0x2c, 0x99, 0x09, 0x42],
};

const FILTER_GUID: GUID = GUID {
    Data1: 0x5951d0fe,
    Data2: 0x5218,
    Data3: 0x44e4,
    Data4: [0xa2, 0xd3, 0xb9, 0xbf, 0x43, 0xef, 0x8f, 0x4f],
};

// 0x5926dfc8_e3cf_4426_a283_dc393f5d0f9d
const FWPM_LAYER_INBOUND_TRANSPORT_V4: GUID = GUID {
    Data1: 0x5926dfc8,
    Data2: 0xe3cf,
    Data3: 0x4426,
    Data4: [0xa2, 0x83, 0xdc, 0x39, 0x3f, 0x5d, 0x0f, 0x9d],
};

#[derive(Debug)]
#[repr(C)]
pub struct UnsafeSend<T>(pub T);

unsafe impl<T> Send for UnsafeSend<T> {}

#[derive(Debug)]
#[repr(C)]
pub struct GlobalVar {
    wfp_handle: HANDLE,
    device_object: PDEVICE_OBJECT,
    register_callout_id: u32,
    add_callout_id: u32,
    filter_callout_id: u64,
}

static GLOBAL: Spinlock<UnsafeSend<GlobalVar>> = Spinlock::new(UnsafeSend(GlobalVar {
    wfp_handle: null_mut(),
    device_object: null_mut(),
    register_callout_id: 0,
    add_callout_id: 0,
    filter_callout_id: 0,
}));

CALLOUT_OUT_GUIDSUBLAYER_GUIDFILTER_GUIDには自分で生成したユニークなUUIDを設定します
ぼくは適当にぐぐって出てきた生成ツールで生成したものをセットしてます
また、4つ目のGUIDの FWPM_LAYER_INBOUND_TRANSPORT_V4 に関してはあまり情報が出回っていませんが、どうやら独自にリバースエンジニアリングされたものから掘り出されてるようです
特に最新のWinで変わったということはなさそうなので、古いソースにはなりますがそこに書かれていたものを抜き出してきています

グローバル変数はこれでだいじょうぶそうです

処理を書いていく前に、ちょっとしたcheck関数を作成します
というのも、WindowsのAPI関数は NT_STATUS と呼ばれる数値を返すので、それでAPIの呼び出しが成功したのかエラーなのかを判断します
ほぼすべてのAPI呼び出しにそれがついてくるので、いちいちif文を書かなくていいように関数にします
エラーの時はそのコードをぐぐって原因を探れるようにするために一応Printするようにしておきます
ついでに関数名も渡して何が成功・失敗なのかもわかるようにしておきましょう

lib.rs
extern "system" fn nt_status_check(code: i32, function_name: &'static str) {
    if code == STATUS_SUCCESS {
        println!("Success: {function_name} successed {code:#010X}");
    } else {
        println!("Error: {function_name} failed {code:#010X}");
    };
}

できました、こいつをNT_STATUSを返す全ての呼び出しで使用しましょう

ではWFPを利用した処理を書いていきます

WFPを利用したドライバーでは以下のような処理たちを追加する必要があります

  • デバイスオブジェクトの作成 (コールアウトの登録に使用)
  • フィルターエンジンへのセッションを開きます
  • フィルターエンジンにコールアウトを登録する
  • コールアウトをシステムに追加する
  • システムにサブレイヤーを追加する
  • サブレイヤーのフィルタを作成する

コールアウトとは、WFPで特定のフィルタールールにマッチしたときに呼ばれる関数のようなものです
今回はこのコールアウトでPingパケットかどうか、つまりICMPパケットかどうかを判別する予定です

なのでまず最初に必要なのは IoCreateDevice を呼んでデバイスオブジェクトを作成することです
デバイスオブジェクトとはなんぞ?と思う方もいるので、Microsoftに聞いてみましょう

オペレーティング システムは、 デバイス オブジェクトによってデバイスを表します。 1 つ以上のデバイス オブジェクトが各デバイスに関連付けられています。 デバイス オブジェクトは、デバイス上のすべての操作のターゲットとして機能します。

カーネル モード ドライバーは、次の例外を除き、デバイスごとに少なくとも 1 つのデバイス オブジェクトを作成する必要があります。

  • 関連付けられたクラスまたはポート ドライバーを持つミニドライバーは、独自のデバイス オブジェクトを作成する必要はありません。 クラスまたはポート ドライバーは、デバイス オブジェクトを作成し、ミニドライバーに操作をディスパッチします。

  • デバイスの種類固有のサブシステムの一部であるドライバー (NDIS ミニポート ドライバーなど) には、サブシステムによって作成されたデバイス オブジェクトがあります。

...正直何を言ってるかよーわからんですが、とりあえず何かのクラスやポートなんちゃらを用意してるわけではないので今回のケースでは必要そうです
処理は新しく wfp_init とでもした関数に記述していきましょう
ドライバーオブジェクトはどこでも使うので、それを引数にしておきます

lib.rs
// ...

unsafe extern "system" fn wfp_init(driver_object: &mut DRIVER_OBJECT) {
    // グローバル変数はこの関数内でロックされてスコープを抜けるまで安全に使用できる(はず)
    let mut global = GLOBAL.lock();

    let nt_status = IoCreateDevice(
        driver_object,
        0,
        core::ptr::null_mut(),
        FILE_DEVICE_UNKNOWN,
        0,
        0,
        &mut global.0.device_object,
    );
    nt_status_check(nt_status, "IoCreateDevice");
// ...

この呼び出しが成功していればデバイスオブジェクトへのポインターがグローバル変数の device_object フィールドへ格納されているはずです
次に、WFPを利用するためにWFPエンジンなるものへのセッションをオープンする必要があるようです
これは FwpmEngineOpen 関数を呼ぶことで初期化できるのですが、Windowsのバージョンによってこの関数名の後ろに特定の数字がつけられているみたいです
今回利用しているWindows11では、0 をつけるみたいなので FwpmEngineOpen0 をコールしていきます

lib.rs
// ...

unsafe extern "system" fn wfp_init(driver_object: &mut DRIVER_OBJECT) {
    // ...
    let nt_status = FwpmEngineOpen0(
        null_mut(),
        RPC_C_AUTHN_WINNT,
        null_mut(),
        null_mut(),
        &mut global.0.wfp_handle,
    );
    nt_status_check(nt_status, "FwpmEngineOpen0");
    // ...
// ...

今回はコールアウトドライバーを作っていくので、大半を null にすることができます(公式ドキュメントより)
気を付けるのは2個目の引数に RPC_C_AUTHN_WINNT を渡しておくことくらいですかね
呼び出しが成功していればグローバル変数の wfp_handle フィールドにセッションへのポインターが格納されています

次にフィルターエンジンにコールアウトを登録するのですが、ここは結構長いです
コールアウトをレジスターするために FwpsCalloutRegister1 を呼ぶのですが、こいつに渡す FWPS_CALLOUT1 構造体を準備します
また、この構造体に最低2つのコールアウト用関数をセットしなければならないので、そのための関数も準備しなければなりません
とりあえず FwpsCalloutRegister1 の呼び出しから見てみましょう

lib.rs
// ...
unsafe extern "system" fn wfp_init(driver_object: &mut DRIVER_OBJECT) {
    // ...
    let callout_register = FWPS_CALLOUT1 {
        calloutKey: CALLOUT_OUT_GUID,
        flags: 0,
        classifyFn: Some(callout_filter),
        notifyFn: Some(callout_notify),
        flowDeleteFn: None,
    };

    let nt_status = FwpsCalloutRegister1(
        global.0.device_object as _,
        &callout_register,
        &mut global.0.register_callout_id,
    );
    nt_status_check(nt_status, "FwpsCalloutRegister1");
    // ...
// ...

calloutKey フィールドに最初の方に定義しておいた CALLOUT_OUT_GUID をセットします
ここで重要なのは callout_filtercallout_notify で、この2つの関数が最低限用意しなければならないものです
flowDeleteFn への関数は必須ではないと公式ドキュメントに書いてありました
この2つの関数も実装してしまいましょう

lib.rs
// ...
unsafe extern "system" fn callout_filter(
    in_fixed_values: *const FWPS_INCOMING_VALUES0,
    in_meta_values: *const FWPS_INCOMING_METADATA_VALUES0,
    layer_data: PVOID,
    _classify_context: PCVOID,
    _filter: *const FWPS_FILTER1,
    _flow_context: u64,
    _classify_out: *mut FWPS_CLASSIFY_OUT0,
) {
    //    パケットが次の条件の時にのみPong!が出る
    // 1) layer_data     が存在する
    // 2) in_fixed_valuesが存在し、ICMPであること
    // 3) in_meta_values が存在し、IPヘッダーが正しい(サイズが0以下の場合、正しくない)

    if layer_data.is_null()
        || in_fixed_values.is_null()
        || (*in_fixed_values).incomingValue.is_null()
        || in_meta_values.is_null()
        || (*in_meta_values).ipHeaderSize <= 0
    {
        return;
    }

    // incomingValueのアドレスをずらすことで別のINCOMING_VALUEになる
    // ここで使用したい FWPS_FIELD_DATAGRAM_DATA_V4_IP_PROTOCOL は0と定義されているため、
    // ここでは正直ずらさなくても処理は出来る :D
    let incoming_offset = (*in_fixed_values).incomingValue.offset(0);
    if incoming_offset.is_null() {
        return;
    }

    let incoming_data = *incoming_offset;
    if *incoming_data.value.u.uint8() as u32 != IPPROTO_ICMP {
        return;
    }

    println!("Pong!");
}

extern "system" fn callout_notify(
    _notify_type: FWPS_CALLOUT_NOTIFY_TYPE,
    _filter_key: *const GUID,
    _filter: *const FWPS_FILTER1,
) -> NTSTATUS {
    return STATUS_SUCCESS;
}
// ...

実は今回 callout_notify の関数は必要ではなく、定義だけは必要という状態です(じゃないと動かないようです)
本来はフィルターエンジンからの通知のようなものを受け取るときに使用する関数らしいのですが、今回は使わなさそうなので中身は空としています

大事なのは callout_filter で、ここに実際にフィルターされたときに呼ばれる処理を書いていきます
ここでICMPパケットかどうかを判定するためのコードを書き、ICMPならば Pong! とPrintされるようにしました
よくわからんと思いますが、今回のコードで大事なのは in_fixed_values の引数で、こいつにパケットの情報が含まれたポインタが格納されているのでこいつに対しての処理がメインになります
layer_data には生のデータへのポインタが格納されており、in_meta_values にはパケットヘッダー?の情報が格納されているようです(要検証)

layer_data をうまく扱うともっと応用的なこともできるのですが、今回はPingコマンドかどうかわかればいいのでちゃんとポインタがあるかどうかだけをチェックするようにしています

さて、これでフィルターエンジンにコールアウトが登録されました
次にやることはフィルターエンジンに登録されたコールアウトをシステムに追加することです
これは FwpmCalloutAdd0 関数を呼ぶことでできます
ここでは FWPM_CALLOUT0 という専用の構造体をセットアップする必要があります

lib.rs
// ...
unsafe extern "system" fn wfp_init(driver_object: &mut DRIVER_OBJECT) {
    // ...
    let callout_add = FWPM_CALLOUT0 {
        calloutKey: CALLOUT_OUT_GUID,
        flags: 0,
        displayData: FWPM_DISPLAY_DATA0 {
            name: w!("Test ICPM Filter"),
            description: w!("Test ICPM Filter Descripton."),
        },
        providerKey: 0 as _,
        providerData: FWP_BYTE_BLOB {
            size: 0,
            data: 0 as _,
        },
        applicableLayer: FWPM_LAYER_INBOUND_TRANSPORT_V4,
        calloutId: global.0.register_callout_id,
    };
    let nt_status = FwpmCalloutAdd0(
        global.0.wfp_handle,
        &callout_add,
        null_mut(),
        &mut global.0.add_callout_id,
    );
    nt_status_check(nt_status, "FwpmCalloutAdd0");
    // ...
// ...

calloutKey には先ほどエンジンにコールアウトを登録するときに使用した CALLOUT_OUT_GUID を指定します
displayData にはコールアウトの名前と説明を入れた FWPM_DISPLAY_DATA0 構造体をセットします
ここで使ってる w! というマクロは windows-sys crateにあり、ワイド文字を簡単に生成するためのマクロです
便利なので今回これだけのためにcrateを追加しています
provider* 系は今回必要ないので空っぽいものをそのまま指定しました
applicableLayer には追加するコールアウトがどのレイヤーで使用されるのかを指定するもので、この レイヤー識別子のフィルター処理 に色々一覧として書かれています
今回は受信した時のレイヤーで呼び出したいので、 FWPM_LAYER_INBOUND_TRANSPORT_V4 としています
このGUIDについては最初のほうにノートとして書いたのではしょります
calloutId にコールアウトがシステムに追加された後、登録番号的なものを格納するためにグローバル変数のフィールドをセットしています
これはアンロード処理時に必要なので必ず取得するようにします

コールアウトをシステムに追加出来たら、フィルターを追加するためのサブレイヤーを作成します
サブレイヤーの追加は FwpmSubLayerAdd0 という関数に FWPM_SUBLAYER0 と呼ばれる構造体を渡して行うことができます

lib.rs
// ...
unsafe extern "system" fn wfp_init(driver_object: &mut DRIVER_OBJECT) {
    // ...
    let sublayer = FWPM_SUBLAYER0 {
        subLayerKey: SUBLAYER_GUID,
        displayData: FWPM_DISPLAY_DATA0 {
            name: w!("Test ICPM Filter Sublayer"),
            description: w!("Test ICPM Filter Sublayer Descripton."),
        },
        flags: 0,
        providerKey: 0 as _,
        providerData: FWP_BYTE_BLOB {
            size: 0,
            data: 0 as _,
        },
        weight: u16::MAX,
    };
    let nt_status = FwpmSubLayerAdd0(global.0.wfp_handle, &sublayer, null_mut());
    nt_status_check(nt_status, "FwpmSubLayerAdd0");
    // ...
// ...

大体似たような引数であまり特別なものはありませんが、 weight に関してはフィルターが優先されるようになるべく高い数字をセットしておきたいところです
subLayerKey には用意しておいたサブレイヤー用のGUIDをセットしてください、間違えて被ると何が起こるかわかりません

ここまで来たらあと少しです
フィルターを追加する FwpmFilterAdd0 関数に FWPM_FILTER0 構造体を渡していきます
...まぁここが一番長くてめんどうなんですけどね

lib.rs
// ...
unsafe extern "system" fn wfp_init(driver_object: &mut DRIVER_OBJECT) {
    // ...
    let fwp_value_empty = zeroed::<FWP_VALUE0_u>();
    let fwp_filter = zeroed::<FWPM_FILTER0_u>();
    let mut fwp_action = zeroed::<FWPM_ACTION0_u>();

    *fwp_action.calloutKey_mut() = CALLOUT_OUT_GUID;

    let filter = FWPM_FILTER0 {
        filterKey: FILTER_GUID,
        displayData: FWPM_DISPLAY_DATA0 {
            name: w!("Test ICPM Filter"),
            description: w!("Test ICPM Filter Descripton."),
        },
        flags: 0,
        providerKey: 0 as _,
        providerData: FWP_BYTE_BLOB {
            size: 0,
            data: 0 as _,
        },
        layerKey: FWPM_LAYER_INBOUND_TRANSPORT_V4,
        subLayerKey: SUBLAYER_GUID,
        weight: FWP_VALUE0 {
            r#type: FWP_EMPTY,
            u: fwp_value_empty,
        },
        numFilterConditions: 0,
        filterCondition: 0 as _,
        action: FWPM_ACTION0 {
            r#type: FWP_ACTION_CALLOUT_INSPECTION,
            u: fwp_action,
        },
        u: fwp_filter,
        reserved: 0 as _,
        filterId: global.0.filter_callout_id,
        effectiveWeight: FWP_VALUE0 {
            r#type: FWP_EMPTY,
            u: fwp_value_empty,
        },
    };
    let nt_status = FwpmFilterAdd0(
        global.0.wfp_handle,
        &filter,
        null_mut(),
        &mut global.0.filter_callout_id,
    );
    nt_status_check(nt_status, "FwpmFilterAdd0");
    // ...
// ...

FWPM_FILTER0 にセットするものが多すぎて困ってしまいますね
filterKey にはフィルター用のGUIDをセットするとして、subLayerKey までは大体なんとなくわかると思うのではしょります
filterCondition に関して、ここをうまく指定すると例えばTCPかつポート80に来たパケットのみに対してフィルターを行ったりができます
今回は特に必要ないので、空にしています

大体は空っぽいものでいいのですが、めんどいのは weightactioneffectiveWeight の3つのフィールド用にまた3種類の構造体が出てくるところです
weighteffectiveWeight に関しては違いもよくわかりませんが、サブレイヤーとして優先度を高めにしてるので空にしています
u64 のMaxでセットしたら仮想環境がフリーズして再起動してしまったのと、空(0)でも動いたのでまぁ動けばなんでもヨシ!です
action にセットする構造体だけは少し注意が必要です

FWPM_ACTION0 の構造体の type というフィールドにこのフィルターがどのように機能するかのenumを指定する必要があります
今回のケースではパケットをちらみして何かしたいというだけなので、中身を見て他のレイヤーに転送するという動作をさせるために FWP_ACTION_CALLOUT_INSPECTION を指定します
例えば、パケットをそのまま拒否したいときには FWP_ACTION_BLOCK をセットしたりもできますが、今回は別に必要ありませんね
u フィールドにはさらにユニオンとして構造体をつっこむ必要がありますが、ここでは *fwp_action.calloutKey_mut() = CALLOUT_OUT_GUID さえ出来ていればよいです
これによりフィルターがどのコールアウトを呼び出すのかわかります

これでドライバーが完成といきたいところですが、ちゃんと後処理もしなければならないのが面倒なところです
とはいえグローバル変数に必要なフィールドを定義してそこに保存するようにしておいてるので、後処理用の関数をまとめて書いてしまうことができます
ここでは wfp_term という関数に後処理をまとめます

lib.rs
// ...
unsafe extern "system" fn wfp_term(driver_object: *mut DRIVER_OBJECT) {
    let global = GLOBAL.lock();

    if !global.0.wfp_handle.is_null() {
        if global.0.filter_callout_id != 0 {
            println!("Deleting filter data...");
            let nt_status = FwpmFilterDeleteById0(global.0.wfp_handle, global.0.filter_callout_id);
            nt_status_check(nt_status, "FwpmFilterDeleteById0");

            println!("Deleting sublayer...");
            let nt_status = FwpmSubLayerDeleteByKey0(global.0.wfp_handle, &SUBLAYER_GUID);
            nt_status_check(nt_status, "FwpmSubLayerDeleteByKey0");
        }

        if global.0.add_callout_id != 0 {
            println!("Deleting callout data...");
            let nt_status = FwpmCalloutDeleteById0(global.0.wfp_handle, global.0.add_callout_id);
            nt_status_check(nt_status, "FwpmCalloutDeleteById0");
        }

        if global.0.register_callout_id != 0 {
            println!("Unregister callout ...");
            let nt_status = FwpsCalloutUnregisterById0(global.0.register_callout_id);
            nt_status_check(nt_status, "FwpsCalloutUnregisterById0");
        }

        let nt_status = FwpmEngineClose0(global.0.wfp_handle);
        nt_status_check(nt_status, "FwpmEngineClose0");
    }

    if !(*driver_object).DeviceObject.is_null() {
        IoDeleteDevice((*driver_object).DeviceObject);
        println!("Success: IoDeleteDevice successed.");
    }
}
// ...

特に説明したいとこもないですね、登録していったのでそれを解除していってるだけの関数たちを呼んでいるだけになります
これをアンロードに追加しておきます

lib.rs
// ...
unsafe extern "C" fn driver_exit(driver: *mut DRIVER_OBJECT) {
    wfp_term(driver);
    println!("Good Bye World !");
}
// ...

忘れていました、 wfp_init もちゃんと呼び出すようにドライバーエントリーに書いておいてくださいね!

lib.rs
// ...
#[export_name = "DriverEntry"]
pub unsafe extern "system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    _registry_path: PCUNICODE_STRING,
) -> NTSTATUS {
    println!("Hello World !");

    driver.DriverUnload = Some(driver_exit);

    wfp_init(driver);

    return STATUS_SUCCESS;
}
// ...

長かったですがようやくドライバーが完成したかな!?

ドライバーの動作テスト

ドライバーをビルドし、仮想環境との共有用フォルダーに移動させておきます
仮想環境を起動する前に、ネットワークの設定を少し変えて、 pingが通るようにします
難しい設定は必要なく、仮想環境の設定からネットワークに行き、2個目のアダプターとして ホストオンリーアダプター を割り当てるだけです

image.png

設定が終わったら仮想環境を起動します
起動したらドライバーの署名強制無効化のためにスタートアップ設定までまた行きます

image.png

デバッグビューアーを起動しておき、ドライバーをローダー経由でロードさせます

image.png

仮想環境内で ipconfig を打ち、イーサネット2の方のIPv4アドレスをメモります
ここに自分のマシンから ping すると通ります

image.png

今回ぼくの環境では 192.168.56.101 が仮想環境のIPみたいですね
自分のマシンからここに ping するとはたして...!

スクリーンショット 2023-12-10 142855.png

きたーーー!!! Pong! といっているので大成功です!!!

ちなみに WFPExplorer を使用すると、実際にシステムに追加されたフィルターなどを確認できます
着信トランスポート v4レイヤー を見ると自作のフィルターとコールアウトが登録されているのが確認できます

image.png

GUIDもコード上で定義したやつと同じになっているはずです

また、ドライバーをテストした後は、ちゃんと後処理を忘れずにしておきましょう

スクリーンショット 2023-12-10 142916.png

フィルターやコールアウトもちゃんと消えていますね!

image.png

まとめ

今回はRustでWindowsのドライバーを実際に開発しながら触れてみました
はしょったりしたところも多かったのでちょっと難しかったでしょうか、でもこういう変な知識がたまっていくのは楽しいですね
ついでにWindows APIの扱いは2023年になってもまだまだめんどくさそうというのもわかりましたね

今回のやつはうまくやれば悪用したりも出来ますが、手法が分かっていれば防御策も立てられると思うので、やはり攻撃手段というのは知っておくに損したことはなさそうです

また面白いものがあればRustでやってみようとおもいます

今回のコードたちはこちらのリポにまとめてあります: 2vg/kmdf

10
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2