亀岡的プログラマ日記

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

Friendlyでお手軽ランタイムIL介入

本記事は、Friendly Advent Calendar 21日目の記事です。

Friendlyの数ある機能の中でも、僕が衝撃だったのはあまりにも簡単にDllインジェクションが可能で、しかもそれをプロダクトプロセス内でキックできるのも容易だ、というところです。

これは、なんというか、衝撃です。

そして、やはり思ってしまう。

これ、テストだけに使うの、勿体無くね??

Friendlyでお手軽簡単IL介入

てなわけで、やっぱりここまでできるのならIL介入したいですよね?やりますよね?

とはいえ、欄ライムのメソッドで事はもうJITコンパイル後、静的なDLLを事前書き換えするのとは結構次元が違います。

とはいえ!やはり先人はいるものなのです。

.NET CLR Injection: Modify IL Code during Run-time - CodeProject *1

詳しくはリンク先を見ていただくとして、ひとまず上記の場所から、

  • DLL介入実行用のDLL(Win32
  • ヘルパクラス(csファイル)

をもらってきましょう。

ちなみに、以降のコードは配布元ライセンスに従い、LGPLとなっています。

Friendlyと、このライブラリを使ってお手軽IL介入してみましょう!

ターゲットプロセス

ターゲットプロセスは以下の様な感じになります。

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

    private void Button_OnClick(object sender, RoutedEventArgs e)
    {
        MessageBox.Show("Hello!!");
    }

    private static  void EnterMessage()
    {
        MessageBox.Show("Enter!!");
    }

    private static void ExitMessage()
    {
        MessageBox.Show("Exit!!");
    }
}

ExitMessageとEnterMessageは、どこからも呼ばれていない、という感じの仕上がりですね。 こいつに介入して、ButtonのOnClickでEnterMessageとExitMessageとメソッドのInとOutで呼ぼうと思います。 さてさて。では、介入側です。

var process = Process.GetProcesses().First(proc => proc.MainWindowTitle == "Injection Target");
var app = new WindowsAppFriend(process);

WindowsAppExpander.LoadAssembly(app, this.GetType().Assembly);

app.Type<PleinSoleilLabo>().Inject();

まずは、ターゲットのプロセスを取得して、自分自身のAssemblyを流し込み、そしてInjectメソッドを呼んでいます。このInjectメソッドでなにをやっているかというと、、、こんな感じです。

private static void Inject()
{
    InjectionHelper.Initialize();

    var initializeWaiter = Task.Factory.StartNew(() =>
    {
        while (!InjectionHelper.IsInitialized())
        {
            Thread.Sleep(100);
        }
        return InjectionHelper.IsInjectionSucceeded();
    });

    var entryAssembly = Assembly.GetEntryAssembly();
    var targetType = entryAssembly.GetType("Target.MainWindow");

    var targetMethod = targetType.GetMethod("Button_OnClick", BindingFlags.NonPublic | BindingFlags.Instance);

    var enterMethod = targetType.GetMethod("EnterMessage", BindingFlags.NonPublic | BindingFlags.Static);
    var exitMethod = targetType.GetMethod("ExitMessage", BindingFlags.NonPublic | BindingFlags.Static);

    var originalIlCodes = targetMethod.GetMethodBody().GetILAsByteArray();

    var enterIlCodes = new byte[5];
    enterIlCodes[0] = (byte)OpCodes.Call.Value;
    enterIlCodes[1] = (byte)(enterMethod.MetadataToken & 0xFF);
    enterIlCodes[2] = (byte)(enterMethod.MetadataToken >> 8 & 0xFF);
    enterIlCodes[3] = (byte)(enterMethod.MetadataToken >> 16 & 0xFF);
    enterIlCodes[4] = (byte)(enterMethod.MetadataToken >> 24 & 0xFF);

    var exitIlCodes = new byte[5];
    exitIlCodes[0] = (byte)OpCodes.Call.Value;
    exitIlCodes[1] = (byte)(exitMethod.MetadataToken & 0xFF);
    exitIlCodes[2] = (byte)(exitMethod.MetadataToken >> 8 & 0xFF);
    exitIlCodes[3] = (byte)(exitMethod.MetadataToken >> 16 & 0xFF);
    exitIlCodes[4] = (byte)(exitMethod.MetadataToken >> 24 & 0xFF);

    var ilCodes = enterIlCodes
        .Concat(originalIlCodes.Take(originalIlCodes.Length - 1))
        .Concat(exitIlCodes)
        .Concat(new[] { (byte)OpCodes.Ret.Value })
        .ToArray();

    if (initializeWaiter.Result == true)
    {
        InjectionHelper.UpdateILCodes(targetMethod, ilCodes);
        MessageBox.Show("Injection Completed!!");
    }
}

