Blog

Notes on the .Net ecosystem, Angular, Software Architecture, Machine Learning and CyberSecurity

All posts
Insecure Deserialization: when reading data is already running code
  • Security
  • Deserialization
  • .NET

Insecure Deserialization: when reading data is already running code

We have reached the last of the five, and the least known. The four before it followed a similar script: the user's data reached somewhere that interprets text and became structure. This one works differently, and the result is usually worse.

Serialization and deserialization: what they are

Before the problem, the two terms, because this series does not use jargon before introducing it.

Serializing is turning an object that lives in memory into something you can store or transmit, usually text or bytes. Deserializing is the way back: taking that text and rebuilding the object.

You do this all the time. Every API that receives a JSON body is deserializing. Every cache that stores an object is serializing.

Insecure Deserialization: rebuilding is not just copying data

The intuition when you read "deserialize" is filling in fields: the text says Name is Maria, so the object comes out with Name as Maria. If that were all, the worst that could happen would be wrong data inside the object.

Except some serialization formats store more than the values. They store which type the object was. And when rebuilding, the deserializer reads the type name from the data and creates an instance of that type.

There is the turn: whoever controls the data gets to choose which class is instantiated. And instantiating a class runs code: constructors, deserialization callbacks, and in some cases code that runs when the object is discarded.

An attacker who knows this will not send strange data. They will craft data describing a chain of objects, of classes that already exist in your application or in .NET itself, chosen because the sequence of things they do while being built ends in running a command. The name for that is a gadget chain, and for the older formats those chains are public and well known.

The result is code execution on your server, and the code that opened the door is this:

var formatter = new BinaryFormatter();

using var stream = new MemoryStream(payload);
var order = (Order)formatter.Deserialize(stream);

Notice there is no concatenation, no string being assembled, no special character. The vulnerability is in the choice of API.

The .NET APIs that carry this behaviour

BinaryFormatter is the most serious case, and Microsoft was explicit about it: it is obsolete, it is considered dangerous by nature, and from .NET 9 onward it throws even if you try to use it. There is no safe way to deserialize untrusted data with it. Alongside it are SoapFormatter, NetDataContractSerializer, LosFormatter and ObjectStateFormatter, all with the same characteristic of carrying the type inside the data.

There is also a case that is not obsolete and shows up often, because it depends on configuration. Newtonsoft.Json is safe by default, and stops being safe when somebody switches on type resolution:

var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All };

var order = JsonConvert.DeserializeObject<Order>(payload, settings);

With TypeNameHandling switched on, the JSON can now say which class to instantiate through a $type property, and you are back to the full problem. That option is usually switched on for a legitimate reason (somebody needed to serialize a hierarchy of classes and polymorphism was getting lost), and whoever switches it on rarely notices what they opened.

And there is the variation where the type comes from outside the data, but still from the user:

var serializer = new XmlSerializer(Type.GetType(typeName));

XmlSerializer is not vulnerable by itself, it requires you to state the type. The problem here is Type.GetType receiving a name chosen by the caller.

The fix: use a format that only carries data

The fix is not filtering the incoming data, and it is not keeping a list of forbidden types. It is swapping the format for one that does not have that capability.

var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };

var order = JsonSerializer.Deserialize<Order>(payload, options);

System.Text.Json deserializes against the type you stated in the code. The JSON content fills properties and nothing more. There is no field in the data able to say "actually, instantiate another class", so there is no object chain for the attacker to build.

It is the same principle as the four previous articles, applied somewhere different: your code decides the structure, and the user's data comes in only as a value.

When you genuinely need polymorphism, System.Text.Json supports it with the types declared by you, through attributes, instead of accepting any name that arrives in the data. The difference is who holds the list: you or whoever sent the payload.

Why it goes unnoticed

This is the quietest of the five, for three reasons.

The first is that there is nothing visually suspicious. In the other four there is a signal: a string being assembled, a concatenation inside a query, a path coming from the request. Here the code is clean, and the danger is in the name of the class that was chosen.

The second is that the data often does not look like user input. An object coming from a cache, a message queue, a session cookie or a hidden form field does not look like something the user controls. But if they can change that value, they control it.

The third is that a lot of this code is old. BinaryFormatter was the standard way to serialize in .NET Framework for years, and there is plenty of production code written when that was simply how it was done.

What this becomes in training

Same as the other four, every pattern goes in as a pair: the version with the format that carries the type labelled as Insecure Deserialization, and the same feature with System.Text.Json labelled as safe.

This ended up being the hardest category for the classifier. Measured against code it had never seen, it misses a quarter of the real cases, and two out of every five things it flags are code that was actually fine.

The most instructive example is this snippet, which is safe:

var serializer = new XmlSerializer(typeof(Report));

var report = (Report)serializer.Deserialize(reader);

Compare it with the vulnerable version, which appeared earlier in this article:

var serializer = new XmlSerializer(Type.GetType(typeName));

The entire difference is in the argument. With typeof(Report) the type is chosen by your code, at compile time, and there is no way for the data to influence it. With Type.GetType(typeName) the type comes from a piece of text that arrived from outside.

The classifier looks at the safe version and answers: Insecure Deserialization, 97.7% confidence.

It saw new XmlSerializer followed by Deserialize, which is the shape of the vulnerable examples it was trained on, and reacted to the shape. Knowing that typeof(Report) pins the type and Type.GetType(typeName) does not requires understanding what each one does, and that it does not have.

Hold on to that number. It is the clearest demonstration in the whole series that the model reads shape and not meaning, and it is the reason the next stage exists.

With that the fundamentals are done. The next article changes subject and moves to construction: how the five categories become training material, and how to train a classifier on top of CodeBERT without needing a graphics card or half a gigabyte of file in the repository.

Sources