Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETAnalyse approfondie

Obfusquer un type sérialisé avec System.Text.Json : [JsonPropertyName] est le contrat, la propriété renommée ne l'est pas

System.Text.Json en mode réflexion lit votre DTO par les noms de ses propriétés — Order devient "order" sur le fil parce que la propriété s'appelle littéralement Order — ce qui signifie qu'à l'instant où un obfuscateur renomme cette propriété en a, le JSON produit par votre service devient { "a": …, "b": … } et chaque consommateur, chaque aller-retour casse, silencieusement, à l'exécution, là où aucune étape de build ne peut le détecter. Cet article identifie précisément quels noms le sérialiseur lie et lesquels il ne lie pas : pourquoi une auto-propriété nue est une mine, pourquoi [JsonPropertyName("order")] est le correctif qui fait du nom sur le fil une constante que le renommeur ne peut pas toucher, pourquoi un [JsonConstructor] paramétré associe les paramètres aux propriétés par nom, de sorte que les arguments du constructeur sont eux aussi épinglés, comment [JsonExtensionData] et l'attribut d'énumération [JsonStringEnumMemberName] se comportent, et pourquoi le JsonSerializerContext généré à la compilation change complètement la donne. Il montre la configuration Nebula qui préserve le contrat — attribuer les noms, laisser les membres se renommer — et le test d'aller-retour qui prouve qu'un build protégé désérialise toujours ce qu'il a sérialisé.

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 le System.Text.Json par défaut, basé sur la réflexion, et vous obtenez {"customer":"…","total":…,"placedOn":"…"}. Passez l’assembly dans un renommeur et sérialisez à nouveau : vous obtenez {"a":"…","b":…,"c":"…"}. Rien n’a levé d’exception. Le build était vert. Chaque consommateur de ce JSON — un frontend, un service frère, un document stocké que vous relirez la semaine prochaine — est maintenant cassé, et il a cassé à l’exécution, à un endroit qu’aucun compilateur ni aucun validateOnBuild ne pourra jamais voir.

C’est la manière la plus courante dont l’obfuscation « casse la sérialisation », et elle mérite d’être comprise précisément, parce que le correctif n’est pas une règle keep brutale — c’est un seul attribut, et il laisse la propriété se faire renommer.

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 un 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é. System.Text.Json en mode réflexion n’utilise pas de jeton. À la sérialisation, il parcourt votre type par réflexion, lit le vrai Name CLR de chaque propriété, applique la politique de nommage configurée, et écrit cette chaîne comme clé JSON :

var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
// For each property p:  key = opts.PropertyNamingPolicy.ConvertName(p.Name);
//   p.Name == "Customer"  ->  "customer"

La clé JSON "customer" n’existe que parce que la propriété est nommée Customer. Aucun jeton ne relie la chaîne dans le fichier au membre dans l’assembly — la jointure est le nom. La désérialisation fait tourner la même mécanique à l’envers : elle lit la clé entrante et la fait correspondre, par chaîne, à une propriété. Donc quand le renommeur transforme Customer en a, le sérialiseur fait exactement ce qu’on lui dit — il lit la propriété désormais appelée a et écrit "a" — et l’aller-retour vers quiconque attend "customer" est mort.

La réflexion se lie à un nom — épinglez-le avec un attributpropriété nuepublic string Customersans attribut → le nom CLR est la cléaprès renommage → aserializer reads p.Name= "a"JSON : { "a": … } cassépropriété attribuée[JsonPropertyName("customer")]public string Customeraprès renommage → bserializer reads literal= "customer"JSON : { "customer": … } tientce que fait le renommageLe renommage réécrit lenom de la propriété etchaque jeton qui y pointe —de façon cohérente. Lesérialiseur par réflexion nesuit pas un jeton ; il lit lenom vivant. L'attribut déplacela clé dans un littéral que lerenommeur ne peut pas toucher.

Le correctif est un attribut, pas une règle keep

Le réflexe est d’exclure le DTO du renommage. Ne le faites pas — cela abandonne précisément 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 [JsonPropertyName] en fait une :

public sealed class Order
{
    [JsonPropertyName("customer")] public string Customer { get; set; } = "";
    [JsonPropertyName("total")]    public decimal Total { get; set; }
    [JsonPropertyName("placedOn")] public DateTime PlacedOn { get; set; }
}

Désormais le sérialiseur lit le littéral de chaîne de l’attribut — "customer" — et non le nom de la propriété. Le renommeur est libre de transformer Customer en b ; l’attribut dit toujours "customer", la clé JSON est toujours "customer", et le contrat tient. C’est la distinction clé : le membre est renommé (ce qui est le but de l’obfuscation), et seule la chaîne que lit le sérialiseur est épinglée. Une règle keep préserverait le nom du membre sur disque — l’inverse de ce que vous voulez. L’attribut préserve le comportement pendant que le nom disparaît.

La règle est donc simple et totale : chaque membre sérialisé reçoit un nom explicite. Une seule propriété sans attribut retombe sur son nom CLR et devient le champ qui casse. C’est facile à imposer en revue, et facile à repérer dans un diff.

Le constructeur est une seconde liaison par nom