まず、最初の部分ですが、

InjectionHelper.Initialize();

var initializeWaiter = Task.Factory.StartNew(() =>
{
    while (!InjectionHelper.IsInitialized())
    {
        Thread.Sleep(100);
    }
    return InjectionHelper.IsInjectionSucceeded();
});

この部分が、使用しているライブラリが要求する初期化処理です。地味に非同期で走るので、Taskで終了監視を別スレッドに逃がしています。このWaiterは最後に使うだけです。

var entryAssembly = Assembly.GetEntryAssembly();
var targetType = entryAssembly.GetType("Target.MainWindow");

var targetMethod = targetType.GetMethod("Button_OnClick", BindingFlags.NonPublic | BindingFlags.Instance);

var enterMethod = targetType.GetMethod("EnterMessage", BindingFlags.NonPublic | BindingFlags.Static);
var exitMethod = targetType.GetMethod("ExitMessage", BindingFlags.NonPublic | BindingFlags.Static);

次にこの部分、ここではアセンブリをひっぱてきて、ターゲットのTypeを取得しています。ここまでは単純なリフレクションと同じ。

var originalIlCodes = targetMethod.GetMethodBody().GetILAsByteArray();

var enterIlCodes = new byte[5];
enterIlCodes[0] = (byte)OpCodes.Call.Value;
enterIlCodes[1] = (byte)(enterMethod.MetadataToken & 0xFF);
enterIlCodes[2] = (byte)(enterMethod.MetadataToken >> 8 & 0xFF);
enterIlCodes[3] = (byte)(enterMethod.MetadataToken >> 16 & 0xFF);
enterIlCodes[4] = (byte)(enterMethod.MetadataToken >> 24 & 0xFF);

var exitIlCodes = new byte[5];
exitIlCodes[0] = (byte)OpCodes.Call.Value;
exitIlCodes[1] = (byte)(exitMethod.MetadataToken & 0xFF);
exitIlCodes[2] = (byte)(exitMethod.MetadataToken >> 8 & 0xFF);
exitIlCodes[3] = (byte)(exitMethod.MetadataToken >> 16 & 0xFF);
exitIlCodes[4] = (byte)(exitMethod.MetadataToken >> 24 & 0xFF);

var ilCodes = enterIlCodes
    .Concat(originalIlCodes.Take(originalIlCodes.Length - 1))
    .Concat(exitIlCodes)
    .Concat(new[] { (byte)OpCodes.Ret.Value })
    .ToArray();

ここだけがILとお付き合いする部分。と言っても今回は引数なしメソッドを呼ぶだけなので楽ちんですね。気軽にILをEmitするわけではないので、Hexを意識して書かないといけないのが、ちょっと大変ですが、引数なしStaticくらいは読める読める。

メソッド呼び出しコードで本体メソッドを挟んでいるだけです。最後のRetオペコードは途中に挿入すると.NETが困ってしまうので注意しますが、ほんとそれくらいですね。

if (initializeWaiter.Result == true)
{
    InjectionHelper.UpdateILCodes(targetMethod, ilCodes);
    MessageBox.Show("Injection Completed!!");
}

最後に、初期化コードの返り値を見て(ここでWaitも走っているわけですね)うまく行っていればILを差し替えます。

DLLをインジェクションしてプロダクトプロセス内で呼ぶ、という行為をFriendlyがサポートしてくれるので、感動的なくらいやりやすいです。ふおー。

仕上がりは

こんな感じです。

なんに使うのさこれ?

僕のIL力の低さ故なのですが、これ、やりたいことの半分しかできていないのです。本当にやりたいのは、この挟む前後のメソッドもInjctor側で定義したメソッドにしてやりたいのです。*2

これができるとですね、ランタイムで動いているプロダクトに対してIL介入して強引にメソッドログを吐き出させる、みたいなことができるわけですよ。

結構ミッションクリティカルな現場だと、実験用のアセンブリに差し替えてもらうことすらかなり難しいことがあるのですが、そういう場合に「いやこのアプリを一緒に動かさせてくださいよー」とか言ってしれっとInjectorを起動させておくと、(リスクがどっちが大きいんだよという根本的な問題は置いておけば)これまで不可能だったログなんかを実験的に取るのがかなり楽になるよなー、とか考えるわけです。

ソースコードは?

そのうち公開しますが、上記のようにちょっと食い足りないのと、時間がなくて散らかりまくっているので、しばしお待ちを。まぁ個々に書いてあることがほとんど全てではあるんですがw

*1:当たり前ですが、こんなものJITが変われば使い物にならなくなりそうなのでRyuJITからは使えなさそうですね…

*2:簡単にできると思ったら無理だった