皆さん、こんにちは。砂漠の果ての狼です。
この記事ではまず以下の質問を投げかけますので、記事内で答えを探し、コメント欄で回答してください。
- APIインターセプトとは何か?
- あるメソッドが多くの場所から呼ばれている場合、そのメソッドのソースコードを変更せずに、呼び出し前後の時間を記録するにはどうすればよいか?
- 同2、ソースコードを変更せずに、メソッドのパラメータを修正(改ざん)するにはどうすればよいか?
- 同3、ソースコードを変更せずに、メソッドの戻り値を偽装するにはどうすればよいか? ...
1. はじめに
はじめにの部分は海外の記事(Hacking .NET – rewriting code you don’t control)から翻訳したものです。こちらの方が理解しやすいでしょう。
制御できないクラスライブラリのメソッドの動作を変更したいと思ったことはありませんか?通常、そのメソッドは非公開であり、その動作を上書きする良い方法はありません。どのように動作するかは分かっていても(あなたは素晴らしく、ResharperやdnSpyのような逆コンパイルツールを使っているからですよね?)、それを変更することはできません。しかし、何らかの理由で本当に変更する必要があります。
いくつかの選択肢があります。
逆コンパイル、またはソースコードが最初から利用可能な場合はダウンロードしてソースコードを入手する。これは、複雑なビルドプロセスや多数の依存関係が伴うことが多く、たとえ小さな変更だけを行いたい場合でも、ライブラリのブランチ全体を保守する責任が生じるため、しばしばリスクが伴います。
ILDasmを使用してアプリケーションを逆コンパイルし、ILコードを直接修正してから、ILAsmでアセンブルし直す。多くの点で、これはより優れています。なぜなら、全面的な「ゼロからの」アプローチではなく、戦略的な手術的な切開を行うことができるからです。欠点は、メソッドを完全にILで実装しなければならないことであり、これは簡単な作業ではありません。
署名されたライブラリを扱っている場合、上記の2つの方法も機能しません。
では、別の解決策であるメモリパッチを見てみましょう。これは、ゲームチートエンジンが何十年も使用してきた技術と同じです。実行中のプロセスにアタッチし、メモリ位置を探し出し、その動作を変更します。複雑に聞こえますか?実際には、.NETでこれを行うのは思ったよりずっと簡単です。Harmonyというライブラリを使用します。このライブラリはNuGetの「Lib.Harmony」パッケージで入手できます。これは、主にUnityで構築されたゲームをターゲットとした.NET用のメモリパッチエンジンですが、もちろんUnityだけではありません。
この記事では、あなたが不可能だと思っていたことを変える方法をお見せします。自分のライブラリのフック(Hook)から始まり、WPFライブラリや.NET基本ライブラリのフックまでを扱います。
2. 自分のライブラリをフックする
2.1. 準備
- コンソールプログラム
HelloHookを作成し、Studentクラスを追加します。
namespace HelloHook;
public class Student
{
public string GetDetails(string name)
{
return $"皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:{name}";
}
}
StudentクラスにはGetDetailsメソッドが定義されており、フォーマットされた自己紹介情報を返します。このメソッドは後のフックテストで使用します。
Program.csにStudentの呼び出しを追加します。
using HelloHook;
var student = new Student();
Console.WriteLine(student.GetDetails("砂漠の果ての狼"));
プログラムを実行すると、以下のように出力されます。
皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:砂漠の果ての狼
基本的な準備は完了しました。これは単純なコンソールプログラムであり、以降の内容はこの2つのプロジェクトをベースに詳しく説明します。
2.2. GetDetailsメソッドをフックする
- フックパッケージ「Lib.Harmony」を導入する
APIのインターセプトにはLib.Harmonyパッケージを使用します。HelloHookプロジェクトに以下のNuGetパッケージを追加します。
<PackageReference Include="Lib.Harmony" Version="2.2.2" />
- フック処理
フッククラスHookStudentを追加します。
using HarmonyLib;
namespace HelloHook;
[HarmonyPatch(typeof(Student))]
[HarmonyPatch(nameof(Student.GetDetails))]
public class HookStudent
{
public static bool Prefix()
{
Console.WriteLine($"Prefix");
return true;
}
public static void Postfix()
{
Console.WriteLine($"Postfix");
}
public static void Finalizer()
{
Console.WriteLine($"Finalizer");
}
}
コード内のコメントを見てください。HookStudentクラスには2つのHarmonyPatch属性が追加されています。
- 1つ目はフック対象のクラス
Student型を関連付けます。 - 2つ目はフック対象のメソッド
GetDetailsを関連付けます。
つまり、プログラム内でStudentクラスのGetDetailsメソッドが呼び出されると、HookStudent内で定義された各メソッドがそれぞれ実行されます。実行順序はPrefix -> Postfix -> Finalizerです。もちろん、定義されているメソッドはこれだけではなく、よく使うのはPrefixとPostfixでしょう。規則に従ったメソッドの意味は後述します。詳しくはHarmony wikiを参照してください...
2.3. フックの登録
Program.csを修正し、Harmonyを使用してアセンブリ全体をフックします。
using HarmonyLib;
using HelloHook;
using System.Reflection;
var student = new Student();
Console.WriteLine(student.GetDetails("砂漠の果ての狼"));
var harmony = new Harmony("https://dotnet9.com");
harmony.PatchAll(Assembly.GetExecutingAssembly());
Console.WriteLine(student.GetDetails("砂漠の果ての狼"));
Console.ReadLine();
プログラムを実行すると、以下のように出力されます。
皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:砂漠の果ての狼
Prefix
Postfix
Finalizer
皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:砂漠の果ての狼
上記のコードで、カスタムクラスのフック処理が完了しました。PatchAllを使用すると、パッチクラスHookStudentが自動的に検出され、StudentクラスのGetDetailsメソッド呼び出しが自動的にフックされます。student.GetDetails("砂漠の果ての狼")の2回目の呼び出し時に、Harmonyの3つのライフサイクルメソッドがすべて呼び出されていることがわかります。
フッククラスの規則メソッド(PrefixやPostfixなど)内で、ログ記録(Console.WriteLine\ILogger.LogInfoなど)を行うことができます。これはB/SにおけるAOPインターセプトに似ており、操作ログの記録に適しています。
これで終わり?何言ってるんですか、まだ始まったばかりです。
2.4. パラメータ改ざんは? API結果の偽装は?
Program.csを修正し、区別しやすいようにデータの出力行を追加します。
using HarmonyLib;
using HelloHook;
using System.Reflection;
var student = new Student();
Console.WriteLine(student.GetDetails("砂漠の果ての狼"));
var harmony = new Harmony("https://dotnet9.com");
harmony.PatchAll(Assembly.GetExecutingAssembly());
Console.WriteLine(student.GetDetails("砂漠の狐"));
Console.WriteLine(student.GetDetails("Dotnet"));
Console.ReadLine();
Harmonyを登録する前に1回出力し、登録後に2回出力します。パラメータの違いに注目してください。
HookStudentを修正します。ここではPrefixメソッドのみを使用します。他のPostfixなどのメソッドも同様です。Harmony wikiで詳細な使用方法を確認できます。修正内容は以下の通りです。
using HarmonyLib;
namespace HelloHook;
[HarmonyPatch(typeof(Student))]
[HarmonyPatch(nameof(Student.GetDetails))]
public class HookStudent
{
public static bool Prefix(ref string name, ref string __result)
{
if ("砂漠の狐".Equals(name))
{
__result = $"これは過去のネットネームです";
return false;
}
if (!"砂漠の果ての狼".Equals(name))
{
name = "非管理者名";
}
return true;
}
}
まず実行して出力を確認します。
皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:砂漠の果ての狼
これは過去のネットネームです
皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:非管理者名
- 1行目「皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:砂漠の果ての狼」は、フック登録前の通常のフォーマット出力です。
- 2行目「これは過去のネットネームです」は、結果の偽装を実現しています。
- 3行目「皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:非管理者名」は、パラメータの改ざんを実現しています。
結果偽装
Prefixメソッドに渡されるパラメータref string __resultに注目してください。refは参照渡しを示し、結果の変更を許可します。stringは元のメソッドの戻り値の型と一致している必要があります。__resultは戻り値の命名規則で、先頭にアンダースコア2つが付きます。つまり、名前は__resultでなければなりません。
if ("砂漠の狐".Equals(name))
{
__result = $"これは過去のネットネームです";
return false;
}
戻り値がfalseであることに注意してください。これは元のメソッドを呼び出さないことを意味し、ここでフックされたメソッドの戻り値が偽装されます。
パラメータ改ざん
渡されるパラメータref string nameに注目してください。refはパラメータが参照渡しであることを示し、パラメータの変更を許可します。string nameは元のメソッドのパラメータ定義と一致している必要があります。
if (!"砂漠の果ての狼".Equals(name))
{
name = "非管理者名";
}
Prefixメソッドはデフォルトでtrueを返します。これは元のメソッドを呼び出す必要があることを意味し、ここでは改ざんされたパラメータが元のメソッドに渡されます。元のメソッドの実行結果は、改ざんされたパラメータを組み合わせて「皆さん、こんにちは。私は砂漠の果ての狼のウェブサイト管理者です:非管理者名」を返します。
注意:
元のパラメータnameと戻り値__resultはオプションです。改ざんを行わない場合は、refを削除しても問題ありません。
上記のサンプルソースコードはこちら。
3. WPFのAPIをフックする
簡単なWPFプログラムHookWpfを作成し、MessageBox.Showメソッドをフックします。
public static MessageBoxResult Show(string messageBoxText, string caption)
まずAppで自動フック登録を使用します。
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var harmony = new Harmony("https://dotnet9.com");
harmony.PatchAll(Assembly.GetExecutingAssembly());
}
}
フッククラスHookMessageBoxを定義します。
using HarmonyLib;
using System.Windows;
namespace HookWpf;
[HarmonyPatch(typeof(MessageBox))]
[HarmonyPatch(nameof(MessageBox.Show))]
[HarmonyPatch(new [] { typeof(string), typeof(string) })]
public class HookMessageBox
{
public static bool Prefix(ref string messageBoxText, string caption)
{
if (messageBoxText.Contains("ゴミ"))
{
messageBoxText = "これは素晴らしいウェブサイトですよ";
}
return true;
}
}
クラスHookMessageBoxは、フック対象のMessageBox.Showのオーバーロードメソッドに関連付けられています。Prefix内でダイアログボックスの内容の正当性を検証し、不正な場合は修正します。
最後に、フォームMainWindow.xamlに2つのメッセージボックスを表示するボタンを追加します。
<Window x:Class="HookWpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<StackPanel>
<Button Content="既定のメッセージボックスを表示" Width="120" Height="30" Click="ShowDialog_OnClick"></Button>
<Button Content="これはゴミサイトです" Width="120" Height="30" Click="ShowBadMessageDialog_OnClick"></Button>
</StackPanel>
</Window>
分離コードでボタンのクリックイベントを処理し、メッセージボックスを表示します。
using System.Windows;
namespace HookWpf;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void ShowDialog_OnClick(object sender, RoutedEventArgs e)
{
MessageBox.Show("https://dotnet9.com は技術共有を愛するプログラマーのウェブサイトです", "Dotnet9");
}
private void ShowBadMessageDialog_OnClick(object sender, RoutedEventArgs e)
{
MessageBox.Show("これはゴミサイトです", "https://dotnet9.com");
}
}
実行結果は以下の通りです。

