Skip to content
← Todas las publicaciones
· Delta1 Labs Nebula.NETOfuscación.NETAnálisis a fondo

Ofuscar enums: por qué sobreviven los nombres al renombrado, y cómo los filtran ToString() y switch

Renombra un enum y sus miembros y un decompilador sigue imprimiendo LicenseTier.Pro, no a.b, porque un enum es una clase sellada cuyos miembros son campos const literales públicos, y en cuanto tu código llama a ToString() o Enum.Parse el runtime lee esos nombres de campo directamente de los metadatos en tiempo de ejecución. Renombrarlos a a, b, c es legal y no te cuesta nada salvo lo único que querías: un ida y vuelta por el nombre se rompe, porque "Pro" ya no coincide con un campo llamado a. Esta entrada lee el IL al que compila un enum, muestra dónde viven los nombres (la tabla Field y el heap #Strings, no los valores), recorre exactamente qué se rompe al renombrar —ToString, Enum.Parse, el formateo de [Flags], la serialización JSON por cadena, Enum.GetNames— y cómo Nebula acota el tratamiento de enums para que los valores que gobiernan tu lógica queden protegidos mientras los nombres de los que depende tu protocolo permanezcan intactos.

Renombra todo en un ensamblado .NET —cada tipo, cada método, cada campo— y decompila el resultado, y casi todo se derrumba en el muro ilegible que querías: a.b(c, d). Luego llegas a un enum, y el decompilador imprime con calma LicenseTier.Pro. El nombre que intentabas ocultar está ahí mismo, en un build Release, sin PDB a la vista.

Esto no es un fallo de tu ofuscador y no es que el decompilador lea símbolos que no deberían estar. Es consecuencia de lo que un enum realmente es en los metadatos, y entenderlo te dice tanto por qué los nombres son tan visibles como, más importante, por qué renombrarlos es a menudo la única transformación que no debes aplicar.

A qué compila un enum

Un enum es azúcar sintáctico sobre una clase. Esto:

public enum LicenseTier
{
    Free = 0,
    Trial = 1,
    Pro = 2,
}

compila a una clase sellada que deriva de System.Enum, con un campo public const literal por miembro más un campo de instancia especial que guarda el valor subyacente. Decompilado literalmente, es:

public sealed class LicenseTier : System.Enum
{
    public int value__;          // the instance field — the actual integer
    public const LicenseTier Free  = (LicenseTier)0;
    public const LicenseTier Trial = (LicenseTier)1;
    public const LicenseTier Pro   = (LicenseTier)2;
}

Aquí se almacenan dos cosas distintas, y la distinción es toda la entrada. El valor de cada miembro —0, 1, 2— es una constante, almacenada en la tabla de metadatos Constant y asociada al campo. El nombre de cada miembro —Free, Trial, Pro— se almacena exactamente como cualquier otro nombre de campo: una fila en la tabla Field que apunta a una entrada en el heap #Strings. En ILDASM las filas de campo se leen así:

.field public static literal valuetype LicenseTier Free  = int32(0x00000000)
.field public static literal valuetype LicenseTier Trial = int32(0x00000001)
.field public static literal valuetype LicenseTier Pro   = int32(0x00000002)
.field public specialname rtspecialname int32 value__

Los nombres Free, Trial, Pro son literales de cadena en los metadatos, tan duraderos y tan legibles como un nombre de tipo. Un decompilador los imprime por la misma razón que imprime LicenseTier mismo, y no necesita símbolos para ello. Hasta aquí esto es solo «los nombres de enum son metadatos», que es por lo que un renombrador puede alcanzarlos. La razón de que normalmente no deba es lo que el runtime hace con esos nombres.

Los nombres son de carga en tiempo de ejecución

Los valores de enum se comparan como enteros —un switch sobre un enum es un switch sobre value__, y tier == LicenseTier.Pro es ldc.i4.2; ceq. Nada de eso toca el nombre. Si tu código solo comparara y usara switch, los nombres serían peso muerto que podrías renombrar libremente.

Pero en cuanto un valor se vuelve texto, o el texto se vuelve un valor, el runtime echa mano de los nombres. Considera lo más común que la gente hace con los enums:

Console.WriteLine($"Current tier: {tier}");   // calls tier.ToString()

Enum.ToString() sin formato no tiene una tabla de búsqueda horneada por el compilador. Reflexiona sobre el tipo enum en tiempo de ejecución: enumera los campos literales, lee el valor constante de cada uno de los metadatos, encuentra el campo cuyo valor es igual a la instancia empaquetada y devuelve el nombre de ese campo. El IL en el sitio de llamada no es más que una llamada virtual empaquetada —

ldloca.s   tier
constrained. LicenseTier
callvirt   instance string [System.Runtime]System.Object::ToString()

— y todo el trabajo ocurre dentro de Enum.ToString, recorriendo las mismas filas Field que leyó el decompilador. Así que si renombraste Pro a a, esta línea ahora imprime Current tier: a. Tus logs, tus diagnósticos, tus tickets de soporte dicen ahora a. No rompiste la compilación; rompiste la salida.

La dirección inversa es peor, porque falla en vez de solo verse mal:

var tier = Enum.Parse<LicenseTier>(config["tier"]);   // config says "Pro"

Enum.Parse compara el texto suministrado con los nombres de campo. Si el campo ahora se llama a, entonces parsear "Pro" lanza ArgumentException —y parsear "a" tiene éxito. Así que un fichero de configuración, un documento guardado, una columna de base de datos o una petición de API que lleve la cadena "Pro" —escrita por una versión anterior de tu propia app, o por un cliente que no controlas— deja de parsear en el instante en que renombras el miembro. El nombre no es decoración; es el formato de cable.

miembro de enumvalor 2 · nombre "Pro"usa el VALOR (int)== · switch · &/| de bits · aritméticausa el NOMBRE (#Strings)ToString · Parse · GetName/GetNamesseguro renombrar a a, b, cformato [Flags] · enum JSON por cadena · configrenombrar rompe el ida y vuelta

Dónde muerde más fuerte

Tres patrones convierten esto de una nota al pie en un fallo distribuido.

Formateo de [Flags]. Un enum de flags formatea un valor combinado descomponiéndolo en bits activos y uniendo los nombres coincidentes con ", ":

[Flags] public enum Caps { None = 0, Read = 1, Write = 2, Admin = 4 }

(Caps.Read | Caps.Admin).ToString();   // "Read, Admin"

Renombra los miembros y eso se vuelve "a, c". Si algo persiste o parsea esa cadena —y los enums de flags se persisten a menudo precisamente porque se empaquetan limpiamente— el renombrado cambia en silencio tu formato.

Serialización de enum por cadena. System.Text.Json con JsonStringEnumConverter escribe y lee el nombre del miembro, no el número:

var opts = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } };
JsonSerializer.Serialize(LicenseTier.Pro, opts);   // "\"Pro\""

Renombra Pro y tu API ahora emite "a", y cada cliente que envíe "Pro" obtiene un fallo de deserialización. Los binders de configuración, appsettings.json, YAML y la mayoría de serializadores que favorecen enums legibles se comportan igual. El nombre es parte de tu contrato público lo hayas declarado así o no.

UI y validación dirigidas por Enum.GetNames/GetValues. Cualquier código que construya un desplegable, un listado de ayuda de CLI o un mensaje de validación a partir de Enum.GetNames<LicenseTier>() mostrará ahora a, b, c a una persona. Los valores siguen funcionando; las palabras desaparecieron.

La regla: acota el renombrado al valor, nunca al contrato

Lo útil que proteger de un enum casi nunca es la grafía de sus miembros, sino la lógica que gobiernan sus valores. Un atacante que lea LicenseTier.Pro no aprende nada que no pudiera adivinar; lo que no quieres que siga con facilidad es el método que decide qué desbloquea Pro y el flujo de control que lo hace cumplir. Y lo que se rompe con el renombrado es precisamente el ida y vuelta del nombre. Esos dos hechos apuntan a una regla:

Renombra miembros de enum solo donde el nombre nunca sale del proceso y nunca se reconstruye desde texto. Excluye del renombrado cualquier enum cuyos nombres crucen una frontera: serializado, parseado, comparado por cadena o mostrado a una persona.

Esto es exactamente el tipo de acotación en torno a la que Nebula está construida. Dejas legibles los enums dependientes del nombre para que ToString(), Enum.Parse y tu contrato JSON sigan funcionando, mientras los métodos y el flujo de control que consumen esos enums —la puerta de tier, la comprobación de capacidad, la aritmética de asientos— reciben el tratamiento completo de renombrado, flujo de control y cifrado de cadenas. En una config de Nebula eso se parece a excluir del renombrado los miembros de un enum de contrato dejando todo lo demás en alcance:

<Rules>
  <!-- Keep the names of enums that cross a boundary; their text is a contract. -->
  <Rule pattern="type MyApp.Contracts.LicenseTier" rename="false" />
  <Rule pattern="type MyApp.Contracts.Caps" rename="false" />
  <!-- Everything else, including the logic that reads these enums, is renamed as usual. -->
</Rules>

La recompensa es que nada que a un atacante le importe se volvió más fácil de leer. El nombre del enum nunca fue el secreto —Pro frente a a no le dice nada sobre cómo se hace cumplir Pro, y dejar el nombre intacto no te cuesta protección sobre el código que importa. Lo que te compra es un build que todavía serializa, parsea y loguea correctamente, en vez de uno que compila limpio y luego falla la primera vez que un "Pro" guardado vuelve por Enum.Parse.

La prueba de una línea

Antes de dejar que un renombrador toque un enum, hazle una pregunta a cada miembro: ¿su nombre se vuelve texto alguna vez, o viene de texto, en cualquier parte que este proceso pueda alcanzar —un log, un fichero de configuración, una carga serializada, una cadena de UI, una entrada parseada, un valor almacenado que lees de vuelta? Si sí, el nombre es parte de tu contrato y debe quedarse. Si genuinamente no —el enum es privado, comparado solo por valor, nunca convertido a cadena— entonces renombra sin miedo; es tan seguro como cualquier otro campo. El error nunca es renombrar un enum; es renombrar uno cuyos nombres sostenían calladamente tu formato de cable.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.