Skip to content
Katabench
Try free
8 min read The Katabench team

Insecure deserialization in .NET: why BinaryFormatter died

Insecure deserialization in .NET turns untrusted input into code execution. Why BinaryFormatter was removed, and the Newtonsoft.Json and XmlSerializer traps.

Deserialization looks like reading. You hand a library some bytes, it hands you back an object, and the object has the fields you expected. The trouble is that "which object" can be a decision the bytes get to make. When the input controls the type, deserialization stops being reading and becomes code execution steered by the shape of the input. The parser is fine. The problem is that you let a stranger pick the class, and constructing a class runs code.

That single idea explains the whole category. Insecure deserialization is not about malformed JSON or a buffer overflow in a parser. It is about a serializer that accepts a type name from the wire, loads that type, and runs its constructors, property setters, and callbacks on the way in. Chain the right types together and a document becomes a program.

Deserialization is a type decision

Who gets to choose the CLR type?

untrusted bytes in

incoming payload → type resolver

Type from input

gadget chain

chosen by the payload

  1. 1. read "$type" names a class
  2. 2. resolve load that arbitrary type
  3. 3. construct run its setters and callbacks

Closed allowlist

known shape

chosen by your contract

  1. 1. read "$kind" discriminator
  2. 2. match one of a fixed set
  3. 3. construct a plain, expected DTO
A gadget chain does not exploit a parser bug. It uses the feature that lets the input name the type. Take that choice away and the same bytes deserialize into a DTO you defined.

A gadget chain does not break the parser. It uses the feature that lets the payload name the type. The fix is almost never a safer parser. It is refusing to let input choose the class.

BinaryFormatter, in one honest paragraph

BinaryFormatter is the canonical example, and its story is now closed. It serialized and restored arbitrary object graphs by type, which made it trivially convenient and impossible to secure: the payload names the types, and known "gadget" types in the framework turn that into command execution. Microsoft documented it as containing a dangerous deserialization pattern that could not be made safe, obsoleted the API, and made it throw by default. In .NET 9 the working implementation was removed from the runtime; what ships in the box now throws when you call it. The BinaryFormatter security guide walks through the reasoning and the migration. If you still have a BinaryFormatter call reachable from any input you do not fully control, that is the first thing to remove, not tune.

The mistake is thinking the danger left with BinaryFormatter. It did not. The same "type from input" pattern lives on in ordinary application code, usually turned on for a good reason and then forgotten.

Newtonsoft.Json with TypeNameHandling

Newtonsoft.Json is safe by default. It becomes dangerous the moment you set TypeNameHandling to anything other than None on data that crosses a trust boundary:

var settings = new JsonSerializerSettings
{
    // Anything but None writes and reads CLR type names in the payload.
    TypeNameHandling = TypeNameHandling.All
};

// The "$type" field in the body now decides which type gets constructed.
var message = JsonConvert.DeserializeObject<object>(body, settings);

People reach for this to round-trip polymorphic objects: "I have a base class and several subclasses, and I want the right one back." The setting delivers that by writing the assembly type name into a $type property and trusting it on the way in. An attacker who can reach this endpoint does not send your subclass. They send a type they know is loaded and does something on construction:

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

That is the shape, not a copy-paste exploit. The point is that no property in the payload is your data. The whole document is a set of instructions for the deserializer, and TypeNameHandling.All agreed to follow them. A SerializationBinder that allowlists types can narrow the blast radius, but the durable fix is to stop encoding CLR types in the wire format at all.

Polymorphism done right in System.Text.Json

You usually do not need type names in the payload. You need a small, closed set of shapes and a field that says which one this is. System.Text.Json supports exactly that with a fixed discriminator, and the type list is closed at compile time:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$kind")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransfer), "bank")]
public abstract class PaymentInstruction
{
    public decimal Amount { get; init; }
}

public sealed class CardPayment : PaymentInstruction
{
    public required string Last4 { get; init; }
}

public sealed class BankTransfer : PaymentInstruction
{
    public required string Iban { get; init; }
}

A payload with "$kind": "card" deserializes into a CardPayment. A payload with "$kind": "wire-me-your-savings" fails, because the discriminator does not match any declared derived type. The input can pick from your list. It cannot introduce a type that is not on it. That is the entire difference between a controlled contract and a gadget chain, and it is why the closed allowlist is the emerald path in the diagram above.

XmlSerializer and DataContractSerializer

The XML serializers have the same failure mode when a type is resolved from input. XmlSerializer constructed with a Type that came from a header or a field, or DataContractSerializer used with a permissive resolver, reintroduces "input chooses the class." A NetDataContractSerializer writes CLR type names much like BinaryFormatter did. The safe posture is identical: fix the expected type in code, do not let the document nominate it, and never resolve a type from an untrusted string. Read the OWASP Top 10 through a C# lens for how this category sits next to injection and broken access control in a real threat model.

Your queue and cache are untrusted input too

Here is the failure that survives every code review, because the data "came from us." Messages on a broker, entries in a distributed cache, rows in an outbox: teams deserialize these with the dangerous settings on the reasoning that the producer is their own service. But a queue is a trust boundary the moment anything other than your exact deploy can write to it: a compromised neighbor service, a poisoned message replayed from a dead-letter queue, a cache an attacker can influence. Treat the wire format of every asynchronous hop as input from a stranger, because under the wrong incident it is. The same discipline that protects an HTTP endpoint protects a RedisValue you are about to turn back into an object.

The defenses, in priority order

The techniques stack, but they are not equal. Ordered by how much they actually buy you:

  • Never let the input choose the CLR type. This is the whole game. No TypeNameHandling above None, no type resolved from a field or header, no BinaryFormatter. A fixed discriminator with a closed derived-type set gives you polymorphism without handing over the type decision.
  • Keep an allowlist, not a blocklist. If you genuinely must map a discriminator to a type, map it through an explicit dictionary of the handful you support. Blocklists of "known gadgets" are a losing game; new chains keep being found.
  • Validate after deserializing, not just during. A well-typed object can still carry a negative quantity, an out-of-range date, or an IBAN for an account you do not own. Deserialization proves the shape; your domain rules prove the meaning.
  • Bound depth and size. A deeply nested or enormous payload is a denial-of-service vector on its own. System.Text.Json exposes MaxDepth; cap request bodies before they reach the parser.
  • Separate data formats from object formats. A format that serializes plain data (fields and values) is a different tool from one that serializes objects (types and behavior). Use the former for anything crossing a boundary. Reserve type-carrying formats for trusted, in-process scenarios, if at all.

The honest trade-off: a closed discriminator means you cannot deserialize a type your code has never heard of, which is exactly the flexibility that made the unsafe settings attractive. That flexibility was the vulnerability. Adding a new supported shape becomes a deliberate code change with a review, which is where a type decision belongs.

Build the instinct on real payloads

Insecure deserialization is hard to unlearn from prose because the vulnerable code reads as convenience: one setting, one helper, and polymorphism "just works." The instinct you want is to see DeserializeObject<object> or a type pulled from a header and immediately ask who chooses the type here. That comes from handling adversarial input, not from reading a checklist.

The same question, "does the input get to make this decision?", runs through Katabench's Secure Coding exercises: a host allowlist that must refuse every internal spelling, a link scheme allowlist, a sort column allowlist, an upload content-type allowlist. Each one is a closed set the input is not allowed to widen, and each is graded by an adversarial suite that sends the inputs your happy-path tests never generate. The Secure Coding track sequences them next to injection and path traversal so the "input controls a decision" pattern becomes one you spot on sight, including the day it shows up as a $type property. The cheapest gadget chain is the one your contract never allowed.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.