混合モード DLL の直接ロード

川俣さんに折角サンプルコードを作っていただいたので,ちょっとばかし実験.
C++/CLIとSetWindowsHookEx APIを使ったキーボードフックの試行
実験用に川俣さんのサンプルコードを書換えさせていただきました.修正後のソースとバイナリはこちらにおいておきます.
http://www.dwahan.net/nyaruru/hatena/cppclihooktest003.zip
まず DLL にこんな感じで Export 関数を作成し,現在使用中の .NET Framework のバージョンを表示させることにしました.また System.Windows.Form.dll を参照設定に加えておきます.

using namespace System;

extern "C" __declspec(dllexport) void CALLBACK Test();
extern "C" void CALLBACK Test()
{
    System::Windows::Forms::MessageBox::Show( 
        System::Environment::Version::get()->ToString() );
}

次に新しく Unmanaged C++ プロジェクトを作ります.実行内容は単純で以下の通り.メッセージフックの背後で行われていることを考えると,とりあえず LoadLibrary で DLL をプロセス空間に読み込んで,その DLL 上の関数にジャンプするとどうなるか試してみるのは悪くない予備実験かと思います.

#include <windows.h>
int main(int argc, char* argv[])
{
    HMODULE module = LoadLibraryExA("hookdll.dll", NULL, 0);
    PROC proc = (PROC) GetProcAddress( module, "_Test@0" );
    proc();
    FreeLibrary(module);

    return 0;
}

これを実行すると,手元の環境 (Windows XP x64 Edition/.NET 2.0 RTM/.NET 1.1 SP1) では,"2.0.50727.42" と表示されました.CLR について何の初期化処理も行わず,とりあえず表面上動いているように見えるのは確かに驚きでした.
より詳しく状況を見てみます.まず LoadLibraryExA が実行された段階で "hookdll.dll" が静的インポートする DLL がプロセス中に読み込まれます.読み込まれた DLL はこのようなものでした.

  • mscoree.dll
  • user32.dll
  • gdi32.dll
  • advapi32.dll
  • rpcrt4.dll
  • msvcm80.dll
  • ole32.dll
  • imm32.dll
  • lpk.dll
  • usp10.dll
  • shlwapi.dll

ここで目に付くのはやはり "mscoree.dll" です.このように "hookdll.dll" がプロセス空間にロードされるとき NT Loader は依存関係にある "mscoree.dll" もプロセスにロードすることになります*1
次に,川俣さんが書かれていたフック DLL が開放されないという問題についてです.

問題点

実行を終了させてもフックDLLをどこかのプロレスが持ち続けて解放しない、という現象が起きるようです。DLLをどこでロード、アンロードするかはシステムに任せっきりなので、そのあたりをもうちょっと煮詰める必要があるでしょう。ただし、アーキテクチャ的に解決可能な問題なのか、それともできないのかは分かりません。

実は上のサンプルコードでも FreeLibrary 直後に "hookdll.dll" はロードされたままとなっています.恐らく「AppDomain は一度ロードしたアセンブリを AppDomain がアンロードされるまで開放できない」という制限に引っかかっているのではないかと思いますが,いずれにせよ何者かが余計に "hookdll.dll" をロードしたのは確かでしょうから,一応誰が呼び出したのか確かめておきます.
Process Explorer でハンドルを注意深く見ていると,proc() を呼び出して .NET のコードが実行された前後で "hookdll.dll" のファイルハンドルが出現することが分かります.このことから,CLR は LoadLibrary ではなくファイルとしてアセンブリファイルを読み込むものと推測できます.実際,例の記事の『APIにブレークポイントを設定する』に従って,LoadLibraryExW や CreateFileW にトレースポイント*2を仕掛けてみると,mscorwks.dll から CreateFile でロードされていることが分かります.FreeLibrary を行ってもプロセス中に居座っていた理由は恐らくこのハンドルが開放されていないためでしょう.以下にスタックトレースを示します.

