はじめに
Windowsでは古来よりDLLインジェクションと呼ばれる手法で他プロセスに任意のコードを実行させることができます。
DLLインジェクションはその名の通りDLLを他プロセスに読み込ませてDLLのエントリーポイント(DllMain)を実行させるという仕組みです。
仕組み上ネイティブのDLLを用意する必要があり、残念なことにマネージドなDLLを使用することはできません。
マネージドなDLLを使用することができれば以下のようなメリットがあります。
- C#でコードが書ける
- (C#でコードが書けるので)WinFormsやWPFといったフレームワークを使用可能
- x86/x64のどちらのプロセスにもインジェクトできる
全てをC#で書きたい人には圧倒的なメリットですね
今回はどうにかしてマネージドなDLLをインジェクトする方法について解説します。
解説する手法を実装したコードは以下のリポジトリにあります。
yaegaki/Mogu
最終的に以下のようなことができるようになります。
// インジェクトする側(ホスト)のメインメソッドstaticasyncTaskMain(string[]args){// メモ帳のプロセスIDを取得するvarpid=(uint)Process.GetProcessesByName("notepad").First().Id;varinjector=newInjector();// メモ帳に自身のDLLをインジェクトし、DLL内のEntoryPoint関数を実行させるusing(varcon=awaitinjector.InjectAsync(pid,c=>EntryPoint(c))){varbuffer=newbyte[1024];while(con.IsConnected){// メモ帳にインジェクトしたマネージドコードからの返答を待つvarcount=awaitcon.Pipe.ReadAsync(buffer,0,buffer.Length,CancellationToken.None);varstr=Encoding.UTF8.GetString(buffer,0,count);Console.WriteLine($"recv:{str}");}}}// インジェクトされた側で実行されるメソッドpublicstaticasyncValueTaskEntryPoint(Connectioncon){vartext="Hello from notepad.exe!";varbuf=Encoding.UTF8.GetBytes(text);// 引数で渡されたConnectionを使用してホストと通信する。awaitcon.Pipe.WriteAsync(buf,0,buf.Length);}方針
先に述べた通り通常の方法ではマネージドコードを他プロセスに実行させることができません。
そこで今回は他プロセスに.NET Coreをホストさせるコードを実行させてその.NET CoreにマネージドDLLを読み込ませるという手法をとります。
参考: カスタム .NET Core ランタイム ホストを作成する - .NET Core | Microsoft Docs
解説
インジェクトする側(ホスト)/マネージド
コード: Mogu/Injector.cs
通常のDLLインジェクションと同様にWriteProcessMemoryでDLLのパスを書き込みCreateRemoteThreadDLLをロードさせます。1
注意が必要な点として相手プロセスが32ビットか64ビットかでLoadLibararyのアドレスが異なるということです。
ホストプロセスと同じビット数のプロセスにインジェクトする場合は特に気にする必要はなく、ホストプロセスでLoadLibaryのアドレスを取得すればそれを使用することができます。
違う場合はめんどくさいのでここを参考にしてください。
簡単に解説すると既にメモリ上に読み込まれたPEイメージから対象の関数のアドレスを取得しています。
ホスト側はDLLをインジェクトするだけではなくインジェクトしたDLLに対象プロセス上で実行するマネージドメソッドを伝える必要があります。
様々な方法が考えられますが今回はメモリーマップドファイルを使用します。
メモリーマップドファイルに必要な情報を書き込み、インジェクトされた側はその情報を読み込みます。
// 対象プロセスのPIDを含んだ名前のメモリーマップドファイルを作成。// インジェクトされた側は自身のPIDを使用してこのメモリーマップドファイルを開く。using(varsharedMemory=MemoryMappedFile.CreateNew(GetMemoryMappedFileName(pid),memorySize))using(varaccessor=sharedMemory.CreateViewAccessor()){intposition=0;// アセンブリの位置、実行するメソッドが定義されている型、実行するメソッドの名前、通信用の名前付きパイプの名前を書き込む。accessor.Write(position,assemblyLocation,outposition);accessor.Write(position,typeName,outposition);accessor.Write(position,methodName,outposition);accessor.Write(position,pipeName,outposition);// 書き終わってからインジェクトする。// ..略..}インジェクトするDLL/アンマネージド
コード: MoguHost/dllmain.cpp
.NET CoreをホストするアンマネージドなDLLです。
このDLLはアンマネージなものなので32ビット版と64ビット版を用意する必要があります。
アンマネージドのコードはあまり書きたくないのでここでは.NET CoreをホストしマネージドDLLのメソッドを実行までを担当します。
メモリーマップドファイルの内容を読み込んで指定されたメソッドを実行などは全てマネージド側で行います。
公式のドキュメントを参考にコードを書きます。coreclr_delegates.hとhostfxr.hは以下から取得できます。
nethost.dllは以下の場所にあります。
$(NetCoreTargetingPackRoot)/Microsoft.NETCore.App.Host.$(NETCoreSdkRuntimeIdentifier)/$(BundledNETCoreAppPackageVersion)/runtimes/$(NETCoreSdkRuntimeIdentifier)/native
参考までに自分の環境では以下の場所です。
C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Host.win-x64\3.1.0\runtimes\win-x64\native
実行するマネージドDLLはアンマネージドDLLと同じパスに固定の名前で配置されているという前提でコードを書きます。
例えばアンマネージドDLLがC:\XX\MoguHost_x64.dllにおいてあればアンマネージドDLLはC:\XX\Mogu.dllという風にします。
これによって簡単にロードすべきマネージドDLLのパスを取得することができます。
MoguHost/dllmain.cpp#L113-L116
// 自身(アンマネージドDLL)のパスを取得GetModuleFileNameW(hModule,path_buffer,max_path);string_tmogu_native_dll_path=path_buffer;// パスからディレクトリを取得constautomogu_directory=get_directory_path(mogu_native_dll_path);// ディレクトリに固定の文字列を加えることでマネージドDLLのパスとするconstautomogu_managed_dll_path=mogu_directory+L"\\Mogu.dll";.NET Coreがホストできれば次はマネージドなコードを実行します。
MoguHost/dllmain.cpp#L191-L199
MoguHost/dllmain.cpp#L63-L64
// 既定の型のメソッド(Mogu.Injector.InjectedEntryPoint)を取得entrypoint_fnentrypoint;constautotype_name=L"Mogu.Injector, Mogu";constautomethod_name=L"InjectedEntryPoint";if(load_assembly_and_get_function_pointer(mogu_managed_dll_path.c_str(),type_name,method_name,nullptr,nullptr,(void**)&entrypoint)!=0){FreeLibraryAndExitThread(hModule,-4);returnnullptr;}// メソッドを実行entrypoint(nullptr,0);これでアンマネージドDLL側のコードの主要部分は終了です。
注意すべき点として既に.NET Coreがmuxerモードで実行されている場合はどうやってもホストに失敗するので諦めましょう。
また二度以上同じプロセスでホストさせる場合は通常の方法ではできません。
最初にホストさせたときに取得したポインタをプロセスに残しておきましょう。
ポインタをプロセスに残すのは少し面倒です。
DLLのグローバル変数として確保している場合、DLLがアンロードされると消えてしまいます。
そこでポインタを保持するだけのDLLを作成し、そのDLLに情報を保持させておきます。
MoguHost/dllmain.cpp#L129-L142
// ポインタをキャッシュしているDLLをロードconstautostore_lib=LoadLibraryW(mogu_store_path.c_str());if(store_lib==nullptr){returnnullptr;}constautostore=reinterpret_cast<void(*)(void*)>(GetProcAddress(store_lib,"Store"));constautoload=reinterpret_cast<void*(*)()>(GetProcAddress(store_lib,"Load"));// キャッシュされているポインタを取得autocached=load();if(cached!=nullptr){FreeLibrary(store_lib);// 既にキャッシュされている場合はそのポインタを使用する。returnreinterpret_cast<entrypoint_fn>(cached);}インジェクトするDLL/マネージド
やることは単純で以下のことを実行します。
- メモリーマップドファイルを開いて実行すべきメソッドの情報を取得する。
- 名前付きパイプでホストとの通信経路を確保する。
- メソッドを実行する。
実際にコードを見ていただければわかると思いますが非常にシンプルです。
.NET CoreにはAppDomainが実質存在しないので少し注意が必要です。
まとめ
C#でDLLインジェクションをしたい人なんていない需要は未知数ですが今回の内容を実装するにあたって.NET Coreについての知見が深まりました。
ソースコードを拾ってきて自分でビルドするというのはハードル高めに思っていましたが、.NET Coreの各種ツールは意外と簡単にビルドできて驚きました。
一度自分でやってみるとなかなか面白いのではないかと思います。
DLLインジェクションだけでは全く意味がないのでフックする処理もC#で書けるようにしたかったのですが、安定して動かず記事にするのは一旦諦めました。
残骸は以下に置いています。
グローバルフックを用いた手法のほうが安全ですが簡単にするためにこの手法を使いました ↩