Einen Typ obfuskieren, den Sie mit System.Text.Json serialisieren: [JsonPropertyName] ist der Vertrag, die umbenannte Property nicht
Das reflexionsbasierte System.Text.Json liest Ihr DTO über seine Property-Namen — Order wird auf dem Draht zu "order", weil die Property buchstäblich Order heißt — und das bedeutet: Sobald ein Obfuskator diese Property in a umbenennt, wird das JSON, das Ihr Dienst erzeugt, zu { "a": …, "b": … }, und jeder Konsument und jeder Round-Trip bricht, lautlos, zur Laufzeit, wo kein Build-Schritt es fangen kann. Dieser Beitrag legt genau fest, an welche Namen der Serializer bindet und an welche nicht: warum eine nackte Auto-Property eine Tretmine ist, warum [JsonPropertyName("order")] die Lösung ist, die den Drahtnamen zu einer Konstante macht, die der Renamer nicht anfassen kann, warum ein parametrisierter [JsonConstructor] Parameter über den Namen auf Properties abbildet, sodass auch die Konstruktorargumente festgenagelt sind, wie sich [JsonExtensionData] und Enum-[JsonStringEnumMemberName] verhalten, und warum der quellgenerierte JsonSerializerContext das Bild komplett verändert. Er zeigt die Nebula-Konfiguration, die den Vertrag intakt hält — Namen attributieren, Member umbenennen lassen — und den Round-Trip-Test, der beweist, dass ein geschützter Build immer noch deserialisiert, was er serialisiert hat.
Hier ist ein Typ, der perfekt funktioniert — bis zu dem Tag, an dem Sie ihn obfuskieren:
public sealed class Order
{
public string Customer { get; set; } = "";
public decimal Total { get; set; }
public DateTime PlacedOn { get; set; }
}
Serialisieren Sie ihn mit dem standardmäßigen, reflexionsbasierten System.Text.Json, und Sie erhalten {"customer":"…","total":…,"placedOn":"…"}. Jagen Sie die Assembly durch einen Renamer und serialisieren Sie erneut, und Sie erhalten {"a":"…","b":…,"c":"…"}. Nichts hat eine Exception geworfen. Der Build war grün. Jeder Konsument dieses JSON — ein Frontend, ein Schwesterdienst, ein gespeichertes Dokument, das Sie nächste Woche zurücklesen — ist jetzt kaputt, und es brach zur Laufzeit, an einer Stelle, die kein Compiler und kein validateOnBuild jemals hätte sehen können.
Das ist die mit Abstand häufigste Art, auf die Obfuskation „die Serialisierung kaputtmacht“, und es lohnt sich, sie genau zu verstehen, denn die Lösung ist keine stumpfe Keep-Regel — es ist ein einziges Attribut, und es erlaubt der Property, weiterhin umbenannt zu werden.
Warum die Umbenennung den Draht erreicht
Interne Aufrufe überleben das Umbenennen, weil sie über ein Metadaten-Token verknüpft sind: Der Compiler emittiert call gegen ein Token, und ein Renamer, der den Aufgerufenen umschreibt, schreibt jedes Token um, das auf ihn zeigt — der Graph bleibt verbunden. Das reflexionsbasierte System.Text.Json verwendet kein Token. Zur Serialisierungszeit durchläuft es Ihren Typ per Reflection, liest den tatsächlichen CLR-Name jeder Property, wendet die konfigurierte Naming Policy an und schreibt diesen String als JSON-Schlüssel:
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
// For each property p: key = opts.PropertyNamingPolicy.ConvertName(p.Name);
// p.Name == "Customer" -> "customer"
Der JSON-Schlüssel "customer" existiert nur, weil die Property Customer heißt. Es gibt kein Token, das den String in der Datei mit dem Member in der Assembly verbindet — die Verbindung ist der Name. Die Deserialisierung lässt dieselbe Maschinerie rückwärts laufen: Sie liest den eingehenden Schlüssel und ordnet ihn, per String, einer Property zu. Wenn der Renamer also Customer in a verwandelt, tut der Serializer genau das, was man ihm sagt — er liest die Property, die jetzt a heißt, und schreibt "a" — und der Round-Trip zu jedem, der "customer" erwartet, ist tot.
Die Lösung ist ein Attribut, keine Keep-Regel
Der Instinkt sagt: das DTO vom Umbenennen ausnehmen. Tun Sie das nicht — damit geben Sie genau den Typ preis, den ein Angreifer am liebsten lesen möchte, und es ist unnötig. Der Drahtname sollte eine Konstante sein, kein Nebeneffekt des Member-Namens, und [JsonPropertyName] macht ihn dazu:
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; }
}
Jetzt liest der Serializer das String-Literal des Attributs — "customer" — nicht den Property-Namen. Der Renamer darf Customer frei in b verwandeln; das Attribut sagt weiterhin "customer", der JSON-Schlüssel ist weiterhin "customer", und der Vertrag hält. Das ist die entscheidende Unterscheidung: Der Member wird umbenannt (was ja der Sinn des Obfuskierens ist), und nur der String, den der Serializer liest, ist festgenagelt. Eine Keep-Regel würde den Member-Namen auf der Platte erhalten — das Gegenteil dessen, was Sie wollen. Das Attribut erhält das Verhalten, während der Name verschwindet.
Die Regel ist also einfach und absolut: Jeder serialisierte Member bekommt einen expliziten Namen. Eine einzige nicht attributierte Property fällt auf ihren CLR-Namen zurück und wird zu dem einen Feld, das bricht. Das lässt sich im Review leicht durchsetzen und in einem Diff leicht erkennen.
Der Konstruktor ist eine zweite Namensbindung
Unveränderliche DTOs verbergen eine zweite Falle. Wenn ein Typ keinen parameterlosen Konstruktor hat, deserialisiert System.Text.Json ihn, indem es jede JSON-Property einem Konstruktorparameter zuordnet — über den Parameternamen, ohne Beachtung der Groß-/Kleinschreibung:
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; }
}
Der Serializer paart den JSON-Wert "customer" mit dem Konstruktorparameter namens customer. Diese Paarung lebt in den Parameternamen-Metadaten — und wenn Ihr Obfuskator Parameternamen in p0, p1, p2 umschreibt, schlägt die Zuordnung fehl und jede Property bleibt still auf ihrem Default. Zwei Dinge halten das sicher. Markieren Sie den gewählten Konstruktor mit [JsonConstructor], damit der Serializer eindeutig weiß, welchen er verwenden soll, und stellen Sie sicher, dass das Umbenennen von Parameternamen für ihn ausgeschaltet ist. Die meisten Obfuskatoren, Nebula eingeschlossen, lassen Parameternamen standardmäßig in Ruhe — sie sind keine nennenswerte Angriffsfläche —, aber wenn ein DTO über einen Konstruktor deserialisiert wird, trägt dieser Default Last, also prüfen Sie ihn, statt ihn anzunehmen.
Enums, Extension Data und die Member, die nicht über den Namen binden
Ein paar weitere Member haben eigene Regeln. Als String serialisierte Enums werden über ihren Member-Namen geschrieben, sofern Sie das nicht überschreiben — dasselbe Reflection-liest-den-Namen-Problem —, also nageln Sie sie explizit fest ([.NET 9] gibt Ihnen genau dafür [JsonStringEnumMemberName]):
[JsonConverter(typeof(JsonStringEnumConverter<Priority>))]
public enum Priority
{
[JsonStringEnumMemberName("low")] Low,
[JsonStringEnumMemberName("high")] High,
}
[JsonExtensionData] auf einem Dictionary<string, object> ist der eine Member, dessen Property-Name den Draht nie erreicht — sein Inhalt wird in das Elternobjekt eingefügt —, also braucht er kein [JsonPropertyName]; die Schlüssel des Dictionarys sind Laufzeitdaten, keine Member-Namen, und Umbenennen berührt sie nie. Und [JsonIgnore]-Member werden per Definition nicht serialisiert und tragen keinen Vertrag.
Der echte Ausweg aus der Reflection: der Source Generator
Alles oben dreht sich darum, dass der Reflection-Serializer einen lebenden Property-Namen liest. Der Source Generator umgeht den Durchlauf vollständig. Ein [JsonSerializable]-Kontext generiert den Serialisierungscode zur Compile-Zeit, backt jeden JSON-Namen als String-Konstante in die generierten Metadaten ein und liest die Property direkt:
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Order))]
internal partial class OrderJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Serialize(order, OrderJsonContext.Default.Order);
Die Namen werden aufgelöst, bevor Nebula läuft, und als Literale im generierten Kontext eingefroren, sodass ein späteres Umbenennen des DTO nicht ändern kann, was der generierte Code ausgibt — er weiß bereits, welche Property "customer" meint. (Der generierte Typ OrderJsonContext und sein Einstiegspunkt Default.Order werden von Ihren Aufrufstellen referenziert und konsistent mit ihnen umbenannt, wie jede andere interne Verknüpfung; nur eine reflektive Verwendung des Kontexts über den Namen wäre eine separate Abhängigkeit.) Für ein heißes DTO, das Sie obfuskieren, ist der Source Generator sowohl der schnellere Pfad als auch der mit den wenigsten Namensgefahren — aber Sie attributieren weiterhin die Namen, die Ihnen wichtig sind, denn der Generator liest [JsonPropertyName] genauso wie die Reflection.
Beweisen Sie es mit einem Round-Trip auf dem geschützten Build
Welchen Pfad Sie auch wählen: Der Test, der zählt, läuft gegen die obfuskierte Ausgabe, nicht gegen den Debug-Build — der ganze Fehlermodus besteht ja darin, dass die Namen erst wandern, nachdem Nebula gelaufen ist. Ein Round-Trip gegen die geschützte Assembly ist die eine Assertion, die ihn fängt:
[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);
}
Verdrahten Sie das in der CI gegen das Artefakt nach der Obfuskation, und der Vorfall „die Serialisierung ist kaputt“ wird zu einem fehlgeschlagenen Test statt zu einer Kunden-E-Mail. Das Prinzip dahinter ist das, das Sie zu jedem reflexionsgetriebenen Framework mitnehmen sollten, das Sie obfuskieren: Ein Name, den ein Framework zur Laufzeit liest, ist ein Vertrag, und ein Vertrag gehört in ein String-Literal, das der Obfuskator nicht verschieben kann — nicht in einen Member-Namen, für dessen Verschieben er bezahlt wird.
Nebula.NET benennt absichtlich aggressiv um; die Attribute oben sind der Weg, den einen Namen zu behalten, der bleiben muss, während Sie die verlieren, die gehen sollen. Für das breitere Muster über Reflection und Serialisierung hinweg siehe Was Obfuskation kaputtmacht.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.