CWE-502: Deserialization of Untrusted Data - C# / .NET
Overview
Insecure deserialization in .NET can lead to remote code execution when untrusted data is deserialized using formatters like BinaryFormatter, NetDataContractSerializer, or ObjectStateFormatter. These formatters can instantiate arbitrary types and execute code during deserialization.
Primary Defence: Use System.Text.Json (JSON serialization) or DataContractSerializer with explicit known types instead of BinaryFormatter. Never use BinaryFormatter with untrusted data. In .NET 9+, the in-box BinaryFormatter implementation throws at runtime; older versions still need code migration because the format cannot be made safe for untrusted input.
Common Vulnerable Patterns
BinaryFormatter (DANGEROUS - Never Use!)
// VULNERABLE - BinaryFormatter with untrusted data
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
public class UserService
{
public object LoadUser(byte[] data)
{
var formatter = new BinaryFormatter();
using (var ms = new MemoryStream(data))
{
return formatter.Deserialize(ms); // RCE vulnerability!
}
}
}
Why this is vulnerable:
- Instantiates arbitrary .NET types from untrusted input.
- Executes constructors, property setters, and callbacks during deserialization.
- Enables gadget chains that lead to remote code execution.
NetDataContractSerializer
// VULNERABLE - Can deserialize any type
using System.IO;
using System.Runtime.Serialization;
public object Deserialize(byte[] data)
{
var serializer = new NetDataContractSerializer();
using (var ms = new MemoryStream(data))
{
return serializer.Deserialize(ms); // DANGEROUS!
}
}
Why this is vulnerable:
- Embeds type metadata in the payload.
- Attackers can choose arbitrary types to instantiate.
- Gadget chains can execute code during object creation.
- No safe configuration for untrusted input.
JavaScriptSerializer with Type Resolution
// VULNERABLE - Type resolver enables RCE
using System.Web.Script.Serialization;
var serializer = new JavaScriptSerializer(new SimpleTypeResolver());
object obj = serializer.Deserialize<object>(json); // Can instantiate any type!
Why this is vulnerable:
- Allows payloads to specify .NET type names.
- Instantiates attacker-chosen types during deserialization.
- Constructors and property setters can execute code.
- Enables gadget chains via type resolution.
Newtonsoft.Json with TypeNameHandling
// VULNERABLE - TypeNameHandling.All is dangerous
using Newtonsoft.Json;
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All // DANGEROUS!
};
var obj = JsonConvert.DeserializeObject(json, settings); // RCE risk!
Why this is vulnerable:
- Reads
$typemetadata out of the incoming JSON payload. - Lets attackers control which types get instantiated.
- Dangerous constructors and callbacks can run.
- Gadget chains enable remote code execution.
Secure Patterns
System.Text.Json (Recommended for Modern .NET)
// SECURE - System.Text.Json has no type resolution by default
using System.Text.Json;
public class UserService
{
public User LoadUser(string json)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
// Only deserializes to specified type (User)
return JsonSerializer.Deserialize<User>(json, options);
}
public string SaveUser(User user)
{
return JsonSerializer.Serialize(user);
}
}
// Example usage:
var user = new User { Name = "John", Email = "john@example.com" };
string json = JsonSerializer.Serialize(user);
User deserialized = JsonSerializer.Deserialize<User>(json);
Why this works:
- Requires an explicit target type (
Deserialize<User>), and creates only that CLR type and its properties. - Does not resolve or honor
$typemetadata by default. - Treats input as data (primitives/objects), not instructions.
- Blocks gadget chains that rely on arbitrary type instantiation.
Newtonsoft.Json WITHOUT TypeNameHandling
// SECURE - No type name handling
using Newtonsoft.Json;
public class UserService
{
public User LoadUser(string json)
{
// Safe: No TypeNameHandling, deserializes only to User type
return JsonConvert.DeserializeObject<User>(json);
}
public string SaveUser(User user)
{
return JsonConvert.SerializeObject(user);
}
}
// If you MUST use TypeNameHandling, use minimal scope:
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.None, // SECURE - this is the default
// OR for polymorphism, use limited scope:
// TypeNameHandling = TypeNameHandling.Objects
// with SerializationBinder to allowlist types
};
Why this works:
- Default
TypeNameHandling.Noneignores$typemetadata, so the payload cannot select the type. - Deserializes only to the specified target type.
- Maps JSON to properties without executing extra logic.
- Allowlisting with a binder keeps polymorphism bounded.
DataContractSerializer (Safe for Explicit Known Types)
// SECURE - DataContractSerializer only deserializes known types
using System.IO;
using System.Runtime.Serialization;
using System.Xml;
public class UserService
{
public User LoadUser(string xml)
{
var serializer = new DataContractSerializer(typeof(User));
using (var reader = XmlReader.Create(new StringReader(xml)))
{
// Can only deserialize to User type
return (User)serializer.ReadObject(reader);
}
}
public string SaveUser(User user)
{
var serializer = new DataContractSerializer(typeof(User));
using (var sw = new StringWriter())
using (var writer = XmlWriter.Create(sw))
{
serializer.WriteObject(writer, user);
writer.Flush();
return sw.ToString();
}
}
}
Why this works:
- Requires explicit root type at construction time.
- Rejects types not in the known types list.
- Uses
[DataContract]/[DataMember]opt-in fields. - Avoids dynamic type resolution from the payload.
- XML parser settings still matter: disable DTD processing and external entity resolution when reading XML from untrusted sources.
Custom SerializationBinder for Type Allowlisting
// SECURE - Allowlist allowed types, mapped to the Type objects themselves
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
public class SafeSerializationBinder : ISerializationBinder
{
// Key: the type name JSON.NET writes into $type.
// Value: the one type it is permitted to resolve to.
private static readonly Dictionary<string, Type> AllowedTypes = new()
{
["MyApp.Models.User"] = typeof(MyApp.Models.User),
["MyApp.Models.Address"] = typeof(MyApp.Models.Address),
};
public Type BindToType(string assemblyName, string typeName)
{
if (!AllowedTypes.TryGetValue(typeName, out var type))
{
throw new JsonSerializationException($"Type '{typeName}' is not allowed");
}
return type;
}
public void BindToName(Type serializedType, out string assemblyName, out string typeName)
{
if (!AllowedTypes.ContainsValue(serializedType))
{
throw new JsonSerializationException($"Type '{serializedType.FullName}' is not allowed");
}
assemblyName = serializedType.Assembly.FullName;
typeName = serializedType.FullName;
}
}
// Usage:
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Objects,
SerializationBinder = new SafeSerializationBinder()
};
var obj = JsonConvert.DeserializeObject<User>(json, settings);
Why this works:
- Intercepts all type resolution requests via
BindToType(). - Allows only explicitly approved types to instantiate, rejecting an unknown
$typebefore any object is created. - Keeps polymorphism bounded to a safe allowlist.
- Blocks gadget chains that rely on arbitrary types.
- Maps the name straight to a
Typerather than callingType.GetType()on the payload's own assembly-qualified string, so the assembly name in$typenever reaches the loader.
Two things go wrong when the allowlist holds strings instead of types, and both
are quiet. The assembly name is the first: a check that only compares
typeName passes for "MyApp.Models.User, Some.Other.Assembly", then hands
that whole string to Type.GetType(). The second is generics. It is tempting
to add an entry such as
"System.Collections.Generic.List`1[[MyApp.Models.User]]" for a list of
users, and it never matches: measured on JSON.NET 13, the $type JSON.NET
actually writes is
System.Collections.Generic.List`1[[MyApp.Models.User, MyApp]] - the inner
type is assembly-qualified too, and the assembly name is a deployment detail.
The entry reads as permission and is dead text. Under
TypeNameHandling.Objects there is no $type on a root array at all - the
elements carry one and the array does not - so a List<User> round-trips with
no entry of its own, which is one more reason to stay on Objects rather than
All.
Adding a typeof(List<User>) entry does not rescue TypeNameHandling.All,
because the key would have to match a string neither you nor the dictionary
controls. BindToName hands back serializedType.FullName, JSON.NET rewrites
the generic argument into its short assembly-qualified form on the way out, and
what arrives at BindToType matches neither the literal above nor
typeof(List<User>).FullName - verified on JSON.NET 13, both spellings throw
Error resolving type specified in JSON. If you genuinely need a polymorphic
collection, make the binder symmetric on a name you own: have BindToName write
a short label such as "user-list" and key BindToType on the same label.
Then round-trip one before trusting it.
Framework-Specific Guidance
ASP.NET Core
// SECURE - ASP.NET Core uses System.Text.Json by default
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult CreateUser([FromBody] User user)
{
// ASP.NET Core automatically deserializes JSON to User
// Uses System.Text.Json (safe by default)
_userService.Save(user);
return Ok(user);
}
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
var user = _userService.GetById(id);
// Automatically serialized to JSON
return Ok(user);
}
}
// Startup.cs / Program.cs - Configure JSON options
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.WriteIndented = true;
// SECURE - no type resolution by default
});
ASP.NET MVC (Legacy)
// SECURE - Use JSON.NET without TypeNameHandling
using Newtonsoft.Json;
public class UsersController : Controller
{
[HttpPost]
public ActionResult Create(string json)
{
// Deserialize to specific type only
var user = JsonConvert.DeserializeObject<User>(json);
_userService.Save(user);
return Json(new { success = true });
}
}
// Global.asax.cs - Configure JSON serializer
GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.None, // SECURE - no type names are honoured
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
};
WCF Services
// SECURE - Use DataContractSerializer (default for WCF)
[ServiceContract]
public interface IUserService
{
[OperationContract]
User GetUser(int id);
[OperationContract]
void SaveUser(User user);
}
[DataContract]
public class User
{
[DataMember]
public int Id { get; set; }
[DataMember]
public string Name { get; set; }
}
// Implementation
public class UserService : IUserService
{
public User GetUser(int id)
{
// WCF uses DataContractSerializer - safe
return _repository.GetById(id);
}
public void SaveUser(User user)
{
_repository.Save(user);
}
}
Migrating from BinaryFormatter
BinaryFormatter is obsolete and dangerous in .NET 5+, and from .NET 9 the in-box implementation throws at runtime. Migrate to safe alternatives:
// OLD CODE - Remove BinaryFormatter
/*
var formatter = new BinaryFormatter();
using (var ms = new MemoryStream(data))
{
return formatter.Deserialize(ms);
}
*/
// NEW CODE - Option 1: Use System.Text.Json
using System.Text.Json;
public T Deserialize<T>(byte[] data)
{
var json = Encoding.UTF8.GetString(data);
return JsonSerializer.Deserialize<T>(json);
}
public byte[] Serialize<T>(T obj)
{
var json = JsonSerializer.Serialize(obj);
return Encoding.UTF8.GetBytes(json);
}
// NEW CODE - Option 2: Use MessagePack (binary, fast)
// Install: dotnet add package MessagePack
using MessagePack;
// MessagePack's DEFAULT options are MessagePackSecurity.TrustedData.
// Opt into the untrusted profile explicitly - see the note below.
private static readonly MessagePackSerializerOptions UntrustedOptions =
MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData);
public T Deserialize<T>(byte[] data)
{
return MessagePackSerializer.Deserialize<T>(data, UntrustedOptions);
}
public byte[] Serialize<T>(T obj)
{
return MessagePackSerializer.Serialize(obj);
}
MessagePack is a data-only format - it cannot be steered into constructing an
arbitrary type the way BinaryFormatter can - but its defaults are tuned for
data you produced yourself. Verified on MessagePack-CSharp 3.1.8,
MessagePackSerializer.DefaultOptions.Security.HashCollisionResistant is
false, so a payload of crafted dictionary keys degrades hash lookups into a
denial of service. WithSecurity(MessagePackSecurity.UntrustedData) turns on
collision-resistant hashing and keeps the object-graph depth cap; pass it
everywhere the bytes came from outside. The one configuration to avoid
entirely on untrusted input is TypelessContractlessStandardResolver (and the
MessagePackSerializer.Typeless entry points), which embeds .NET type names in
the payload and reintroduces exactly the weakness the migration was for.
.NET Library Safety Matrix
A scan reports the API it found, not whether that API can be used safely. The sections above show how to use the safe ones and what the unsafe ones do; this table is for deciding, from a type name in a finding, which of those you are dealing with.
| API | Verdict | What decides it |
|---|---|---|
System.Text.Json |
Safe | No type resolution by default. Polymorphic deserialization exists from .NET 7 via [JsonDerivedType], but it is opt-in and bounded by the attributes you declare |
DataContractSerializer |
Safe with known types | Root type fixed at construction. Reading XML from an untrusted source still needs DTD processing disabled - see CWE-611 |
XmlSerializer |
Safe with known types | Same as above: the type is fixed, but it is still an XML parser, so the same DTD and external-entity settings apply |
Newtonsoft.Json |
Safe only with TypeNameHandling.None |
The default is None. Any other value reads $type from the payload |
BinaryFormatter |
Cannot be secured | Microsoft's own guidance is "do not use under any circumstances". Obsolete from .NET 5, and the in-box implementation throws at runtime from .NET 9 |
NetDataContractSerializer |
Cannot be secured | Embeds type metadata in the payload, so the payload chooses the types. No configuration removes this |
LosFormatter |
Cannot be secured | Legacy ViewState formatter; uses BinaryFormatter internally |
ObjectStateFormatter |
Cannot be secured | Legacy ViewState formatter with known gadget chains |
JavaScriptSerializer with a type resolver |
Cannot be secured | SimpleTypeResolver lets the payload name any type. Without a resolver it is safe but legacy |
Two entries are worth reading twice. DataContractSerializer and
XmlSerializer are safe against this weakness - the payload cannot choose
the type - and that is not the same as safe against everything the XML parser
underneath them will do with a DOCTYPE. The type restriction and the parser
settings are separate controls, and a finding on one says nothing about the
other.
The ViewState formatters
BinaryFormatter, NetDataContractSerializer and TypeNameHandling are
covered in Common Vulnerable Patterns above. The two ViewState formatters are
not, because they are rarely called directly - they turn up in legacy Web Forms
applications, in code that persists control state, and in anything that
round-trips a __VIEWSTATE value of its own.
// VULNERABLE - both deserialize an arbitrary object graph
var los = new LosFormatter(); // wraps BinaryFormatter
var osf = new ObjectStateFormatter(); // documented gadget chains
LosFormatter uses BinaryFormatter internally, so everything said about
BinaryFormatter applies to it, one layer down and easier to miss. Neither has
a safe configuration for attacker-supplied input.
The fix differs from the others in one way worth knowing. For a formatter you
call yourself, replace it. For the ViewState that ASP.NET manages, you are not
choosing the formatter - the platform is - so the control is the integrity and
encryption configuration below, which is what stops a client-supplied
__VIEWSTATE reaching the formatter in the first place.
Migration Considerations
If you find these patterns in security scan results:
- BinaryFormatter.Deserialize() → Switch to System.Text.Json
- NetDataContractSerializer → Switch to DataContractSerializer with known types
- LosFormatter → Use modern ASP.NET Core with JSON
- ObjectStateFormatter → Enable ViewState MAC and encryption
- JSON.NET TypeNameHandling.All → Set to TypeNameHandling.None
Example migration:
// BEFORE - VULNERABLE
var formatter = new BinaryFormatter();
var user = (User)formatter.Deserialize(stream);
// AFTER (Safe - System.Text.Json)
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var json = await reader.ReadToEndAsync();
var user = JsonSerializer.Deserialize<User>(json);
// OR (Safe - MessagePack for binary)
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var data = ms.ToArray();
var user = MessagePackSerializer.Deserialize<User>(
data,
MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData));
Microsoft's Official Guidance:
- BinaryFormatter Security Guide
- Recommendation: "Do not use BinaryFormatter under any circumstances"
Input Validation
// Validate data after deserialization
using System.ComponentModel.DataAnnotations;
public class User
{
[Required]
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
[Range(0, 150)]
public int Age { get; set; }
}
// Controller with validation
[HttpPost]
public IActionResult CreateUser([FromBody] User user)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Additional custom validation
if (user.Email.EndsWith("@malicious.com"))
{
return BadRequest("Email domain not allowed");
}
_userService.Save(user);
return Ok(user);
}
// Manual validation
var context = new ValidationContext(user);
var results = new List<ValidationResult>();
bool isValid = Validator.TryValidateObject(user, context, results, true);
if (!isValid)
{
foreach (var error in results)
{
Console.WriteLine(error.ErrorMessage);
}
}
Signature Verification
// SECURE - Verify HMAC before deserializing
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public class SignedDeserializer
{
private readonly byte[] _key;
public SignedDeserializer(byte[] key)
{
_key = key;
}
public T Deserialize<T>(byte[] signedData)
{
// Format: [32-byte HMAC][JSON data]
if (signedData.Length < 32)
throw new ArgumentException("Invalid signed data");
var signature = signedData[..32];
var data = signedData[32..];
// Verify HMAC
using (var hmac = new HMACSHA256(_key))
{
var expectedSignature = hmac.ComputeHash(data);
if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature))
{
throw new CryptographicException("Invalid signature");
}
}
// Only deserialize if signature is valid
var json = Encoding.UTF8.GetString(data);
return JsonSerializer.Deserialize<T>(json);
}
public byte[] Serialize<T>(T obj)
{
var json = JsonSerializer.Serialize(obj);
var data = Encoding.UTF8.GetBytes(json);
using (var hmac = new HMACSHA256(_key))
{
var signature = hmac.ComputeHash(data);
// Combine signature and data
var result = new byte[signature.Length + data.Length];
Buffer.BlockCopy(signature, 0, result, 0, signature.Length);
Buffer.BlockCopy(data, 0, result, signature.Length, data.Length);
return result;
}
}
}
Configuration Security
<!-- web.config / app.config -->
<configuration>
<system.web>
<!-- Enable ViewState MAC and encryption -->
<pages enableViewStateMac="true"
viewStateEncryptionMode="Always"
enableEventValidation="true" />
<!-- Use strong machine key -->
<machineKey validationKey="[GENERATE_STRONG_KEY]"
decryptionKey="[GENERATE_STRONG_KEY]"
validation="HMACSHA256"
decryption="AES" />
</system.web>
</configuration>
Testing
- Test normal payloads for each supported DTO and confirm unknown fields, missing fields, and wrong types fail predictably.
- Test payloads containing
$typemetadata and confirm they are ignored or rejected when using JSON.NET. - Test legacy
BinaryFormatterpayload paths and confirm they are removed, unreachable, or throw before object construction. - Test tampered signed payloads and ViewState values to confirm integrity checks fail closed.
- Search generated code, plugins, queues, session storage, and configuration binding paths for hidden deserialization sinks.
- Re-run static analysis and dependency scans for dangerous formatter usage and known gadget-chain packages.
Common Pitfalls
- Keeping
BinaryFormatterfor "trusted" data that can be copied, uploaded, replayed, or modified by another service. - Setting JSON.NET
TypeNameHandlingtoAuto,Objects, orAllwithout a narrow binder and a trusted-only boundary. - Validating object properties after dangerous deserialization has already instantiated attacker-controlled types.
- Treating HMAC verification as permission to keep accepting arbitrary object graphs from clients.
- Migrating only controller inputs while leaving cache, queue, session, or ViewState paths unchanged.
- Assuming .NET 9 runtime failures remove the need to migrate source code and stored legacy payloads.
- Writing a
SerializationBinderthat allowlists a base type or a whole assembly: Binding to a broad base class orMyCompany.*re-admits every gadget that happens to derive from it or ship in it, which is most of what the binder was added to exclude. Allowlist the concrete types the payload legitimately contains, and expect that list to be short.
Dependencies and Installation
System.Text.Jsonis built into modern .NET and should be preferred for data-only JSON.- JSON.NET is safe for untrusted data only when type-name handling is disabled or tightly bound for trusted-only cases.
- Legacy ASP.NET ViewState protections depend on correct machine key, MAC, event validation, and encryption configuration.
- Keep serializer packages and framework versions current because deserialization gadget exposure depends on available assemblies.