mscorwks.dll!WszCreateFile() 
mscorwks.dll!PEImage::GetFileHandle() 
mscorwks.dll!PEImage::GetLayoutInternal() 
mscorwks.dll!PEImage::GetLayout() 
mscorwks.dll!_RuntimeOpenImageInternal@16() 
mscorwks.dll!_GetAssemblyMDInternalImportEx@16() 
mscorwks.dll!CreateMetaDataImport() 
mscorwks.dll!CAssemblyManifestImport::Init() 
mscorwks.dll!CreateAssemblyManifestImport() 
mscorwks.dll!_ExplicitBind@28() 
mscorwks.dll!AppDomain::BindExplicitAssembly() 
mscorwks.dll!AppDomain::LoadExplicitAssembly() 
mscorwks.dll!ExecuteDLLForAttach() 
mscorwks.dll!ExecuteDLL() 
mscorwks.dll!CorDllMainForThunk() 
mscoree.dll!CorDllMainWorkerForThunk() 
mscoree.dll!_VTableBootstrapThunkInitHelper@4() 
mscoree.dll!_VTableBootstrapThunkInitHelperStub@0() 
cpphooktest002.exe!main() 
cpphooktest002.exe!__tmainCRTStartup() 
kernel32.dll!_BaseProcessStart@4() 
mscorwks.dll!WszCreateFile() 
mscorwks.dll!LoadAssembly() 
mscorwks.dll!_StrongNameSignatureVerification@12() 
mscorwks.dll!PEImage::VerifyStrongName() 
mscorwks.dll!PEAssembly::CheckSecurity() 
mscorwks.dll!PEAssembly::PEAssembly() 
mscorwks.dll!PEAssembly::DoOpenHMODULE() 
mscorwks.dll!PEAssembly::OpenHMODULE() 
mscorwks.dll!AppDomain::BindExplicitAssembly() 
mscorwks.dll!AppDomain::LoadExplicitAssembly() 
mscorwks.dll!ExecuteDLLForAttach() 
mscorwks.dll!ExecuteDLL() 
mscorwks.dll!CorDllMainForThunk() 
mscoree.dll!CorDllMainWorkerForThunk() 
mscoree.dll!_VTableBootstrapThunkInitHelper@4() 
mscoree.dll!_VTableBootstrapThunkInitHelperStub@0() 
cpphooktest002.exe!main() 
cpphooktest002.exe!__tmainCRTStartup() 
kernel32.dll!_BaseProcessStart@4() 

いずれにせよ最初は Native C++ で書かれた Win32 プロセスだったこのアプリケーションも,今や立派な .NET アプリケーションになってしまった模様です.ここで .NET の知識があれば,"hookdll.dll" を開放するには少なくとも AppDomain をアンロードする必要があることが分かります.
さて,今回は無事に動いているように見えますが,このような方法で各プロセスに CLR をロードして回るようなフックが安全かどうかはずいぶんと疑問があります.というわけでちょっと意地悪な状況を作ってみましょう.
社本さんのサンプルコード*3を拝借して,予め .NET 1.1 の CLR をプロセスにロードしておきます.「同一プロセス中にロードできる CLR は一種類のみ」ということを知っていれば,当然試してみたくなる実験です.

#include <windows.h>

#include <mscoree.h>
#import <mscorlib.tlb>
#pragma comment(lib, "mscoree.lib")

_COM_SMARTPTR_TYPEDEF(ICorRuntimeHost, __uuidof(ICorRuntimeHost));

int main(int argc, char* argv[])
{
    if( FAILED(CoInitialize(NULL)) )
    {
        return 1;
    }
    {
        HRESULT hr = S_OK;

        ICorRuntimeHostPtr spCorRuntimeHost;
        hr = ::CorBindToRuntimeEx(
            L"v1.1.4322",
            L"wks",
            STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN_HOST | STARTUP_CONCURRENT_GC,
            CLSID_CorRuntimeHost,
            __uuidof(ICorRuntimeHost),
            (void**)&spCorRuntimeHost);

        if (FAILED(hr))
        {
            return -1;
        }

        // 開始
        hr = spCorRuntimeHost->Start();
        if (FAILED(hr))
        {
            return -1;
        }

        // アプリケーションドメインの取得
        IUnknownPtr spUnkAppDomain;
        hr = spCorRuntimeHost->GetDefaultDomain(&spUnkAppDomain);
        if (FAILED(hr))
        {
            return -1;
        }

        mscorlib::_AppDomainPtr spAppDomain = spUnkAppDomain;
        if (spAppDomain == NULL)
        {
            return -1;
        }

        HMODULE module = LoadLibraryExA("hookdll.dll", NULL, 0);
        PROC proc = (PROC) GetProcAddress( module, "_Test@0" );
        proc();
        FreeLibrary(module);

        // 終了
        hr = spCorRuntimeHost->Stop();
        if (FAILED(hr))
        {
            return -1;
        }
    }
    CoUninitialize();

    return 0;
}

結果は……見事クラッシュしました.というわけで C++/CLI でフックハンドラを実装するのはどうも旗色が悪そうな気がするわけですが,いかがでしょうか?

*1:川俣さんのオリジナルのフックコードで,各プロセスに実際これが起こっていることは Process Explorer からも確かめられます

*2:{*( (wchar_t **)(@esp+4) ),su} $CALLSTACK あたりで引っ掛ける.詳しくはC++ の書式指定子等を参照のこと

*3:CLRのホスト』より.一部改変