上記の効果により、メッセージボックスの内容の検証が行われ、内容に「ゴミ」というキーワードが含まれている場合は、良い言葉(これは素晴らしいウェブサイトですよ)に置き換えられます。
このサンプルのソースコードはこちら。
4. .NETのデフォルトAPIをフックする
コンソールプログラムHookDotnetAPIを作成し、Lib.Harmony nugetパッケージを導入します。Program.csを以下のように修正します。
using HarmonyLib;
var dotnet9Domain = "https://dotnet9.com";
Console.WriteLine($"9の位置:{dotnet9Domain.IndexOf('9',0)}");
var harmony = new Harmony("com.dotnet9");
harmony.PatchAll();
Console.WriteLine($"9の位置:{dotnet9Domain.IndexOf('9', 0)}");
[HarmonyPatch(typeof(String))]
[HarmonyPatch(nameof(string.IndexOf))]
[HarmonyPatch(new Type[] { typeof(char), typeof(int) })]
public static class HookClass
{
public static bool Prefix(ref int __result)
{
__result = 100;
return false;
}
}
使用方法は前述と同様です。string.IndexOfメソッドがフックされ、常に100を返すようになります。検索する文字の位置に関係なく、もちろんこのテストコードに意味はありませんが、あくまでデモです。実行結果は以下の通りです。
9の位置:14
9の位置:100
5. まとめと共有
5.1. まとめ
Harmonyの原理は、リフレクションを使用して対応するクラスのメソッドを取得し、属性タグを追加してロジックを制御することです。これにより、元のコードを壊さずに更新を行うことができます。
Harmonyは、実行時に .NET / .NET Core メソッドをパッチ適用、置換、装飾するためのライブラリです。どの .NET バージョンでも使用できます。同じメソッドへの複数の変更は、上書きされるのではなく蓄積されます。
改めてフックが必要になる可能性のあるシナリオを分析し、本記事の記憶を深めてください。
- .NETの一部のメソッドは、コードレベルで直接変更できない場合があります。
- サードパーティライブラリがソースコードを提供していないが、その一部のメソッドを変更したい。
- サードパーティライブラリがソースコードを提供しているが、それを修正することは可能だが、後でそのライブラリがバージョンアップした場合、自分で行った変更も追従して更新する必要があり、手間がかかる可能性がある。
フックの注意点:ご覧のとおり、これにより多くの新しい可能性が生まれます。力が大きいほど責任も伴うことを忘れないでください。オリジナルの開発者が意図していない方法で動作を上書きするため、新しいバージョンのコードがリリースされたときにパッチコードが機能しなくなる可能性があります。上記のポイント3のように、サードパーティライブラリのAPI構造が変更された場合、フックロジックも修正する必要があります。
『Harmony wiki patching』から以下の使用上の注意を翻訳しました。
- 最新の2.0版は .NET Core をサポートしています。
- Harmonyは手動(Patch、Harmony wikiの使用法を参照)と自動(PatchAll、この記事でデモに使用した方法。Lib.HarmonyはC#の属性メカニズムを使用)の両方をサポートしています。
- 各オリジナルメソッドに対してDynamicMethodを作成し、そのメソッドにコードを織り込みます。このコードは、開始時(Prefix)および終了時(Postfix)にカスタムメソッドを呼び出します。また、オリジナルのILコードを処理するためのフィルター(Transpiler)を作成することもでき、オリジナルメソッドをより詳細に操作できます。
- Getter/Setter、仮想/非仮想メソッド、静的メソッド。
- パッチメソッドは静的メソッドである必要があります。
- Prefixはvoidまたはbool型を返す必要があります(voidはフックしません)。
- Postfixはvoid型、または最初のパラメータと同じ型を返す必要があります(スルーモード)。
- 元のメソッドが静的でない場合、
__instance(アンダースコア2つ)という名前のパラメータを使用してオブジェクトインスタンスにアクセスできます。 __result(アンダースコア2つ)という名前のパラメータを使用してメソッドの戻り値にアクセスできます。Prefixの場合、戻り値のデフォルト値を取得します。__state(アンダースコア2つ)という名前のパラメータを使用して、Prefixパッチで任意の型の値を保存し、Postfixで使用できます。Prefixでその値を初期化する責任があります。- 元のメソッドと同じ名前のパラメータを使用して対応するパラメータにアクセスできます。参照型でないパラメータに書き込む場合は、
refキーワードを使用してください。 - パッチで使用するパラメータは、型(またはobject型)と名前を厳密に対応させる必要があります。
- パッチは必要なパラメータのみを定義すればよく、すべてのパラメータを記述する必要はありません。
- パッチの再利用を可能にするために、
__originalMethod(アンダースコア2つ)という名前のパラメータを使用してオリジナルメソッドを注入できます。
最後に、.NET 7でHarmonyを使用する際には若干の問題があります。サイト管理者がWPF APIと.NET基本ライブラリのフックデモをテストした際に、効果が得られず、2、3晩試行錯誤しました。自分の使い方の問題かと思いましたが、最終的にHarmonyのissue .NET 7 Runtime Skipping Patches #504を確認し、プログラムを.NET 6にダウングレードすることで解決しました。
5.2 共有
読者の皆さん、Harmonyや他の.NETフックライブラリを使用したことがある方は、コメントでご共有ください。疑問点や使用した感想をお聞かせください。
- このライブラリを使ってAPIをフックしたことがあります。それはXXXです。
- 同様の機能を自分で実装したことがあります。共有記事のリンクはXXXです。
- 質問:このAPIをフックできますか?シナリオはXXXXです。
[私の共有] + [あなたの共有] ∈ [.NETコミュニティの小さな力]
6. 参考
この記事を書くにあたり、以下の記事を参考にしました。特にHarmony wikiにはHarmonyの詳細な使用方法が書かれていますので、ご覧になることをお勧めします。
- Harmony
- Harmony wiki
- Harmony APIドキュメント
- Hacking .NET – rewriting code you don’t control
- Rimworld Mod制作教程6 使用Harmony对C#代码Patch
- 动态IL织入框架Harmony简单入手
- 一个开放源代码,实现动态IL注入(Hook或补丁工具)框架:Lib.Harmony
- .NET 7 Runtime Skipping Patches #504
7. 補足
- 2023年9月23日 手動登録によるフック
非publicなクラスやメソッドをフックする方法について:.NETクラスライブラリ内のpublicに限らないクラスやメソッドをインターセプト・改ざん・偽装する