Ofuscar un contrato JSON polimórfico: por qué [JsonDerivedType] sobrevive al renombrado y TypeNameHandling no
La serialización polimórfica pone la identidad del tipo en el cable. TypeNameHandling de Newtonsoft incrusta el nombre del tipo CLR, que la ofuscación renombra, rompiendo cada documento persistido. [JsonDerivedType] de System.Text.Json usa un discriminador de cadena desacoplado del tipo, así que se renombra sin problemas. Aquí está exactamente por qué, y cómo ofuscar la jerarquía de forma segura.
Los fallos de renombrado a nivel de propiedad son molestos pero superficiales: una clave sale mal, la fijas con [JsonPropertyName], listo. La serialización polimórfica es un peligro más profundo, porque lo que va al cable no es un nombre de propiedad: es la identidad del tipo mismo. La ofuscación existe para destruir esa identidad. Así que un contrato polimórfico es exactamente el lugar donde «el renombrado rompió la serialización» deja de ser un problema cosmético de nombres de cable y se convierte en «todos los documentos que hemos persistido son ahora ilegibles».
La buena noticia es que esto es por completo una decisión de diseño, y el diseño moderno de .NET es el seguro. Esta entrada trata de por qué [JsonDerivedType] de System.Text.Json es seguro ante el renombrado por construcción, por qué TypeNameHandling de Newtonsoft no lo es, y cómo ofuscar una jerarquía de tipos sin conservar un solo nombre.
Dos formas de poner un tipo en el cable
Supón que persistes una lista de formas —algunas círculos, algunas rectángulos— a través de una referencia base Shape. La deserialización tiene que saber de qué tipo concreto era cada elemento. Hay dos maneras fundamentalmente distintas de codificar eso.
TypeNameHandling de Newtonsoft escribe el nombre de tipo CLR:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(shape, settings);
// {"$type":"MyApp.Shapes.Circle, MyApp","Radius":4.0,"Area":50.2}
El valor de $type es el nombre calificado por ensamblado de Circle. Al leer, Newtonsoft resuelve esa cadena de vuelta a un Type (el equivalente moral de Type.GetType) y lo construye. El formato del cable está soldado a la identidad CLR.
[JsonDerivedType] de System.Text.Json escribe un discriminador que tú eliges:
[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}
El valor de $type es el literal "circle". Al leer, System.Text.Json busca ese literal en la tabla de registro que construyeron los atributos, encuentra typeof(Circle) y lo construye. El formato del cable está soldado a una cadena que te pertenece, no al nombre del tipo.
Esa diferencia es invisible hasta que un ofuscador renombra Circle.
Qué le hace el renombrado a cada diseño
El renombrado reescribe el nombre del tipo y cada token de metadatos que lo referencia, de forma consistente: call, newobj, typeof, tipos de campo, todo se mueve junto, por eso la ofuscación es transparente para el código ordinario. La pregunta es siempre solo: ¿el formato lee un token (que se mueve) o un nombre/cadena (que puede no moverse)?
Newtonsoft lee un nombre. Tras la ofuscación, se rompen dos cosas distintas. Los documentos escritos antes del renombrado dicen "MyApp.Shapes.Circle, MyApp", y el ensamblado ofuscado no tiene ningún tipo con ese nombre: JsonSerializationException: Could not load type 'MyApp.Shapes.Circle'. Y los documentos escritos después del renombrado incrustan el nombre ofuscado (digamos a.b), acoplando tus datos persistidos al mapa de renombrado de una compilación y filtrando la identidad renombrada directamente en el JSON. En cualquier caso el formato del cable sigue a la identidad CLR, y la identidad CLR es precisamente lo que cambiaste.
System.Text.Json lee el literal discriminador "circle". El atributo sigue llevando "circle"; el argumento typeof(Circle) es un token de metadatos que el renombrador reescribe para apuntar al tipo renombrado; la tabla de registro sigue asignando "circle" a ese tipo. El cable byte por byte es idéntico antes y después de la ofuscación, y un documento escrito por la compilación del año pasado se deserializa aún en la ofuscada de hoy. Nada que conservar, nada que filtrar.
La única forma de romper el diseño seguro
[JsonDerivedType] es seguro porque el discriminador no tiene relación con el nombre de la clase. Hay exactamente una forma popular de tirar eso por la borda:
[JsonDerivedType(typeof(Circle), typeDiscriminator: nameof(Circle))] // no lo hagas
nameof(Circle) lo resuelve el compilador al literal "Circle" y lo congela en el atributo, así que esto no se rompe al renombrar: el cable sigue diciendo "Circle". Pero vuelve a acoplar silenciosamente tu formato de cable público al nombre de clase original y, peor, le entrega a un atacante los nombres de tipo en claro que pagaste por ocultar: tu JSON «protegido» ahora anuncia Circle, Rectangle, PremiumSubscription, AdminCommand. Usa discriminadores opacos e intencionados —"circle", o incluso "s1"— elegidos como un vocabulario de cable, nunca derivados del identificador. Lo mismo vale para el nombre de la propiedad discriminadora: si quieres algo distinto del "$type" por defecto, pon la cadena en [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")], nunca en un miembro.
Ofuscar la jerarquía: nada que conservar
Como los discriminadores viven en literales y los registros de tipo viven en tokens, la jerarquía no necesita ninguna regla keep —lo que normalmente fuerza una exclusión simplemente no está presente aquí. Ofuscas Shape y todos sus subtipos como cualquier otro código: renombra los tipos, aplana el flujo de control de sus métodos, cifra sus cadenas, virtualiza los cuerpos valiosos. La única higiene por DTO es la ordinaria que cubre cualquier tipo serializado: dar a cada propiedad serializada un [JsonPropertyName] explícito para que las claves no recaigan en nombres CLR vivos.
Por tanto nebula.config.json no tiene ningún caso especial para el modelo:
{
"schemaVersion": 1,
"inputs": ["publish/MyApp.dll"],
"outputDirectory": "protected",
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "MyApp.Shapes.*", "action": "rename" }
]
}
}
No hay regla keep porque las cadenas discriminadoras no son nombres de miembros: encryptStrings incluso las cifra en el ensamblado, y aun así se descifran a "circle" en el momento en que System.Text.Json las escribe, así que el cable no cambia. Contrasta eso con el diseño de Newtonsoft, que te obligaría o bien a hacer keep de los nombres de tipo (entregando la jerarquía que más quieres ocultar) o bien a registrar un SerializationBinder personalizado que traduzca el mapa de renombrado en tiempo de ejecución (acoplando tus datos persistidos al ofuscador para siempre). El diseño del discriminador no pide ninguna de las dos.
Ejecútalo de la forma habitual, en el servidor de compilación para que la depuración local siga siendo legible:
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>
Prefiere el generador de código para un contrato caliente
Para un contrato en una ruta caliente, un contexto [JsonSerializable] elimina el recorrido de reflexión en tiempo de ejecución y hornea los discriminadores y los nombres de propiedad en código generado en tiempo de compilación, antes de que Nebula se ejecute:
[JsonSerializable(typeof(Shape))] // recoge los registros [JsonDerivedType]
internal partial class ShapeJsonContext : JsonSerializerContext { }
string json = JsonSerializer.Serialize(shape, ShapeJsonContext.Default.Shape);
El código generado ya contiene "circle" y "rect" como constantes, así que renombrar la jerarquía después no puede cambiar un byte de la salida. El tipo de contexto y su punto de entrada Default.Shape se renombran de forma consistente con los sitios de llamada que los usan.
Pruébalo en la compilación ofuscada — incluso entre compilaciones
La aserción que importa se ejecuta contra el artefacto protegido, y para un contrato persistido debe también probar que el formato es estable entre renombrados: serializa con una compilación, lee con la ofuscada:
[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); // el discriminador es el literal estable
Assert.DoesNotContain("Circle", json); // el nombre de tipo original nunca se filtra
var back = JsonSerializer.Deserialize<Shape>(json)!;
Assert.IsType<Circle>(back); // el registro aún resuelve tras el renombrado
Assert.Equal(4.0, ((Circle)back).Radius);
}
Congela una cadena JSON capturada de una compilación anterior como fixture y deserialízala en el ensamblado ofuscado; si aún produce un Circle, tus documentos persistidos están a salvo entre renombrados. Esa única prueba convierte la peor clase de incidente de ofuscación —el silencioso, el que corrompe datos y descubres semanas después— en una compilación en rojo.
El principio se generaliza a cada framework que codifica la identidad de tipo: pon la identidad en el cable como un valor que te pertenece, nunca como el nombre CLR que el ofuscador está pagado para cambiar. [JsonDerivedType] hace exactamente eso, por lo que Nebula.NET puede renombrar una jerarquía polimórfica entera sin que conserves nada. Para el complemento a nivel de propiedad de esto, consulta ofuscar un contrato de System.Text.Json, y para el patrón más amplio, qué rompe la ofuscación en la reflexión y la serialización.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.