BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル C#のsealedメソッドをオーバーライドする

C#のsealedメソッドをオーバーライドする

ブックマーク

キーポイント

  • Methods have runtime metadata that we can examine and modify.
  • A method handle can be modified to point to a different method.
  • We can generate machine code in C# and execute it directly.
  • We can override any method this way, including built-in ones.
  • We can use this trick to modify wrappers around the WinAPI (or any other wrappers).

原文(投稿日:2021/11/12)へのリンク

メソッドは、一連の命令を含んだコードのブロックです。メソッド名称、パラメータ、戻り値、そしてアクセスレベルやabstractsealedで構成されるメソッドシグネチャを記述することで、クラスや構造体、あるいはインターフェース内に宣言することができます。

メソッドシグネチャは、特定の実行コンテキスト内で使用されるメソッドを一意的に決定できるものでなければなりません。戻り値は、コンテキストによって、シグネチャに含まれる場合(デレゲーションとそれが示すメソッドの互換性を決定する場合など)と、無視される場合(メソッドがオーバーロードされた場合など)があります。メソッドをコールする時には、メソッド名称とメソッドパラメータを指定する必要があります。C#では戻り型を指定しませんが、中間言語(IL)では必要です。このような方法で、コールするメソッドを特定することができます。それ以後のプロセスについては、.NETプラットフォームが面倒を見てくれます。

メソッドはvirtualであることも可能です。この場合には、遅延バインディング機構によるポリモフィックな実行がサポートされます(派生クラスによるメソッドの再定義が可能です)。これはオブジェクト指向プログラミングの基礎のひとつで、C#では広範に使用されます。しかしながら、すべてのメソッドが仮想化してオーバーライドをサポートできる訳ではありません — スタティックメソッド、コンストラクタ、演算子はこのメカニズムをサポートしていません。メソッドをsealedとしてマークすれば、ベースクラスでvirtualとマークされたメソッドであっても、サブクラス内でオーバーライドできないようにすることが可能です。

しかしながら、実は、sealedメソッドの実行方法を修正して、ある程度のオーバーライドを行う方法があるのです。その話題に入る前に、まずは.NETプラットフォームでメソッドがどのように実装されているかを見ていきましょう。

メソッドの内部構造

メソッドのコードは通常2回コンパイルされます。最初のコンパイルはC#コンパイラが実行します。このプロセスは、C#ソースを入力として取り、中間言語(IL)コードを出力として生成します。その後、通常は実行時のJust-Int-Time(JIT)コンパイラによって、そのILコードがもう一度コンパイルされます。Ahead-Of-Time(AIT)モードでアプリケーションを実行する前に、ngenReadyToRun(R2R)機構によってコンパイルされる場合もあります。2回目のコンパイルはILコードを入力として、現在のハードウェア(CPU)アーキテクチャに適合するマシンコードを出力します。マシンコードは後にCPUによって直接実行できます。.NETプラットフォームによる支援は必要ありません。

マシンコードのレベルでメソッドを呼び出す場合には、C#のコードでは無視できた複数の問題を意識する必要があります。名称とパラメータをメソッドに提供するだけではなく、メソッドへの値の渡し方(レジスタ経由か、スタック経由か)を知っておかなくてはなりません。それだけではありません。メソッド終了時に誰がスタックをクリーンアップするか(呼び出される側か、呼び出す側か)、どうやって値を返すのか、パラメータの順番は(左から右、あるいは右から左)など、さまざまな詳細も知っておく必要があります。C#で書く場合には、.NETプラットフォームがこれらを処理してくれるので、詳細を無視することができるのですが、マシンコードのレベルでは、バイナリプロトコルに準拠するように注意しなければなりません。それを怠れば、セグメンテーション違反やアクセス違反などを受けることになるでしょう。

