All Articles

A PART OF ANTI-VIRUS【3 章 Scanner のサンプルコードを読む】

本章では、Scanner File System Minifilter Driver のサンプルコードの解説を通して、 Windows 用 AntiVirus ソフトウェアのリアルタイムファイルスキャンのしくみを学びます。

プログラムの実装に関する理解をスムーズにするため、 本書では Scanner のソースコードを 1 行目から順に追うのではなく、 実際のプログラムの動作に合わせた順序で解説を行います。

また、本書では紙面の都合上、解説するコードの全文は引用せず、一部を抜粋する形で記載しています。

コードを抜粋する際には可読性を維持するため、構造体やグローバル変数の併記のほか、コメントの削除などを行う場合があります。

そのため、本書で抜粋しているコードは必ずしも元のサンプルコードの構成とは一致しない場合がある点をご承知おきください。

オリジナルのソースコードは以下のサンプルコードリポジトリで公開されているので、 本書をお読みいただく際には事前に Scanner ミニフィルタドライバーのソースコードを取得いただくことをおすすめします。


URL:Windows-driver-samples - Scanner

https://github.com/microsoft/Windows-driver-samples


もくじ

Scanner の全体像

Scanner のサンプルコードは、AvScan などの同じリポジトリの他のサンプルコードと比較して、とてもシンプルに実装されています。

Scanner は、カーネルモードのミニフィルタドライバーとユーザモードプログラムの 2 つのコンポーネントで構成されており、 主に scanner.c と scanUser.c というファイルでそれぞれ実装されています。

Scanner ソリューション内のファイル

以下は、Scanner の全体像を模したイメージ図です。

Scanner の全体図(イメージ)

scanner.sys によって登録されるミニフィルタドライバーは、システム内の I/O 要求のうちのいくつかをフィルタリングします。

その際、ミニフィルタドライバーはスキャン対象のデータを含む情報をユーザモードで稼働する クライアントプログラムである scanuser.exe に送信します。

ミニフィルタドライバーから送信された情報は scanuser.exe のワーカースレッドでスキャンされ、 その結果がミニフィルタドライバーに返却されます。

この時、scanuser.exe によりスキャンされたデータが不正ファイルのシグネチャ(文字列 foul)を含む場合、 ミニフィルタドライバーは I/O 要求を拒否し、ファイルオープンやデータの書き込みなどの操作をブロックします。

なお、Scanner は非常にシンプルなサンプルコードとして用意されているため、 実際の AntiVirus ソフトウェアと比較して様々な点が簡略化されている点に注意が必要です。

例えば、Scanner の場合、検出対象となるシグネチャはプログラム内にハードコードされています。

しかし、一般的な商用 AntiVirus ソフトウェアでは通常、 ソフトウェアのベンダから毎日配信されるコンポーネントファイルにシグネチャが含まれます。

このコンポーネントファイルは一般的に定義ファイルやパターンファイルと呼ばれることが多いです。

ただし、主要な商用 AntiVirus ソフトウェアは通常、上記のシグネチャベースの検出機能に加えて、 様々なテクノロジを利用した独自の検出機能を備えています。

そのため、各ソフトウェアベンダから配布されるコンポーネントファイルの中には、 パターンマッチングのためのシグネチャ以外にも様々な情報が含まれる場合があります。

また、Scanner ミニフィルタドライバーでは、IRP_MJ_CREATEIRP_MJ_CLEANUPIRP_MJ_WRITE の I/O 要求が発生した場合にのみファイルスキャンを試みるように実装されていますが、 商用の AntiVirus ソフトウェアではより多くの種類の要求に対して適切なフィルタリングを行うコールバック関数がセットされます。

実際に、AvScan のサンプルコードでは上記に加えて IRP_MJ_FILE_SYSTEM_CONTROLIRP_MJ_SET_INFORMATION の I/O 要求のフィルタリングが登録されており、 より商用の AntiVirus ソフトウェアに近い実装となっています。

さらに、Scanner ミニフィルタドライバーではスキャンするファイルデータサイズの上限が 1024 バイトに制限されている上に、 スキャン対象のファイルデータはミニフィルタドライバー側で読み取った後にユーザモードプログラムに送信されます。

このような、スキャンデータサイズの上限の小ささや、カーネルドライバ側でファイルのデータを読み出す点も、 一般的な商用 AntiVirus ソフトウェアの実装とは異なる点です。

以上の通り、Scanner のサンプルコードには実際の商用 AntiVirus ソフトウェアの実装とは大きく異なる点が多々あります。

しかし、Scanner のサンプルコードは、ミニフィルタドライバーを使用したリアルタイムファイルスキャンのしくみを理解する上では非常に有用なサンプルとなるでしょう。

Scanner ミニフィルタドライバーの DriverEntry 関数

初期化とミニフィルタの登録

Scanner ミニフィルタドライバーのソースコードを読むにあたり、まずは scanner.c の DriverEntry 関数を確認します。

Scanner ミニフィルタドライバーの DriverEntry 関数では、フィルタやコールバック関数の登録など、いくつかの重要な初期化処理を行います。

Scanner の DriverEntry 関数ではまず、ExInitializeDriverRuntime 関数1 により、 非ページプールのメモリが割り当てられる際に、メモリ内の命令の実行が禁止されている NonPagedPoolNx を使用するように初期化を行います。

ExInitializeDriverRuntime( DrvRtPoolNxOptIn );

続けて、前の章で解説した FltRegisterFilter 関数を使用してフィルタマネージャーにミニフィルターを登録しています。

status = FltRegisterFilter( 
    DriverObject,
    &FilterRegistration,
    &ScannerData.Filter 
);

