S
SkaterPro
Field Manuals → Skater .NET Obfuscator
Home / Documentation / Serialization-Safe Obfuscation Field Guide
4.6 out of 5 (71 reviews on Visual Studio Marketplace)
Skater Field Notes · Code Protection Series

Serialization-Safe .NET Obfuscation: A Field Guide to JSON and XML Contracts

A field guide for teams whose protected builds ship clean and then quietly lose data: what actually breaks when renaming meets System.Text.Json, Newtonsoft.Json, or XmlSerializer — and the two habits that keep contracts alive through every protection pass.

C# & VB.NET Intermediate ~12 min read • Rustemsoft LLC · Skater engineering field notes

The Field Rule

Never rename a string the runtime is going to look up.

JSON and XML serializers resolve members by name at execution time. If a serializer will search your assembly metadata for "Currency" at runtime, that name has to survive obfuscation. Every safe configuration comes in one of two flavors:

Pin it

Put the wire name inside an attribute — [JsonPropertyName("payment_ref")] — so the symbol beneath it can be scrambled freely.

Shield it

Exclude the symbol from renaming and let the wire name ride on the metadata name. The symbol freezes; so does the contract.

Everything else — engines, converters, validators, business logic — keeps full renaming, string encryption, and control-flow protection. The rest of this guide is the discipline behind that rule.

CH.01

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
Symptom one is the one that reaches production. System.Text.Json and Newtonsoft.Json both skip payload keys they cannot match — by design. Nothing throws, nothing logs; the values simply never arrive. The other two at least fail loudly.
CH.02

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.

invoice-payload.json — one message, two kinds of names
{
  "$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 typeDiscriminator strings ("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 a NotSupportedException.
  • •Newtonsoft.Json: TypeNameHandling embeds 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 strict SerializationBinder whitelist.
  • •XmlSerializer: polymorphic instances serialize with xsi:type="CardPaymentDto", taken straight from the metadata name. Pin it with [XmlType("CardPayment")] or shield the type name.
CH.03

The fix: pin it or shield it

Strategy A

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.

Strategy B

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.

The invariant, either way: the rest of the assembly loses nothing. Renaming, string encryption, and control-flow obfuscation stay at full intensity on engines, converters, validators, and services.

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.

CH.04

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.

BillingContracts.vb/cs
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; }
    }
}

The rules are identical in VB.NET: Public Property Currency As String carries the same metadata name, and the same exposure.

Binding report — protected build with no contract rules
JsonSerializer.Deserialize<CardPaymentDto>(invoice-payload.json)
"$type" = "card"resolves — discriminator is an explicit string
"payment_ref" = "PAY-20391"binds — key pinned by [JsonPropertyName]
"last_four" = "4242"binds — key pinned by attribute
"Currency" = "EUR"skipped — no property answers to "Currency" anymore
"ExpiryMonth" = 12skipped — property renamed to a non-descriptive token
Result: PaymentRef = "PAY-20391" · LastFour = "4242" · Currency = null · ExpiryMonth = 0
No exception. No log entry. The invoice ships anyway.
CH.05

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. 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. 2

    Locate the contract surface

    Walk the tree to your DTO namespaces — Billing.Contracts in the example. If your contracts share one home, you are a single toggle away from managing them all.

  3. 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. 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. 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. 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.

Field tip: if your architecture allows it, isolate contracts into their own assembly and shield that entire module. Your protection budget then concentrates exactly where it pays.
CH.06

What the decompiler sees

Proof, in ILSpy terms. Same assembly, two configurations:

No contract rules partial data loss
// protected build, renaming everywhere
public abstract class a
{
  [JsonPropertyName("payment_ref")] public string b { get; set; }
  public string c { get; set; }
}
public class d : a { ... }

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.

With Skater contract rules contracts intact
namespace Billing.Contracts
{
  public abstract class PaymentMethodDto // shielded
  [JsonPropertyName("payment_ref")] public string b { get; set; }
  public string Currency { get; set; }
}
namespace Billing.Engine
{ internal class \u0001 { ... } } // renamed, strings encrypted, flow scrambled

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.

CH.07

The round-trip drill

Five steps, run against the protected binary, before every release:

  1. 01Golden payloads. Commit one real payload per shape — JSON and XML, one per polymorphic subtype — as test fixtures.
  2. 02Round-trip both directions. Deserialize and assert every property binds to a non-default value; re-serialize and compare key sets, ignoring formatting.
  3. 03Cover every discriminator and xsi:type. Each polymorphic subtype must resolve in both directions.
  4. 04Drill the artifact, not the source. Swap the obfuscated binary into the test output. Testing the development build proves nothing — it was never renamed.
  5. 05Make silence fatal. A null where a value belongs must fail the build, not open a support ticket.
RoundTripTests.cs
[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
}
CH.08

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
CH.09

Questions from the field

Is it safer to disable renaming across my entire assembly? +
It eliminates the breakage risk but also strips away your strongest protection layer. The disciplined approach is scope: keep renaming, string encryption, and control-flow obfuscation fully active for engines and business logic, and shield only the contract namespaces where wire names depend on symbol names. Skater's member tree lets you apply the exclusion at the namespace level so it cascades to every DTO beneath it.
Do [DataContract] and [DataMember] attributes make members rename-safe? +
Only when they carry an explicit name. [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? +
No. String encryption transforms literals inside method bodies while preserving their runtime values, and serialization keys for attributed contracts are read from attribute metadata, which renaming and string encryption leave untouched. Keys for un-attributed members come from symbol names, so they are protected through renaming exclusions instead.
How do I catch a broken serialization contract before release? +
Run a round-trip drill against the obfuscated binary itself: deserialize committed golden payloads, assert that every property binds to a non-default value, cover every polymorphic discriminator and xsi:type, and fail the build if any member comes back null. Testing the development build proves nothing, because the development build was never renamed.

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.

Safeguarding Dynamic Type Resolution → Practical guidance for keeping Type.GetType, GetMethod, and Activator.CreateInstance working after Skater renames symbols, while preserving string encryption and control-flow protection. Decoding Obfuscated .NET Stack Traces → Learn how to recover original .NET method names from protected assemblies using Rename Maps. Automating Obfuscation in Visual Studio → Integrate Skater into Visual Studio post-build events with CLI commands, batch scripts, parameter explanations, testing, and production recommendations. Why .NET Apps Crash After Obfuscation → Diagnose and repair runtime failures in C# and VB.NET applications involving reflection, WPF bindings, My.Settings, JSON serialization, and strong-name signing. Building Deterministic Skater Release Pipelines → Produce byte-identical protected .NET assemblies with versioned XML profiles, MSBuild determinism, secure key injection, and archived rename maps. Protecting .NET Secrets from Decompilers → Explore what decompilers reveal about tokens, endpoints, and connection strings, and learn about Skater's two string-storage modes and their limitations.