JITコンパイルは複数ステップからなるプロセスですが、その構成は.NETプラットフォームの内部とCPUアーキテクチャの詳細によって異なります。そこでは実行時のさまざまな面を考慮しなければなりません。

  1. どうやってメソッドにパラメータを渡すのか?アーキテクチャによって、使用されるレジスタセットが異なります。32bitアーキテクチャでは、最初の2つのパラメータがecxおよびedxレジスタで、その他はスタック経由で渡されます。64bitアーキテクチャでは、先頭から4つのパラメータがrcx、rdx、r8、r9の各レジスタを使って、その他はスタック経由で渡されます。ただし、これは変更される可能性がありますし、コンパイラのバージョン間でも同じであるという保証はありません。

  2. 戻り値はどうやって渡されるのか?整数値はeaxレジスタで戻されますが、浮動小数点値はFPまたはXMMレジスタを通じて返されます。

  3. パラメータの順番はどうなっているか?パラメータが左から右で渡されるか、あるいは右から左で渡されるのかはアーキテクチャ依存で、プラットフォームがコントロールします。

  4. マシンコードのメモリはどのように割り当てられるのか?アプリケーション起動時にはマシンコードが利用できないので、アプリケーションが書き込んでどこかに保管しなくてはなりません。一般的には、新たなメモリページが割り当てられて、オペレーティングシステムのVirtualProtextExまたはmprotect機能で実行可能とマークされます。

  5. スタックからパラメータを取り除くのはどちらか?呼び出し側が行う場合には、メソッドを呼び出す度に、戻り時にスタックからパラメータを削除しなくてはならないため、コード重複という重要なリスクが発生します。一方で、呼び出された側がクリーンアップすることにすると、printf(任意の数のパラメータを受け入れ可能)のような可変パラメータのメソッドを、信頼性のある形で実装することができません。

  6. メソッドを呼び出す価値はあるのか、インラインにするべきか?大き過ぎるか、あるいはtry-catchを使ってスタックトレースを変更する可能性のある場合には、インラインにすることはできません。

  7. メソッドの最適化は可能か?定数の事前計算、デッドコードの削除、インストラクションの順序変更は?

  8. エンディアンはどうか?インストラクションやアドレスのエンコード方法は?

C#でコードを書くときには、通常はこのようなことを考慮する必要はありません。これらが重要になるのは、メソッドを他のプラットフォームからP/Invoke機構を使ってコールする場合に限られます。

.NET Core 2.1以降は、マルチティア(multi tear)のコンパイル機構により、メソッドが複数回コンパイルされる可能性があります。最初のコンパイルはラフで簡単なもので、最適化されないマシンコードを生成します。しばらくの後、2回目のコンパイルが実行される可能性があります(そのメソッドがホットパス上にあり、頻繁に実行されることを.NETプラットフォームが検知した場合など)。その場合、コンパイラはもう少し時間を費やしてコードを最適化します。その結果、レジスタ使用数の低減、デッドコードの削除、値の事前計算と定数の使用などが行われます。マルチティアコンパイルは、.NET Core 3以降は既定値として有効になっています。事実として、ひとつのメソッドに対するILコードのインスタンスは常にひとつですが、マシンコードのインスタンスは複数になる場合があります。

リフレクションを使ってメソッドの詳細を確認することもできます。メソッド名やパラメータ、戻り型、その他すべての特性の取得に使用可能です。リフレクションはメソッドディスクリプタ(メタデータ)を内部で使用します。個々のディスクリプタは、ユニークなメソッドハンドル(メソッド呼び出しに使用する)を提供し、高度なメタデータ(メソッド修飾子など)を保持し、メソッドの実行状態を記録する構造体です。ディスクリプタに問い合わせることで、メソッドがJITコンパイル済か、生成されたマシンコードがどこにあるのか、といった判断をすることができます。メソッドディスクリプタにはType.getMethod()によるリフレクション経由でアクセスできます。そのMethodHandleプロパティを参照して、メソッドハンドルを取得します。 

メソッドハンドルを調べることによって、メソッドの内部構造にアクセスできるのです。例えば、メソッドのマシンコードの場所を参照するポインタが取得できます。これを使うことで、メソッドを別のコードにポイントさせたり、ロジックをインプレースで修正することが可能になります。つまり、sealedメソッドをオーバーライドできるのです。非virtualで非staticなメソッドXがあって、これをYメソッドをコールするように変更したいとしましょう。Xがvirtualであれば、基本クラスからそのメソッドを継承し、Yでオーバーライドして、ポリモフィックな実行を使用すれば目標を達成できるのですが、Xはvirtualではないので、もっと低いレベルで変更する必要があります。

メタデータを変更してsealedメソッドをオーバーライドする

sealedメソッドをオーバーライドするための最初のアプローチは、メタデータの変更に基づくものです。必要なのは、メソッドXのメタデータを取得し、メソッドのマシンコードを示すポインタを見つけ出して、それをシグネチャの一致する別のメソッドをポイントするように変更することです。以下のコードを例にしましょう。