if (!NT_SUCCESS( status )) {

    return status;
}

この時引数に使用している FilterRegistration と ScannerData.Filter はどちらもグローバル変数として定義されています。

FilterRegistration が指す FLT_REGISTRATION 構造体では、コンテキストと IRP のルーチンに対応するコールバック関数を登録するための構造体配列がそれぞれ指定されています。

ここで登録しているコンテキストとコールバック関数の種類については後述します。

SCANNER_DATA ScannerData;

typedef struct _SCANNER_DATA {
    PDRIVER_OBJECT DriverObject;
    PFLT_FILTER Filter;
    PFLT_PORT ServerPort;
    PEPROCESS UserProcess;
    PFLT_PORT ClientPort;

} SCANNER_DATA, *PSCANNER_DATA;


const FLT_REGISTRATION FilterRegistration = {

  sizeof( 
    FLT_REGISTRATION 
  ),                        //  Size
  FLT_REGISTRATION_VERSION, //  Version
  0,                        //  Flags
  ContextRegistration,      //  Context Registration.
  Callbacks,                //  Operation callbacks
  ScannerUnload,            //  FilterUnload
  ScannerInstanceSetup,     //  InstanceSetup
  ScannerQueryTeardown,     //  InstanceQueryTeardown
  NULL,                     //  InstanceTeardownStart
  NULL,                     //  InstanceTeardownComplete
  NULL,                     //  GenerateFileName
  NULL,                     //  GenerateDestinationFileName
  NULL                      //  NormalizeNameComponent

};

スキャン対象に関する設定情報をロードする

FltRegisterFilter 関数によりミニフィルタドライバーの登録に成功した後は、ScannerInitializeScannedExtensions 関数という独自の関数を実行します。

この関数は Scannner のサービスレジストリ内の設定情報をロードし、スキャン対象のファイルの拡張子を登録します。

status = ScannerInitializeScannedExtensions( 
    DriverObject, RegistryPath 
);

if (!NT_SUCCESS( status )) {

    status = STATUS_SUCCESS;

    ScannedExtensions = &ScannedExtensionDefault;
    ScannedExtensionCount = 1;
}

ここでレジストリから取得した拡張子の情報には、以下のグローバル変数を使用してアクセスすることができます。

PUNICODE_STRING ScannedExtensions;
ULONG ScannedExtensionCount;

また、この時レジストリからロードされる構成情報は scanner.inf ファイルにて定義されており、 既定では exe,doc,txt,bat,cmd,inf の拡張子が登録されています。

ScannedExtensions は以下のように実装されており、 ScannedExtensions のポインタが指すアドレスにレジストリからロードした拡張子の文字列情報が連続して格納されます。

ScannedExtensions の構造

カーネルデバッガで確認してみると、scanner.inf ファイルで定義されている 6 つの拡張子の情報が順に格納されていることを確認できます。

拡張子をロードしたことを確認する

ここでロードしたスキャン対象の拡張子の情報は、後に I/O 要求のフィルタリングを行う際に、 対象のファイルをスキャンするか否かを決定する際に使用されます。

ファイルのスキャンは一般的な AntiVirus ソフトウェアの中で最も多くのシステムリソースを消費する動作の 1 つです。

ほとんどの場合、AntiVirus ソフトウェアによるリアルタイムファイルスキャンはシステムのパフォーマンスとのトレードオフの関係にあります。

そのため、一般的な AntiVirus ソフトウェアではシステムの可用性を高めることを目的として、 何らかの形でリアルタイムファイルスキャンのパフォーマンスを軽減するチューニングが行われています。

実際にいくつかの商用 AntiVirus ソフトウェアでも、 スキャン対象となるファイルの拡張子の指定や特定の拡張子をスキャン対象から除外する設定などをユーザ側で行うことができます。

なお、Scanner の場合、このようなチューニングはファイルの拡張子のチェック機能以外実装されていません。

しかし、AvScan のサンプルコードを読むと、より実用的なチューニングのために 様々な条件でスキャンをスキップする機能が追加されることを確認できます。

ユーザモードプログラムとの接続を待機する

サービスレジストリからスキャン対象の拡張子の情報をロードした後は、 クライアントとなるユーザモードプログラムとの接続のために必要な設定を行います。

まずはじめに、UNICODE_STRING 構造体の変数 uniString を初期化し、文字列 \\\\ScannerPort を保存します。

さらに、FltBuildDefaultSecurityDescriptor 関数2 を使用して、ポインタ sd が指す SECURITY_DESCRIPTOR 構造体に アクセスマスク FLT_PORT_ALL_ACCESS をセットします。

RtlInitUnicodeString( &uniString, ScannerPortName );


PSECURITY_DESCRIPTOR sd;

status = FltBuildDefaultSecurityDescriptor(
    &sd, 
    FLT_PORT_ALL_ACCESS 
);

これらは、いずれもカーネルモードで動作する Scanner ミニフィルタドライバーと、 ユーザーモードで動作する scanUser プログラムの接続に使用する情報です。

FltBuildDefaultSecurityDescriptor 関数ではミニフィルタドライバーがユーザーモードプログラムと 接続するためのポートを作成する際に使用するセキュリティ識別子の割り当てを行います。

この関数を使用してセキュリティ識別子がオブジェクトに割り当てられている場合、 そのオブジェクトにはシステムもしくは管理者ユーザーのみがアクセスできるようになります。3

FltBuildDefaultSecurityDescriptor 関数によるセキュリティ識別子の割り当てに成功した場合、 InitializeObjectAttributes 4 による OBJECT_ATTRIBUTES 構造体の初期化と、 FltCreateCommunicationPort 関数 5 による通信ポートの作成を行います。

