Einen polymorphen JSON-Vertrag obfuskieren: warum [JsonDerivedType] das Umbenennen überlebt und TypeNameHandling nicht
Polymorphe Serialisierung schreibt die Typidentität auf die Leitung. TypeNameHandling von Newtonsoft bettet den CLR-Typnamen ein, den die Obfuskation umbenennt, wodurch jedes persistierte Dokument bricht. [JsonDerivedType] von System.Text.Json nutzt einen vom Typ entkoppelten String-Diskriminator und lässt sich daher frei umbenennen. Hier steht genau, warum, und wie man die Hierarchie sicher obfuskiert.
Umbenennungsbrüche auf Eigenschaftsebene sind lästig, aber oberflächlich: Ein Schlüssel kommt falsch heraus, du fixierst ihn mit [JsonPropertyName], fertig. Polymorphe Serialisierung ist eine tiefere Gefahr, denn was auf die Leitung geht, ist kein Eigenschaftsname, sondern die Identität des Typs selbst. Obfuskation existiert, um diese Identität zu zerstören. Ein polymorpher Vertrag ist also genau der Ort, an dem „das Umbenennen hat die Serialisierung gebrochen” aufhört, ein kosmetisches Problem von Leitungsnamen zu sein, und zu „alle jemals von uns persistierten Dokumente sind nun unlesbar” wird.
Die gute Nachricht: Das ist vollständig eine Entwurfsentscheidung, und der moderne .NET-Entwurf ist der sichere. Dieser Beitrag handelt davon, warum [JsonDerivedType] von System.Text.Json von Natur aus umbenennungssicher ist, warum TypeNameHandling von Newtonsoft es nicht ist, und wie man eine Typhierarchie obfuskiert, ohne einen einzigen Namen zu behalten.
Zwei Wege, einen Typ auf die Leitung zu bringen
Angenommen, du persistierst eine Liste von Formen — einige Kreise, einige Rechtecke — über eine Basisreferenz Shape. Die Deserialisierung muss wissen, welcher konkrete Typ jedes Element war. Es gibt zwei grundlegend verschiedene Arten, das zu kodieren.
TypeNameHandling von Newtonsoft schreibt den CLR-Typnamen:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(shape, settings);
// {"$type":"MyApp.Shapes.Circle, MyApp","Radius":4.0,"Area":50.2}
Der Wert von $type ist der assembly-qualifizierte Name von Circle. Beim Lesen löst Newtonsoft diese Zeichenfolge zurück in einen Type auf (das moralische Äquivalent von Type.GetType) und konstruiert ihn. Das Leitungsformat ist mit der CLR-Identität verschweißt.
[JsonDerivedType] von System.Text.Json schreibt einen Diskriminator, den du wählst:
[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}
Der Wert von $type ist das Literal "circle". Beim Lesen schlägt System.Text.Json dieses Literal in der Registrierungstabelle nach, die die Attribute aufgebaut haben, findet typeof(Circle) und konstruiert ihn. Das Leitungsformat ist mit einer Zeichenfolge verschweißt, die dir gehört, nicht mit dem Typnamen.
Dieser Unterschied ist unsichtbar, bis ein Obfuskator Circle umbenennt.
Was das Umbenennen mit jedem Entwurf macht
Das Umbenennen schreibt den Typnamen und jedes Metadaten-Token, das ihn referenziert, konsistent um: call, newobj, typeof, Feldtypen, alles bewegt sich zusammen — deshalb ist Obfuskation für gewöhnlichen Code transparent. Die einzige Frage ist stets: Liest das Format ein Token (das sich bewegt) oder einen Namen/eine Zeichenfolge (die sich möglicherweise nicht bewegt)?
Newtonsoft liest einen Namen. Nach der Obfuskation brechen zwei getrennte Dinge. Vor dem Umbenennen geschriebene Dokumente sagen "MyApp.Shapes.Circle, MyApp", und die obfuskierte Assembly hat keinen Typ unter diesem Namen: JsonSerializationException: Could not load type 'MyApp.Shapes.Circle'. Und nach dem Umbenennen geschriebene Dokumente betten den obfuskierten Namen ein (sagen wir a.b), koppeln deine persistierten Daten an die Umbenennungskarte eines Builds und geben die umbenannte Identität direkt ins JSON preis. In beiden Fällen folgt das Leitungsformat der CLR-Identität, und die CLR-Identität ist genau das, was du geändert hast.
System.Text.Json liest das Diskriminator-Literal "circle". Das Attribut trägt weiterhin "circle"; das Argument typeof(Circle) ist ein Metadaten-Token, das der Umbenenner so umschreibt, dass es auf den umbenannten Typ zeigt; die Registrierungstabelle ordnet "circle" weiterhin diesem Typ zu. Die Leitung ist Byte für Byte vor und nach der Obfuskation identisch, und ein von letztjährigem Build geschriebenes Dokument deserialisiert noch auf dem heutigen, obfuskierten. Nichts zu behalten, nichts preiszugeben.
Die einzige Art, den sicheren Entwurf zu brechen
[JsonDerivedType] ist sicher, weil der Diskriminator keinen Bezug zum Klassennamen hat. Es gibt genau eine verbreitete Art, das wegzuwerfen:
[JsonDerivedType(typeof(Circle), typeDiscriminator: nameof(Circle))] // nicht tun
nameof(Circle) wird vom Compiler zum Literal "Circle" aufgelöst und ins Attribut eingefroren, also bricht dies beim Umbenennen nicht: Die Leitung sagt weiterhin "Circle". Aber es koppelt dein öffentliches Leitungsformat still wieder an den ursprünglichen Klassennamen und, schlimmer, übergibt einem Angreifer die Klartext-Typnamen, für deren Verbergen du bezahlt hast: Dein „geschütztes” JSON bewirbt nun Circle, Rectangle, PremiumSubscription, AdminCommand. Verwende undurchsichtige, absichtliche Diskriminatoren — "circle" oder sogar "s1" —, gewählt als Leitungs-Vokabular, nie vom Bezeichner abgeleitet. Dasselbe gilt für den Namen der Diskriminator-Eigenschaft: Wenn du etwas anderes als das Standard-"$type" willst, lege die Zeichenfolge in [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")], nie in ein Mitglied.
Die Hierarchie obfuskieren: nichts zu behalten
Weil die Diskriminatoren in Literalen und die Typregistrierungen in Token leben, braucht die Hierarchie überhaupt keine keep-Regel — das, was normalerweise eine Ausnahme erzwingt, ist hier schlicht nicht vorhanden. Du obfuskierst Shape und alle seine Subtypen wie jeden anderen Code: Benenne die Typen um, verflache den Kontrollfluss ihrer Methoden, verschlüssle ihre Zeichenfolgen, virtualisiere die wertvollen Rümpfe. Die einzige Hygiene pro DTO ist die gewöhnliche, die jeden serialisierten Typ betrifft: Gib jeder serialisierten Eigenschaft ein explizites [JsonPropertyName], damit die Schlüssel nicht auf lebende CLR-Namen zurückfallen.
nebula.config.json hat daher keinen Sonderfall für das Modell:
{
"schemaVersion": 1,
"inputs": ["publish/MyApp.dll"],
"outputDirectory": "protected",
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "MyApp.Shapes.*", "action": "rename" }
]
}
}
Es gibt keine keep-Regel, weil die Diskriminator-Zeichenfolgen keine Mitgliedsnamen sind: encryptStrings verschlüsselt sie sogar in der Assembly, und sie entschlüsseln sich dennoch zu "circle" in dem Moment, in dem System.Text.Json sie schreibt, sodass die Leitung unverändert bleibt. Vergleiche das mit dem Newtonsoft-Entwurf, der dich entweder zum keep der Typnamen zwingen würde (wobei du die Hierarchie preisgibst, die du am meisten verbergen willst) oder zum Registrieren eines eigenen SerializationBinder, der die Umbenennungskarte zur Laufzeit übersetzt (wobei du deine persistierten Daten für immer an den Obfuskator koppelst). Der Diskriminator-Entwurf verlangt keines von beiden.
Führe es auf die übliche Weise aus, auf dem Build-Server, damit das lokale Debuggen lesbar bleibt:
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>
Bevorzuge den Codegenerator für einen heißen Vertrag
Für einen Vertrag auf einem heißen Pfad entfernt ein [JsonSerializable]-Kontext den Reflexionsdurchlauf zur Laufzeit und backt die Diskriminatoren und Eigenschaftsnamen in generierten Code zur Kompilierzeit ein, bevor Nebula läuft:
[JsonSerializable(typeof(Shape))] // greift die [JsonDerivedType]-Registrierungen auf
internal partial class ShapeJsonContext : JsonSerializerContext { }
string json = JsonSerializer.Serialize(shape, ShapeJsonContext.Default.Shape);
Der generierte Code enthält bereits "circle" und "rect" als Konstanten, sodass das Umbenennen der Hierarchie danach kein Byte der Ausgabe ändern kann. Der Kontexttyp und sein Einstiegspunkt Default.Shape werden konsistent mit den Aufrufstellen umbenannt, die sie verwenden.
Beweise es auf dem obfuskierten Build — auch über Builds hinweg
Die Zusicherung, die zählt, läuft gegen das geschützte Artefakt, und für einen persistierten Vertrag muss sie auch beweisen, dass das Format über ein Umbenennen hinweg stabil ist: Serialisiere mit einem Build, lies mit dem obfuskierten:
[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); // der Diskriminator ist das stabile Literal
Assert.DoesNotContain("Circle", json); // der ursprüngliche Typname tritt nie aus
var back = JsonSerializer.Deserialize<Shape>(json)!;
Assert.IsType<Circle>(back); // die Registrierung löst noch nach dem Umbenennen auf
Assert.Equal(4.0, ((Circle)back).Radius);
}
Friere eine aus einem älteren Build erfasste JSON-Zeichenfolge als Fixture ein und deserialisiere sie auf der obfuskierten Assembly; wenn sie noch einen Circle ergibt, sind deine persistierten Dokumente über das Umbenennen hinweg sicher. Dieser eine Test verwandelt die schlimmste Klasse von Obfuskationsvorfällen — den stillen, datenbeschädigenden, den du Wochen später entdeckst — in einen roten Build.
Das Prinzip verallgemeinert sich auf jedes Framework, das Typidentität kodiert: Bringe die Identität als Wert auf die Leitung, der dir gehört, nie als CLR-Namen, den der Obfuskator ändern soll. [JsonDerivedType] tut genau das, weshalb Nebula.NET eine ganze polymorphe Hierarchie umbenennen kann, ohne dass du etwas behältst. Für das Gegenstück auf Eigenschaftsebene siehe einen System.Text.Json-Vertrag obfuskieren, und für das umfassendere Muster was Obfuskation bei Reflexion und Serialisierung bricht.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.