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?
incoming payload → type resolver
Type from input
gadget chainchosen by the payload
- 1. read
"$type" names a class - 2. resolve
load that arbitrary type - 3. construct
run its setters and callbacks
Closed allowlist
known shapechosen by your contract
- 1. read
"$kind" discriminator - 2. match
one of a fixed set - 3. construct
a plain, expected DTO
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
TypeNameHandlingaboveNone, no type resolved from a field or header, noBinaryFormatter. 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.JsonexposesMaxDepth; 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.