Obfusquer un type sérialisé avec Newtonsoft.Json : [JsonProperty] est le contrat, et le $type de TypeNameHandling est une mine
Newtonsoft.Json lit votre DTO par les noms de ses membres via la réflexion — Customer devient "Customer" sur le fil parce que la propriété s'appelle littéralement Customer — donc à l'instant où un obfuscateur la renomme en a, le JSON produit par votre service devient { "a": … } et chaque consommateur et chaque document stocké casse, silencieusement, à l'exécution. Json.NET n'a pas de générateur de code à la compilation pour échapper à ce parcours par réflexion, donc épingler les noms avec [JsonProperty] n'est pas une optimisation, c'est tout le contrat. Et il cache un second piège, pire : TypeNameHandling écrit le nom qualifié par assembly de votre type dans le document sous la forme $type, de sorte que renommer un type casse chaque charge utile polymorphe qu'un autre build a jamais écrite — une panne qu'aucun attribut ne corrige. Cet article identifie à quels noms Json.NET se lie, pourquoi [JsonProperty] et [EnumMember] font des noms sur le fil des constantes que le renommeur ne peut pas toucher, pourquoi le constructeur de désérialisation est un second lien par nom, et pourquoi TypeNameHandling a besoin d'un ISerializationBinder stable plutôt que d'une règle de keep. Il se termine par le test d'aller-retour, exécuté sur le build protégé, qui transforme « la sérialisation a cassé » en un test en échec plutôt qu'en un e-mail d'un client.
Voici un type qui fonctionne parfaitement jusqu’au jour où vous l’obfusquez :
public sealed class Order
{
public string Customer { get; set; } = "";
public decimal Total { get; set; }
public DateTime PlacedOn { get; set; }
}
Sérialisez-le avec JsonConvert.SerializeObject(order) et vous obtenez {"Customer":"…","Total":…,"PlacedOn":"…"}. Passez l’assembly dans un renommeur et sérialisez de nouveau, et vous obtenez {"a":"…","b":…,"c":"…"}. Rien n’a levé d’exception. Le build était vert. Chaque consommateur de ce JSON — un frontend, un service jumeau, un document que vous avez stocké la semaine dernière et relu aujourd’hui — est maintenant cassé, et cela a cassé à l’exécution, à un endroit qu’aucun compilateur et aucune étape de build ne pourrait jamais voir.
C’est la façon la plus courante dont l’obfuscation « casse la sérialisation », et avec Newtonsoft.Json elle mord plus fort qu’avec System.Text.Json, pour une raison structurelle : Json.NET n’a pas de générateur de code à la compilation. System.Text.Json vous laisse échapper entièrement au parcours par réflexion avec un contexte [JsonSerializable] qui gèle les noms dans le code généré avant que l’obfuscateur ne s’exécute. Json.NET n’a pas d’équivalent natif — la sérialisation passe toujours par un résolveur de contrat qui réfléchit sur votre type vivant à l’exécution. Il n’y a pas d’échappatoire. Donc épingler les noms sur le fil avec des attributs n’est pas une optimisation que vous pouvez sauter pour un chemin critique ; c’est le contrat tout entier.
Pourquoi le renommage atteint le fil
Les appels internes survivent au renommage parce qu’ils sont liés par jeton de métadonnées : le compilateur émet call contre un jeton, et un renommeur qui réécrit l’appelé réécrit chaque jeton qui pointe vers lui, donc le graphe reste connecté. Le DefaultContractResolver de Json.NET n’utilise pas de jeton. À la sérialisation il parcourt votre type par réflexion, lit le Name CLR réel de chaque membre, applique la stratégie de nommage du résolveur et écrit cette chaîne comme clé JSON :
// Inside the contract resolver, conceptually:
// property.PropertyName = namingStrategy.GetPropertyName(member.Name, hasSpecifiedName: false);
// member.Name == "Customer" -> "Customer" (default strategy: verbatim)
// -> "customer" (CamelCaseNamingStrategy)
var settings = new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() }
};
La clé JSON "Customer" n’existe que parce que la propriété s’appelle Customer. Aucun jeton ne joint la chaîne du fichier au membre de l’assembly — le lien est le nom. La désérialisation exécute la même mécanique à l’envers : elle lit la clé entrante et l’associe, par chaîne, à un membre. Donc quand le renommeur transforme Customer en a, le résolveur fait exactement ce qu’on lui dit — il lit le membre désormais appelé a et écrit "a" — et l’aller-retour avec quiconque attend "Customer" est mort. Une stratégie de nommage change la casse de cette chaîne ; elle ne fait rien pour vous sauver, car elle dérive toujours la clé d’un nom que l’obfuscateur vient de déplacer.
Le correctif est [JsonProperty], pas une règle de keep
L’instinct est d’exclure le DTO du renommage. Ne le faites pas — cela livre justement le type qu’un attaquant veut le plus lire, et c’est inutile. Le nom sur le fil devrait être une constante, pas un effet de bord du nom du membre, et [JsonProperty] en fait une :
public sealed class Order
{
[JsonProperty("customer")] public string Customer { get; set; } = "";
[JsonProperty("total")] public decimal Total { get; set; }
[JsonProperty("placedOn")] public DateTime PlacedOn { get; set; }
}
Maintenant le résolveur lit le littéral de chaîne de l’attribut — "customer" — pas le nom du membre. Ce littéral vit dans le blob d’attributs personnalisés des métadonnées, que le renommage ne touche jamais ; le renommeur est libre de transformer Customer en b, et la clé JSON reste "customer". C’est la distinction clé : le membre est bien renommé (ce qui est le but de l’obfuscation), et seule la chaîne que lit Json.NET est épinglée. Une règle de keep préserverait le nom du membre sur le disque — l’inverse de ce que vous voulez.
La règle est donc simple et totale : chaque membre sérialisé reçoit un nom [JsonProperty] explicite. Une seule propriété sans attribut retombe sur son nom CLR et devient le seul champ qui casse. Si vous sérialisez des champs ou optez un type pour MemberSerialization.Fields avec [JsonObject], cela s’applique de même à chaque champ. C’est facile à faire respecter en revue et facile à voir dans un diff — et contrairement à une stratégie de nommage, cela ne dépend pas silencieusement de la survie du nom du membre.
Le constructeur est un second lien par nom
Les DTO immuables cachent un second piège. Quand un type n’a pas de constructeur par défaut, Json.NET le désérialise en associant chaque valeur JSON à un paramètre de constructeur — par nom de paramètre, sans tenir compte de la casse :
public sealed class Order
{
[JsonConstructor]
public Order(string customer, decimal total, DateTime placedOn) // param names matter
{
Customer = customer; Total = total; PlacedOn = placedOn;
}
[JsonProperty("customer")] public string Customer { get; }
[JsonProperty("total")] public decimal Total { get; }
[JsonProperty("placedOn")] public DateTime PlacedOn { get; }
}
Json.NET associe la valeur "customer" au paramètre de constructeur nommé customer. Cette association vit dans les métadonnées de nom de paramètre — et si votre obfuscateur réécrit les noms de paramètre en p0, p1, p2, l’association échoue et chaque propriété reste silencieusement à sa valeur par défaut. Deux choses la gardent en sécurité. Marquez le constructeur choisi avec [JsonConstructor] pour que Json.NET ne soit pas ambigu sur celui à utiliser, et assurez-vous que le renommage des noms de paramètre est désactivé pour lui. La plupart des obfuscateurs, Nebula compris, laissent les noms de paramètre tranquilles par défaut — ce n’est pas une surface d’attaque significative — mais quand un DTO se désérialise via un constructeur, ce défaut est critique, alors confirmez-le plutôt que de le supposer.
Les énumérations se sérialisent aussi par leur nom de membre
Par défaut Json.NET écrit les énumérations comme leur valeur numérique, que le renommage ne touche jamais. Mais dès que vous ajoutez un StringEnumConverter — courant, car une chaîne est plus lisible et plus stable sur le fil — l’énumération est écrite par son nom de membre, et c’est de nouveau un identifiant renommable :
[JsonConverter(typeof(StringEnumConverter))]
public enum Priority
{
[EnumMember(Value = "low")] Low,
[EnumMember(Value = "high")] High,
}
Json.NET respecte [EnumMember(Value = …)] sur les membres d’énumération, alors épinglez chacun. Désormais Priority.High se sérialise en "high" que le champ High ait été renommé ou non, car le convertisseur lit le littéral de l’attribut au lieu du nom du membre.
Le piège majeur : TypeNameHandling écrit le nom de votre type dans le document
Tout ce qui précède est une propriété ou une valeur. TypeNameHandling est différent : il écrit l’identité de votre type dans le document. Quand vous l’activez pour faire l’aller-retour de graphes polymorphes — un Payment de base avec des sous-classes CreditCardPayment et BankTransferPayment, un champ de type object, une List<IMessage> — Json.NET intègre un discriminant $type pour pouvoir reconstruire le type exact à l’exécution au retour :
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(payment, settings);
// {
// "$type": "MyApp.Payments.CreditCardPayment, MyApp",
// "last4": "4242", "amount": 42.50
// }
Regardez la valeur de $type : c’est le nom qualifié par assembly du type — espace de noms, nom de type, assembly — assemblé à partir des identifiants exacts qu’un obfuscateur renomme. Renommez CreditCardPayment en a et aplatissez l’espace de noms, et le même objet se sérialise désormais en "$type": "a, MyApp". Vos attributs [JsonProperty] ne font rien ici, car $type n’est pas une propriété de votre type ; c’est le nom du type, et aucun attribut de propriété n’épingle un nom de type.
Cela rend $type bien plus dangereux qu’une clé de propriété, car il casse entre les builds et entre les versions, pas seulement au sein d’un seul. Un document écrit par un build non obfusqué, ou par la version du mois dernier, porte "…CreditCardPayment, MyApp". Donnez-le à votre build obfusqué — dont le type s’appelle désormais a — et Json.NET ne peut pas résoudre le nom et lève une exception à la désérialisation. Tout ce qui persiste du JSON polymorphe (un document stocké, un message en file, une charge utile mise en cache, un fichier de projet enregistré) est exposé à l’instant où les noms de type des deux côtés divergent.
Le correctif n’est ni un attribut ni une règle de keep. C’est un ISerializationBinder personnalisé qui fait correspondre un nom logique stable que vous choisissez au Type, de sorte que le fil porte une constante que vous contrôlez pendant que le type en dessous se renomme librement :
public sealed class StableTypeBinder : ISerializationBinder
{
// The wire names are string literals; the Type values rename consistently with your code.
private static readonly Dictionary<string, Type> ByName = new()
{
["creditCardPayment"] = typeof(CreditCardPayment),
["bankTransferPayment"] = typeof(BankTransferPayment),
};
private static readonly Dictionary<Type, string> ByType =
ByName.ToDictionary(kv => kv.Value, kv => kv.Key);
public void BindToName(Type serializedType, out string? assemblyName, out string? typeName)
{
assemblyName = null; // keep the assembly out of the wire entirely
typeName = ByType[serializedType]; // -> "creditCardPayment", a constant
}
public Type BindToType(string? assemblyName, string typeName) => ByName[typeName];
}
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto,
SerializationBinder = new StableTypeBinder(),
};
// {"$type":"creditCardPayment","last4":"4242","amount":42.50}
Les clés du dictionnaire — "creditCardPayment" — sont des littéraux de chaîne que l’obfuscateur ne peut pas toucher. Les valeurs du dictionnaire sont typeof(CreditCardPayment), que Json.NET et le renommeur résolvent tous deux via le jeton de métadonnées, donc elles se renomment de façon cohérente avec le reste de votre code. Le résultat est un discriminant stable entre chaque build et chaque version, quelle que soit l’agressivité du renommage des types, et en prime il garde la disposition de votre espace de noms hors du document. (C’est aussi la posture recommandée pour TypeNameHandling pour des raisons de sécurité : un binder qui ne résout qu’une liste blanche fixe de types refuse d’instancier quoi que ce soit d’autre, ce qui est la mitigation du risque de gadgets de désérialisation que TypeNameHandling porte sinon.)
Prouvez-le avec un aller-retour sur le build protégé
Quel que soit le chemin choisi, le test qui compte s’exécute sur la sortie obfusquée, pas sur le build de débogage — tout le mode de panne est que les noms ne bougent qu’après l’exécution de Nebula. Un aller-retour sur l’assembly protégé est la seule assertion qui l’attrape :
[Fact]
public void Protected_build_round_trips_the_wire_contract()
{
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto,
SerializationBinder = new StableTypeBinder(),
};
Payment payment = new CreditCardPayment { Last4 = "4242", Amount = 42.50m };
string json = JsonConvert.SerializeObject(payment, settings);
// The contract, asserted literally — these are what consumers and stored docs depend on.
Assert.Contains("\"$type\":\"creditCardPayment\"", json);
Assert.Contains("\"last4\":\"4242\"", json);
Assert.DoesNotContain("\"a\"", json); // no leaked renamed member or type
var back = (CreditCardPayment)JsonConvert.DeserializeObject<Payment>(json, settings)!;
Assert.Equal("4242", back.Last4); // constructor/members matched by pinned names
Assert.Equal(42.50m, back.Amount);
}
Branchez cela sur l’artefact post-obfuscation en CI et l’incident « la sérialisation a cassé » devient un test en échec plutôt qu’un e-mail d’un client. Le principe sous-jacent est celui qui mérite d’être emporté vers chaque framework piloté par la réflexion que vous obfusquez : un nom qu’un framework lit à l’exécution est un contrat, et un contrat appartient à un littéral de chaîne que l’obfuscateur ne peut pas déplacer — pas à un nom de membre, un nom de type ou un espace de noms qu’il est payé pour déplacer.
Nebula.NET renomme agressivement par conception ; [JsonProperty], [EnumMember] et un ISerializationBinder stable sont la façon de conserver la poignée de noms qui doivent rester tout en perdant ceux qui doivent partir. Pour le versant System.Text.Json de la même histoire, voir obfusquer un type sérialisé avec System.Text.Json ; pour le motif plus large, ce que l’obfuscation casse.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.