Ofuscar un tipo que serializas con System.Text.Json: [JsonPropertyName] es el contrato, la propiedad renombrada no
System.Text.Json basado en reflexión lee tu DTO por los nombres de sus propiedades — Order se convierte en "order" en el cable porque la propiedad se llama literalmente Order — lo que significa que en el momento en que un ofuscador renombra esa propiedad a a, el JSON que produce tu servicio se convierte en { "a": …, "b": … } y cada consumidor y cada ida y vuelta se rompen, en silencio, en tiempo de ejecución, donde ningún paso de build puede detectarlo. Esta entrada precisa exactamente a qué nombres se vincula el serializador y a cuáles no: por qué una auto-propiedad pelada es una mina, por qué [JsonPropertyName("order")] es el arreglo que convierte el nombre en el cable en una constante que el renombrador no puede tocar, por qué un [JsonConstructor] parametrizado empareja parámetros con propiedades por nombre de modo que los argumentos del constructor también quedan fijados, cómo se comportan [JsonExtensionData] y [JsonStringEnumMemberName] en enums, y por qué el JsonSerializerContext generado en compilación cambia el panorama por completo. Muestra la configuración de Nebula que mantiene el contrato intacto — atribuye los nombres, deja que los miembros se renombren — y la prueba de ida y vuelta que demuestra que un build protegido sigue deserializando lo que serializó.
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 el System.Text.Json por defecto basado en reflexión y obtienes {"customer":"…","total":…,"placedOn":"…"}. Pasa el ensamblado por un renombrador y vuelve a serializar, y obtienes {"a":"…","b":…,"c":"…"}. Nada lanzó una excepción. El build estaba en verde. Cada consumidor de ese JSON — un frontend, un servicio hermano, un documento almacenado que leerás la semana que viene — está ahora roto, y se rompió en tiempo de ejecución, en un lugar que ningún compilador y ningún validateOnBuild podrían ver jamás.
Esta es la forma más común con diferencia en que la ofuscación «rompe la serialización», y merece la pena entenderla con precisión, porque el arreglo no es una regla de keep a lo bruto — es un único atributo, y deja que la propiedad siga renombrándose.
Por qué el renombrado llega al 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. System.Text.Json basado en reflexión no usa un token. Al serializar recorre tu tipo con reflexión, lee el Name CLR real de cada propiedad, aplica la política de nombres configurada y escribe esa cadena como clave JSON:
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
// For each property p: key = opts.PropertyNamingPolicy.ConvertName(p.Name);
// p.Name == "Customer" -> "customer"
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 — la unión es el nombre. La deserialización ejecuta la misma maquinaria al revés: lee la clave entrante y la empareja, por cadena, con una propiedad. Así que cuando el renombrador convierte Customer en a, el serializador hace exactamente lo que se le dice — lee la propiedad que ahora se llama a y escribe "a" — y la ida y vuelta con cualquiera que espere "customer" está muerta.
El arreglo es un atributo, no una regla de keep
El instinto es excluir el DTO del renombrado. No lo hagas — eso entrega exactamente 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 [JsonPropertyName] lo convierte en una:
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; }
}
Ahora el serializador lee el literal de cadena del atributo — "customer" — no el nombre de la propiedad. El renombrador es libre de convertir Customer en b; el atributo sigue diciendo "customer", la clave JSON sigue siendo "customer", y el contrato se mantiene. 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 el serializador. Una regla de keep preservaría el nombre del miembro en disco — lo contrario de lo que quieres. El atributo preserva el comportamiento mientras el nombre desaparece.
La regla, entonces, es simple y total: cada miembro serializado recibe un nombre explícito. Una sola propiedad sin atributo cae en su nombre CLR y se convierte en el único campo que se rompe. Eso es fácil de imponer en revisión, y fácil de ver en un diff.
El constructor es un segundo vínculo por nombre
Los DTO inmutables esconden una segunda trampa. Cuando un tipo no tiene constructor sin parámetros, System.Text.Json lo deserializa emparejando cada propiedad JSON con un parámetro del constructor — por nombre de parámetro, sin distinguir mayúsculas:
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; }
}
El serializador empareja el valor JSON de "customer" con el parámetro del constructor llamado customer. Ese emparejamiento vive en los metadatos de nombres 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 silenciosamente en su valor por defecto. Dos cosas mantienen esto a salvo. Marca el constructor elegido con [JsonConstructor] para que el serializador 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 se deserializa a través de un constructor, ese valor por defecto sostiene carga, así que confírmalo en lugar de asumirlo.
Enums, extension data y los miembros que no se vinculan por nombre
Unos cuantos miembros más tienen sus propias reglas. Los enums serializados como cadena se escriben por su nombre de miembro a menos que lo sobrescribas — el mismo problema de la-reflexión-lee-el-nombre — así que fíjalos explícitamente (.NET 9 te da [JsonStringEnumMemberName] exactamente para esto):
[JsonConverter(typeof(JsonStringEnumConverter<Priority>))]
public enum Priority
{
[JsonStringEnumMemberName("low")] Low,
[JsonStringEnumMemberName("high")] High,
}
[JsonExtensionData] sobre un Dictionary<string, object> es el único miembro cuyo nombre de propiedad nunca llega al cable — su contenido se inserta en el objeto padre — así que no necesita [JsonPropertyName]; las claves del diccionario son datos de tiempo de ejecución, no nombres de miembros, y el renombrado nunca las toca. Y los miembros [JsonIgnore], por definición, no se serializan y no llevan contrato.
La verdadera escapatoria de la reflexión: el generador de código fuente
Todo lo anterior trata de que el serializador por reflexión lea un nombre de propiedad vivo. El generador de código fuente esquiva el recorrido por completo. Un contexto [JsonSerializable] genera el código de serialización en tiempo de compilación, horneando cada nombre JSON en los metadatos generados como constante de cadena y leyendo la propiedad directamente:
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Order))]
internal partial class OrderJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Serialize(order, OrderJsonContext.Default.Order);
Los nombres se resuelven antes de que Nebula se ejecute y se congelan en el contexto generado como literales, así que renombrar el DTO después no puede cambiar lo que emite el código generado — ya sabe qué propiedad significa "customer". (El tipo generado OrderJsonContext y su punto de entrada Default.Order los referencian tus puntos de llamada y se renombran de forma consistente con ellos, como cualquier otro vínculo interno; solo un uso reflexivo del contexto por nombre sería una dependencia aparte.) Para un DTO caliente que estás ofuscando, el generador de código fuente es a la vez la ruta más rápida y la que tiene menos peligros de nombres — pero sigues atribuyendo los nombres que te importan, porque el generador lee [JsonPropertyName] igual que lo hace la reflexión.
Demuéstralo con una ida y vuelta sobre el build protegido
Elijas la ruta que elijas, la prueba que importa se ejecuta contra la salida ofuscada, no contra el build de depuración — todo el modo de fallo consiste en que los nombres solo se mueven después de que Nebula se ejecute. 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 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);
}
Conecta esto contra el artefacto post-ofuscación en CI y el incidente de «se rompió la serialización» se convierte en una prueba fallida en lugar de un correo de un cliente. El principio que hay debajo es el que merece la pena llevarse a cada framework basado en 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 que está pagado para mover.
Nebula.NET renombra agresivamente por diseño; los atributos de arriba son cómo conservas el único nombre que tiene que quedarse mientras pierdes los que deben irse. Para el patrón más amplio sobre reflexión y serialización, consulta qué rompe la ofuscación.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.