using System;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;

namespace OverridingSealedMethodNetCore
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine($"Calling StaticString method before hacking:\t{TestClass.StaticString()}");
            HijackMethod(typeof(TestClass), nameof(TestClass.StaticString), typeof(Program), nameof(StaticStringHijacked));
            Console.WriteLine($"Calling StaticString method after hacking:\t{TestClass.StaticString()}");

            Console.WriteLine();

            var instance = new TestClass();
            Console.WriteLine($"Calling InstanceString method before hacking:\t{instance.InstanceString()}");
            HijackMethod(typeof(TestClass), nameof(TestClass.InstanceString), typeof(Program), nameof(InstanceStringHijacked));
            Console.WriteLine($"Calling InstanceString method after hacking:\t{instance.InstanceString()}");

            Console.WriteLine();

            Vector2 v = new Vector2(9.856331f, -2.2437377f);
            for (int i = 1; i <= 35; i++)
            {
                MultiTieredClass.Test(v, i);
                Thread.Sleep(100);
            }

            Console.WriteLine($"Examine MethodDescriptor: {typeof(MultiTieredClass).GetMethod(nameof(MultiTieredClass.Test)).MethodHandle.Value.ToString("X")}");
            Console.ReadLine();
        }

        public static void HijackMethod(Type sourceType, string sourceMethod, Type targetType, string targetMethod)
        {
            // Get methods using reflection
            var source = sourceType.GetMethod(sourceMethod);
            var target = targetType.GetMethod(targetMethod);

            // Prepare methods to get machine code (not needed in this example, though)
            RuntimeHelpers.PrepareMethod(source.MethodHandle);
            RuntimeHelpers.PrepareMethod(target.MethodHandle);

            var sourceMethodDescriptorAddress = source.MethodHandle.Value;
            var targetMethodMachineCodeAddress = target.MethodHandle.GetFunctionPointer();

            // Pointer is two pointers from the beginning of the method descriptor
            Marshal.WriteIntPtr(sourceMethodDescriptorAddress, 2 * IntPtr.Size, targetMethodMachineCodeAddress);
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public static string StaticStringHijacked()
        {
            return "Static string hijacked";
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public string InstanceStringHijacked()
        {
             return "Instance string hijacked";
        }
    }

    class TestClass
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public static string StaticString()
        {
            return "Static string";
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public string InstanceString()
        {
            return "Instance string";
        }
    }

    class MultiTieredClass
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public static void Test(Vector2 v, int i)
        {
            v = Vector2.Normalize(v);
            Console.WriteLine($"Vector iteration {i:0000}:\t{v}\t{TestClass.StaticString()}");
        }
    }
}

上の例では、TestClassというクラスに、StaticString(71行)とInstanceString(77行)という2つのメソッドがあります。いずれもvirtualではなく、ハードコードされた文字列を返します。目標は、これらのメソッドをハイジャックして、StaticStringがコールされた時に、.NETプラットフォームがStaticStringHijackedメソッド(56行)を実行するように変更することです。同じように、InstanceStringがコールされた時には、InstanceStringHijacked(62行)がコールされるようにします。

Mainメソッドでは、まず最初にStaticStringメソッドをコールして、その出力を表示しています。次にStaticStringHijackedでそれをハイジャックした上で、再びStaticStringをコールし、オーバーライドに成功したことを確認します。その後は、同じ処理をInstanceStringメソッドに対して行っています。すべてのマジックを行うのはHijackMethod(38行)メソッドです。

HijackMethodは4つのパラメータを受け入れます。最初の2つでオーバーライドする対象メソッド(メソッドX、この例ではStaticString)を定義し、残る2つでターゲットメソッド(メソッドY、この例ではStaticStringHijacked)を定義します。メソッドを特定するには、メソッドとメソッドの名称を保持するTypeインスタンスが必要です。今回は単に例なので、同じ名称でパラメータの異なるメソッドが複数存在する場合の処理はしていませんが、上記のコードをそのような目的のために拡張することは難しくありません。

まず最初は、リフレクション機構から通常のGetMethod関数をコールして、メソッドのディスクリプタを取得します(41~42行)。

// Get methods using reflection
var source = sourceType.GetMethod(sourceMethod);
var target = targetType.GetMethod(targetMethod);