InitializeObjectAttributes( 
    &oa,
    &uniString,
    OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
    NULL,
    sd 
);

status = FltCreateCommunicationPort( 
    ScannerData.Filter,
    &ScannerData.ServerPort,
    &oa,
    NULL,
    ScannerPortConnect,
    ScannerPortDisconnect,
    NULL,
    1 
);

InitializeObjectAttributes マクロは、オブジェクトやハンドルに適用する属性情報である OBJECT_ATTRIBUTES 構造体の初期化を行います。

InitializeObjectAttributes マクロでハンドルを開くオブジェクトの名前を指定する パラメータである uniString には、文字列 \\\\ScannerPort が保存されています。

また、OBJ_KERNEL_HANDLE がフラグとして指定されているため、 取得したハンドルにはカーネルモードからのみアクセスできるようになることが期待されます。

これらの情報は FltCreateCommunicationPort 関数の引数として渡され、 ミニフィルタドライバーがユーザモードプログラムと通信するためのポートを開く際に使用されます。

Scanner ミニフィルタドライバー側で接続ポートが開放され、ユーザモードプログラムが開放されたポートに接続している場合、!fltkd.portlist <ミニフィルタドライバーのアドレス> を実行することでポートの情報を出力することができます。

fltkd の portlist コマンドを使用する

Scanner ミニフィルタドライバーによるフィルタリングを開始する

FltCreateCommunicationPort 関数による接続ポートの開放に成功した場合、 FltStartFiltering 関数によりミニフィルタドライバーのフィルタリングを開始し、DriverEntry 関数の処理を終了します。

status = FltStartFiltering( ScannerData.Filter );

if (NT_SUCCESS( status )) {

    return STATUS_SUCCESS;
}

ユーザモードプログラムとの連携

Scanner ミニフィルタドライバーは、DriverEntry 関数でセットした接続ポートを利用し、 ユーザモードで動作する scanUser プログラムとの連携を行います。

scanUser プログラムは主に scanUser.c ファイルで実装されています。

scanUser プログラムを起動する

scanUser プログラムの動作を確認するため、まずはじめに main 関数のコードを読むことにします。

scanUser プログラムが実行されると、コマンドライン引数の有無を確認してグローバルに定義されている requestCount と threadCount の更新を行います。

requestCount は各実行スレッドが受け取る要求の数を、そして threadCount は scanUser プログラムの実行スレッドの数を指定する値です。

#define SCANNER_DEFAULT_REQUEST_COUNT 5
#define SCANNER_DEFAULT_THREAD_COUNT  2
#define SCANNER_MAX_THREAD_COUNT      64


DWORD requestCount = SCANNER_DEFAULT_REQUEST_COUNT;
DWORD threadCount = SCANNER_DEFAULT_THREAD_COUNT;
HANDLE threads[SCANNER_MAX_THREAD_COUNT] = { NULL };

if (argc > 1) {

    requestCount = atoi( argv[1] );

    if (requestCount <= 0) {

        Usage();
        return 1;
    }

    if (argc > 2) {

        threadCount = atoi( argv[2] );
    }

    if (threadCount <= 0 || threadCount > 64) {

        Usage();
        return 1;
    }
}

requestCount は 0 以上の整数値を、threadCount は 0 から 64 までの整数値である必要があります。

この制限を満たさない値がコマンドライン引数として与えられた場合、 Usage 関数が呼び出されて Usage: scanuser [requests per thread] [number of threads(1-64)] というメッセージを表示した後、プログラムが終了します。

scanUser プログラムからミニフィルタドライバーのポートに接続する

requestCount と threadCount の値を更新したら、FilterConnectCommunicationPort 関数6 を使用して、 ミニフィルタドライバーが作成した通信ポートに接続を試みます。

この時、接続先のポート名として \\\\ScannerPort を使用します。

また、接続に成功した場合、新しく作成した接続ポートのハンドルは main 関数内で定義されている port に格納されます。

const PWSTR ScannerPortName = L"\\ScannerPort";

HANDLE port, completion;

hr = FilterConnectCommunicationPort( 
    ScannerPortName,
    0,
    NULL,
    0,
    NULL,
    &port );

if (IS_ERROR( hr )) {

    printf( "ERROR: Connecting to filter port: 0x%08x\n",
             hr );
    return 2;

}

ミニフィルタドライバーへの接続に成功した場合、CreateIoCompletionPort 関数7 を使用して ミニフィルタドライバーとの接続ポートへのハンドルに I/O Completion ポートを割り当てます。

completion = CreateIoCompletionPort( 
    port,
    NULL,
    0,
    threadCount );

if (completion == NULL) {

    printf( "ERROR: Creating completion port: %d\n", 
             GetLastError() );
    CloseHandle( port );
    return 3;
}

I/O Completion ポート8 とは、関連づけられた特定のファイルハンドル(ここでは、ディスク上のファイルだけではなく TCP ソケットや名前付きパイプなどを含む I/O エンドポイントを指す)にて、効率的に非同期 I/O 要求を処理するために使用されるキューオブジェクトです。

I/O Completion ポートをミニフィルタドライバーとの接続ポートへのハンドルに割り当てることで、 複数のユーザモードスレッドで効率的にミニフィルタドライバーから受け取った要求を処理できるようになります。

I/O Completion ポートの仕組みについては本書では扱いませんが、「インサイド Windows 第 7 版 上」に詳しい説明が記載されているので参考になると思います。9

