Obscurcir un contrat JSON polymorphe : pourquoi [JsonDerivedType] survit au renommage et pas TypeNameHandling
La sérialisation polymorphe met l'identité du type sur le fil. TypeNameHandling de Newtonsoft incorpore le nom de type CLR, que l'obscurcissement renomme, cassant chaque document persisté. [JsonDerivedType] de System.Text.Json utilise un discriminateur de chaîne découplé du type, et se renomme donc librement. Voici exactement pourquoi, et comment obscurcir la hiérarchie en toute sécurité.
Les cassures de renommage au niveau des propriétés sont agaçantes mais superficielles : une clé sort fausse, vous la fixez avec [JsonPropertyName], terminé. La sérialisation polymorphe est un danger plus profond, car ce qui part sur le fil n’est pas un nom de propriété : c’est l’identité du type lui-même. L’obscurcissement existe pour détruire cette identité. Un contrat polymorphe est donc exactement l’endroit où « le renommage a cassé la sérialisation » cesse d’être un problème cosmétique de noms sur le fil pour devenir « tous les documents que nous avons persistés sont désormais illisibles ».
La bonne nouvelle, c’est que c’est entièrement un choix de conception, et la conception .NET moderne est la bonne. Cet article explique pourquoi [JsonDerivedType] de System.Text.Json est sûr face au renommage par construction, pourquoi TypeNameHandling de Newtonsoft ne l’est pas, et comment obscurcir une hiérarchie de types sans conserver un seul nom.
Deux façons de mettre un type sur le fil
Supposons que vous persistiez une liste de formes — des cercles, des rectangles — via une référence de base Shape. La désérialisation doit savoir de quel type concret était chaque élément. Il existe deux façons fondamentalement différentes de l’encoder.
TypeNameHandling de Newtonsoft écrit le nom de type CLR :
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(shape, settings);
// {"$type":"MyApp.Shapes.Circle, MyApp","Radius":4.0,"Area":50.2}
La valeur de $type est le nom qualifié par assembly de Circle. À la lecture, Newtonsoft résout cette chaîne en un Type (l’équivalent moral de Type.GetType) et le construit. Le format du fil est soudé à l’identité CLR.
[JsonDerivedType] de System.Text.Json écrit un discriminateur que vous choisissez :
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Circle), typeDiscriminator: "circle")]
[JsonDerivedType(typeof(Rectangle), typeDiscriminator: "rect")]
public abstract class Shape
{
[JsonPropertyName("area")] public double Area { get; set; }
}
public sealed class Circle : Shape
{
[JsonPropertyName("radius")] public double Radius { get; set; }
}
Shape shape = new Circle { Radius = 4.0, Area = 50.2 };
string json = JsonSerializer.Serialize(shape);
// {"$type":"circle","area":50.2,"radius":4.0}
La valeur de $type est le littéral "circle". À la lecture, System.Text.Json recherche ce littéral dans la table d’enregistrement que les attributs ont construite, trouve typeof(Circle) et le construit. Le format du fil est soudé à une chaîne qui vous appartient, pas au nom du type.
Cette différence est invisible jusqu’à ce qu’un obscurcisseur renomme Circle.
Ce que le renommage fait à chaque conception
Le renommage réécrit le nom du type et chaque jeton de métadonnées qui le référence, de façon cohérente : call, newobj, typeof, types de champ, tout bouge ensemble — c’est pourquoi l’obscurcissement est transparent pour le code ordinaire. La seule question est toujours : le format lit-il un jeton (qui bouge) ou un nom/chaîne (qui peut ne pas bouger) ?
Newtonsoft lit un nom. Après obscurcissement, deux choses distinctes cassent. Les documents écrits avant le renommage disent "MyApp.Shapes.Circle, MyApp", et l’assembly obscurci n’a aucun type sous ce nom : JsonSerializationException: Could not load type 'MyApp.Shapes.Circle'. Et les documents écrits après le renommage incorporent le nom obscurci (disons a.b), couplant vos données persistées à la carte de renommage d’une compilation et divulguant l’identité renommée directement dans le JSON. Dans les deux cas, le format du fil suit l’identité CLR, et l’identité CLR est précisément ce que vous avez changé.
System.Text.Json lit le littéral discriminateur "circle". L’attribut porte toujours "circle" ; l’argument typeof(Circle) est un jeton de métadonnées que le renommeur réécrit pour pointer vers le type renommé ; la table d’enregistrement associe toujours "circle" à ce type. Le fil, octet par octet, est identique avant et après l’obscurcissement, et un document écrit par la compilation de l’an dernier se désérialise encore sur celle d’aujourd’hui, obscurcie. Rien à conserver, rien à divulguer.
La seule façon de casser la conception sûre
[JsonDerivedType] est sûr parce que le discriminateur n’a aucune relation avec le nom de la classe. Il existe exactement une façon répandue de jeter cela aux orties :
[JsonDerivedType(typeof(Circle), typeDiscriminator: nameof(Circle))] // à éviter
nameof(Circle) est résolu par le compilateur en le littéral "Circle" et figé dans l’attribut, donc cela ne casse pas au renommage : le fil dit toujours "Circle". Mais cela recouple silencieusement votre format de fil public au nom de classe d’origine et, pire, livre à un attaquant les noms de type en clair que vous avez payé pour masquer : votre JSON « protégé » annonce désormais Circle, Rectangle, PremiumSubscription, AdminCommand. Utilisez des discriminateurs opaques et intentionnels — "circle", ou même "s1" — choisis comme un vocabulaire de fil, jamais dérivés de l’identifiant. Il en va de même pour le nom de la propriété discriminante : si vous voulez autre chose que le "$type" par défaut, mettez la chaîne dans [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")], jamais dans un membre.
Obscurcir la hiérarchie : rien à conserver
Parce que les discriminateurs vivent dans des littéraux et que les enregistrements de type vivent dans des jetons, la hiérarchie n’a besoin d’aucune règle keep — ce qui force habituellement une exclusion n’est tout simplement pas présent ici. Vous obscurcissez Shape et tous ses sous-types comme n’importe quel autre code : renommez les types, aplatissez le flux de contrôle de leurs méthodes, chiffrez leurs chaînes, virtualisez les corps de valeur. La seule hygiène par DTO est l’ordinaire qui couvre tout type sérialisé : donner à chaque propriété sérialisée un [JsonPropertyName] explicite pour que les clés ne retombent pas sur des noms CLR vivants.
nebula.config.json n’a donc aucun cas particulier pour le modèle :
{
"schemaVersion": 1,
"inputs": ["publish/MyApp.dll"],
"outputDirectory": "protected",
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "MyApp.Shapes.*", "action": "rename" }
]
}
}
Il n’y a pas de règle keep car les chaînes discriminantes ne sont pas des noms de membres : encryptStrings les chiffre même dans l’assembly, et elles se déchiffrent toujours en "circle" au moment où System.Text.Json les écrit, donc le fil est inchangé. Comparez cela à la conception de Newtonsoft, qui vous forcerait soit à keep les noms de type (livrant la hiérarchie que vous voulez le plus cacher), soit à enregistrer un SerializationBinder personnalisé qui traduit la carte de renommage à l’exécution (couplant vos données persistées à l’obscurcisseur pour toujours). La conception à discriminateur n’en demande aucune.
Exécutez-le de la façon habituelle, sur le serveur de build pour que le débogage local reste lisible :
dotnet publish MyApp -c Release -o publish
nebula protect --config nebula.config.json
<Target Name="NebulaProtect" AfterTargets="Publish" Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Préférez le générateur de code pour un contrat chaud
Pour un contrat sur un chemin chaud, un contexte [JsonSerializable] supprime le parcours de réflexion à l’exécution et cuit les discriminateurs et les noms de propriété dans du code généré à la compilation, avant que Nebula ne s’exécute :
[JsonSerializable(typeof(Shape))] // récupère les enregistrements [JsonDerivedType]
internal partial class ShapeJsonContext : JsonSerializerContext { }
string json = JsonSerializer.Serialize(shape, ShapeJsonContext.Default.Shape);
Le code généré contient déjà "circle" et "rect" comme constantes, donc renommer la hiérarchie après ne peut pas changer un octet de la sortie. Le type de contexte et son point d’entrée Default.Shape se renomment de façon cohérente avec les sites d’appel qui les utilisent.
Prouvez-le sur la compilation obscurcie — y compris entre compilations
L’assertion qui compte s’exécute contre l’artefact protégé, et pour un contrat persisté elle doit aussi prouver que le format est stable à travers un renommage : sérialisez avec une compilation, lisez avec l’obscurcie :
[Fact]
public void Protected_build_round_trips_the_polymorphic_contract()
{
Shape original = new Circle { Radius = 4.0, Area = 50.2 };
string json = JsonSerializer.Serialize(original);
Assert.Contains("\"$type\":\"circle\"", json); // le discriminateur est le littéral stable
Assert.DoesNotContain("Circle", json); // le nom de type original ne fuite jamais
var back = JsonSerializer.Deserialize<Shape>(json)!;
Assert.IsType<Circle>(back); // l'enregistrement résout encore après renommage
Assert.Equal(4.0, ((Circle)back).Radius);
}
Figez une chaîne JSON capturée d’une ancienne compilation comme fixture et désérialisez-la sur l’assembly obscurci ; si elle produit encore un Circle, vos documents persistés sont sûrs à travers le renommage. Ce seul test convertit la pire classe d’incident d’obscurcissement — le silencieux, celui qui corrompt les données et que vous découvrez des semaines plus tard — en une compilation au rouge.
Le principe se généralise à chaque framework qui encode l’identité de type : mettez l’identité sur le fil comme une valeur qui vous appartient, jamais comme le nom CLR que l’obscurcisseur est payé pour changer. [JsonDerivedType] fait exactement cela, c’est pourquoi Nebula.NET peut renommer une hiérarchie polymorphe entière sans que vous conserviez quoi que ce soit. Pour le complément au niveau des propriétés, voir obscurcir un contrat System.Text.Json, et pour le schéma plus large, ce que l’obscurcissement casse dans la réflexion et la sérialisation.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.