メソッドがまだJITコンパイルされていない可能性があるので、RuntimeHelpers.PrepareMethodをコールすることにより、手動でコンパイルを起動しています(45~46行)。

// Prepare methods to get machine code (not needed in this example, though)
RuntimeHelpers.PrepareMethod(source.MethodHandle);
RuntimeHelpers.PrepareMethod(target.MethodHandle);

これで変更可能なポインタを取得することができるようになります。最初のポインタは、ソースメソッドの内部的なメソッドディスクリプタのアドレスです。メソッドディスクリプタは、メソッドをサポートするマシンコードのアドレスを保持する構造体です。アドレスは、構造体の先頭にある2つのポインタに格納されています(32bitアプリケーションの場合は先頭8バイト、64bitアプリケーションの場合は先頭16バイトです)。内部表現は常に変更される可能性があるため、これは.NETのバージョン依存になりますが、.NET Framework 1から.NET 5までは一貫して同じです。構造体のアドレスは48行で取得しています。

var sourceMethodDescriptorAddress = source.MethodHandle.Value;

次に、ターゲットメソッドのマシンコードのアドレスを取得します。.NETプラットフォームには、まさにそれを行うためのGetFunctionPointerというメソッドがありますが、内部ディスクリプタのアドレスを取得して.ポインタを読み取ることで、これを手動で取り出すこともできます。この値は、使用するCPUアーキテクチャによって、先頭から8ないし16バイトの位置にあります(49行)。

var targetMethodMachineCodeAddress = target.MethodHandle.GetFunctionPointer();

メソッドをオーバーライドするために、内部ディスクリプタ構想体のポインタを取得して、それを直接変更します(52行)。

Marshal.WriteIntPtr(sourceMethodDescriptorAddress, 2 * IntPtr.Size, targetMethodMachineCodeAddress);

この変更でStaticStringメソッドのポインタが変更されて、StaticStringHijackedのコードを示すようになりました。このメソッドをコールすると、アプリケーションの出力で見られるように、実際には後者のマシンコードが実行されます。

Calling StaticString method before hacking:     Static string
Calling StaticString method after hacking:      Static string hijacked

以上により、今回のプログラムの構造は次のようなものになります。

メソッドハイジャック前:

  • StaticStringメソッドの呼び出しを開始する。
  • マシンコードのアドレスを取得する。このアドレスは、実際のStaticStringメソッドのコードを示している。
  • StaticStringコードを実行する。

メソッドハイジャック後:

  • StaticStringメソッドの呼び出しを開始する。
  • マシンコードのアドレスを取得する。このアドレスは、StaticStringHijackedメソッドのコードを示している。
  • StaticStringHijackedメソッドを実行する。

同じ構造をInstanceStringメソッドにも適用して、コードのハイジャックを行います。このハイジャックメソッドは、Windows 10 x64の.NET 5.0.102、およびWSL2 Ubuntu 20.04の.NET 5.0.401で動作します。DebugコンフィギュレーションとReleaseコンフィギュレーション、x86とx64、いずれでも実行可能です。

ただし100パーセントの動作保証はなく、正しく機能しない場合があります。これは、.NETがマルチティアコンパイル用にコードキャッシュインフラストラクチャを導入したことにより、時間的な影響を受ける可能性があるためです。すなわち、ハイジャックしたメソッドがポインタを変更した"直後"に参照されて呼び出された場合には、ポインタがまだ変更以前のメソッドを指している可能性があるのです。この効果について確認するため、2番目のコードを検討したいと思います。

ここでは、86行目にTestというメソッドがあります。このメソッドは2つの2次元ベクトルを引数として取り、正規化して、その値を出力します。数学的処理の詳細は、ここでは重要ではありません。重要なのは、Vector.Normalize()メソッドがSSEインストラクションを使用するように高度に最適化される可能性がある、という事実の方です。これはマルチティアコンパイルの結果であると理解できます。89行では、以下のような出力をしています。

Console.WriteLine($"Vector iteration {i:0000}:\t{v}\t{TestClass.StaticString()}");

ここでは、イテレーション数と正規化したベクトルを表示した後、StaticStringメソッドをコールします。27行にあるように、TestメソッドはMainメソッドから複数回呼び出されています。

Vector2 v = new Vector2(9.856331f, -2.2437377f);
for (int i = 1; i <= 35; i++)
{
	MultiTieredClass.Test(v, i);
	Thread.Sleep(100);
}