I/O Completion ポートをハンドルに割り当てることに成功した場合、SCANNER_THREAD_CONTEXT 構造体にミニフィルタドライバーとの接続ポートへのハンドルと、 それに割り当てた I/O Completion ポートへのハンドルをそれぞれ保存します。

typedef struct _SCANNER_THREAD_CONTEXT {

    HANDLE Port;
    HANDLE Completion;

} SCANNER_THREAD_CONTEXT, *PSCANNER_THREAD_CONTEXT;


SCANNER_THREAD_CONTEXT context;


printf( "Scanner: Port = 0x%p Completion = 0x%p\n",
         port, completion );

context.Port = port;
context.Completion = completion;

この情報は、後に scanUser プロセスのスレッドを作成する際のパラメータとして渡されます。

ScannerWorker スレッドを起動する

続いて、main 関数内では calloc 関数によって SCANNER_MESSAGE 構造体を保存するためのメモリ領域を必要な分だけ確保します。

SCANNER_MESSAGE 構造体については後述します。

typedef struct _SCANNER_MESSAGE {

    FILTER_MESSAGE_HEADER MessageHeader;

    SCANNER_NOTIFICATION Notification;

    OVERLAPPED Ovlp;
    
} SCANNER_MESSAGE, *PSCANNER_MESSAGE;


PSCANNER_MESSAGE messages;


messages = calloc(
    ((size_t) threadCount) * requestCount,
    sizeof(SCANNER_MESSAGE)
);

if (messages == NULL) {

    hr = ERROR_NOT_ENOUGH_MEMORY;
    goto main_cleanup;
}

必要なメモリ領域の割り当てに成功したら、threadCount で指定した数のワーカースレッドを起動します。

この時、CreateThread 関数で起動するスレッドの実行アドレスとして ScannerWorker 関数のアドレスが指定されています。

また、前項で確認した SCANNER_THREAD_CONTEXT 構造体 context のポインタがスレッドにパラメータとして渡されます。

for (DWORD i = 0; i < threadCount; i++) {
    
    threads[i] = CreateThread( 
        NULL,
        0,
        (LPTHREAD_START_ROUTINE) ScannerWorker,
        &context,
        0,
        &threadId 
    );

}

ScannerWorker 関数の機能について

ScannerWorker 関数は scanUser プログラムのワーカースレッドとして実行される関数です。

この関数はミニフィルタドライバーから受け取ったデータをスキャンし、その結果をミニフィルタドライバーに返す関数です。

ワーカースレッド内の主な処理は while (TRUE) によるループブロック内で行われます。

ワーカースレッドとして起動した ScannerWorker 関数では、まずはじめに GetQueuedCompletionStatus 関数10 を実行して、 Context->Completion として受け取った I/O Completion ポートのハンドルを使用して I/O Completion パケットの取り出しを試みます。

result = GetQueuedCompletionStatus( 
    Context->Completion, 
    &outSize, 
    &key, 
    &pOvlp, 
    INFINITE
);

上記を実行することで、ミニフィルタドライバーから送信された情報を受け取ることができます。

ミニフィルタドライバーからの情報を受け取ることに成功した場合、CONTAINING_RECORD マクロを使用し、 受け取った OVERLAPPED 構造体のポインタから、対応する SCANNER_MESSAGE 構造体のアドレスを取得します。

LPOVERLAPPED pOvlp;
PSCANNER_MESSAGE message;

message = CONTAINING_RECORD( 
    pOvlp, 
    SCANNER_MESSAGE, 
    Ovlp 
);

if (!result) {

    hr = HRESULT_FROM_WIN32( GetLastError() );
    break;
}

SCANNER_MESSAGE 構造体は以下通り複数の構造体をメンバとして構成されています。

typedef struct _FILTER_MESSAGE_HEADER {

    ULONG ReplyLength;

    ULONGLONG MessageId;

} FILTER_MESSAGE_HEADER, *PFILTER_MESSAGE_HEADER;


typedef struct _SCANNER_NOTIFICATION {

    ULONG BytesToScan;

    ULONG Reserved;

    UCHAR Contents[SCANNER_READ_BUFFER_SIZE];
    
} SCANNER_NOTIFICATION, *PSCANNER_NOTIFICATION;


typedef struct _OVERLAPPED {

    ULONG_PTR Internal;

    ULONG_PTR InternalHigh;

    union {
        struct {
            DWORD Offset;
            DWORD OffsetHigh;
        } DUMMYSTRUCTNAME;
        PVOID Pointer;
    } DUMMYUNIONNAME;

    HANDLE  hEvent;

} OVERLAPPED, *LPOVERLAPPED;


typedef struct _SCANNER_MESSAGE {

    FILTER_MESSAGE_HEADER MessageHeader;

    SCANNER_NOTIFICATION Notification;

    OVERLAPPED Ovlp;
    
} SCANNER_MESSAGE, *PSCANNER_MESSAGE;

MessageHeader は FILTER_MESSAGE_HEADER 構造体11 であり、カーネルモードのミニフィルタドライバーから受信するメッセージに必須のヘッダ情報を持ちます。

また、Scanner 固有の SCANNER_NOTIFICATION 構造体には、ミニフィルタドライバー側で割り当てられたスキャン対象のコンテンツが保存されます。(これは Scanner ミニフィルタドライバー特有の実装です)

ミニフィルタドライバーから受け取った SCANNER_MESSAGE 構造体の message を取得した scanUser プログラムは、 受け取った message の中から SCANNER_NOTIFICATION 構造体の Notification を取り出し、 取得したスキャン対象のデータを ScanBuffer 関数の引数として渡して、スキャン結果を受け取ります。

notification = &message->Notification;

