2018年6月10日日曜日

仮想ネットワークドライバ「TAP-Windows」をC#から操作する - Tap.NET 0.1.0

ネットワークの勉強をしていると、イーサネットパケットレベルで他のコンピューターとやり取りをしてみたくなることがあります。パソコンを2台起動して、リモートデスクトップを使いながらいろいろ実験するのも悪くないですが、1台ですべて完結できたら便利ですよね。Wiresharkでキャプチャしたパケットを自身のPCに送り直すくらいのことなら、仮想ネットワークドライバをインストールして、ソフトウェア的にエミュレートすることもできるはずです。

ググっていると、OpenVPNに付属している仮想ネットワークドライバ「TAP-Windows」というものを見つけました。本来はVPNで使用するための仮想ネットワークドライバのようですが、Win32APIを介してパケットのやり取りができるようです。

というわけで、この.NETラッパーを作ってC#等から使えるようにしてみました。

1. TAP-Windowsのインストール

まずはTAP-Windowsをインストールしなければ始まりません。
Download - OpenVPN.net
この中からOpenVPNのWindows用インストーラをダウンロードしインストールをします。OpenVPN本体自体は不要ですので、インストールにて他のチェックボックスは極力外しておきましょう。

インストールが終わったらネットワークと共有センターを開いてちゃんとインストールできているか確認しておきましょう。名前をわかりやすいものに変更しておくといいかもしれません。

余談ですが、Wiresharkがパケットキャプチャで使っているWinPcapサービスは起動時に存在するネットワークアダプタをスキャンしているようです。なので、WinPcapからTAP-Windowsを見るためには一旦パソコンを再起動する必要があります。

2. ラッパーを作る

TAP-WindowsにアクセスするにはWin32APIのCreateFileReadFileWriteFile関数などを呼び出します。概ねファイルの読み書きと同様にできると言っていいでしょう。
C#からP/Invokeで頑張るのもありかもしれませんが、だんだんわけがわからなくなってきたり、結局定数の中身を除くために延々とwindows.hを辿ったり、unsafeを強要されそうになってしまったり…というお約束のルートが見えています。ですので、プログラミング界の黒魔術(※)と呼ばれているC++/CLIでラッパーを作ります。あまり深入りはし過ぎないように、ほどほどの作りこみにしておきましょう。
※私が勝手に呼んでいるだけです。
 

コンストラクタ

/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="guid">TAPデバイスのGUID</param>
TapDotNet::Tap::Tap(String ^ guid)
{
    String^ path = "\\\\.\\Global\\" + guid + ".tap";
    pin_ptr<const wchar_t> pszPath = PtrToStringChars(path);

    ULONG status = TRUE;
    DWORD dwLen;


    //TAPデバイスを開く
    hTap = CreateFileW(pszPath, GENERIC_READ | GENERIC_WRITE, 0, 0, OPEN_EXISTING, FILE_ATTRIBUTE_SYSTEM, 0);

    if(hTap == INVALID_HANDLE_VALUE)
        throw gcnew InvalidOperationException("Cannot open the device");

    //TAPデバイスをアクティブに
    status = TRUE;
    if(!DeviceIoControl(hTap, CTL_CODE(FILE_DEVICE_UNKNOWN, 6, METHOD_BUFFERED, FILE_ANY_ACCESS), &status, sizeof(status), &status, sizeof(status), &dwLen, NULL)) {
        CloseHandle(hTap);
        hTap = NULL;
        throw gcnew InvalidOperationException("Cannot activate device");
    }
}
まずはデバイスの初期化をします。
CreateFileのファイル名を指定するパラメーターには"\\.\Global\{GUID}.tap"を渡します。GUIDの取得方法はまた後程。その他のパラメーターはなんとなくわかると思います。
デバイスを開いたら次はDeviceIoControl関数を呼び出してデバイスをアクティブにします。この時点でWindowsはネットワークケーブルが差し込まれたと認識します。

パケット送信

/// <summary>
/// データを送信するメソッド
/// </summary>
/// <param name="data">パケット</param>
void TapDotNet::Tap::SendData(array<Byte>^ data)
{
    if(data == nullptr) throw gcnew ArgumentNullException("data");
    if(hTap == NULL) throw gcnew ObjectDisposedException("Tap");

    pin_ptr<Byte> pinned = &data[0];
    DWORD dwWritten;

    if(!WriteFile(hTap, pinned, data->Length * sizeof(Byte), &dwWritten, NULL))
        throw gcnew InvalidOperationException("WriteFile Error: " + GetLastError().ToString());
}
データの送信にはWriteFile関数を使います。書き込んだバイトを取得するパラメーターは必要無くてもNULLにしてはいけないようです。Windows10では動きましたがWindows7ではAccessViolationExceptionを吐いて動かず、しばらくハマってしまいました(ドキュメンテーションにもOverlapped構造体とともにNULLにはできないと書いてありました…)。

パケット受信

void TapDotNet::Tap::StartWatching()
{
    if(!ReceiveWatching) {
        WatcherThread = gcnew Thread(gcnew ThreadStart(this, &TapDotNet::Tap::WatchReceive));
        WatcherThread->Start();
    }
}

