Skip to content

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 $type metadata 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

// 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 $type metadata 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.None ignores $type metadata, 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 $type before 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 Type rather than calling Type.GetType() on the payload's own assembly-qualified string, so the assembly name in $type never 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:

  1. BinaryFormatter.Deserialize() → Switch to System.Text.Json
  2. NetDataContractSerializer → Switch to DataContractSerializer with known types
  3. LosFormatter → Use modern ASP.NET Core with JSON
  4. ObjectStateFormatter → Enable ViewState MAC and encryption
  5. 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:

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 $type metadata and confirm they are ignored or rejected when using JSON.NET.
  • Test legacy BinaryFormatter payload 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 BinaryFormatter for "trusted" data that can be copied, uploaded, replayed, or modified by another service.
  • Setting JSON.NET TypeNameHandling to Auto, Objects, or All without 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 SerializationBinder that allowlists a base type or a whole assembly: Binding to a broad base class or MyCompany.* 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.Json is 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.

Additional Resources