assert(notification->BytesToScan <= SCANNER_READ_BUFFER_SIZE);
_Analysis_assume_(notification->BytesToScan <= SCANNER_READ_BUFFER_SIZE);

result = ScanBuffer( notification->Contents, notification->BytesToScan );

Scanner においてスキャンを行う ScanBuffer 関数は非常に簡略化されており、 スキャン対象のデータの中に foul という文字列が存在しているか否かのみをチェックします。

UCHAR FoulString[] = "foul";

PUCHAR p;
ULONG searchStringLength = sizeof(FoulString) - sizeof(UCHAR);

for (p = Buffer;
    p <= (Buffer + BufferSize - searchStringLength);
    p++) 
{

    if (RtlEqualMemory( p, FoulString, searchStringLength )) {

        printf( "Found a string\n" );

        return TRUE;
    }
}

スキャン対象のデータに foul という文字列が含まれている場合、 つまり、スキャンを行った結果、そのファイルが検出対象と判定された場合、ScanBuffer 関数は TRUE を返します。

ここで返却されたスキャン結果は SCANNER_REPLY_MESSAGE 構造体のメンバである replyMessage.Reply.SafeToOpen に保存され、 FilterReplyMessage 関数12 の引数としてミニフィルタドライバーに渡されます。

typedef struct _SCANNER_REPLY {

    BOOLEAN SafeToOpen;
    
} SCANNER_REPLY, *PSCANNER_REPLY;


typedef struct _SCANNER_REPLY_MESSAGE {

    FILTER_REPLY_HEADER ReplyHeader;

    SCANNER_REPLY Reply;

} SCANNER_REPLY_MESSAGE, *PSCANNER_REPLY_MESSAGE;


SCANNER_REPLY_MESSAGE replyMessage;

replyMessage.ReplyHeader.Status = 0;
replyMessage.ReplyHeader.MessageId = message->MessageHeader.MessageId;
replyMessage.Reply.SafeToOpen = !result;

printf( "Replying message, SafeToOpen: %d\n",
        replyMessage.Reply.SafeToOpen );

hr = FilterReplyMessage( 
    Context->Port,
    (PFILTER_REPLY_HEADER) &replyMessage,
    sizeof( replyMessage ) 
);

SCANNER_REPLY_MESSAGE 構造体は、上記の通り FILTER_REPLY_HEADER 構造体13SCANNER_REPLY 構造体の 2 つのメンバから構成されています。

FILTER_REPLY_HEADER 構造体はミニフィルタドライバーからのメッセージに FilterReplyMessage 関数で応答する際に必須のヘッダ情報です。

また、SCANNER_REPLY 構造体は BOOLEAN 型のメンバ SafeToOpen を 1 つだけ持つ構造体で、 ScanBuffer 関数の戻り値を反転した真偽値をミニフィルタドライバーに受け渡すために使用されます。

以上の通り、ユーザモードプログラムのワーカースレッドとして動作する ScannerWorker 関数は、 ミニフィルタドライバーから受け取ったデータをスキャンし、その結果を返却します。

スキャンを担当している ScanBuffer 関数内でハードコードされた文字列が検出された場合は、 ミニフィルタドライバーが対象ファイルの I/O 操作をブロックし、ファイルの保存に失敗します。

実際に、Scanner を実行している環境でこの文字列を含むファイルを保存しようとすると、操作がブロックされてファイルの保存に失敗することを確認できます。

不正な文字列が Scanner により検出される

IRP のルーチンと対応するコールバック関数

本章ではここまでに Scanner ミニフィルタドライバーとユーザモードプログラムとの接続について確認しました。

次は Scanner ミニフィルタドライバーが登録した各コールバック関数の実装を確認します。

Scanner ミニフィルタドライバーは以下の構造体配列をフィルタマネージャーに登録します。

const FLT_OPERATION_REGISTRATION Callbacks[] = {

    { IRP_MJ_CREATE,
      0,
      ScannerPreCreate,
      ScannerPostCreate},

    { IRP_MJ_CLEANUP,
      0,
      ScannerPreCleanup,
      NULL},

    { IRP_MJ_WRITE,
      0,
      ScannerPreWrite,
      NULL},

#if (WINVER>=0x0602)

    { IRP_MJ_FILE_SYSTEM_CONTROL,
      0,
      ScannerPreFileSystemControl,
      NULL
    },

#endif

    { IRP_MJ_OPERATION_END}
};

この構造体配列から確認できる通り、Scanner ミニフィルタドライバーは IRP_MJ_CREATE 要求の前後と、IRP_MJ_CLEANUPIRP_MJ_WRITEIRP_MJ_FILE_SYSTEM_CONTROL の完了後にコールバック関数が設定されています。

IRP_MJ_CREATE と対応する ScannerPreCreate 関数

IRP_MJ_CREATE 14 はファイルオブジェクトやデバイスオブジェクトへのハンドルを開く際に送信される要求です。

Scanner ミニフィルタドライバーは、IRP_MJ_CREATE 要求に対して「Preoperation Callback Routine」と「Postoperation Callback Routine」をそれぞれ登録しています。

このうち、Scanner ミニフィルタドライバーが IRP_MJ_CREATE 要求に対して登録している 「Preoperation Callback Routine」は ScannerPreCreate 関数です。

ScannerPreCreate 関数は以下のチェックのみを行う小さな関数です。

if (IoThreadToProcess( Data->Thread ) == ScannerData.UserProcess) {

    DbgPrint( "!!! scanner.sys -- allowing create for trusted process \n" );

    return FLT_PREOP_SUCCESS_NO_CALLBACK;
}

return FLT_PREOP_SUCCESS_WITH_CALLBACK;

