Ofuscar un tipo que serializas con Newtonsoft.Json: [JsonProperty] es el contrato, y el $type de TypeNameHandling es una mina
Newtonsoft.Json lee tu DTO por los nombres de sus miembros mediante reflexión — Customer se convierte en "Customer" en el cable porque la propiedad se llama literalmente Customer — así que en el momento en que un ofuscador la renombra a a, el JSON que produce tu servicio se convierte en { "a": … } y cada consumidor y cada documento almacenado se rompen, en silencio, en tiempo de ejecución. Json.NET no tiene un generador de código fuente en tiempo de compilación para esquivar ese recorrido por reflexión, así que fijar los nombres con [JsonProperty] no es una optimización, es todo el contrato. Y esconde una segunda trampa, peor: TypeNameHandling escribe el nombre cualificado por ensamblado de tu tipo en el documento como $type, de modo que renombrar un tipo rompe cada payload polimórfico que otro build haya escrito — un fallo que ningún atributo arregla. Esta entrada precisa a qué nombres se vincula Json.NET, por qué [JsonProperty] y [EnumMember] convierten los nombres en el cable en constantes que el renombrador no puede tocar, por qué el constructor de deserialización es un segundo vínculo por nombre, y por qué TypeNameHandling necesita un ISerializationBinder estable en lugar de una regla de keep. Termina con la prueba de ida y vuelta, ejecutada contra el build protegido, que convierte "la serialización se rompió" en un test fallido en vez de un correo de un cliente.
Aquí tienes un tipo que funciona perfectamente hasta el día en que lo ofuscas:
public sealed class Order
{
public string Customer { get; set; } = "";
public decimal Total { get; set; }
public DateTime PlacedOn { get; set; }
}
Serialízalo con JsonConvert.SerializeObject(order) y obtienes {"Customer":"…","Total":…,"PlacedOn":"…"}. Pasa el ensamblado por un renombrador y serialízalo de nuevo, y obtienes {"a":"…","b":…,"c":"…"}. Nada lanzó una excepción. El build estaba verde. Cada consumidor de ese JSON — un frontend, un servicio hermano, un documento que almacenaste la semana pasada y lees hoy — está ahora roto, y se rompió en tiempo de ejecución, en un lugar que ningún compilador ni ningún paso de build podría ver jamás.
Esta es la forma más común en que la ofuscación “rompe la serialización,” y con Newtonsoft.Json muerde más fuerte que con System.Text.Json, por una razón estructural: Json.NET no tiene generador de código fuente en tiempo de compilación. System.Text.Json te deja esquivar por completo el recorrido por reflexión con un contexto [JsonSerializable] que congela los nombres en el código generado antes de que corra el ofuscador. Json.NET no tiene equivalente de primera parte — la serialización siempre pasa por un resolver de contrato que reflexiona sobre tu tipo vivo en tiempo de ejecución. No hay vía de escape. Así que fijar los nombres en el cable con atributos no es una optimización que puedas saltarte en una ruta caliente; es el contrato entero.
Por qué el renombrado llega hasta el cable
Las llamadas internas sobreviven al renombrado porque están vinculadas por token de metadatos: el compilador emite call contra un token, y un renombrador que reescribe al llamado reescribe cada token que apunta a él, así que el grafo sigue conectado. El DefaultContractResolver de Json.NET no usa un token. Al serializar recorre tu tipo con reflexión, lee el Name CLR real de cada miembro, aplica la estrategia de nombres del resolver y escribe esa cadena como clave JSON:
// 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() }
};
La clave JSON "Customer" existe solo porque la propiedad se llama Customer. No hay ningún token que una la cadena del fichero con el miembro del ensamblado — el vínculo es el nombre. La deserialización ejecuta la misma maquinaria a la inversa: lee la clave entrante y la empareja, por cadena, con un miembro. Así que cuando el renombrador convierte Customer en a, el resolver hace exactamente lo que se le dice — lee el miembro que ahora se llama a y escribe "a" — y la ida y vuelta con cualquiera que espere "Customer" está muerta. Una estrategia de nombres cambia la capitalización de esa cadena; no hace nada por salvarte, porque sigue derivando la clave de un nombre que el ofuscador acaba de mover.
El arreglo es [JsonProperty], no una regla de keep
El instinto es excluir el DTO del renombrado. No lo hagas — eso entrega justo el tipo que un atacante más quiere leer, y es innecesario. El nombre en el cable debería ser una constante, no un efecto secundario del nombre del miembro, y [JsonProperty] lo convierte en una:
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; }
}
Ahora el resolver lee el literal de cadena del atributo — "customer" — no el nombre del miembro. Ese literal vive en el blob de atributos personalizados en los metadatos, que el renombrado nunca toca; el renombrador es libre de convertir Customer en b, y la clave JSON sigue siendo "customer". Esta es la distinción clave: el miembro sí se renombra (que es la gracia de ofuscar), y solo se fija la cadena que lee Json.NET. Una regla de keep preservaría el nombre del miembro en disco — lo contrario de lo que quieres.
La regla, entonces, es simple y total: cada miembro serializado recibe un nombre [JsonProperty] explícito. Una sola propiedad sin atributo cae en su nombre CLR y se convierte en el único campo que se rompe. Si serializas campos o optas un tipo a MemberSerialization.Fields con [JsonObject], lo mismo aplica a cada campo. Esto es fácil de hacer cumplir en revisión y fácil de ver en un diff — y, a diferencia de una estrategia de nombres, no depende en silencio de que el nombre del miembro sobreviva.
El constructor es un segundo vínculo por nombre
Los DTO inmutables esconden una segunda trampa. Cuando un tipo no tiene constructor por defecto, Json.NET lo deserializa emparejando cada valor JSON con un parámetro del constructor — por nombre de parámetro, sin distinguir mayúsculas:
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 empareja el valor "customer" con el parámetro del constructor llamado customer. Ese emparejamiento vive en los metadatos de nombre de parámetro — y si tu ofuscador reescribe los nombres de parámetro a p0, p1, p2, el emparejamiento falla y cada propiedad se queda en silencio en su valor por defecto. Dos cosas lo mantienen seguro. Marca el constructor elegido con [JsonConstructor] para que Json.NET no tenga ambigüedad sobre cuál usar, y asegúrate de que el renombrado de nombres de parámetro está desactivado para él. La mayoría de los ofuscadores, Nebula incluido, dejan en paz los nombres de parámetro por defecto — no son una superficie de ataque significativa — pero cuando un DTO deserializa a través de un constructor, ese valor por defecto es crítico, así que confírmalo en vez de darlo por hecho.
Los enums también se serializan por su nombre de miembro
Por defecto Json.NET escribe los enums como su valor numérico, que el renombrado nunca toca. Pero en cuanto añades un StringEnumConverter — algo común, porque una cadena es más legible y más estable en el cable — el enum se escribe por su nombre de miembro, y eso vuelve a ser un identificador renombrable:
[JsonConverter(typeof(StringEnumConverter))]
public enum Priority
{
[EnumMember(Value = "low")] Low,
[EnumMember(Value = "high")] High,
}
Json.NET respeta [EnumMember(Value = …)] en los miembros de enum, así que fija cada uno. Ahora Priority.High se serializa como "high" tanto si el campo High ha sido renombrado como si no, porque el converter lee el literal del atributo en vez del nombre del miembro.
La trampa mayor: TypeNameHandling escribe el nombre de tu tipo en el documento
Todo lo anterior es una propiedad o un valor. TypeNameHandling es distinto: escribe la identidad de tu tipo en el documento. Cuando lo activas para hacer ida y vuelta con grafos polimórficos — un Payment base con subclases CreditCardPayment y BankTransferPayment, un campo de tipo object, una List<IMessage> — Json.NET incrusta un discriminador $type para poder reconstruir el tipo exacto en tiempo de ejecución a la vuelta:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(payment, settings);
// {
// "$type": "MyApp.Payments.CreditCardPayment, MyApp",
// "last4": "4242", "amount": 42.50
// }
Mira el valor de $type: es el nombre cualificado por ensamblado del tipo — espacio de nombres, nombre del tipo, ensamblado — ensamblado a partir de los identificadores exactos que un ofuscador renombra. Renombra CreditCardPayment a a y aplana el espacio de nombres, y el mismo objeto ahora se serializa como "$type": "a, MyApp". Tus atributos [JsonProperty] no hacen nada aquí, porque $type no es una propiedad de tu tipo; es el nombre del tipo, y ningún atributo de propiedad fija un nombre de tipo.
Eso hace que $type sea mucho más peligroso que una clave de propiedad, porque se rompe entre builds y entre versiones, no solo dentro de uno. Un documento escrito por un build sin ofuscar, o por la versión del mes pasado, lleva "…CreditCardPayment, MyApp". Entrégaselo a tu build ofuscado — cuyo tipo ahora se llama a — y Json.NET no puede resolver el nombre y lanza una excepción al deserializar. Cualquier cosa que persista JSON polimórfico (un documento almacenado, un mensaje en cola, un payload cacheado, un fichero de proyecto guardado) queda expuesta en el instante en que los nombres de tipo de ambos lados no coinciden.
El arreglo no es un atributo ni una regla de keep. Es un ISerializationBinder personalizado que mapea un nombre lógico estable que tú eliges al Type, de modo que el cable lleva una constante que tú controlas mientras el tipo de debajo se renombra libremente:
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}
Las claves del diccionario — "creditCardPayment" — son literales de cadena que el ofuscador no puede tocar. Los valores del diccionario son typeof(CreditCardPayment), que tanto Json.NET como el renombrador resuelven a través del token de metadatos, así que se renombran de forma consistente con el resto de tu código. El resultado es un discriminador estable entre cada build y cada versión, por muy agresivamente que se renombren los tipos, y como extra mantiene la disposición de tu espacio de nombres fuera del documento. (Es además la postura recomendada para TypeNameHandling por motivos de seguridad: un binder que solo resuelve una lista blanca fija de tipos se niega a instanciar cualquier otro, que es la mitigación del riesgo de gadgets de deserialización que, de otro modo, acarrea TypeNameHandling.)
Demuéstralo con una ida y vuelta sobre el build protegido
Elijas la ruta que elijas, el test que importa corre contra la salida ofuscada, no contra el build de depuración — todo el modo de fallo es que los nombres solo se mueven después de que corre Nebula. Una ida y vuelta contra el ensamblado protegido es la única aserción que lo atrapa:
[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);
}
Conecta esto contra el artefacto posterior a la ofuscación en CI y el incidente de “la serialización se rompió” se convierte en un test fallido en vez de un correo de un cliente. El principio que hay debajo es el que merece la pena llevarse a cada framework guiado por reflexión que ofusques: un nombre que un framework lee en tiempo de ejecución es un contrato, y un contrato pertenece a un literal de cadena que el ofuscador no puede mover — no a un nombre de miembro, un nombre de tipo o un espacio de nombres que está pagado por mover.
Nebula.NET renombra agresivamente por diseño; [JsonProperty], [EnumMember] y un ISerializationBinder estable son cómo conservas el puñado de nombres que tienen que quedarse mientras pierdes los que deberían irse. Para la parte de System.Text.Json de la misma historia, consulta ofuscar un tipo que serializas con System.Text.Json; para el patrón más amplio, qué rompe la ofuscación.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.