Einen Typ obfuskieren, den Sie mit Newtonsoft.Json serialisieren: [JsonProperty] ist der Vertrag, und das $type von TypeNameHandling ist eine Tretmine
Newtonsoft.Json liest Ihr DTO über die Namen seiner Member per Reflexion — Customer wird auf dem Draht zu "Customer", weil die Property buchstäblich Customer heißt — sodass in dem Moment, in dem ein Obfuskator sie in a umbenennt, das JSON, das Ihr Dienst erzeugt, zu { "a": … } wird und jeder Konsument und jedes gespeicherte Dokument bricht, lautlos, zur Laufzeit. Json.NET hat keinen Quellcode-Generator zur Kompilierzeit, um diesem Reflexions-Durchlauf zu entkommen, daher ist das Festnageln der Namen mit [JsonProperty] keine Optimierung, sondern der gesamte Vertrag. Und es verbirgt eine zweite, schlimmere Falle: TypeNameHandling schreibt den assembly-qualifizierten Namen Ihres Typs als $type in das Dokument, sodass das Umbenennen eines Typs jede polymorphe Nutzlast bricht, die ein anderer Build je geschrieben hat — ein Fehler, den kein Attribut behebt. Dieser Beitrag legt fest, an welche Namen Json.NET bindet, warum [JsonProperty] und [EnumMember] die Drahtnamen zu Konstanten machen, die der Renamer nicht anfassen kann, warum der Deserialisierungs-Konstruktor eine zweite Namensbindung ist und warum TypeNameHandling einen stabilen ISerializationBinder statt einer Keep-Regel braucht. Er endet mit dem Round-Trip-Test, ausgeführt gegen den geschützten Build, der aus "die Serialisierung ist kaputt" einen fehlgeschlagenen Test statt einer Kunden-E-Mail macht.
Hier ist ein Typ, der einwandfrei 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 JsonConvert.SerializeObject(order), und Sie erhalten {"Customer":"…","Total":…,"PlacedOn":"…"}. Schicken Sie die Assembly durch einen Renamer und serialisieren Sie erneut, und Sie erhalten {"a":"…","b":…,"c":"…"}. Nichts hat eine Ausnahme geworfen. Der Build war grün. Jeder Konsument dieses JSON — ein Frontend, ein Schwesterdienst, ein Dokument, das Sie letzte Woche gespeichert und heute zurückgelesen haben — ist jetzt kaputt, und es brach zur Laufzeit, an einer Stelle, die kein Compiler und kein Build-Schritt je sehen könnte.
Das ist die häufigste Art, wie Obfuskierung „die Serialisierung kaputtmacht”, und mit Newtonsoft.Json beißt sie härter als mit System.Text.Json, aus einem strukturellen Grund: Json.NET hat keinen Quellcode-Generator zur Kompilierzeit. System.Text.Json lässt Sie dem Reflexions-Durchlauf vollständig entkommen, mit einem [JsonSerializable]-Kontext, der die Namen vor dem Lauf des Obfuskators in generierten Code einfriert. Json.NET hat kein hauseigenes Äquivalent — die Serialisierung läuft immer über einen Contract Resolver, der zur Laufzeit per Reflexion über Ihren lebenden Typ geht. Es gibt keine Ausweichmöglichkeit. Das Festnageln der Drahtnamen mit Attributen ist also keine Optimierung, die Sie für einen Hot Path auslassen können; es ist der gesamte Vertrag.
Warum das Umbenennen bis auf den Draht durchschlägt
Interne Aufrufe überleben das Umbenennen, weil sie über ein Metadaten-Token verbunden sind: Der Compiler emittiert call gegen ein Token, und ein Renamer, der den Aufgerufenen umschreibt, schreibt jedes Token um, das auf ihn zeigt, sodass der Graph verbunden bleibt. Der DefaultContractResolver von Json.NET verwendet kein Token. Beim Serialisieren läuft er per Reflexion über Ihren Typ, liest den echten CLR-Name jedes Members, wendet die Namensstrategie des Resolvers an und schreibt diese Zeichenfolge als JSON-Schlüssel:
// 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() }
};
Der JSON-Schlüssel "Customer" existiert nur, weil die Property Customer heißt. Kein Token verbindet die Zeichenfolge in der Datei mit dem Member in der Assembly — die Verbindung ist der Name. Die Deserialisierung führt dieselbe Mechanik rückwärts aus: Sie liest den eingehenden Schlüssel und ordnet ihn per Zeichenfolge einem Member zu. Wenn der Renamer Customer also in a verwandelt, tut der Resolver genau, was ihm gesagt wird — er liest den jetzt a genannten Member und schreibt "a" — und der Round-Trip zu jedem, der "Customer" erwartet, ist tot. Eine Namensstrategie ändert die Groß-/Kleinschreibung dieser Zeichenfolge; sie tut nichts, um Sie zu retten, denn sie leitet den Schlüssel immer noch aus einem Namen ab, den der Obfuskator gerade verschoben hat.
Die Behebung ist [JsonProperty], keine Keep-Regel
Der Instinkt ist, das DTO vom Umbenennen auszuschließen. Tun Sie das nicht — das liefert genau den Typ aus, den ein Angreifer am liebsten liest, und es ist unnötig. Der Drahtname sollte eine Konstante sein, kein Nebeneffekt des Member-Namens, und [JsonProperty] macht ihn zu einer:
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; }
}
Jetzt liest der Resolver das Zeichenfolgenliteral des Attributs — "customer" — nicht den Member-Namen. Dieses Literal lebt im Blob der benutzerdefinierten Attribute in den Metadaten, den das Umbenennen nie anfasst; der Renamer darf Customer frei in b verwandeln, und der JSON-Schlüssel bleibt "customer". Das ist die entscheidende Unterscheidung: Der Member wird umbenannt (das ist der Sinn der Obfuskierung), und nur die Zeichenfolge, die Json.NET liest, wird festgenagelt. Eine Keep-Regel würde den Member-Namen auf der Platte bewahren — das Gegenteil von dem, was Sie wollen.
Die Regel ist also einfach und total: jeder serialisierte Member erhält einen expliziten [JsonProperty]-Namen. Eine einzige Property ohne Attribut fällt auf ihren CLR-Namen zurück und wird zum einzigen Feld, das bricht. Wenn Sie Felder serialisieren oder einen Typ mit [JsonObject] auf MemberSerialization.Fields umstellen, gilt dasselbe für jedes Feld. Das ist in der Review leicht durchzusetzen und im Diff leicht zu sehen — und anders als eine Namensstrategie hängt es nicht still davon ab, dass der Member-Name überlebt.
Der Konstruktor ist eine zweite Namensbindung
Unveränderliche DTOs verbergen eine zweite Falle. Wenn ein Typ keinen Standardkonstruktor hat, deserialisiert Json.NET ihn, indem es jeden JSON-Wert einem Konstruktorparameter zuordnet — über den Parameternamen, ohne Beachtung der Groß-/Kleinschreibung:
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 paart den Wert "customer" mit dem Konstruktorparameter namens customer. Diese Paarung lebt in den Parameternamen-Metadaten — und wenn Ihr Obfuskator die Parameternamen in p0, p1, p2 umschreibt, scheitert die Paarung, und jede Property bleibt still auf ihrem Standardwert. Zwei Dinge halten das sicher. Markieren Sie den gewählten Konstruktor mit [JsonConstructor], damit Json.NET eindeutig weiß, welchen es verwenden soll, und stellen Sie sicher, dass das Umbenennen der Parameternamen dafür ausgeschaltet ist. Die meisten Obfuskatoren, Nebula eingeschlossen, lassen Parameternamen standardmäßig in Ruhe — sie sind keine bedeutende Angriffsfläche — aber wenn ein DTO über einen Konstruktor deserialisiert, ist dieser Standard tragend, also bestätigen Sie ihn, statt ihn anzunehmen.
Enums serialisieren ebenfalls über ihren Member-Namen
Standardmäßig schreibt Json.NET Enums als ihren numerischen Wert, den das Umbenennen nie anfasst. Aber sobald Sie einen StringEnumConverter hinzufügen — üblich, weil eine Zeichenfolge lesbarer und auf dem Draht stabiler ist — wird das Enum über seinen Member-Namen geschrieben, und das ist wieder ein umbenennbarer Bezeichner:
[JsonConverter(typeof(StringEnumConverter))]
public enum Priority
{
[EnumMember(Value = "low")] Low,
[EnumMember(Value = "high")] High,
}
Json.NET beachtet [EnumMember(Value = …)] an Enum-Membern, also nageln Sie jeden fest. Jetzt serialisiert Priority.High als "high", egal ob das Feld High umbenannt wurde oder nicht, weil der Konverter das Literal des Attributs statt des Member-Namens liest.
Die größere Falle: TypeNameHandling schreibt den Namen Ihres Typs ins Dokument
Alles bisher ist eine Property oder ein Wert. TypeNameHandling ist anders: Es schreibt die Identität Ihres Typs ins Dokument. Wenn Sie es aktivieren, um polymorphe Graphen zu round-trippen — eine Basis Payment mit Unterklassen CreditCardPayment und BankTransferPayment, ein Feld vom Typ object, eine List<IMessage> — bettet Json.NET einen $type-Diskriminator ein, um den exakten Laufzeittyp auf dem Rückweg rekonstruieren zu können:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(payment, settings);
// {
// "$type": "MyApp.Payments.CreditCardPayment, MyApp",
// "last4": "4242", "amount": 42.50
// }
Sehen Sie sich den Wert von $type an: Es ist der assembly-qualifizierte Name des Typs — Namespace, Typname, Assembly — zusammengesetzt aus genau den Bezeichnern, die ein Obfuskator umbenennt. Benennen Sie CreditCardPayment in a um und flachen Sie den Namespace ab, und dasselbe Objekt serialisiert jetzt als "$type": "a, MyApp". Ihre [JsonProperty]-Attribute tun hier nichts, denn $type ist keine Property Ihres Typs; es ist der Name des Typs, und kein Property-Attribut nagelt einen Typnamen fest.
Das macht $type weit gefährlicher als einen Property-Schlüssel, denn es bricht zwischen Builds und zwischen Versionen, nicht nur innerhalb eines einzigen. Ein Dokument, das von einem nicht obfuskierten Build oder von der Version des letzten Monats geschrieben wurde, trägt "…CreditCardPayment, MyApp". Geben Sie es Ihrem obfuskierten Build — dessen Typ jetzt a heißt — und Json.NET kann den Namen nicht auflösen und wirft beim Deserialisieren. Alles, was polymorphes JSON persistiert (ein gespeichertes Dokument, eine Nachricht in einer Warteschlange, eine zwischengespeicherte Nutzlast, eine gespeicherte Projektdatei), ist in dem Augenblick gefährdet, in dem die Typnamen auf beiden Seiten voneinander abweichen.
Die Behebung ist weder ein Attribut noch eine Keep-Regel. Es ist ein eigener ISerializationBinder, der einen stabilen logischen Namen, den Sie wählen, auf den Type abbildet, sodass der Draht eine Konstante trägt, die Sie kontrollieren, während der Typ darunter frei umbenannt wird:
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}
Die Schlüssel des Dictionarys — "creditCardPayment" — sind Zeichenfolgenliterale, die der Obfuskator nicht anfassen kann. Die Werte des Dictionarys sind typeof(CreditCardPayment), die sowohl Json.NET als auch der Renamer über das Metadaten-Token auflösen, also werden sie konsistent mit dem Rest Ihres Codes umbenannt. Das Ergebnis ist ein Diskriminator, der über jeden Build und jede Version stabil ist, egal wie aggressiv die Typen umbenannt werden, und als Zugabe hält er das Layout Ihres Namespace aus dem Dokument heraus. (Es ist zudem die empfohlene Haltung für TypeNameHandling aus Sicherheitsgründen: Ein Binder, der nur eine feste Positivliste von Typen auflöst, weigert sich, irgendetwas anderes zu instanziieren, was die Minderung des Deserialisierungs-Gadget-Risikos ist, das TypeNameHandling sonst mit sich bringt.)
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 gesamte Fehlermodus besteht darin, dass sich die Namen erst bewegen, 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 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);
}
Hängen Sie das in CI an das Artefakt nach der Obfuskierung, und der Vorfall „die Serialisierung ist kaputt” wird zu einem fehlgeschlagenen Test statt zu einer Kunden-E-Mail. Das Prinzip darunter ist das, das es sich lohnt, zu jedem reflexionsgetriebenen Framework mitzunehmen, das Sie obfuskieren: ein Name, den ein Framework zur Laufzeit liest, ist ein Vertrag, und ein Vertrag gehört in ein Zeichenfolgenliteral, das der Obfuskator nicht verschieben kann — nicht in einen Member-Namen, einen Typnamen oder einen Namespace, für dessen Verschieben er bezahlt wird.
Nebula.NET benennt von Haus aus aggressiv um; [JsonProperty], [EnumMember] und ein stabiler ISerializationBinder sind der Weg, die Handvoll Namen zu behalten, die bleiben müssen, während Sie die verlieren, die gehen sollten. Für die System.Text.Json-Seite derselben Geschichte siehe einen Typ obfuskieren, den Sie mit System.Text.Json serialisieren; für das breitere Muster was Obfuskierung kaputtmacht.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.