このコードではまず、ScannerPreCreate 関数が受け取った FLT_CALLBACK_DATA 構造体 15 の中に含まれる I/O 操作を開始したスレッドオブジェクトへのポインタから、その操作を行ったプロセスへのポインタを取得しています。

その後、取得したプロセスへのポインタと ScannerData.UserProcess に格納されているユーザモードプロセス(scanuser.exe) のポインタを比較し、一致する場合は FLT_PREOP_SUCCESS_NO_CALLBACK を、一致しない場合は FLT_PREOP_SUCCESS_WITH_CALLBACK を返します。

ScannerPreCreate 関数のような「Preoperation Callback Routine」が FLT_PREOP_SUCCESS_NO_CALLBACK を返した場合、フィルタマネージャーはその I/O 操作の完了時に「Postoperation Callback Routine」を呼び出しません。16

逆に、FLT_PREOP_SUCCESS_WITH_CALLBACK が返された場合にはフィルタマネージャーは登録された「Postoperation Callback Routine」を I/O 操作の完了時に呼び出します。17

後述する通り、IRP_MJ_CREATE 要求に対応する「Postoperation Callback Routine」では、 ScannerpScanFileInUserMode 関数を使用してユーザモードプログラムに対してファイルスキャン要求が行われます。

つまり、ScannerPreCreate 関数では、IRP_MJ_CREATE 要求を実行したプロセスが信頼できるプロセス(scanuser.exe) であるか否かを確認し、要求が信頼できるプロセスによるものである場合にはスキャンをスキップする機能を持っているといえます。

このようなスキャンを効率化するための工夫は、ほとんどの AntiVirus ソフトウェアでより高度に実装されていますが、 Scanner ミニフィルタドライバーは非常に単純なチェックのみが実装されています。

IRP_MJ_CREATE と対応する ScannerPostCreate 関数

続いて、IRP_MJ_CREATE と対応する「Postoperation Callback Routine」である ScannerPostCreate 関数の実装を確認します。

IRP_MJ_CREATE の要求が完了した後に実行されるこの関数では、ScannerpScanFileInUserMode 関数を使用したファイルスキャン要求が行われます。

前項で確認した ScannerPreCreate 関数でスキャンを行わない理由は、 この段階ではファイルシステムがファイルを読み取る準備が完了しておらず、 ファイルのスキャンを行うことができないためです。

この関数ではまず、引数として受け取った FLT_CALLBACK_DATA 構造体へのポインタから I/O 操作の状態などの情報を含む IoStatus(IO_STATUS_BLOCK) の情報を取得し、チェックを行います。

if (!NT_SUCCESS( Data->IoStatus.Status ) ||
    (STATUS_REPARSE == Data->IoStatus.Status)) {

    return FLT_POSTOP_FINISHED_PROCESSING;
}

I/O 操作が成功している場合、IoStatus.Status には STATUS_SUCCESS が格納されます。18

もし ScannerPostCreate 関数が呼び出された時点で I/O 要求が失敗している場合、 ファイルスキャンなどの操作を行わずに FLT_POSTOP_FINISHED_PROCESSING を返して処理を終了します。

I/O 操作に成功した場合は、2 章で実装したようにファイルの拡張子情報を取得し、 ScannerpCheckExtension 関数を使用してスキャン対象の拡張子に合致しているかどうかを確認します。

status = FltGetFileNameInformation( 
    Data,
    FLT_FILE_NAME_NORMALIZED |
    FLT_FILE_NAME_QUERY_DEFAULT,
    &nameInfo );

if (!NT_SUCCESS( status )) {

    return FLT_POSTOP_FINISHED_PROCESSING;
}

FltParseFileNameInformation( nameInfo );

scanFile = ScannerpCheckExtension( 
    &nameInfo->Extension 
);

FltReleaseFileNameInformation( nameInfo );

if (!scanFile) {

    return FLT_POSTOP_FINISHED_PROCESSING;
}

対象ファイルの拡張子が事前に設定されたスキャン対象に合致する場合、 ScannerpScanFileInUserMode 関数を使用してユーザモードプログラムである scanUser に対してファイルスキャン要求が行われます。

ユーザモードプログラムから返却されたスキャンの結果は safeToOpen に保持されます。

BOOLEAN safeToOpen, scanFile;

(VOID) ScannerpScanFileInUserMode( 
    FltObjects->Instance,
    FltObjects->FileObject,
    &safeToOpen 
);

ファイルスキャンの結果、対象が不正なファイル(文字列 foul を含むファイル)であることが確認された場合、 ミニフィルタドライバーは FltCancelFileOpen 関数19 を用いてファイルを閉じた後、 I/O Status ブロックのステータスを STATUS_ACCESS_DENIED に差し替えます。

FltCancelFileOpen 関数は、ファイルシステムがすでに実行した IRP_MJ_CREATE 操作を ミニフィルタドライバーからキャンセルできる関数です。

if (!safeToOpen) {

    FltCancelFileOpen( 
        FltObjects->Instance, FltObjects->FileObject 
    );

    Data->IoStatus.Status = STATUS_ACCESS_DENIED;
    Data->IoStatus.Information = 0;

    returnStatus = FLT_POSTOP_FINISHED_PROCESSING;

}

対象ファイルが不正ファイルと判定されていない場合、ユーザはそのままファイルを開くことができます。

ただし、ファイルが書き込みアクセスで開かれている場合、ミニフィルタドライバーは後でもう一度ファイルのスキャンを行うよう、 ストリームハンドルに付与するコンテキストで RescanRequired を True に設定します。