このコードの最初の出力は、次のようなものになるでしょう。

Vector iteration 0001:  <0.9750545, -0.22196561>        Static string
Vector iteration 0002:  <0.9750545, -0.22196561>        Static string
Vector iteration 0003:  <0.9750545, -0.22196561>        Static string
Vector iteration 0004:  <0.9750545, -0.22196561>        Static string
Vector iteration 0005:  <0.9750545, -0.22196561>        Static string hijacked

StaticStringメソッドがStaticStringHijackedを示すようにハイジャックしているにも関わらず、最初のイテレーションでは変更前のコード(ハイジャックされていないコード)を呼び出していることが分かります。しかし0.5秒後、出力の内容が変わっています。これがコードキャッシュによる実際の影響なのですが、

調べていくと、さらに興味深いことが分かりました。35回目のイテレーション付近で、マルチティアコンパイルが起動されて、メソッドを再コンパイルしているのです。出力は次のようになりました。

Vector iteration 0034:  <0.9750545, -0.22196561>        Static string hijacked
Vector iteration 0035:  <0.97505456, -0.22196563>       Static string

ここには2つの重要なことがあります。まず最初に、Vector2.Normalize()メソッドの結果が変わっています。それまでは"0.9750545"と表示されていたものが、"0.97505456"(値の最後に6が追加された)が返されるようになりました。これは、内部的にコードが再コンパイルされ、その結果として値が変わったためです。最適化されたバージョンで精度の高いSSEインストラクションが使用されていることが、その理由です。この動作に関する詳細はこちらで解説しています。 

2つめの重要なポイントは、35回目に通常のStaticStringメソッドがコールされていることです。これはメソッドのインライン化によるものです。コードをデバッグして内部構造体を確認すれば、次のような内容が分かります。

Method Name:          OverridingSealedMethodNetCore.MultiTieredClass.Test(System.Numerics.Vector2, Int32)
Class:                00007ffa38474978
MethodTable:          00007ffa38464d48
mdToken:              0000000006000009
Module:               00007ffa3843f888
IsJitted:             yes
Current CodeAddr:     00007ffa383aded0
Version History:
  ILCodeVersion:      0000000000000000
  ReJIT ID:           0
  IL Addr:            0000000000000000
     CodeAddr:           00007ffa383aded0  (OptimizedTier1)
     NativeCodeVersion:  0000018E3EF1D140
     CodeAddr:           00007ffa383a7ae0  (QuickJitted)
     NativeCodeVersion:  0000000000000000

マシンコードのインスタンスが2つあることが分かります。最初のインスタンス(QuickJittedというラベル)はメソッドをコールしますが、第2のインスタンス(OptimizedTier1というラベル)は文字列リテラルをインラインで代入します。StaticStringStaticStringHijackedもコールされません。

このテクニックは、すべてのシナリオで動作するとは限りません。AOT形式でコンパイルされるメソッドでは動作しない場合があります。また、標準ライブラリはメソッドディスクリプタが異なるため、一部のメソッドはサポートされません。さらに、今確認したように、マルチティアコンパイルやコードのインライン化によって動作しなくなる可能性があります。

メリット:

  • マシンコードを理解する必要がない
  • オリジナルのマシンコードを棄損しない

デメリット:

  • 時間的影響を受けやすいため、信頼性の低い場合がある
  • マルチティアコンパイルによって結果が元に戻される場合がある
  • この方法では、すべてのメソッドを修正することはできない
  • インライン化によって無効になる

マシンコードを変更してsealedメソッドをオーバーライドする

私たちが使う第2のテクニックでは、実行時メタデータの変更は行いません。今回はメソッドのマシンコードを直接修正して、別の場所にジャンプするようにします。ソースメソッドのマシンコードを見つけて、それをバイナリレベルで変更し、ターゲットメソッドに移動するジャンプ命令を実行するようにするのです。

