読者です 読者をやめる 読者になる 読者になる

亀岡的プログラマ日記

京都のベッドタウン、亀岡よりだらだらとお送りいたします。

C# で動的メソッド入替えを試みてみた記録

動機とか

動いているプログラムを止めずに、ログを仕込んだりちょっとだけ動きを変えたりしたい、ってのは割とある要求だと思う。 そんなときに、外部からDLLをInjectionして任意のメソッドを置き換えられたら、結構面白いんじゃないかと思って、C# で動的メソッド入替えを行ってみた。

結論から言うと、動くものはできたが実用的なものはできていない、というのが現状である。

アプローチ1: 動的IL書き換え

このアプローチは、Friendlyでお手軽ランタイムIL介入 - 亀岡的プログラマ日記で紹介した手法から、差し込む対象となるメソッドを変更しよう、というものである。結論から言うと、これはうまく行かなかった。

何故かといえば、この記事で用いているcallオペコードに指定するトークンは、動的ロードを行った際には使用できないトークンだからである。 じつは、callオペコードは、メソッドの定義そのもの(MethodDefテーブル)ではなく、メンバ参照テーブル(MemberRef)テーブルのアドレスを参照している。つまり、コンパイル時に参照した状態で作ったアセンブリでないと、単純なcallオペコードでは使用できない、ということになる。

もちろん他にも方法ある。Reflectionで呼んでしまえばよい。しかし、それを行うためには、最低限メソッド名と型名を指定する文字列参照を追加しないといけない。これが元記事で使用しているライブラリではサポートされていないのである。

もちろん、元のライブラリを頑張って改変すれば文字列参照テーブルを追記することは不可能ではない。とうか、次に述べる手法は動作が不安定すぎてプロダクションではとても怖くて使えないので、こちらのアプローチを洗練させるのが正しい道だろう。

とはいえ、とりあえずぱっぱと動かしたかったのである。

アプローチ2:動的メモリ書き換え

というわけで、もう一つのアプローチを見てみよう。動的なメモリ書き換えである。つまり、関数のメモリ上での参照アドレスを動的に書き換えてしまうのである。詳しくは以下の記事参照。

ちょっとだけコードを紹介しておこう。完全なのは元記事を参照。*1

メソッドアドレスを取るって具体的にどうするかっていうと、こうである。

public static IntPtr GetMethodAddress(MethodBase method)
{
    RuntimeHelpers.PrepareMethod(method.MethodHandle);

    unsafe
    {
        // Some dwords in the met
        int skip = 10;

        // Read the method index.
        UInt64* location = (UInt64*)(method.MethodHandle.Value.ToPointer());
        int index = (int)(((*location) >> 32) & 0xFF);

        if (IntPtr.Size == 8)
        {
            // Get the method table
            ulong* classStart = (ulong*)method.DeclaringType.TypeHandle.Value.ToPointer();
            ulong* address = classStart + index + skip;
            return new IntPtr(address);
        }
        else
        {
            // Get the method table
            uint* classStart = (uint*)method.DeclaringType.TypeHandle.Value.ToPointer();
            uint* address = classStart + index + skip;
            return new IntPtr(address);
        }
    }
}

MethodHandleには実メモリアドレスが入ってたのか!と驚くとこである(個人の感想)

ちなみに入替えはこうである。

public static void ReplaceMethod(IntPtr srcAdr, MethodBase dest)
{
    IntPtr destAdr = GetMethodAddress(dest);
    unsafe
    {
        if (IntPtr.Size == 8)
        {
            ulong* d = (ulong*)destAdr.ToPointer();
            *d = *((ulong*)srcAdr.ToPointer());
        }
        else
        {
            uint* d = (uint*)destAdr.ToPointer();
            *d = *((uint*)srcAdr.ToPointer());
        }
    }
}

unsafeってここまでunsafeなのかよ!と驚くとこである(個人のry)

これを使うと、仮に外から放り込んだアセンブリであろうと、MethodInfoさえ取れれば差し替えることができる。 ただ個人的にやりたかったのは、メソッドのIn/Out取得なので、以下の様なアプローチを取った。

  • 元のメソッド(MethodA)を別のメソッド(MethodB)に退避
    • これでMethodAとMethodBは、両方共MethodAの実行アドレスを指すことになる
  • MethodAと同一シグネチャのデリゲートを持つクラスを作成し、そのデリゲートの実行前後にログを仕込むMethodCを作成
  • 上記クラスのデリゲートにMethodBをセット
  • MethodAの実効アドレスをMethodCに差し替える

これだとイメージわきにくいと思うのでコードも紹介しよう。

まず、ターゲットなるMethodAとそのクラスである。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void Button_OnClick(object sender, RoutedEventArgs e)
    {
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");

        MessageBox.Show("Hello!!");
    }
}

次に、一時退避用のダミークラスである。

class Dummy
{
    public void Button_OnClick_Dummy(object sender, RoutedEventArgs e)
    {
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
        Trace.WriteLine("XXXXXXXXXXXXXXX");
    }
}

最後に、MethodのIn/Outに処理を仕込むためのクラスである。

class Injected
{
    public static Action<object, RoutedEventArgs> MethodBody { get; set; }

    private void Button_OnClick_Dummy(object sender, RoutedEventArgs e)
    {
        MessageBox.Show("Enter");

        MethodBody(sender, e);

        MessageBox.Show("Exit");

    }
}

これらを、上記Method入替えメソッドと組み合わせて、以下のように使う。

private static void Inject()
{
    var targetMethod = Type.GetType("Target.MainWindow, Target, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null").GetMethod("Button_OnClick", BindingFlags.Instance|BindingFlags.NonPublic);
    var replacedMethod = typeof(Dummy).GetMethod("Button_OnClick_Dummy", BindingFlags.Instance | BindingFlags.Public);
    var injector = typeof(Injected).GetMethod("Button_OnClick_Dummy",  BindingFlags.Instance|BindingFlags.NonPublic);

    MethodUtil.ReplaceMethod(targetMethod, replacedMethod);
    MethodUtil.ReplaceMethod(injector, targetMethod);

    Injected.MethodBody = (sender, arg) =>
    {
        var replaced = new Dummy();
        replaced.Button_OnClick_Dummy(sender, arg);
    };
}

これでめでたく、元のメソッドを実行しつつ、メソッド前後に処理をバイパスできる、のであるが・・・

この手法、とんでもなく不安定である。どういう場合に不安定化というと、クラスフィールドを使用するメソッドを置き換えようとするあたりで破綻する。

例えば、

class Target
{
    public int Echo(int input)
    {
        return input + 1;
    }
}

class Replaced
{
    private int _baseNum = 1;

    public int Echo(int input)
    {
        return input - _baseNum;
    }
}

のような2つのメソッドを入れ替えて、Target#EchoReplaced#Echoに差し替えた場合、Replaced#Echoで使用している_baseNumフィールドが無いために、正常動作しない。 これは、差し替え対象のクラスの状態をまるっと複製しろと言っているわけで、現実問題として不可能である(完全に副作用がないメソッドに関しては、割と動作するだろう)

*1:ちなみにコメント欄でも結構興味深いやり取りがある