Insecure Deserialization.NET

.NET Deserialization: BinaryFormatter, Json.NET and TypeNameHandling

On .NET, the type information lives inside the payload. BinaryFormatter and a Json.NET $type directive will happily instantiate whatever class an attacker names — including gadgets that reach a process start. Here is how it leads to RCE, and the binder allowlist that stops it.

SelfSec Team4 min read
Part 2 of 5from theInsecure Deserializationseries
{"$type":"ObjectDataProvider"}
On this page

On .NET the danger is that the serialized payload carries its own type information, and several built-in serializers will faithfully instantiate whatever type the bytes name. BinaryFormatter and friends reconstruct an object graph by reading the assembly-qualified type out of the stream; Json.NET does the same when TypeNameHandling is anything other than None. In both cases the attacker, not your code, chooses which classes get built — and the .NET ecosystem ships gadget types that turn "build this object" into "start this process."

This is a direct route to remote code execution, and the tooling to exploit it (ysoserial.net) is mature. This post shows how an untrusted blob instantiates an attacker-chosen gadget during deserialization, where these sinks hide in real apps, and how a strict serialization binder closes them.

How it happens

The root cause is a serializer that resolves types from the payload itself. BinaryFormatter is the textbook case — it reads the type, the assembly and the fields straight from the stream:

public object Load(byte[] body)
{
    using var ms = new MemoryStream(body);
    var formatter = new BinaryFormatter();
    return formatter.Deserialize(ms);
}

Nothing here constrains what Deserialize will build. The same hazard applies to SoapFormatter, LosFormatter and NetDataContractSerializer, all of which embed type identity in their output. Json.NET is safe by default, but a single opt-in reopens the door:

var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.All
};
var model = JsonConvert.DeserializeObject<Model>(json, settings);

With TypeNameHandling.All, Json.NET honours a $type field in the JSON and instantiates whatever assembly-qualified type it names — exactly the control an attacker wants.

A concrete attack

The attacker supplies a $type that points at a known gadget. ObjectDataProvider is the favourite: it is a WPF helper that invokes an arbitrary method on an arbitrary object when its properties are set, which deserialization does during reconstruction:

POST /api/import HTTP/1.1
Content-Type: application/json

{
  "$type": "System.Windows.Data.ObjectDataProvider, PresentationFramework",
  "MethodName": "Start",
  "ObjectInstance": {
    "$type": "System.Diagnostics.Process, System",
    "StartInfo": {
      "$type": "System.Diagnostics.ProcessStartInfo, System",
      "FileName": "cmd.exe",
      "Arguments": "/c calc.exe"
    }
  }
}

When DeserializeObject rebuilds this graph, setting ObjectInstance and MethodName causes ObjectDataProvider to call Process.Start with the attacker's command line — RCE during deserialization, no vulnerable code of yours required. The same gadget rides inside a BinaryFormatter stream when that formatter is the sink instead.

The most common real-world entry point is ASP.NET Web Forms __VIEWSTATE. ViewState is a LosFormatter blob; if MAC validation is disabled or the machineKey leaks, an attacker forges a valid ViewState carrying the gadget above:

POST /page.aspx HTTP/1.1
Content-Type: application/x-www-form-urlencoded

__VIEWSTATE=/wEy...AAEAAAD/////AQAAAAAAAAAMAgAAAF9T...

The $type hint was never metadata you could trust — it is a constructor call signed by the attacker. The serializer is behaving exactly as documented, on input that should never have reached it.

Why it matters

Because the gadget runs inside the worker process during the deserialize call, the impact is severe:

  • Remote code execution — arbitrary process start under the app pool identity.
  • Forged ViewState — a leaked machineKey turns every Web Forms page into an RCE sink.
  • Privilege and trust abuse — gadgets run with the application's permissions and database access.
  • Full server compromise — RCE leads to credential theft, persistence and lateral movement.

The fix: kill the dangerous formatters, lock the type resolver

The first rule is simple: do not use BinaryFormatter. It is obsolete and removed from modern .NET precisely because it cannot be made safe on untrusted input; the same goes for SoapFormatter, LosFormatter and NetDataContractSerializer. Move to a data-only contract and keep Json.NET at its safe default:

var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.None
};
var model = JsonConvert.DeserializeObject<Model>(json, settings);

If polymorphism is genuinely required, do not loosen TypeNameHandling blindly — pin a strict SerializationBinder that allows only the exact types you expect and rejects everything else:

public sealed class AllowListBinder : ISerializationBinder
{
    private static readonly HashSet<string> Allowed = new()
    {
        "MyApp.Models.Invoice", "MyApp.Models.LineItem"
    };

    public Type BindToType(string assemblyName, string typeName)
    {
        if (!Allowed.Contains(typeName))
            throw new SerializationException($"Type not allowed: {typeName}");
        return Type.GetType($"{typeName}, {assemblyName}");
    }

    public void BindToName(Type t, out string? asm, out string? name)
        => (asm, name) = (null, t.FullName);
}

For Web Forms, keep ViewState MAC validation enabled, encrypt it (ViewStateEncryptionMode.Always), and rotate any machineKey that may have leaked. Every type you admit through the binder is a type an attacker will try to chain — keep the list as small as the feature allows.

How SelfSec finds it

SelfSec's crawler locates the parameters, cookies, JSON bodies and form fields that feed a deserializer, then submits known serialized markers — Java's serialized base64 preamble, PHP object and array structures, YAML python-object tags and fastjson type directives, alongside .NET $type and __VIEWSTATE probes — and watches for deserialization-specific errors such as ClassNotFoundException, InvalidClassException and autoType that reveal an unguarded sink. Matched evidence is rescored to separate a genuinely exploitable deserializer from incidental error output, so findings are confirmed rather than guessed. The entire scan runs locally on 127.0.0.1, your scan data never leaves your machine, and every finding ships with a reproduction request you can replay.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about Insecure Deserialization

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The Insecure Deserialization series

  1. 01OverviewInsecure Deserialization: How a Serialized Blob Becomes Remote Code Execution3 min
  2. 02.NET.NET Deserialization: BinaryFormatter, Json.NET and TypeNameHandlingReading
  3. 03JavaJava Deserialization: ObjectInputStream and the Gadget-Chain Problem4 min
  4. 04PHPPHP Object Injection: unserialize() and the Magic-Method Chain5 min
  5. 05PythonPython Deserialization: pickle, PyYAML and the __reduce__ Trap4 min

Related reading