typedef struct _SCANNER_STREAM_HANDLE_CONTEXT {

    BOOLEAN RescanRequired;
    
} SCANNER_STREAM_HANDLE_CONTEXT, *PSCANNER_STREAM_HANDLE_CONTEXT;


PSCANNER_STREAM_HANDLE_CONTEXT scannerContext;


else if (FltObjects->FileObject->WriteAccess) {

    status = FltAllocateContext( 
        ScannerData.Filter,
        FLT_STREAMHANDLE_CONTEXT,
        sizeof(SCANNER_STREAM_HANDLE_CONTEXT),
        PagedPool,
        &scannerContext );

    if (NT_SUCCESS(status)) {

        scannerContext->RescanRequired = TRUE;

        (VOID) FltSetStreamHandleContext( 
            FltObjects->Instance,
            FltObjects->FileObject,
            FLT_SET_CONTEXT_REPLACE_IF_EXISTS,
            scannerContext,
            NULL );

        FltReleaseContext( scannerContext );
    }
}

ScannerpScanFileInUserMode 関数でユーザモードプログラムにスキャンを要求する

ScannerPostCreate 関数の実装を確認できたので、 続いてユーザモードプログラムにファイルスキャンを要求する ScannerpScanFileInUserMode 関数のコードを確認します。

すでに確認している通り、この関数は FLT_INSTANCE 構造体と FILE_OBJECT 構造体へのポインタを引数として受け取り、 ユーザモードプログラム側でのファイルスキャンの結果を SafeToOpen に保存します。

NTSTATUS
ScannerpScanFileInUserMode (
    _In_ PFLT_INSTANCE Instance,
    _In_ PFILE_OBJECT FileObject,
    _Out_ PBOOLEAN SafeToOpen
)

この関数では、ミニフィルタドライバーがユーザモードプログラムと接続するための接続ポートが作成されていることを確認した後、 引数として受け取ったインスタンスへのポインタを FltGetVolumeFromInstance 関数20 に渡し、 ミニフィルタドライバーのインスタンスがアタッチされているボリュームへのポインタを取得します。

PFLT_VOLUME volume = NULL;

status = FltGetVolumeFromInstance( 
    Instance,
    &volume 
);

if (!NT_SUCCESS( status )) {

    leave;

}

ボリュームの情報を取得した後は、スキャン用にファイルデータを読み出して非ページプール領域に確保したメモリ領域に保存する操作を行います。

なお、ファイルスキャンのために大量の非ページプールの割り当てと解放を繰り返すことはシステムのパフォーマンス上のデメリットが大きいため、 商用の AntiVirus ソフトウェアでは通常、ユーザモードプログラム側でファイルを直接スキャンするようです。

そのため、この動作の詳細については本書では詳しく扱いません。

スキャン用のデータを非ページプール領域に保存した後は、前の項でも確認した SCANNER_NOTIFICATION 構造体にスキャン対象のデータを含む情報を格納し、 FltSendMessage 関数21 でセットアップした接続ポートを通してユーザモードプログラムに情報を送信します。

typedef struct _SCANNER_NOTIFICATION {

    ULONG BytesToScan;

    ULONG Reserved;

    UCHAR Contents[SCANNER_READ_BUFFER_SIZE];
    
} SCANNER_NOTIFICATION, *PSCANNER_NOTIFICATION;

notification->BytesToScan = (ULONG) bytesRead;

RtlCopyMemory( 
    &notification->Contents,
    buffer,
    min( 
        notification->BytesToScan, 
        SCANNER_READ_BUFFER_SIZE 
    ) 
);

replyLength = sizeof( 
    SCANNER_REPLY 
);

status = FltSendMessage( 
    ScannerData.Filter,
    &ScannerData.ClientPort,
    notification,
    sizeof(SCANNER_NOTIFICATION),
    notification,
    &replyLength,
    NULL 
);

if (STATUS_SUCCESS == status) {

    *SafeToOpen = ((PSCANNER_REPLY) notification)->SafeToOpen;

}

FltSendMessage 関数でユーザモードプログラムにファイルデータが送信された後の動作についてはすでに確認した通りです。

IRP_MJ_WRITE と対応する ScannerPreWrite 関数

IRP_MJ_CREATE と対応するコールバック関数を確認したので、続いては IRP_MJ_WRITE と対応するコールバック関数を確認します。

Scanner がフィルタマネージャーに登録する FLT_OPERATION_REGISTRATION 構造体の情報から確認した通り、 IRP_MJ_WRITE の要求と対応する「Preoperation Callback Routine」は設定されておらず、 「Postoperation Callback Routine」として ScannerPreWrite 関数のみが登録されています。

この関数ではまず、ユーザモードプログラムとの接続ポートをチェックした後に、 FltGetStreamHandleContext 関数22 を使用してストリームハンドルに設定されたコンテキストの取得を試み、 取得に成功した場合にのみ処理を継続します。

FltGetStreamHandleContext 関数はストリームハンドルに割り当てられたコンテキストの取得を試みる関数です。

このコンテキストは、各ファイルオブジェクトのストリームに割り当てられるものであり、 Scanner の中では IRP_MJ_CREATE と対応する ScannerPostCreate 関数で割り当てが行われています。22

PSCANNER_STREAM_HANDLE_CONTEXT context = NULL;

status = FltGetStreamHandleContext( 
    FltObjects->Instance,
    FltObjects->FileObject,
    &context 
);

if (!NT_SUCCESS( status )) {

    return FLT_PREOP_SUCCESS_NO_CALLBACK;

}

FltGetStreamHandleContext 関数はストリームハンドルに割り当てられたコンテキストを取得できなかった場合、STATUS_NOT_FOUND のエラーコードを返却します。

