Triage: three symptoms, one cause
The pattern is always the same. The protected build sails through smoke tests, deploys clean, and then production starts returning invoices whose Currency is null. Which symptom you get depends on how each serializer reacts to a name it can no longer find.
| What you see | Where you see it | First suspect |
|---|---|---|
| Properties come back null, 0, or false — no exception, logs clean | API responses, message consumers | Un-attributed DTO members were renamed; payload keys no longer match any property |
| JsonException or NotSupportedException on polymorphic payloads | Endpoints carrying $type or derived-type metadata |
A type-name-based discriminator stopped resolving after class renaming |
| InvalidOperationException / XmlException, or xsi:type mismatches with partners | XML endpoints, SOAP-adjacent services | Derived type names changed — xsi:type values derive from class names |
Root cause: your metadata is the contract
Every DTO is governed by two contracts. The wire contract — keys, element names, discriminators — is owned by your partners and captured in every payload you have ever exchanged. The symbol contract — the identifiers in your assembly metadata — is owned by you and, ideally, by your obfuscator.
Contract attributes decouple the two: the wire name moves into an immutable attribute payload, and the symbol becomes free to scramble. Bare members weld the two together — the serializer uses the symbol as the wire name, so any rename silently rewrites your public data format.
A serializer treats your assembly metadata like a phone book: hand it the string "Currency" and it looks up which property that names. Renaming is reprinting the phone book with scrambled entries — callers keep asking for "Currency", but no such entry exists.
{
"$type": "card",
"payment_ref": "PAY-20391",
"Currency": "EUR",
"last_four": "4242",
"ExpiryMonth": 12
}
| Wire token | Who owns the name | Survives renaming? |
|---|---|---|
| "$type": "card" | Explicit JsonDerivedType discriminator string |
Rename-safe |
| "payment_ref" | [JsonPropertyName("payment_ref")] payload |
Rename-safe |
| "last_four" | Attribute payload | Rename-safe |
| "Currency" | The C# / VB property symbol itself | Rename-sensitive — shield it |
| "ExpiryMonth" | The property symbol itself | Rename-sensitive — shield it |
Discriminators are names, too
- •System.Text.Json: explicit
typeDiscriminatorstrings ("card") live in attribute metadata, so they are rename-proof. Omit them and the discriminator defaults to the type's name — rename the class and deserialization dies with aNotSupportedException. - •Newtonsoft.Json:
TypeNameHandlingembeds assembly-qualified names like"Billing.Contracts.CardPaymentDto, MyApp"into$type— renaming any segment breaks resolution. It is also a known deserialization attack surface: prefer explicit discriminators, or gate it behind a strictSerializationBinderwhitelist. - •XmlSerializer: polymorphic instances serialize with
xsi:type="CardPaymentDto", taken straight from the metadata name. Pin it with[XmlType("CardPayment")]or shield the type name.
The fix: pin it or shield it
Pin it — attribute the contract
Move every wire name into attribute payloads: [JsonPropertyName] / [JsonProperty] for JSON members, [XmlElement] / [XmlAttribute] / [XmlRoot] for XML, explicit discriminator strings for polymorphism, [XmlType] for xsi:type, [DataMember(Name = ...)] for DataContract.
Attribute strings are compile-time metadata — obfuscators leave them alone — so symbols scramble freely while the wire never moves. Best for code you still own.
Shield it — freeze the symbol
For DTOs already in the wild whose wire format rides on bare member names, exclude those types — or the whole contract namespace — from renaming. The symbols freeze, and so does the wire.
No partner coordination, no attribute sweep, no risk of fat-fingering a live key. Best for legacy contracts you cannot re-attribute without breaking someone.
And lose the guilt about a readable DTO. It is a data bag with no logic in it, and its names were public the moment the first payload left your network. What an attacker must never read is the code that fills the data bag — and that code keeps every protection you have.
Worked example: a payments contract
Here is the contract behind that invoice payload: a polymorphic payment hierarchy for System.Text.Json and Newtonsoft.Json, plus an XML envelope for System.Xml.Serialization. Members are annotated with which strategy protects them.
using System;
using System.Text.Json.Serialization;
using System.Xml.Serialization;
using Newtonsoft.Json;
namespace Billing.Contracts
{
// Polymorphic hierarchy for System.Text.Json. Discriminators are
// explicit strings, so the class names may be scrambled freely.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(CardPaymentDto), typeDiscriminator: "card")]
[JsonDerivedType(typeof(WalletPaymentDto), typeDiscriminator: "wallet")]
public abstract class PaymentMethodDto
{
// PINNED: the wire key lives in the attribute payload,
// so the symbol underneath can be renamed freely.
[JsonPropertyName("payment_ref")]
[JsonProperty("payment_ref")]
public string PaymentRef { get; set; }
// SHIELDED: no attribute, so the symbol IS the wire key.
// This exact name must survive obfuscation untouched.
public string Currency { get; set; }
}
public class CardPaymentDto : PaymentMethodDto
{
[JsonPropertyName("last_four")]
public string LastFour { get; set; }
// SHIELDED: "ExpiryMonth" on the wire depends on this symbol.
public int ExpiryMonth { get; set; }
}
public class WalletPaymentDto : PaymentMethodDto
{
[JsonPropertyName("provider_id")]
public string ProviderId { get; set; }
}
// XML envelope. [XmlRoot]/[XmlAttribute]/[XmlElement] pin element and
// attribute names. Derived type names surface in xsi:type, so shield
// them - or pin them with [XmlType("...")].
[XmlRoot("InvoiceEnvelope")]
[XmlInclude(typeof(CardPaymentDto))]
[XmlInclude(typeof(WalletPaymentDto))]
public class InvoiceEnvelope
{
[XmlAttribute("InvoiceNumber")]
public string InvoiceNumber { get; set; }
[XmlElement("Payment")]
public PaymentMethodDto Payment { get; set; }
}
}
Imports System
Imports System.Text.Json.Serialization
Imports System.Xml.Serialization
Imports Newtonsoft.Json
Namespace Billing.Contracts
' Polymorphic hierarchy for System.Text.Json. Discriminators are
' explicit strings, so the class names may be scrambled freely.
<JsonPolymorphic(TypeDiscriminatorPropertyName:="$type")>
<JsonDerivedType(GetType(CardPaymentDto), typeDiscriminator:="card")>
<JsonDerivedType(GetType(WalletPaymentDto), typeDiscriminator:="wallet")>
Public MustInherit Class PaymentMethodDto
' PINNED: the wire key lives in the attribute payload,
' so the symbol underneath can be renamed freely.
<JsonPropertyName("payment_ref")>
<JsonProperty("payment_ref")>
Public Property PaymentRef As String
' SHIELDED: no attribute, so the symbol IS the wire key.
' This exact name must survive obfuscation untouched.
Public Property Currency As String
End Class
Public Class CardPaymentDto
Inherits PaymentMethodDto
<JsonPropertyName("last_four")>
Public Property LastFour As String
' SHIELDED: "ExpiryMonth" on the wire depends on this symbol.
Public Property ExpiryMonth As Integer
End Class
Public Class WalletPaymentDto
Inherits PaymentMethodDto
<JsonPropertyName("provider_id")>
Public Property ProviderId As String
End Class
' XML envelope contract.
<XmlRoot("InvoiceEnvelope")>
<XmlInclude(GetType(CardPaymentDto))>
<XmlInclude(GetType(WalletPaymentDto))>
Public Class InvoiceEnvelope
<XmlAttribute("InvoiceNumber")>
Public Property InvoiceNumber As String
<XmlElement("Payment")>
Public Property Payment As PaymentMethodDto
End Class
End Namespace
The rules are identical in VB.NET: Public Property Currency As String carries the same metadata name, and the same exposure.
The Skater walkthrough
Skater .NET Obfuscator is built around a member tree: every namespace, type, and member of the loaded assembly appears as a node, and protection options apply per node. That makes the pin-or-shield doctrine a five-minute setup.
-
1
Load the assembly
Open your DLL or EXE in Skater. It indexes the metadata and lays out the full member tree — namespaces, types, properties, methods.
-
2
Locate the contract surface
Walk the tree to your DTO namespaces —
Billing.Contractsin the example. If your contracts share one home, you are a single toggle away from managing them all. -
3
Shield what is symbol-pinned
On the contract nodes, switch off renaming for types and properties. An exclusion set on a namespace node in Skater cascades down to every DTO beneath it — you never enumerate members one by one.
-
4
Let everything else scramble
Attributed DTOs lose nothing — their wire names are pinned in attribute payloads. Engines, converters, validators, and services keep renaming, string encryption, and control-flow obfuscation at full intensity.
-
5
Save the exclusion map as a project
Skater stores your settings as a reusable project file, so the identical shield list applies on every build — including when your pipeline drives obfuscation without a human at the keyboard.
-
6
Protect, then drill
Run protection, and before anything else leaves the building, execute the round-trip drill from Chapter 07 — against the protected binary itself.
What the decompiler sees
Proof, in ILSpy terms. Same assembly, two configurations:
The attributed member still binds — its key lives in the attribute payload. The two bare members are dead: "Currency" and "ExpiryMonth" match nothing. Any consumer resolving this hierarchy by type name fails outright.
The contract surface reads like a data bag — which is all a decompiler learns from any captured payload anyway. The engine next door gives up nothing: names, strings, and control flow are all gone.
The round-trip drill
Five steps, run against the protected binary, before every release:
- 01Golden payloads. Commit one real payload per shape — JSON and XML, one per polymorphic subtype — as test fixtures.
- 02Round-trip both directions. Deserialize and assert every property binds to a non-default value; re-serialize and compare key sets, ignoring formatting.
- 03Cover every discriminator and xsi:type. Each polymorphic subtype must resolve in both directions.
- 04Drill the artifact, not the source. Swap the obfuscated binary into the test output. Testing the development build proves nothing — it was never renamed.
- 05Make silence fatal. A null where a value belongs must fail the build, not open a support ticket.
[Fact]
public void Card_payment_binds_every_wire_key()
{
var dto = JsonSerializer.Deserialize<CardPaymentDto>(Golden.CardPayment);
Assert.Equal("PAY-20391", dto.PaymentRef); // attribute-pinned
Assert.Equal("EUR", dto.Currency); // symbol-pinned: fails loudly if renamed
Assert.Equal(12, dto.ExpiryMonth); // symbol-pinned: fails loudly if renamed
Assert.Equal("4242", dto.LastFour); // attribute-pinned
}
Pitfall matrix
| Symptom | Root cause | Field fix |
|---|---|---|
| Nulls / defaults, no exceptions | Un-attributed members renamed | Pin with an attribute, or shield the node |
| NotSupportedException / JsonException on $type payloads | Discriminator derived from the type name | Explicit discriminator strings, SerializationBinder, or shield the type names |
| XmlException; xsi:type mismatch with partners | Derived type names renamed | Pin with [XmlType("...")] or shield the type names |
| A companion project stops compiling after protection | Public DTO surface renamed across assembly boundaries | Shield the public contract surface, or move contracts to a dedicated assembly |
| CI stays green, production breaks | The drill ran against the development build | Run the drill on the obfuscated artifact |
| Security review flags TypeNameHandling | $type loads types by name — a known attack surface | Prefer explicit discriminators; enforce a strict SerializationBinder whitelist |
Questions from the field
Is it safer to disable renaming across my entire assembly? +
Do [DataContract] and [DataMember] attributes make members rename-safe? +
[DataMember(Name = "invoice_total")] pins the wire key in attribute metadata, so the underlying symbol can be scrambled. A bare [DataMember] without a Name argument still binds by the member's metadata name, which means the symbol remains rename-sensitive and must be shielded instead.
Can string encryption corrupt JSON keys or XML element names? +
How do I catch a broken serialization contract before release? +
Protection that preserves the contract isn't a compromise — it's the whole craft.
Rename everything the runtime will never look up. Pin or shield everything it will. Then ship, with the drill green.