Ofuscar código generado por generadores de código: por qué los contextos de System.Text.Json, [GeneratedRegex] y [LoggerMessage] sobreviven al renombrado — y el único nombre que filtran
Los generadores de código se ejecutan dentro del compilador y emiten C# ordinario en tu ensamblado, así que el IL que producen es indistinguible del código que escribiste tú. La consecuencia útil para la ofuscación es que los generadores resuelven los nombres en tiempo de compilación: un contexto de System.Text.Json captura "Name" como un literal de cadena y accede a la propiedad a través de un delegado, el enlazador de configuración hace switch sobre claves literales y un GeneratedRegex es pura identidad de tipo. Renómbralo todo y el JSON, las opciones enlazadas y la regex siguen funcionando — a diferencia de sus equivalentes basados en reflexión. Lo que los generadores sí dejan atrás son literales: el patrón de la regex, la plantilla del mensaje de log y un nameof() que graba el nombre original de tu método en un EventId. Aquí tienes qué emite cada generador, qué renombra Nebula.NET de forma segura y qué cifrar o excluir.
Un generador de código es un plugin del compilador que lee tu código y emite más código en la misma compilación. Para cuando el compilador de C# termina, los archivos generados son simplemente parte de tu ensamblado — las mismas tablas de metadatos, el mismo IL, ningún marcador en la salida que distinga un método que escribiste tú de uno que escribió el generador. Lo que plantea la pregunta obvia para un ofuscador: cuando Nebula renombra tus tipos y miembros, ¿qué pasa con el código que se generó alrededor de ellos?
La respuesta es mejor de lo que esperarías, con un giro. Los generadores resuelven los nombres en tiempo de compilación, que es exactamente la propiedad que hace que el código sea seguro frente al renombrado. Pero la forma en que lo hacen es anotando tus nombres como literales de cadena, y esos literales sobreviven al renombrado tal cual — y de vez en cuando le cuentan a un atacante cómo se llamaba antes un método.
Búsqueda en tiempo de ejecución frente a captura en tiempo de compilación
Toda la razón por la que la ofuscación rompe cosas es la búsqueda de nombres en tiempo de ejecución. Type.GetProperty("Name"), JsonSerializer.Serialize(obj) sin un contexto, IConfiguration.Bind(options) — cada uno recorre los metadatos en tiempo de ejecución, encuentra un miembro por su cadena y deja de encontrarlo en cuanto el miembro se renombra. Por eso autoDetect preserva los miembros que llevan atributos de serialización y la documentación te dice que excluyas tus clases de configuración.
Un generador hace la búsqueda en tiempo de compilación y emite dos cosas en su lugar: una referencia por token al miembro (un call/ldfld directo, o un delegado) y, donde se necesita un nombre en tiempo de ejecución, un literal copiado de tu código fuente. El renombrado reescribe cada referencia por token y deja los literales en paz.
Así que la regla para el código generado es la inversa de la regla para la reflexión: el miembro es seguro de renombrar; el literal es en lo que tienes que pensar. Veamos qué emiten realmente cuatro generadores habituales.
System.Text.Json: el contexto captura el nombre y el accesor
Toma un DTO sin ningún atributo de serialización y un contexto generado por código:
public sealed class Customer
{
public string Name { get; set; } = "";
public int Tier { get; set; }
}
[JsonSerializable(typeof(Customer))]
internal partial class AppJsonContext : JsonSerializerContext { }
Activa una vez la salida de archivos generados — <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> en el proyecto — y lee lo que el generador produjo para Customer. El inicializador de metadatos tiene este aspecto (recortado):
private static JsonPropertyInfo[] CustomerPropInit(JsonSerializerOptions options)
{
var properties = new JsonPropertyInfo[2];
var info0 = new JsonPropertyInfoValues<string>
{
IsProperty = true,
DeclaringType = typeof(global::Customer),
Getter = static obj => ((global::Customer)obj).Name,
Setter = static (obj, value) => ((global::Customer)obj).Name = value!,
PropertyName = "Name", // captured at generation time
JsonPropertyName = null,
};
properties[0] = JsonMetadataServices.CreatePropertyInfo<string>(options, info0);
// ... Tier ...
return properties;
}
Dos cosas que observar. El getter y el setter son lambdas que tocan la propiedad directamente — en IL eso es un ldftn sobre un método generado por el compilador cuyo cuerpo es callvirt Customer::get_Name(). El nombre serializado es un ldstr "Name". Después de que Nebula renombre Customer a a y Name a b, el cuerpo de la lambda pasa a ser callvirt a::get_b() — reescrito por token, como todos los sitios de llamada — mientras que el ldstr sigue diciendo "Name". Serializa un cliente y obtienes {"Name":"…","Tier":3} exactamente igual que antes.
En el modo de serialización de ruta rápida el generador hace lo mismo con un nombre precodificado — private static readonly JsonEncodedText PropName_Name = JsonEncodedText.Encode("Name"); — y un writer.WriteString(PropName_Name, value.Name) escrito a mano. La misma forma: literal más acceso directo al miembro.
Eso es estrictamente mejor que la reflexión, y significa que el DTO no necesita la red de seguridad de [JsonPropertyName] ni una entrada en exclude. La propiedad puede renombrarse y el contrato se mantiene, porque el contrato se capturó cuando se ejecutó el generador.
La única forma en que esto todavía se rompe es una ruta de código que no usa el contexto. JsonSerializer.Serialize(customer) sin opciones, o una cadena de resolutores que termina en DefaultJsonTypeInfoResolver, resuelve los nombres a partir de los metadatos en tiempo de ejecución y producirá {"b":"…"} tras el renombrado. No quieres descubrir eso en la compilación ofuscada. Desactiva la ruta por reflexión a nivel de proyecto:
<PropertyGroup>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
Ahora cualquier llamada que habría recurrido silenciosamente a la reflexión lanza una excepción en desarrollo, y toda ruta de serialización que llega a producción es una que el generador capturó.
[GeneratedRegex]: pura identidad de tipo
public static partial class Parsers
{
[GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")]
private static partial Regex IsoDate();
}
El generador completa IsoDate() para que devuelva un singleton cacheado de una clase generada — un tipo sealed derivado de Regex cuyo constructor establece pattern, roptions y factory, con un RunnerFactory anidado que crea un Runner anidado que implementa Scan, TryFindNextPossibleStartingPosition y TryMatchAtCurrentPosition. Nada ahí dentro resuelve nada por nombre. Al runner se llega a través del campo factory que la clase base ya espera; la llamada del usuario es call Parsers::IsoDate() → ldsfld de la instancia. Renombra el método, la clase generada, la factory y el runner, y la regex sigue coincidiendo.
Lo que queda es el literal del patrón, asignado en el constructor generado (base.pattern = "^\d{4}-\d{2}-\d{2}$"). Es una cadena ordinaria en IL y encryptStrings la oculta en reposo como cualquier otra.
La consideración real aquí es el rendimiento, no la corrección. TryMatchAtCurrentPosition es el bucle de coincidencia compilado — exactamente el tipo de código ajustado y lleno de ramas que el aplanamiento del flujo de control hace medíblemente más lento. Si la regex se ejecuta en una ruta caliente, mantén el aplanamiento lejos de ella. Los tipos generados llevan nombres decorados por el compilador, así que localiza los nombres exactos de los métodos con nebula inspect --methods y añádelos a controlFlowExclude, o — más sencillo — limita el aplanamiento a los métodos que realmente necesitas proteger con controlFlowInclude.
[LoggerMessage]: el generador escribe nameof() por ti
public static partial class Log
{
[LoggerMessage(EventId = 12, Level = LogLevel.Warning,
Message = "Order {OrderId} rejected: {Reason}")]
public static partial void OrderRejected(ILogger logger, int orderId, string reason);
}
La mitad generada es un callback LoggerMessage.Define cacheado:
private static readonly Action<ILogger, int, string, Exception?> __OrderRejectedCallback =
LoggerMessage.Define<int, string>(
LogLevel.Warning,
new EventId(12, nameof(OrderRejected)), // ← your method name, as a string
"Order {OrderId} rejected: {Reason}",
new LogDefineOptions { SkipEnabledCheck = true });
public static partial void OrderRejected(ILogger logger, int orderId, string reason)
{
if (logger.IsEnabled(LogLevel.Warning))
__OrderRejectedCallback(logger, orderId, reason, null);
}
Ese nameof(OrderRejected) lo evalúa el compilador de C#. En el IL es ldstr "OrderRejected" — no queda ninguna referencia al método, solo los caracteres. Nebula renombra Log.OrderRejected a a.b; el EventId sigue llevando el nombre "OrderRejected". Dos consecuencias:
- Tu logging sigue funcionando como se diseñó. Los paneles y las alertas indexados por el nombre del evento ven el mismo nombre antes y después de la ofuscación. Bien.
- El binario contiene ahora una entrada de mapa. Un decompilador muestra un método
bcuyo inicializador estático dice"OrderRejected". Para una clase llena de métodos de log, eso es un índice legible de lo que hace cada método renombrado. Es el mismo mecanismo descrito en cuando nameof filtra tus miembros renombrados, producido aquí por un generador en lugar de por tu propio código.
encryptStrings es la solución, y es la correcta: el literal se cifra en el ensamblado y se descifra cuando se ejecuta el inicializador estático, así que tus logs siguen diciendo OrderRejected mientras que el archivo en disco no. La plantilla del mensaje recibe el mismo tratamiento, lo que también oculta los nombres de parámetro {OrderId}/{Reason} — poca cosa, pero describen los argumentos del método.
El generador de enlace de configuración: claves literales en lugar de un recorrido de metadatos
El ConfigurationBinder basado en reflexión está en todas las listas de “preserva esto” por una razón: Bind hace coincidir las claves de configuración con los nombres de propiedad reflexionando sobre el tipo de opciones en tiempo de ejecución. .NET 8 añadió un enlazador generado por código:
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
Con él activado, builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp")) se intercepta y se redirige a código generado con esta forma:
public static void BindCore(IConfiguration configuration, ref SmtpOptions instance, BinderOptions? binderOptions)
{
if (configuration["Host"] is string value0)
instance.Host = value0; // direct assignment, literal key
if (configuration["Port"] is string value1)
instance.Port = ParseInt(value1, configuration.GetSection("Port").Path);
}
"Host" y "Port" son ldstr; instance.Host = … es callvirt SmtpOptions::set_Host. Renombra SmtpOptions.Host a a.c y el enlazador generado sigue leyendo Smtp:Host de appsettings.json y sigue llamando al setter renombrado. La entrada de exclude para tus clases de configuración que el enlazador por reflexión te obligaba a tener ya no es necesaria para el enlace.
Comprueba qué más lee esas clases por nombre antes de quitar la exclusión: ValidateDataAnnotations informa de los nombres de miembro en sus mensajes de error (cosmético), y cualquier llamada a Bind que quede compilada sin el generador sigue usando reflexión. La carpeta de archivos generados te dice qué llamadas se interceptaron.
Una configuración que encaja con el código generado
Juntándolo todo para un servicio típico que usa los cuatro generadores:
{
"inputs": ["out/Acme.Service.dll"],
"outputDirectory": "out/obf",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"controlFlowIntensity": "normal",
"controlFlowExclude": [
"Acme.Service.Parsers.<RegexGenerator_g>F…__IsoDate_0.Runner.TryMatchAtCurrentPosition"
]
}
encryptStrings es la línea que más trabajo hace aquí: cubre el patrón de la regex, las plantillas de log y los literales de nameof con una sola opción. controlFlowExclude mantiene el aplanamiento fuera del bucle interno del runner de la regex (pega el nombre decorado exacto que te da nebula inspect --methods). Nada en la configuración preserva el DTO, la clase de opciones ni los métodos de log, porque nada lo necesita.
La lista de comprobación para cualquier generador que aún no conozcas es la misma que este artículo aplicó cuatro veces. Emite los archivos generados, ábrelos y hazle dos preguntas a cada acceso a un miembro: ¿es un token (una llamada directa, un typeof, un delegado) o una cadena (GetProperty("…"), Type.GetType("…"))? Los tokens son seguros frente al renombrado. Después mira cada literal y pregúntate si es un contrato de serialización que necesitas sin cambios (bien — no cambia) o una copia de un nombre que esperabas ocultar (cífralo). Los generadores que hacen su trabajo en tiempo de compilación son, casi por construcción, el código más fácil de ofuscar de tu ensamblado. Consulta Mantener tu aplicación funcionando para las reglas del lado de la reflexión que estos generadores te permiten relajar.
Descarga Nebula.NET gratis y ejecuta tus pruebas de serialización contra la compilación ofuscada.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.