Do not let the payload choose the type. With Newtonsoft.Json keep TypeNameHandling at None and bind to a concrete class; a SerializationBinder blocklist is not enough and even quiets the analyzer. For real polymorphism use System.Text.Json with [JsonDerivedType], which allowlists the subtypes, instead of Type.GetType. BinaryFormatter is removed in modern .NET.
- Find every JsonConvert/JsonSerializer call whose settings set TypeNameHandling to Auto, Objects, Arrays or All; every BinaryFormatter, NetDataContractSerializer, SoapFormatter, LosFormatter or ObjectStateFormatter; and every hand-rolled Type.GetType/Activator.CreateInstance driven by request data.
- For Newtonsoft, leave TypeNameHandling at None (the default) and deserialize to a concrete DTO. Do not rely on a SerializationBinder to make Auto/All safe.
- When you genuinely need polymorphism, use System.Text.Json with [JsonPolymorphic] and [JsonDerivedType] on the base type; the declared discriminators are an allowlist and an unknown one throws.
- Never resolve a type from a request string with Type.GetType and construct it with Activator.CreateInstance.
- Add regression tests with this card's payloads (a $type pointing at a side-effecting class, and a 'kind' that is a .NET type name) next to functional tests that round-trip the seed profile and preference.
- Run the app outside Development in production so a deserialization error does not return the developer exception page with the type and stack.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public Profile RestoreProfile(string token)
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(token));
// TypeNameHandling.All reads the $type field from the payload and
// constructs that type - including a class whose setter has side
// effects. $type is an instruction, not data.
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All };
var result = JsonConvert.DeserializeObject(json, settings);
return result as Profile ?? throw new ArgumentException("not a profile");
}With IMPL=vulnerable, 2 of 7 tests fail (2 exploit tests).
The fix
public Profile RestoreProfile(string token)
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(token));
// Bind to a concrete type with TypeNameHandling at its default (None).
// Any $type field in the payload is treated as ordinary data and
// ignored, so it cannot choose a class.
return JsonConvert.DeserializeObject<Profile>(json) ?? throw new ArgumentException("empty profile");
}With IMPL=fixed, 7 of 7 tests pass.
The fix: System.Text.Json allowlist
public string ResolvePreference(string json)
{
// System.Text.Json polymorphism with an allowlist: PrefValue declares
// its permitted subtypes with [JsonDerivedType]. A "kind" that is not
// one of them throws instead of constructing an arbitrary type.
var value = System.Text.Json.JsonSerializer.Deserialize<PrefValue>(json, Web)
?? throw new ArgumentException("empty preference");
return value.Resolve();
}With IMPL=fixed, 7 of 7 tests pass.
A fix that looks safe but is not
// Reject the type names an incident taught us about, allow the rest.
private sealed class BlocklistBinder : ISerializationBinder
{
private static readonly string[] Blocked =
{
"ObjectDataProvider", "WindowsIdentity", "FileSystemInfo", "ClaimsIdentity", "WindowsPrincipal",
};
public Type BindToType(string? assemblyName, string typeName)
{
if (Blocked.Any(b => typeName.Contains(b, StringComparison.OrdinalIgnoreCase)))
throw new ArgumentException("blocked type");
return Type.GetType($"{typeName}, {assemblyName}") ?? throw new ArgumentException("unknown type");
}
public void BindToName(Type serializedType, out string? assemblyName, out string? typeName)
{
assemblyName = serializedType.Assembly.FullName;
typeName = serializedType.FullName;
}
}
public Profile RestoreProfile(string token)
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(token));
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All,
SerializationBinder = new BlocklistBinder(),
};
var result = JsonConvert.DeserializeObject(json, settings);
return result as Profile ?? throw new ArgumentException("not a profile");
}With IMPL=wrong, 2 of 7 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: each payload's only job is to fire the harmless marker file
/// during deserialization. The fix must leave it untouched.</summary>
public sealed class ExploitTests : SessionApiTests
{
public ExploitTests(SessionApiFactory factory) : base(factory) { }
[Fact]
public async Task AnUnexpectedTypeIsNotConstructed()
{
var marker = NewMarkerPath();
// $type names MarkerGadget; TypeNameHandling.All (and the wrong binder)
// construct it and its setter fires.
using var response = await Restore(AttackProfileToken(marker));
Assert.False(File.Exists(marker), "insecure deserialization: an attacker-chosen type was constructed");
}
[Fact]
public async Task AnUnknownPreferenceKindIsNotConstructed()
{
var marker = NewMarkerPath();
// kind is a .NET type name; the Type.GetType impls build MarkerGadget.
using var response = await PostPreference(AttackPreferencePayload(marker));
Assert.False(File.Exists(marker), "insecure deserialization: an attacker-chosen type was constructed");
}
}- BinaryFormatter is removed from modern .NET: its methods throw PlatformNotSupportedException on .NET 9 and 10, and re-enabling it with the unsupported compatibility package brings the CWE-502 risk straight back. Do not.
- A SerializationBinder (ISerializationBinder.BindToType) that blocklists known gadget type names is bypassed by the next gadget, and CA2327 stops warning once any binder is present, so the code looks reviewed.
- FromSqlRaw-style traps exist here too: Json.NET honours $type on the ROOT when the declared type is object and TypeNameHandling is Auto/All; typing the variable as object is enough to be exploitable.
- System.Text.Json is safe by default (it ignores $type unless you opt in) - the mistake is re-adding polymorphism with a custom JsonConverter that calls Type.GetType.
- [JsonDerivedType] requires the discriminator to be present (and, by default, first) in the JSON; keep the base type abstract so an unknown discriminator cannot fall back to the base.
- Signing the serialized blob (and checking the signature before deserializing) stops a client swapping it, but only layered on top of None/allowlist, never instead of them.
- MessagePack, protobuf-net and DataContract with a shared known-types list are fine; the danger is any format that lets the payload name the type.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csharp-aspnetcore does this for you).
- Run dotnet build src/Session/Session.csproj --no-incremental -p:AnalysisModeSecurity=All; expect CA2326 on vulnerable and wrong, CA2327 on vulnerable, and nothing on fixed.
- Against your own endpoint, send a token whose $type is a side-effecting class and a preference whose kind is a .NET type name, and confirm no marker file is written and the request is rejected with 400.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/csharp | 0 findings on src/Session/impl. The free CE C# pack has no rule for Json.NET TypeNameHandling or Type.GetType+Activator deserialization, so Semgrep CE is not a control for this card; .NET's own CA2326/CA2327 analyzers and the regression tests are. (The src classes use ordinary constructors, not C# 12 primary constructors, so Semgrep could parse them.)Checked: Semgrep 1.180.0 (CE) |
| CodeQL | cs/unsafe-deserialization | Not run here. The C# model treats JsonConvert.DeserializeObject with TypeNameHandling other than None as an unsafe-deserialization sink (and models BinaryFormatter, NetDataContractSerializer, LosFormatter and others), so it is expected to flag vulnerable and wrong. A hand-rolled Type.GetType+Activator resolver may not be modelled.Checked: github/codeql main; not executed |
| .NET code analysis | CA2326Do not use TypeNameHandling values other than None | Flagged VulnerableSessionService L17 and WrongSessionService L41 (TypeNameHandling.All). No finding in FixedSessionService (TypeNameHandling stays None).Checked: .NET SDK 10.0.107 with -p:AnalysisModeSecurity=All |
| .NET code analysis | CA2327Do not use insecure JsonSerializerSettings | Flagged VulnerableSessionService L18 only. It did NOT flag WrongSessionService: CA2327 is satisfied by the mere presence of a SerializationBinder, even though that binder is a blocklist that still lets MarkerGadget through. The analyzer passing is exactly why the 'wrong' fix ships.Checked: .NET SDK 10.0.107 with -p:AnalysisModeSecurity=All |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| Newtonsoft.Json | 13.0.4 |
| System.Text.Json (in .NET 10) | 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.2 (Microsoft Testing Platform v2) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/csharp-aspnetcorefolder.
dotnet restore tests/Session.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Session.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Session.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Session.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs deserialization/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- newtonsoftJson 13.0.4
- xunitV3 4.0.2
- Matrix
- fixed: 7 of 7 tests pass; vulnerable: 2 of 7 tests fail (2 exploit tests); wrong: 2 of 7 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 7/7 pass; IMPL=vulnerable 2 exploit tests fail (Newtonsoft $type, hand-rolled Type.GetType); IMPL=wrong 2 exploit tests fail (the binder blocklist missed MarkerGadget, the namespace blocklist missed Session.MarkerGadget).
- Scanners
- Semgrep 1.180.0 from a throwaway venv (../.semgrep-venv); CA rules from the .NET 10 SDK.
- Analyzers
- CA2326 on vulnerable+wrong, CA2327 on vulnerable only (the binder silences it for wrong). Semgrep CE p/csharp: 0 findings.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Deserialization Cheat Sheet
- Newtonsoft.Json: TypeNameHandling caution
- System.Text.Json: polymorphic serialization ([JsonDerivedType])
- BinaryFormatter migration guide (removed in modern .NET)
- CA2326/CA2327: insecure TypeNameHandling and JsonSerializerSettings
- CWE-502: Deserialization of Untrusted Data
- CAPEC-586: Object Injection
- ASP.NET Core: Integration tests (WebApplicationFactory)
Attack patterns: