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

Ofuscar atributos personalizados: qué conservan los metadatos, qué rompe el renombrado y la reflexión que sigue funcionando

Un atributo personalizado no es un comentario que el compilador descarta: es una fila de la tabla de metadatos CustomAttribute que apunta, por token, a un constructor, y transporta un blob de argumentos posicionales y con nombre. Esa estructura decide exactamente qué puede tocar la ofuscación y qué no. Renombra la clase del atributo y el GetCustomAttribute<T>() basado en tokens sigue funcionando, porque a los tokens no les importan los nombres; renómbrala y el GetCustomAttributes().First(a => a.GetType().Name == "FeatureGate") basado en cadenas se rompe, porque estaba leyendo un nombre que acabas de borrar. Los argumentos con nombre son la parte sutil: referencian un campo o propiedad por su nombre en forma de cadena dentro del blob, así que un renombrador que mueve uno sin el otro produce un ensamblado que lanza TypeLoadException al cargar. Esta entrada lee los metadatos reales, muestra qué patrones de reflexión sobreviven a un renombrado y cuáles no, y explica cómo Nebula mantiene coherentes los usos de atributos mientras sigue eliminando los nombres que leería un atacante.

Los desarrolladores tienden a pensar en un atributo personalizado como metadatos que “describen” un tipo, y ahí lo dejan. Para la ofuscación, el modelo mental útil es más preciso: una aplicación de atributo personalizado es una fila concreta en una tabla de metadatos que apunta, por token, a un constructor, y transporta un blob serializado de argumentos. Una vez que lo ves así, la pregunta “¿la ofuscación rompe mis atributos?” tiene una respuesta precisa: depende por completo de si lo que lee el atributo sigue el token o lee un nombre.

La tabla CustomAttribute

Cuando escribes [FeatureGate(Tier = "pro")] sobre un método, el compilador de C# no almacena el texto. Emite una fila en la tabla de metadatos CustomAttribute (0x0C), y esa fila tiene tres columnas que importan:

CustomAttribute row
  Parent  -> the thing decorated   (a MethodDef token here)
  Type    -> the attribute ctor    (a MemberRef/MethodDef token, NOT a name)
  Value   -> a blob: prolog 0x0001, positional args, then named args

La palabra crítica es token. La columna Type no dice "FeatureGateAttribute"; contiene un token numérico que se resuelve a la fila del constructor en los metadatos. Renombra la clase del atributo y el nombre del constructor cambia, pero su token —y la fila CustomAttribute que apunta a él— no. Por eso renombrar una clase de atributo es mucho más seguro de lo que sugiere la intuición: todo lo que encuentra el atributo recorriendo el token sigue funcionando, porque no moviste el destino, solo lo reetiquetaste.

El blob Value es donde la cosa se pone interesante. Los argumentos posicionales del constructor se serializan solo por posición y tipo —no hay nombres en esa parte del blob—, así que renombrar los parámetros del constructor es invisible para él. Los argumentos con nombre son la excepción, y la fuente de casi todos los errores de ofuscación relacionados con atributos.

Fila CustomAttributeParent → MethodDef (decorado)Type → token (ctor), sin nombreBlob Value: posicional "pro"+ con nombre Tier = "pro"ctor: a..ctor(string)renombrado — token sin cambios"Tier" almacenado como cadenadebe coincidir con el nombre renombrado del miembropor token ✓por nombre ⚠

Argumentos con nombre: lo único que debes renombrar al unísono

Un argumento con nombre —Tier = "pro"— es azúcar sintáctico para “construye el atributo y después asigna este campo o propiedad”. El CLR no puede hacer la segunda mitad sin saber qué miembro, así que el blob almacena el nombre del miembro como cadena UTF-8. Esa cadena es el peligro. Si un ofuscador renombra la propiedad Tier de la clase del atributo a b pero deja el blob diciendo "Tier", el cargador busca un miembro llamado Tier que ya no existe y lanza TypeLoadException en el momento en que el atributo se materializa. Renombra la cadena del blob pero no la propiedad y obtienes la imagen especular del mismo fallo.

public sealed class FeatureGateAttribute : Attribute
{
    public FeatureGateAttribute(string feature) => Feature = feature;
    public string Feature { get; }          // positional — blob has no name for it
    public string Tier { get; set; } = "";  // named — blob stores "Tier" as a string
}

[FeatureGate("export", Tier = "pro")]       // "export" positional, Tier="pro" named
public void ExportReport() { /* ... */ }

Un renombrador correcto trata la propiedad y la cadena del argumento con nombre como una sola unidad: o renombra ambos —la propiedad Tier → b y la cadena del blob "Tier" → "b"— o no renombra ninguno. Nebula hace lo primero por defecto, siguiendo el enlace del argumento con nombre a través de los metadatos para que los dos lados nunca se desvíen. Es exactamente el tipo de referencia cruzada que una pasada ingenua de renombrar-todo hace mal, y la razón por la que “renombró mi atributo y ahora la app no carga” es casi siempre una cadena de argumento con nombre que se quedó atrás.

Qué reflexión sobrevive

Ahora la parte práctica. Que tu propio código siga encontrando sus atributos tras la ofuscación depende de cómo los busca: por token o por nombre.

// SURVIVES: generic overload resolves the type to a token.
// Rename FeatureGateAttribute -> a; the token is unchanged, so this still matches.
var gate = method.GetCustomAttribute<FeatureGateAttribute>();
if (gate is not null) Enforce(gate.Tier);

// SURVIVES: typeof(...) is a token reference too, resolved at compile time.
var attrs = method.GetCustomAttributes(typeof(FeatureGateAttribute), inherit: false);

// BREAKS: compares a runtime name string that renaming deleted.
var gate2 = method.GetCustomAttributes()
    .FirstOrDefault(a => a.GetType().Name == "FeatureGateAttribute"); // -> null after rename

// BREAKS: reads a named-argument value by reflecting a property name string.
var tier = (string)gateType.GetProperty("Tier")!.GetValue(gate)!;    // "Tier" is gone

La regla sale limpia: busca los atributos por tipo, nunca por nombre. Tanto GetCustomAttribute<T>() como typeof(T) compilan a referencias por token que el renombrador reescribe de forma coherente, así que siguen apuntando al mismo atributo (ahora renombrado). Cualquier ruta de código que reconstruya una decisión a partir de una cadena —GetType().Name, GetProperty("..."), Enum.Parse, un nombre comparado con un valor de configuración— está leyendo una etiqueta que la ofuscación se dedica a destruir, y devolverá silenciosamente null o nada.

Qué debes preservar

Tus propios atributos marcadores internos —los que solo lees con GetCustomAttribute<T>()— pueden renombrarse libremente, y renombrarlos es una ganancia real de seguridad: un atributo llamado LicenseCheckAttribute sobre un método es una señal, y convertirlo en a elimina la señal. Pero algunos atributos los lee código que no controlas, por nombre o por tipo conocido, y esos deben conservar sus nombres:

  • Serialización: [JsonPropertyName], [DataMember], [XmlElement]: el serializador los empareja por tipo y lee sus argumentos con nombre; preserva el atributo y el miembro que decora (consulta trampas de serialización).
  • Interpretados por el runtime: [Serializable], [DllImport], [MarshalAs], [StructLayout]: el propio CLR actúa sobre ellos; renombrarlos cambia el comportamiento en tiempo de ejecución, no solo las etiquetas.
  • Descubrimiento por frameworks: atributos de pruebas ([Fact], [Theory]), atributos de enrutado y model binding de ASP.NET Core, marcadores de DI escaneados por ensamblado: todos se encuentran escaneando en busca de un tipo conocido.

Ese límite lo trazas en nebula.config.json, preservando los atributos que miran hacia el framework por espacio de nombres o tipo mientras dejas que tus marcadores internos se renombren:

{
  "schemaVersion": 1,
  "inputs": ["bin/Release/net8.0/MyApp.dll"],
  "outputDirectory": "protected",
  "preservePublicApi": true,
  "rename": {
    "preserveAttributes": [
      "System.Text.Json.Serialization.*",
      "System.Runtime.InteropServices.*",
      "MyApp.Contracts.*"
    ]
  }
}

O inline, sobre la propia clase del atributo, con el mismo atributo de exclusión que Nebula respeta en otros sitios:

[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)]
public sealed class WireContractAttribute : Attribute { /* read by the serializer */ }

La regla

Un atributo personalizado vive en los metadatos como un constructor referenciado por token más un blob de valor, y esa estructura te dice exactamente qué puede hacer la ofuscación. Los tokens son a prueba de renombrado, así que busca los atributos por tipo y sobrevivirán intactos; los nombres del blob no lo son, así que un argumento con nombre debe renombrarse en ambos lados o en ninguno, y todo lo que un framework resuelva por nombre debe preservarse. Con esos dos hechos claros puedes eliminar la intención legible por humanos de tus atributos marcadores internos —que es precisamente la señal por la que se orientaría un atacante— sin romper una sola llamada a GetCustomAttribute<T>() ni confundir al serializador. El hecho estructural de que un atributo está aplicado no se puede ocultar; el significado que lo hacía digno de leerse, sí.

El manejo de atributos es una pieza de cómo Nebula.NET consigue que un ensamblado renombrado se comporte de forma idéntica al original. Para el modo de fallo más amplio al que pertenece, lee trampas de reflexión y serialización; para ver por qué el renombrado es solo la primera capa, ofuscación más allá del renombrado. La edición gratuita renombra y verifica la salida sobre ensamblados reales para que puedas confirmar que tus atributos se comportan antes de publicar.

Prueba Nebula.NET

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