void TapDotNet::Tap::StopWatching()
{
    if(ReceiveWatching) {
        WatcherThread->Abort();
        WatcherThread->Join();
        WatcherThread = nullptr;
    }
}

void TapDotNet::Tap::WatchReceive()
{
    try {
        UCHAR Buf[1518] = { 0 };
        DWORD dwLen = 0;

        while(true) {
            if(ReadFile(hTap, Buf, sizeof(Buf), &dwLen, NULL))
                if(Filter(Buf, dwLen))
                    RaiseReceiveEvent(Buf, dwLen);
            else {
                DWORD lasterr = GetLastError();
                if(lasterr != 0) {
                    RaiseErrorEvent("ReadFile Error", lasterr);
                    break;
                }
            }            
        }
    }
    catch(ThreadAbortException^) {
    }
}
データの受信は受信用スレッドを作って行います。
えっ、今の時代Thread直作りは流行らないって?C++/CLI自体が流行らないので許してください…。

Filter関数は受信したパケットにフィルターを掛けるものです。全てのパケットをイベントでRaiseしていてはイベントの嵐になってしまいますので、そういうのはできるだけ根っこで止めるということで、受信直後にフィルターを掛ける機能を実装しました。
ただ、ここで受信するのはイーサネットフレームで、そのヘッダで得られる情報は発信元と宛先のMACアドレスと、IPv4などのネットワーク層水準のプロトコルの種類くらいです。
トランスポート層のプロトコル種類(TCPとかUDPとか)やIPアドレスなどを判別する機能も付けたいところではありますが、じゃあ同じようにAppleTalkのフィルターも作るのかと言ったらそんなの作る気にもなれず、ひとまずそのレベルのフィルターでとどめておくことにしました。
C++/CLIですからね。深入りは厳禁です。

デストラクタ/ファイナライザ

TapDotNet::Tap::~Tap()
{
    StopWatching();
    this->!Tap();
}

TapDotNet::Tap::!Tap()
{
    if(hTap != NULL) {
        CloseHandle(hTap);
        hTap = NULL;
    }
}
最後は終了処理をします。C++/CLIではDisposeパターンが自動的に実装されるらしく、C#でのディスポーザはC++/CLIではデストラクタとして実装するそうです。
マネージドリソース(今回はThread)はデストラクタ、アンマネージドリソース(CreateFileのハンドル)はファイナライザにて解放処理を行います。

3. 実際に使う

やっとC++/CLIの呪縛から逃れました。
前述のTAP-WindowsのGUIDを求める方法は超簡単です。NetworkInterfaceクラスIdプロパティがそれに相当します。ですのでこのようなプログラムでどうでしょうか。
var tapnic = NetworkInterface.GetAllNetworkInterfaces().SingleOrDefault(p => p.Description == "TAP-Windows Adapter V9");

using(var tap = new Tap(tapnic.Id)) {
    tap.DataReceived += Tap_DataReceived;
    tap.ReceiveWatching = true;
    Task.Delay(TimeSpan.FromSeconds(10)).Wait();
    tap.ReceiveWatching = false;
    tap.DataReceived -= Tap_DataReceived;
}
バージョン違い等でDescriptionが書き換わったらこれではうまく動かなくなってしまいますが…となるとユーザーに選択させるとかですかね。まあ、サンプルコードとしては十分でしょう。
また、これはコンソールアプリケーションで作ったので、Task.Delay().Wait();ができますが、GUIアプリケーションに移植する際は気を付けてください。あくまでもTaskはasync/awaitすべきです。
private static void Tap_DataReceived(object sender, DataReceivedEventArgs e)
{
    Console.WriteLine($"======== {e.Data.Length} bytes ========");

    const int linecnt = 16;
    const int separatedcnt = 8;

    foreach(var (list, index) in e.Data.Buffer(linecnt).Select((list, index) => (list, index))) {
        var line = new StringBuilder();

        line.Append($"{index * linecnt:X4}  ");
        line.Append(string.Join("  ", list
            .Select(p => p.ToString("X2"))
            .Concat(Enumerable.Repeat("  ", linecnt - list.Count))
            .Buffer(separatedcnt)
            .Select(p => string.Join(" ", p))));
        line.Append("  ");
        line.Append(string.Join(" ", list
            .Select(p => (char)p)
            .Select(p=>char.IsControl(p) ? '.' : p)
            .Concat(Enumerable.Repeat(' ', linecnt - list.Count))
            .Buffer(separatedcnt)
            .Select(p => new string(p.ToArray()))));

        Console.WriteLine(line);
    }

    Console.WriteLine();
}
データを受信したときにカッコよくダンプするコードを書きました。
ValueTupleとIxのBuffer()を使っているので、相応のC#バージョンと参照が必要です。

送信は超簡単です。
using(var tap = new Tap(tapnic.Id)) {
    tap.SendData(ping);
}
pingはpingのイーサネットフレームのバイト列です。Wiresharkの下のほうに見えるアレです。
Tap.NETから送ったpingパケットをWiresharkで無事拾えました。

4. ダウンロード

例によってNugetに置いておきました。現時点ではひとまずプレリリース扱いにしております。
Tap.NET 0.1.0-beta1

0 件のコメント:

コメントを投稿