はじめに
Windowsでは古来よりDLLインジェクションと呼ばれる手法で他プロセスに任意のコードを実行させることができます。
DLLインジェクションはその名の通りDLLを他プロセスに読み込ませてDLLのエントリーポイント(DllMain
)を実行させるという仕組みです。
仕組み上ネイティブのDLLを用意する必要があり、残念なことにマネージドなDLLを使用することはできません。
マネージドなDLLを使用することができれば以下のようなメリットがあります。
- C#でコードが書ける
- (C#でコードが書けるので)WinFormsやWPFといったフレームワークを使用可能
- x86/x64のどちらのプロセスにもインジェクトできる
全てをC#で書きたい人には圧倒的なメリットですね
今回はどうにかしてマネージドなDLLをインジェクトする方法について解説します。
解説する手法を実装したコードは以下のリポジトリにあります。
yaegaki/Mogu
最終的に以下のようなことができるようになります。
// インジェクトする側(ホスト)のメインメソッド
static async Task Main(string[] args)
{
// メモ帳のプロセスIDを取得する
var pid = (uint)Process.GetProcessesByName("notepad").First().Id;
var injector = new Injector();
// メモ帳に自身のDLLをインジェクトし、DLL内のEntoryPoint関数を実行させる
using (var con = await injector.InjectAsync(pid, c => EntryPoint(c)))
{
var buffer = new byte[1024];
while (con.IsConnected)
{
// メモ帳にインジェクトしたマネージドコードからの返答を待つ
var count = await con.Pipe.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None);
var str = Encoding.UTF8.GetString(buffer, 0, count);
Console.WriteLine($"recv:{str}");
}
}
}
// インジェクトされた側で実行されるメソッド
public static async ValueTask EntryPoint(Connection con)
{
var text = "Hello from notepad.exe!";
var buf = Encoding.UTF8.GetBytes(text);
// 引数で渡されたConnectionを使用してホストと通信する。
await con.Pipe.WriteAsync(buf, 0, buf.Length);
}
方針
先に述べた通り通常の方法ではマネージドコードを他プロセスに実行させることができません。
そこで今回は他プロセスに**.NET Coreをホストさせるコードを実行させてその.NET CoreにマネージドDLLを読み込ませる**という手法をとります。
参考: カスタム .NET Core ランタイム ホストを作成する - .NET Core | Microsoft Docs
解説
インジェクトする側(ホスト)/マネージド
コード: Mogu/Injector.cs
通常のDLLインジェクションと同様にWriteProcessMemory
でDLLのパスを書き込みCreateRemoteThread
DLLをロードさせます。1
注意が必要な点として相手プロセスが32ビットか64ビットかでLoadLibarary
のアドレスが異なるということです。
ホストプロセスと同じビット数のプロセスにインジェクトする場合は特に気にする必要はなく、ホストプロセスでLoadLibary
のアドレスを取得すればそれを使用することができます。
違う場合はめんどくさいのでここを参考にしてください。
簡単に解説すると既にメモリ上に読み込まれたPEイメージから対象の関数のアドレスを取得しています。
ホスト側はDLLをインジェクトするだけではなくインジェクトしたDLLに対象プロセス上で実行するマネージドメソッドを伝える必要があります。
様々な方法が考えられますが今回はメモリーマップドファイルを使用します。
メモリーマップドファイルに必要な情報を書き込み、インジェクトされた側はその情報を読み込みます。
// 対象プロセスのPIDを含んだ名前のメモリーマップドファイルを作成。
// インジェクトされた側は自身のPIDを使用してこのメモリーマップドファイルを開く。
using (var sharedMemory = MemoryMappedFile.CreateNew(GetMemoryMappedFileName(pid), memorySize))
using (var accessor = sharedMemory.CreateViewAccessor())
{
int position = 0;
// アセンブリの位置、実行するメソッドが定義されている型、実行するメソッドの名前、通信用の名前付きパイプの名前を書き込む。
accessor.Write(position, assemblyLocation, out position);
accessor.Write(position, typeName, out position);
accessor.Write(position, methodName, out position);
accessor.Write(position, pipeName, out position);
// 書き終わってからインジェクトする。
// ..略..
}
インジェクトする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_t mogu_native_dll_path = path_buffer;
// パスからディレクトリを取得
const auto mogu_directory = get_directory_path(mogu_native_dll_path);
// ディレクトリに固定の文字列を加えることでマネージドDLLのパスとする
const auto mogu_managed_dll_path = mogu_directory + L"\\Mogu.dll";
.NET Coreがホストできれば次はマネージドなコードを実行します。
MoguHost/dllmain.cpp#L191-L199
MoguHost/dllmain.cpp#L63-L64
// 既定の型のメソッド(Mogu.Injector.InjectedEntryPoint)を取得
entrypoint_fn entrypoint;
const auto type_name = L"Mogu.Injector, Mogu";
const auto method_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);
return nullptr;
}
// メソッドを実行
entrypoint(nullptr, 0);
これでアンマネージドDLL側のコードの主要部分は終了です。
注意すべき点として既に**.NET Coreがmuxerモードで実行されている場合**はどうやってもホストに失敗するので諦めましょう。
また二度以上同じプロセスでホストさせる場合は通常の方法ではできません。
最初にホストさせたときに取得したポインタをプロセスに残しておきましょう。
ポインタをプロセスに残すのは少し面倒です。
DLLのグローバル変数として確保している場合、DLLがアンロードされると消えてしまいます。
そこでポインタを保持するだけのDLLを作成し、そのDLLに情報を保持させておきます。
MoguHost/dllmain.cpp#L129-L142
// ポインタをキャッシュしているDLLをロード
const auto store_lib = LoadLibraryW(mogu_store_path.c_str());
if (store_lib == nullptr)
{
return nullptr;
}
const auto store = reinterpret_cast<void(*)(void*)>(GetProcAddress(store_lib, "Store"));
const auto load = reinterpret_cast<void*(*)()>(GetProcAddress(store_lib, "Load"));
// キャッシュされているポインタを取得
auto cached = load();
if (cached != nullptr)
{
FreeLibrary(store_lib);
// 既にキャッシュされている場合はそのポインタを使用する。
return reinterpret_cast<entrypoint_fn>(cached);
}
インジェクトするDLL/マネージド
やることは単純で以下のことを実行します。
- メモリーマップドファイルを開いて実行すべきメソッドの情報を取得する。
- 名前付きパイプでホストとの通信経路を確保する。
- メソッドを実行する。
実際にコードを見ていただければわかると思いますが非常にシンプルです。
.NET CoreにはAppDomainが実質存在しないので少し注意が必要です。
まとめ
C#でDLLインジェクションをしたい人なんていない需要は未知数ですが今回の内容を実装するにあたって.NET Coreについての知見が深まりました。
ソースコードを拾ってきて自分でビルドするというのはハードル高めに思っていましたが、.NET Coreの各種ツールは意外と簡単にビルドできて驚きました。
一度自分でやってみるとなかなか面白いのではないかと思います。
DLLインジェクションだけでは全く意味がないのでフックする処理もC#で書けるようにしたかったのですが、安定して動かず記事にするのは一旦諦めました。
残骸は以下に置いています。
-
グローバルフックを用いた手法のほうが安全ですが簡単にするためにこの手法を使いました ↩