説明
2020 年、.NET Remoting に関連するいくつかのバグが Microsoft に報告されました[1]。そのうちの 1 つが、BinaryServerFormatterSink::ProcessMessage関数における DoSバグです(Microsoft によれば「意図した動作」とのことです)。この DoS は実際には、ガジェットを変更することでコード実行ができます(研究者は Process.Startガジェットを使用していましたが、TypeFilterLevel=Low のために未処理の例外が発生するとクラッシュしてしまいます)。
我々は、4 つのガジェットを使用して、任意のメモリに touch/読み取り/書き込み/実行するエクスプロイトを作成しました。.NETコードは MSILコードを実行するために JIT を使用しているため、プロセス内の既存の RWXページを悪用してシェルコードを配置し、後で最後のガジェットによって実行するようにしています。
攻撃者にとって唯一必要なのは、攻撃者がメソッドを実行できるエンドポイントです(特別に細工した引数で Object.Equals を使用します)。
サーバアプリケーションのコードに制御が渡る前に脆弱性が引き起こされるため、インターフェイスによって公開されるメソッドのコード変更で緩和することはできないようです。脆弱性はリモートコールの引数をデシリアライズする関数で発生し、エクスプロイト試行が例外で終わるため、アプリケーションは何の通知も受けません。
*BinaryServerFormatterSink::ProcessMessage の明らかなデシリアライズバグの他に、(任意のメモリ書き込みなどの安全でない関数を公開しているにもかかわらず) Marshal** 静的クラスの関数に [SecuritySafeCritical] 属性が欠けています。
技術詳細
●BinaryFormatter の TypeConfuseDelegate と DataSet ラッパー
このエクスプロイトで使用されている TypeConfuseDelegate の詳細について説明します。これは、サーバーサイドで何が起こっているかを理解するために重要なことです。
コード例([2]の ysoserial のコードを参照して下さい。)

上記のコードでは、Comparator に String.Compare関数が使用されています。 public static int String.Compare(string strA, string strB)
この関数は、(MulticastDelegate を使用して) (署名により)適切な静的関数Process.Start とマージされます。
public static Process Process.Start(string fileName)
ここで、ちょうど 2 つの要素を含む SortedSet<string> を作成すると、String.Compare(“calc.exe”, “dummy”)の他にデシリアライズの瞬間に、2 度目の呼び出し Process.Start(“calc.exe”, “dummy”) が行われることになります。
注: 関数の引数の数が、与えられた引数の数より少ない場合、余分な引数は無視されます。戻り値は、1つ目の関数でのみ重要で、2つ目の関数では何でも構いません。
関数の 2 つの引数のみを制御することができますが、この引数は同じかキャスト可能な型でなければなりません。

ここで別の例として、ベース型(Array)と Comparator(Array.IndexOf)が異なることで、SortedSet<Array> 以外に byte[] をシリアライズして Assembly.Load を呼ぶものもあります。
署名:

デシリアライゼーションに対する効果的なアクション:

Set には 2 つのオブジェクト(Assembly.Load に渡したいアセンブリの byte[] と byte[]型の”dummy”)があるので、デシリアライズ時に Comparator (MulticastDelegate)が呼び出されることになります。
それに応じて SortedSet の最初のオブジェクトが最初の引数として、2 番目のオブジェクトが第 2 引数としてそれぞれ使用されることが保証されているようです。
SortedSet<Array> を(BinaryDeserializer を使って)TypeFilterLevel=Low でデシリアライズするには、さらにそれをガジェット(ISerializable を実装するクラス)の一つにラップする必要があります。この場合、DataSet です。
シリアライズされた DataSet は、DataSetオブジェクトをデシリアライズする際に、任意の BinaryFormatter のバイト列をリモートアプリケーションに強制的にデシリアライズさせることができます。ysoserial での使い方は[3]で見ることができます。 つまり、最終的なペイロードは次のようになります。
DataSet(BinaryFormatter(SortedSet<Array>))
コード:

サーバから直接値を返すことはできないので(MulticastDelegate の呼び出しに成功すると InvalidCastException が発生します)、呼び出しの失敗・成功(とそれが返す例外)をもとに oracle を作成することになります。
●DynamicInvoke による TypeConfuseDelegateガジェットの改良(メモリtouchガジェット)
TypeConfuseDelegate には制限があるため、2つ以上の制御された引数をサポートするように変更する必要があります。対象の関数を直接呼び出すのではなく、別の Delegateインスタンスを作り、それを MulticastDelegate の内部でシリアライズします。

署名:

デシリアライゼーションに関する効果的なアクション:

この例では、IntPtr引数で Marshal.ReadByte を呼び出し、メモリが READアクセスできるどうかをテストしています。
この関数は適切な例外処理をしているので、Marshal.ReadByte 内で System.AccessViolationException を取得し、アクセスできないアドレスがあってもアプリケーションがクラッシュすることはありません。
DataSetオブジェクトが SortedSet<Array> のデシリアライズを完了し、SortedSet<Array> を System.Data.DataTable にキャストできないことに気づくと、touch成功時に System.InvalidCastException が発生します。
touch成功時のスタックトレース:

BAD メモリ touch の際のスタックトレース:

●書き込みメモリガジェット
public static void WriteByte (IntPtr ptr, byte val);
touch とほぼ同じですが、関数に 2つの引数があります。

署名:

●実行メモリガジェット
署名:

IUnknown::AddRef のラッパーである Marshal.AddRef 関数を悪用することでコードの実行を実現することができます。基本的には、AddRef メソッドを持つ VTable で偽の COM オブジェクトを構築し、それを Marshal.AddRef に渡せばよいのです。

RWXメモリが 0xAAAA0000 に配置されていると仮定してみましょう。0xAAAA0100 で偽の COMオブジェクトの構築を開始し、その VTable は 0xAAAA0200 に配置されます。実行したいメモリは 0xAAAA0300 に用意されています。ですから、アクションは次のような疑似コードに単純化することができます。

このアクションの後、アプリケーションの状態は破壊されるので、コードは .NET に制御を戻すべきではありません。エクスプロイトに成功すると、例外もタイムアウトも発生しません(エクスプロイトは、ターゲットアプリケーションが終了しなければ永遠に結果を待ちます)。
●読み出しメモリガジェット
例外以外に直接何かを返すことができないので、このガジェットにはちょっとした工夫が必要です。幸運なことに BitConverter.ToBoolean 関数が利用できます。

配列のバイトがゼロかそれ以外かをブール値で返すだけなので、一見しただけでは、どうやってメモリを読みだすのかわかりません。
ここで、.NET で byte[]型がどのようにメモリ上で表現されるか、正確には Length の値がどこに格納されているかを理解する必要があります。簡単に言うと、byte[] へのポインタの +0x8 に格納されます。

byte[] へのポインタが 0xAAAA00 の場合、配列 Length は 0xAAAA08 に符号付き int32 (0 から 0x7fffff の範囲の値、負数は除く)として格納されます。
この情報の使い方を理解するためには、例を見る必要があります(前に示した図の BitConverter.ToBoolean のコードの流れに従ってください)。
0xAAAA08 のメモリに DWORD値100 を持たせます。



この場合、DWORD を読み込むために、System.ArgumentOutOfRangeException例外とその他の例外の間に変化が生じるまで、バイナリサーチを使用して推測する必要があります。

Int32 が負の値の場合、常に読み込めるとは限りません。しかし、次のコードのように、この関数を BYTEリーダにラップすることで、このようなケースを最小にすることができます。

署名:

ガジェットコード:

正しい推測時の例外: System.InvalidCastException
間違った推測時の例外: System.ArgumentOutOfRangeException
間違った推測/不正なメモリ例外: System.AccessViolationException
●RWX権限で JITページを探す
この時点で、ターゲットプロセスの任意のメモリの読み出し、書き込み、実行が可能です。
エクスプロイトのための最後の課題は、実行するためのシェルコードを準備できるメモリを見つけることです。
.NET JIT に属するページの最初の 32 バイトを見てみましょう。

すぐにパターンに気づき、次のような構造に単純化することができます。

このヘッダを持つ全てのページが実際に RWX のパーミッションを持つわけではありませんが、我々のテストでは、以下の条件を満たす領域は通常シェルコードにとって適したものでした。

●任意のコード実行のための戦略
そこで、我々のエクスプロイト戦略は次のようになります。
1.プロセスのハイメモリをスキャンして、メモリをコミットしているブロックを探します
(高速化のために大きなサイズのステップを使用します)。
2.UInt64 START = 0x7ff000000000; UInt64 FINISH = 0x7ffffff000; UInt64 STEP = 0x40 * 0x1000;
3.発見した領域の境界をより小さなステップで精査します。
4.UInt64 STEP = 0x1000;
5.各ページのオフセット+0x08 のポインタを読み取り、それがブロック自体のアドレスと同じかをチェックします。
6.このチェックに通った場合、残りの 3 つのポインタを読み、このブロックが RWXメモリになり得るか判断します。
7.シェルコードを書いて偽の COMオブジェクトを構築し、Marshal.AddRefガジェットを呼び出します。
●想定される緩和ケース (Veeam Agent for Microsoft Windows)
最近、MDSec から出されている、Veeamソフトウェアのデシリアライズの脆弱性についての新しい記事がありました[5]。彼らは James Forshaw 氏による ExploitRemotingService [6]を使用し、TrustLevelをLow にダウングレードすることで修正しています。
我々の手法を使えば理論的には回避が可能なため、この方法は不十分な修正と思われました。Veeam の最近のバージョンをテストしたところ、驚くことにこの方法はうまくいきませんでした。
Veeam はカスタムBinaryFormatterデシリアライザーを実装しており、ホワイトリストにないタイプをブロックすることが判明しました。これは、我々のエクスプロイト方法にとってふさわしい修正のように思えるので、このケースについて言及しておきます。




●まとめ
このように、TypeFilterLevel=Low で MarshalByRefインターフェイス(オリジナルの James Forshaw 氏のエクスプロイトに必要)なしでも、コードの実行が可能です。
.NET Remoting は長い間非推奨の機能であり、攻撃者にアクセスされ悪用される可能性のあるアプリケーションでは使用すべきではありません。
この方法以外にも、例えば[4]の最新研究など、様々な活用方法があります。
また、Microsoft に問い合わせたところ、この問題は生産中止のソフトウェアにおける既知の問題であることを確認しました。公式には、WCF への移植を推奨しています。関連するドキュメントは[7]にあります。
●リソース
[1] MZ-20-03 – New security advisory regarding vulnerabilities in .Net
[2] TypeConfuseDelegateGenerator.cs