マシンコードを作るためには、まず、ジャンプ命令がどのように動作するのかを理解しなくてはなりません。x86アーキテクチャでは、パラメータとしてひとつの値(4または8バイト長)を使用します。これは、メモリアドレス内を移動する(文字通り"ジャンプ"する)距離を示すオフセット数値です。絶対メモリアドレスではなくオフセットを使用するので、ジャンプする距離(オフセット)を計算する必要がある分、使用が少し難しくなります。しかし、絶対アドレスに移動するトリックがあるのです。32bitモードであれば、アドレスをスタックにプッシュして、その後でreturn命令を実行すれば、スタックから取得されたアドレスに移動することができます。64bitアーキテクチャの場合は、アドレスを直接プッシュすることはできない(8バイトをスタックにプッシュする命令がない)ので、レジスタにアドレスを設定した上で、スタックにレジスタをプッシュします。

このコードを生成して、StaticStringメソッドの先頭で実行されるようにしたいのです。実質的には、必ずソースメソッドが実行されて、すぐにターゲットにジャンプすることになります。

以下のコードを例にしましょう。

using System;
using System.Linq;
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;

namespace MethodHijackerNetCore
{
    public class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine($"Calling StaticString method before hacking:\t{TestClass.StaticString()}");
            HijackMethod(typeof(TestClass), nameof(TestClass.StaticString), typeof(Program), nameof(StaticStringHijacked));
            Console.WriteLine($"Calling StaticString method after hacking:\t{TestClass.StaticString()}");

            Console.WriteLine();

            var instance = new TestClass();
            Console.WriteLine($"Calling InstanceString method before hacking:\t{instance.InstanceString()}");
            HijackMethod(typeof(TestClass), nameof(TestClass.InstanceString), typeof(Program), nameof(InstanceStringHijacked));
            Console.WriteLine($"Calling InstanceString method after hacking:\t{instance.InstanceString()}");

            Console.WriteLine();

            Vector2 v = new Vector2(9.856331f, -2.2437377f);
            for (int i = 1; i <= 35 ; i++)
            {
                MultiTieredClass.Test(v, i);
                Thread.Sleep(100);
            }

            Console.WriteLine($"Examine MethodDescriptor: {typeof(MultiTieredClass).GetMethod(nameof(MultiTieredClass.Test)).MethodHandle.Value.ToString("X")}");
            Console.ReadLine();
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public static string StaticStringHijacked()
        {
            return "Static string hijacked";
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public string InstanceStringHijacked()
        {
            return "Instance string hijacked";
        }

        public static void HijackMethod(Type sourceType, string sourceMethod, Type targetType, string targetMethod)
        {
            var source = sourceType.GetMethod(sourceMethod);
            var target = targetType.GetMethod(targetMethod);

            HijackMethod(source, target);
        }

        public static void HijackMethod(MethodBase source, MethodBase target)
        {
            RuntimeHelpers.PrepareMethod(source.MethodHandle);
            RuntimeHelpers.PrepareMethod(target.MethodHandle);


            var offset = 2 * IntPtr.Size;
            IntPtr sourceAddress = Marshal.ReadIntPtr(source.MethodHandle.Value, offset);
            IntPtr targetAddress = Marshal.ReadIntPtr(target.MethodHandle.Value, offset);

            var is32Bit = IntPtr.Size == 4;
            byte[] instruction;

            if (is32Bit)
            {
                instruction = new byte[] {
                    0x68, // push <value>
                }
                 .Concat(BitConverter.GetBytes((int)targetAddress))
                 .Concat(new byte[] {
                    0xC3 //ret
                 }).ToArray();
            }
            else
            {
                instruction = new byte[] {
                    0x48, 0xB8 // mov rax <value>
                }
                .Concat(BitConverter.GetBytes((long)targetAddress))
                .Concat(new byte[] {
                    0x50, // push rax
                    0xC3  // ret
                }).ToArray();
            }

            Marshal.Copy(instruction, 0, sourceAddress, instruction.Length);
        }
    }

    class TestClass
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public static string StaticString()
        {
            return "Static string";
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public string InstanceString()
        {
            return "Instance string";
        }
    }

    class MultiTieredClass
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public static void Test(Vector2 v, int i)
        {
            v = Vector2.Normalize(v);
            Console.WriteLine($"Vector iteration {i:0000}:\t{v}\t{TestClass.StaticString()}");
        }
    }
}

重要な部分は、59行目から始まるHijackMethodです。最初にソースメソッドとターゲットメソッドをコンパイルして、マシンコードを生成します。

次に、ソースとターゲット両メソッドのマシンコードのアドレスを取得します(65行)。そのためには、メソッドディスクリプタからアドレスを読み取る必要があります。