つまり、上記のコードではすでにコンテキストが割り当てられている対象のみが ScannerPreWrite 関数におけるスキャン対象となることがわかります。

FltGetStreamHandleContext 関数により割り当てられたコンテキストを取得した後、 つまり対象のファイルがスキャン対象であることを確認した後の挙動は ScannerPostCreate 関数とは少し異なります。

ここではまず、引数として受け取った FLT_CALLBACK_DATA 構造体へのポインタである Data から Data->Iopb->Parameters.Write.Length を取り出し、その値が 0 ではないことを確認します。

Data->Iopb->Parameters.Write.LengthIRP_MJ_WRITE 要求の IRP において書き込むデータのサイズを示しています。23

Data->Iopb->Parameters.Write.Length が 0 ではないことを確認した後は、 書き込み対象のバイトデータを含むバッファを取得します。

この時、もしバッファが占有する物理メモリを表す構造体情報を含むメモリ記述子リスト(MDL) が定義されている場合は、 MmGetSystemAddressForMdlSafe 関数を使用してバッファのアドレスを取得します。24

if (Data->Iopb->Parameters.Write.MdlAddress != NULL) {

    buffer = MmGetSystemAddressForMdlSafe( 
        Data->Iopb->Parameters.Write.MdlAddress,
        NormalPagePriority | MdlMappingNoExecute
    );

} else {

    buffer = Data->Iopb->Parameters.Write.WriteBuffer;

}

この時取得したバッファには書き込みデータが格納されているので、後は ScannerPostCreate 関数と同じく、 SCANNER_NOTIFICATION 構造体にスキャン対象のデータを格納した後、FltSendMessage 関数でユーザモードプログラムにスキャン要求を発行し、その結果を受け取ります。

スキャンの結果書き込みデータ内に不正な文字列が検出された場合、 Data->IoStatus.StatusSTATUS_ACCESS_DENIED に差し替え、 書き込みアクセスをブロックします。

本章のまとめ

本章では、Scanner File System Minifilter Driver のサンプルコードの実装を解説しました。

実際の商用 AntiVirus ソフトウェアでは Scanner のような単純な実装ではなく、 またファイルスキャン以外の様々な保護機能も実装されています。

しかし、2 章で確認した [FSFilter Anti-Virus] のカテゴリとしてミニフィルタドライバーを登録しているベンダの一覧からもわかる通り、 多くの商用 AntiVirus ソフトウェアは何らかの形でミニフィルタドライバーに依存しています。

そのため、本章で解説した Scanner のサンプルコードの実装を理解することは、 AntiVirus ソフトウェアを学ぶ初めの一歩として意義のあるものかと思います。

本書のもくじ


  1. ExInitializeDriverRuntime 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/wdm/nf-wdm-exinitializedriverruntime

  2. FltBuildDefaultSecurityDescriptor 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltbuilddefaultsecuritydescriptor

  3. FltBuildDefaultSecurityDescriptor 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltbuilddefaultsecuritydescriptor

  4. InitializeObjectAttributes マクロ https://learn.microsoft.com/ja-jp/windows/win32/api/ntdef/nf-ntdef-initializeobjectattributes

  5. FltCreateCommunicationPort 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltcreatecommunicationport

  6. FilterConnectCommunicationPort 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/fltuser/nf-fltuser-filterconnectcommunicationport

  7. CreateIoCompletionPort 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/ioapiset/nf-ioapiset-createiocompletionport

  8. I/O Completion ポート https://learn.microsoft.com/ja-jp/windows/win32/fileio/i-o-completion-ports

  9. インサイド Windows 第 7 版 上 P.606 (Pavel Yosifovich, Alex Ionescu, Mark E. Russinovich, David A. Solomon 著 / 山内 和朗 訳 / 日系 BP 社 / 2018 年)

  10. GetQueuedCompletionStatus 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/ioapiset/nf-ioapiset-getqueuedcompletionstatus

  11. FILTER_MESSAGE_HEADER 構造体 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltuserstructures/ns-fltuserstructures-filtermessage_header

  12. FilterReplyMessage 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/fltuser/nf-fltuser-filterreplymessage

  13. FILTER_REPLY_HEADER 構造体 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltuserstructures/ns-fltuserstructures-filterreply_header

  14. IRP_MJ_CREATE https://learn.microsoft.com/ja-jp/windows-hardware/drivers/kernel/irp-mj-create

  15. FLT_CALLBACK_DATA 構造体 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/ns-fltkernel-fltcallback_data

  16. FLT_PREOP_SUCCESS_NO_CALLBACK が返される場合 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ifs/returning-flt-preop-success-no-callback

  17. FLT_PREOP_SUCCESS_WITH_CALLBACK が返される場合 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ifs/returning-flt-preop-success-with-callback

  18. IO_STATUS_BLOCK 構造体 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/wdm/ns-wdm-iostatus_block

  19. FltCancelFileOpen 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltcancelfileopen

  20. FltGetVolumeFromInstance 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltgetvolumefrominstance

  21. FltSendMessage 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltsendmessage

  22. FltGetStreamHandleContext 関数 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltgetstreamhandlecontext

  23. Windows Kernel Programming, Second Edition P.434 (Pavel Yosifovich 著 / Independently published / 2023 年)

  24. IRP_MJ_WRITE https://learn.microsoft.com/ja-jp/windows-hardware/drivers/kernel/irp-mj-write

  25. インサイド Windows 第 7 版 上 P.593 (Pavel Yosifovich, Alex Ionescu, Mark E. Russinovich, David A. Solomon 著 / 山内 和朗 訳 / 日系 BP 社 / 2018 年)