Les DTO immuables cachent un second piège. Quand un type n’a pas de constructeur sans paramètre, System.Text.Json le désérialise en faisant correspondre chaque propriété JSON à un paramètre du constructeur — par nom de paramètre, sans distinction de casse :

public sealed class Order
{
    public Order(string customer, decimal total, DateTime placedOn)  // param names matter
    {
        Customer = customer; Total = total; PlacedOn = placedOn;
    }

    [JsonPropertyName("customer")] public string Customer { get; }
    [JsonPropertyName("total")]    public decimal Total { get; }
    [JsonPropertyName("placedOn")] public DateTime PlacedOn { get; }
}

Le sérialiseur apparie la valeur JSON "customer" avec le paramètre de constructeur nommé customer. Cet appariement vit dans les métadonnées de noms de paramètres — et si votre obfuscateur réécrit les noms de paramètres en p0, p1, p2, la correspondance échoue et chaque propriété est silencieusement laissée à sa valeur par défaut. Deux choses gardent cela sûr. Marquez le constructeur choisi avec [JsonConstructor] pour que le sérialiseur sache sans ambiguïté lequel utiliser, et assurez-vous que le renommage des noms de paramètres est désactivé pour lui. La plupart des obfuscateurs, Nebula compris, laissent les noms de paramètres 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 devient porteur, alors vérifiez-le plutôt que de le supposer.

Énumérations, données d’extension, et les membres qui ne se lient pas par nom

Quelques autres membres ont leurs propres règles. Les énumérations sérialisées en chaîne sont écrites par leur nom de membre sauf si vous le surchargez — le même problème de réflexion-lit-le-nom — alors épinglez-les explicitement (.NET 9 vous donne [JsonStringEnumMemberName] exactement pour cela) :

[JsonConverter(typeof(JsonStringEnumConverter<Priority>))]
public enum Priority
{
    [JsonStringEnumMemberName("low")]  Low,
    [JsonStringEnumMemberName("high")] High,
}

[JsonExtensionData] sur un Dictionary<string, object> est le seul membre dont le nom de propriété n’atteint jamais le fil — son contenu est fusionné dans l’objet parent — donc il n’a pas besoin de [JsonPropertyName] ; les clés du dictionnaire sont des données d’exécution, pas des noms de membres, et le renommage ne les touche jamais. Et les membres [JsonIgnore], par définition, ne sont pas sérialisés et ne portent aucun contrat.

La vraie échappatoire à la réflexion : le générateur de source

Tout ce qui précède concerne le sérialiseur par réflexion qui lit un nom de propriété vivant. Le générateur de source contourne entièrement ce parcours. Un contexte [JsonSerializable] génère le code de sérialisation à la compilation, figeant chaque nom JSON dans les métadonnées générées sous forme de constante de chaîne et lisant la propriété directement :

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Order))]
internal partial class OrderJsonContext : JsonSerializerContext { }

var json = JsonSerializer.Serialize(order, OrderJsonContext.Default.Order);

Les noms sont résolus avant que Nebula ne s’exécute et gelés dans le contexte généré sous forme de littéraux, donc renommer ensuite le DTO ne peut pas changer ce que le code généré émet — il sait déjà quelle propriété "customer" désigne. (Le type généré OrderJsonContext et son point d’entrée Default.Order sont référencés par vos sites d’appel et se renomment de façon cohérente avec eux, comme tout autre lien interne ; seul un usage réflexif du contexte par nom serait une dépendance distincte.) Pour un DTO critique que vous obfusquez, le générateur de source est à la fois le chemin le plus rapide et celui qui présente le moins de risques liés aux noms — mais vous attribuez toujours les noms qui vous importent, parce que le générateur lit [JsonPropertyName] de la même façon que la réflexion.

Prouvez-le avec un aller-retour sur le build protégé

Quel que soit le chemin choisi, le test qui compte s’exécute contre la sortie obfusquée, pas contre le build de debug — tout le mode de défaillance tient au fait que les noms ne bougent qu’après le passage de Nebula. Un aller-retour contre l’assembly protégé est la seule assertion qui l’attrape :

[Fact]
public void Protected_build_round_trips_the_wire_contract()
{
    var order = new Order("Acme", 42.50m, new DateTime(2026, 10, 16));

    var json = JsonSerializer.Serialize(order);
    // The contract, asserted literally — these keys are what consumers depend on.
    Assert.Contains("\"customer\":\"Acme\"", json);
    Assert.Contains("\"total\":42.50", json);
    Assert.DoesNotContain("\"a\":", json);          // no leaked renamed member

    var back = JsonSerializer.Deserialize<Order>(json)!;
    Assert.Equal("Acme", back.Customer);            // constructor matched by name
    Assert.Equal(42.50m, back.Total);
}

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 de client. Le principe sous-jacent est celui qu’il faut emporter 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 qu’il est payé pour déplacer.

Nebula.NET renomme agressivement par conception ; les attributs ci-dessus sont la manière de conserver le seul nom qui doit rester tout en perdant ceux qui doivent partir. Pour le schéma plus large à travers la réflexion et la sérialisation, voir ce que l’obfuscation casse.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.