Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #14

OWASP A08: Software or Data Integrity Failures — Deserialization and Mass Assignment

Mert Özen Nov 6, 2026 9 min 7 views
OWASP A08: Software or Data Integrity Failures — Deserialization and Mass Assignment

We explain conceptually why letting incoming data choose its own type is dangerous, and show with real output how BinaryFormatter ends on net10.0 with a build error and a PlatformNotSupportedException. We set up allow-list polymorphism in System.Text.Json, close mass assignment with DTOs and JsonUnmappedMemberHandling, and look at code integrity through CI/CD and SRI.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

The Bookstore API has an endpoint that updates the user profile: a display name and a short bio. What happens if the client also adds "isAdmin": true to the body? The second question comes from old code: if you move a class that writes the book cache to disk with BinaryFormatter to .NET 10 and build it, what do you see?

Both questions have the same thing in common: trusting data from outside without verifying what it is and where it came from. That is the core of A08.

A08:2025 Software or Data Integrity Failures

A08:2025 Software or Data Integrity Failures kept its 2021 position; the "and" in its name became "or", and it has 14 CWEs under it. The separate Insecure Deserialization heading from the 2017 list was merged into this category in 2021. The notable CWEs are deserialization of untrusted data (CWE-502), uncontrolled modification of object attributes, in other words mass assignment (CWE-915), and inclusion of functionality from an untrusted source (CWE-829).

The category cares about the integrity of two things. One is data: content coming from the client, a queue or a file should not decide by itself what happens inside the application. The other is code: the packages that enter your application, build outputs, scripts loaded from a CDN and automatic updates should come from the source you expect, in the form you expect. A03 Software Supply Chain Failures already covered the wider version of the second topic; the weight here is on data.

The examples run in the lab on your own machine; only try these techniques on systems you own or have written permission to test.

Why Is Insecure Deserialization Dangerous?

Serialization turns an object into bytes or text, and deserialization turns it back. The danger starts when the deserializer reads which type to create from the incoming data itself. BinaryFormatter, NetDataContractSerializer, SoapFormatter and Newtonsoft.Json with TypeNameHandling set to anything other than None belong to this family. A type name is written into the stream and the deserializer creates that type.

Think of a post office to see why that is a problem. Normally you open an incoming parcel and put its contents away according to your own list. These deserializers instead look at the label on the parcel and follow the instruction "put me in that room and switch on the machine there" without question. An attacker can pick any class in the application's loaded assemblies; that class's constructor, property setters or deserialization callbacks then run with the values the attacker supplied. Even if the application itself has no bug at all, it is enough for the framework or a package to contain a class with side effects. The result can go as far as remote code execution, which is why this is a design flaw, not a missing input validation step. Validating the incoming data does not help, because the damage happens before validation, while the object is being created.

The rule is short: data from outside decides only the data, never the type. Your code picks the type, at compile time.

The End of BinaryFormatter

Microsoft applied this rule in .NET in the strictest way. Say an old book cache was written like this:

using System.Runtime.Serialization.Formatters.Binary;

BookCacheEntry entry = new() { Id = 42, Title = "Clean Code", Price = 34.90m };

BinaryFormatter formatter = new();
using MemoryStream stream = new();
formatter.Serialize(stream, entry);
Console.WriteLine($"Serialized {stream.Length} bytes");

[Serializable]
public sealed class BookCacheEntry
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
}

Building it for net10.0 gives you an error, not a warning:

$ dotnet build
Program.cs(5,1): error SYSLIB0011: 'BinaryFormatter' is obsolete: 'BinaryFormatter serialization is obsolete and should not be used. See https://aka.ms/binaryformatter for more information.' (https://aka.ms/dotnet-warnings/SYSLIB0011)
Build FAILED.
    1 Error(s)

If someone silences the error with NoWarn and also turns on the EnableUnsafeBinaryFormatterSerialization setting that worked in older .NET versions, the build passes, but there is another wall at run time:

<NoWarn>$(NoWarn);SYSLIB0011</NoWarn>
<EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
$ dotnet run
Unhandled exception. System.PlatformNotSupportedException: BinaryFormatter serialization and deserialization have been removed. See https://aka.ms/binaryformatter for more information.
   at System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Serialize(Stream serializationStream, Object graph)
   at Program.<Main>$(String[] args) in .../Program.cs:line 7

Since .NET 9 the runtime has no real implementation of BinaryFormatter; the APIs are still there, but every call throws PlatformNotSupportedException. The only way back is the System.Runtime.Serialization.Formatters NuGet package, which Microsoft explicitly says it does not support. Instead of installing it, migrate the data: rewrite the cache with System.Text.Json. If you have to read old files once, NrbfDecoder from the System.Formats.Nrbf package parses the stream into records without creating types; on net10.0 that package has to be added separately.

The Safe Path: System.Text.Json and an Allow-List

System.Text.Json does not pick a type by looking at a field in the incoming JSON; it reads according to the type you give it and that type's properties. If you need polymorphic data, for example a payment request that can be a card or a gift card, you list the allowed subtypes yourself at compile time:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(GiftCardPayment), "giftcard")]
public abstract record PaymentRequest(decimal Amount);
public sealed record CardPayment(decimal Amount, string Last4) : PaymentRequest(Amount);
public sealed record GiftCardPayment(decimal Amount, string Code) : PaymentRequest(Amount);

The kind field is not a type name but a label you defined. The output of a small console app that reads four different bodies with JsonSerializer.Deserialize<PaymentRequest>:

OK   CardPayment 34.90
OK   GiftCardPayment 10
FAIL JsonException: Read unrecognized type discriminator id 'crypto'. Path: $ | LineNumber: 0 | BytePositionInLine: 26.
FAIL NotSupportedException: The JSON payload for polymorphic interface or abstract type 'PaymentRequest' must specify a type discriminator. Path: $ | LineNumber: 0 | BytePositionInLine: 10.

A label that is not on the list is rejected, and so is a body without a label. The options are the two subtypes and nothing else. If you have an old project that uses Newtonsoft.Json, make sure TypeNameHandling stays at its default, None; if you think you need another value, do not do it without an ISerializationBinder that lists the allowed types, and consider moving to the allow-list model of System.Text.Json first.

Mass Assignment: DTO, Not Entity

Mass assignment is a quieter form of the same idea. In code that takes the EF Core entity User directly as an endpoint parameter and writes it to the database, whatever the client puts in the body fills that property of the entity. If the User class has IsAdmin, it does not matter that the profile form has no such field; the client builds the request, not your form, and the client can write anything into it. The fix is for the endpoint to take a DTO that carries only the fields you allow to change:

app.MapPut("/me/profile", async (UpdateProfileRequest req, AppDb db) =>
{
    User user = await db.Users.SingleAsync(u => u.Id == Lab.CurrentUserId);
    // Only the fields in the DTO are copied to the entity
    user.DisplayName = req.DisplayName;
    user.Bio = req.Bio;
    await db.SaveChangesAsync();
    return Results.Ok(new { user.Id, user.DisplayName, user.Bio, user.IsAdmin });
});

public sealed record UpdateProfileRequest(string DisplayName, string? Bio);

Since authentication is not the topic of this post, the signed-in user in the lab is fixed: Ada, id 1. Now let's add an extra field to the body:

$ curl -X PUT http://localhost:5153/me/profile -H "Content-Type: application/json" -d '{"displayName":"Ada L.","bio":"Reads a lot","isAdmin":true}'
{"id":1,"displayName":"Ada L.","bio":"Reads a lot","isAdmin":false}
$ curl http://localhost:5153/me
{"id":1,"email":"ada@example.com","displayName":"Ada L.","bio":"Reads a lot","isAdmin":false}

Because isAdmin is not in the DTO, System.Text.Json silently ignored it. Safe, but silent. If you want to treat a client sending fields you do not expect as an error, you can say so explicitly. One detail: the built-in validation that comes with AddValidation() in .NET 10 does not reject unknown fields; there is a separate setting for that:

builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
$ curl -i -X PUT http://localhost:5153/me/profile -H "Content-Type: application/json" -d '{"displayName":"Ada L.","bio":"Reads a lot","isAdmin":true}'
HTTP/1.1 400 Bad Request
Content-Length: 0
...
$ curl -X PUT http://localhost:5153/me/profile -H "Content-Type: application/json" -d '{"displayName":"Ada L.","bio":"Reads a lot"}'
{"id":1,"displayName":"Ada L.","bio":"Reads a lot","isAdmin":false}

In production the response is an empty 400. Sending the same request in the Development environment shows the reason as well: The JSON property 'isAdmin' could not be mapped to any .NET member contained in type 'UpdateProfileRequest'. This setting has a cost: when a client adds a new field, an older server version rejects the request. On a public, versioned API choose it deliberately; for an API that talks to your own Angular client it is usually the right choice.

Code Integrity: Updates, CI/CD and SRI

The second half of integrity is code. If your application downloads a plugin or an update from a URL and runs it, it should not run what it downloaded without verifying its signature or hash against a key you trust. HTTPS alone is not enough; HTTPS protects the channel, not who put the file on the server.

The CI/CD pipeline is part of this chain too. Who can trigger the build, who can change the pipeline definition, which secrets does it reach? If a change from a pull request can run with production secrets, the integrity of your code is in the hands of whoever opened that PR. Lock files, protected branches, pipeline changes that cannot merge without review, and pinning third-party actions to a commit SHA instead of a tag are the basic measures here. We covered protected branches and rulesets in the team workflow post of the Git series, and commit signing in the hooks post.

On the browser side, the counterpart of the same problem is scripts loaded from a CDN. The polyfill.io incident we saw in the A03 post showed that a script from another domain can one day arrive with completely different content. Subresource Integrity (SRI) is the answer: you write the file's expected hash into the script tag as the integrity attribute, and the browser does not run the script if the content does not match that hash. Serve scripts from your own server where possible; where you cannot, do not load outside scripts without SRI.

There is also a forgotten kind of integrity on the data side: values that travel to the client and come back. A price in a hidden form field, a discount rate in a cookie or a role in the query string is in the client's hands. If you need to trust such a value, recompute it on the server, or protect it with the Data Protection API and verify it when it comes back.

Common Pitfalls

The first pitfall is treating SYSLIB0011 like a warning, silencing it and adding the compatibility package. That is opening, with your own hands, the door the runtime closed. If the error shows up, it is time to migrate the data.

The second is saying "we do not use BinaryFormatter" and forgetting the dependencies. An old cache library, a message queue client or a package that configures Newtonsoft with TypeNameHandling.Auto carries the same risk. Search the code base for TypeNameHandling.

The third is writing a DTO and then copying it to the entity with a tool like AutoMapper set to "map everything". The moment an extra property appears in the DTO, mass assignment is back. Write the mapping explicitly or verify the mapping configuration with a test.

The fourth is using the same DTO for both reading and writing. You may want to show IsAdmin in the response; that field must not leak into the update request's DTO. Keep read and write DTOs separate.

Checklist

Is there any BinaryFormatter, NetDataContractSerializer, SoapFormatter or Newtonsoft usage with TypeNameHandling other than None left in the code base and dependencies? If you read polymorphic JSON, are the subtypes listed with [JsonDerivedType], and is the discriminator your own label rather than a type name?

Do all write endpoints take a DTO that carries only the fields that may change, instead of an entity? Have you made a deliberate decision about unknown fields (ignore or Disallow)? Are values that come back from the client, such as price, role or discount, recomputed on the server? Do scripts loaded from outside have SRI, are actions in the pipeline pinned by SHA, and are updates taken with signature verification?

A Small Experiment

★☆☆ Add a third subtype to PaymentRequest, for example BankTransferPayment, but do not add the [JsonDerivedType] line. See that a body with that type's label is rejected, then add the line and try again.

★★☆ Move the response of the lab's /me endpoint into a separate ProfileResponse record and separate the update DTO from the read DTO. Explain why a client that sends back the isAdmin field it saw in the response gets 400 while Disallow is on.

★★★ Write a version that writes and reads the book cache to disk with System.Text.Json. To tell whether the file was changed, protect the content with the Data Protection API; change a single byte in the file, try to read it and observe what happens.

We learned to reject a request whose integrity is broken. But who sees the request we rejected? In the next post we move to A09 Security Logging and Alerting Failures: what to log, what never to log, log injection, structured logging and turning logs into alerts.