var offset = 2 * IntPtr.Size;
IntPtr sourceAddress = Marshal.ReadIntPtr(source.MethodHandle.Value, offset);
IntPtr targetAddress = Marshal.ReadIntPtr(target.MethodHandle.Value, offset);

32bitプラットフォームで動作している場合は、アドレスをスタックにプッシュした上でreturnを実行します。これは74~80行で行っています。0x68は値をスタックにプッシュする命令のコードです。その後、アドレスを整数(32bitプラットフォームであることはすでに分かっているので4バイト長)にキャストした上でバイト列に変換します。最後の命令である0xC3は、スタックからアドレスを取得して削除し、そのアドレスにジャンプします。

instruction = new byte[] {
	0x68, // push <value>
}
 .Concat(BitConverter.GetBytes((int)targetAddress))
 .Concat(new byte[] {
	0xC3 //ret
 }).ToArray();

64bitプラットフォームではアドレス値を直接プッシュすることができないので、最初にアドレスをraxレジスタにロードして、そのレジスタをスタックにプッシュした上でreturnします。これを84~92行で行っています。こちらでは、アドレスをintegerではなく、longにキャストしている点に注意してください(64bitプラットフォームではアドレスは8バイトであるため)。

instruction = new byte[] {
	0x68, // push <value>
}
 .Concat(BitConverter.GetBytes((int)targetAddress))
 .Concat(new byte[] {
	0xC3 //ret
 }).ToArray();

最後にこのコードを、ソースメソッドのマシンコードの先頭にコピーします。

Marshal.Copy(instruction, 0, sourceAddress, instruction.Length);

以上により、このテクニックでのプログラムの動作は次のようになります。

ハイジャック前:

  • StaticStringメソッドの呼び出しを開始する。
  • StaticStringメソッドの実コードを示すマシンコードのアドレスを取得する。
  • StaticStringコードを実行する。
  • StaticStringが終了すると、呼び出し元に戻る。

ハイジャック後:

  • StaticStringメソッドの呼び出しを開始する。
  • ハイジャック前と同じコードを示すマシンコードのアドレスを取得する。
  • StaticStringコードを実行する。
  • StaticStringの最初の部分がStaticStringHijackedへのジャンプであるため、別のメソッドにジャンプする。
  • StaticStringHijackedメソッドを実行する。
  • StaticStringHijackedの実行が終わると、呼び出し元に直接戻る(スタック上の戻りアドレスはStaticStringをコールした時のものであるため)。

この方法はメタデータを変更しない(実行するコードのみを変更する)ので、時間経過による影響を受けることはありませんが、マルチティアコンパイルが実行されて直接インライン化を行った場合には、依然としてその影響を受けることになります。

Vector iteration 0033:  <0.9750545, -0.22196561>        Static string hijacked
Vector iteration 0034:  <0.97505456, -0.22196563>       Static string

また、このテクニックは、マシンコードのアドレスの取得が可能であれば、すべてのメソッドに対して機能します。AOTコンパイルされたメソッド(アドレスがメソッドディスクリプタの8ないし16バイトに直接格納されていないため)や、P/Invokeで呼び出される外部のネイティブコード(コードを直接書き換えられない可能性があるため、VirtualProtextExまたはmprotectをコールしてコードを変更する必要がある)ではもう少し難しくなりますが、概念上はすべてのケースで機能します。

メリット:

  • 時間経過の影響を受けない
  • すべてのメソッドに対して機能する

デメリット:

  • 元々のマシンコードを棄損する
  • マルチティアコンパイルによって結果が元に戻される場合がある
  • インライン化によって無効になる
  • マシンコードやオペレーティングシステムに関する知識が必要である。

実用的なアプリケーション — WinAPIプロセス生成ラッパを変更する

メソッドハイジャックを使って何らかのビジネス価値を提供できる状況はいくつもあります。私自身の経験(運用システムへのデプロイ)から例を挙げると、

  • VEH(Vectored Exception Handling)機構を使ってStackOverflowExceptionを処理することで、テストスイートの強制終了を回避する
  • 新規スレッド生成にtry-catchブロックをインジェクトすることにより、処理されない例外によるプロセスの終了を回避する
  • WinAPIラッパを変更して、他のデスクトップでのプロセス実行を可能にする 

最後の例について、詳細に検討してみましょう。

Windowsはアプリケーションを分離するために、複数のデスクトップをサポートしています。このメカニズムは20年以上前からあるのですが、それがUIに現れたことはありませんでした。現在はDesktopsというアプリケーションがあり、複数のデスクトップのコントロールと切り替えが可能になっています。この方法は、ユーザの入力をキャプチャしたり、フォーカスを取得したりするアプリケーションを自動化する必要のある場合(ヘッドフルモードのPuppeteerによる自動化UIテストなど)に利用できます。

別のデスクトップ上でアプリケーションを実行するためには、STARTUPINFO構造体のlpDesktopフィールドを参照する必要があります。 

しかしC#では、このAPIを直接コールしません — 標準ライブラリの提供するラッピングコードが使用されています。.NET Frameworkのコードを調べたところ、残念ながらlpDesktopの値をセットすることはできず、常にnull値で初期化されています。 

C#を使って別のデスクトップ上でアプリケーションを実行したければ、いくつかのソリューションが必要になります。

  • WinAPIを直接呼び出すことは可能ですが、それでは.NET APIのサポートを失うことになりますし、自分自身でプロセスをコントロール(マーシャリングや入出力のリダイレクション)しなければなりません。
  • ラッピングコードを別の場所にコピーしておいて修正することも可能ですが、標準ライブラリが変更された場合のメンテナンスと更新が必要になります。
  • コードを直接変更して、lpDesktopの値を挿入することも可能です。そのために前述のテクニックを使って、sealedメソッドをオーバーライドすることができます。

コードをハイジャックするためには、STARTUPINFO構造体が生成されてWinAPIに渡されるまでの間に、私たちのコードを挿入する方法を見付けなくてはなりません。コンストラクタを使えばこれが実現できます。 

まず最初に、コンストラクタのメソッドディスクリプタを取得します。

var matchingType = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).First(t => t.Name.Contains("STARTUPINFO"));
var constructor = matchingType.GetConstructor(new Type[0]);
var newConstructor = typeof(Program).GetMethod(nameof(NewConstructor), BindingFlags.Static | BindingFlags.Public);

次に、以下の置き換えコンストラクタを使ってハイジャックします。

public static void NewConstructor(object startupInfo)
{
	startupInfo.GetType().GetField("cb", BindingFlags.Instance | BindingFlags.Public).SetValue(startupInfo, Marshal.SizeOf(startupInfo));
	startupInfo.GetType().GetField("lpDesktop", BindingFlags.Instance | BindingFlags.Public).SetValue(startupInfo, desktopNameStringHandle.AddrOfPinnedObject());
}

上記のコードでは、オリジナルのコンストラクタがcbフィールドに直接、適切な値を設定しています。次に、新たなコンストラクタを用意します。このコンストラクタでは、リフレクションを使ってcbフィールドをセットするとともに、lpDesktopフィールドにも使用したいデスクトップの名前をセットしています。

これによるハイジャック後のコードは、次のように動作します。

  • ProcessStart()メソッドをコールする。
  • Process.Start()でSTARTUPINFO構造体のインスタンスが生成される。 
  • 通常のコンストラクタが呼ばれる代わりに、私たちの独自のコンストラクタが実行され、リフレクション経由でフィールド値を設定する。

私はこのテクニックを、.NET FrameworkやWindows Server 2012/2016で以前から使用しています。

要約

内部構造体に手を加えることで、プラットフォームの動作を変更できることを見てきました。そのためにはコード生成、オペレーティングシステムのメカニズム、.NETプラットフォームの内部に関する理解が必要です。それでも結局のところは、ニーズに合わせて変更可能なバイト列に過ぎないのです。

著者について

Adam Furmanek氏は、10年以上の経験を持つプロフェッショナルソフトウェアエンジニアです。そのキャリアの中で氏は、ロジスティクスやEコマース、マシンラーニング、データ分析、データベース管理など、あらゆるソフトウェアレイヤや、さまざまなタイプのアプリケーションに関わってきました。氏は常に、マシンコードを深く掘り下げて実装の詳細を調査し、日々使用するテクノロジの内部に関する理解を深めることに関心を持っています。氏がコードのデバッグ、逆コンパイル、逆アセンブルを行って、メモリモデルや並行性問題など深い部分に隠された詳細を理解することが好きなのは、そのような理由からです。余暇には卓球をしたり、Woody Allenの映画やブログ記事の執筆をしています。


 

この記事に星をつける

おすすめ度
スタイル

BT