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

Ofuscar genéricos: renombrar parámetros de tipo, y las restricciones y la reflexión que fijan los nombres

Un parámetro de tipo genérico como TResult es un nombre en los metadatos — una fila de la tabla GenericParam con una cadena en #Strings —, así que un renombrador puede convertirlo en T0 y un decompilador imprimirá fielmente T0, que es exactamente el resultado inofensivo que quieres. Pero los genéricos son donde el renombrado ingenuo rompe cosas en silencio, porque el CLR identifica una instanciación por posición y aridad, no por nombre, mientras que tu código de reflexión identifica un parámetro de tipo por su nombre. Este artículo lee en qué se compilan realmente un método y una clase genéricos — las tablas GenericParam y GenericParamConstraint, el MethodSpec y el TypeSpec que registran una instanciación por índice —, muestra por qué renombrar el parámetro es gratis pero renombrar un tipo de restricción no lo es, y recorre las llamadas de reflexión (Type.GetGenericArguments().Name, MakeGenericType, GetMethod sobre un genérico abierto) que leen nombres de vuelta y se rompen cuando un renombrador toca lo que no debe.

Renombra un ensamblado y decompila un método no genérico, y la firma se colapsa estupendamente: int a.b(string c). Haz lo mismo con un método genérico y a menudo ves algo que parece a medio terminar — TResult a.b<TResult, TState>(TState c). El nombre del método se fue, el nombre del parámetro se fue, pero los nombres de los parámetros de tipo siguen ahí entre los corchetes angulares, perfectamente legibles.

Eso no es un fallo de ofuscación, y perseguirlo es un error. Los nombres de los parámetros de tipo son los nombres más inofensivos de todo el ensamblado, y la historia interesante sobre ofuscar genéricos es la contraria: estos nombres son tan seguros de renombrar que el riesgo real es renombrar más allá de ellos hasta las dos cosas que los genéricos llevan y que de verdad soportan carga — los tipos de restricción y los nombres que la reflexión lee de vuelta en tiempo de ejecución. Para ver dónde está la línea, hay que mirar en qué se compila realmente un genérico.

En qué se compila un genérico

Toma un pequeño método genérico con un par de restricciones:

public static TResult Map<TState, TResult>(TState state, Func<TState, TResult> f)
    where TState : notnull
    where TResult : class
{
    return f(state);
}

Los dos parámetros de tipo, TState y TResult, no se almacenan donde cabría esperar: no forman parte del nombre del método ni están incrustados en su IL. Son filas de una tabla de metadatos dedicada, GenericParam, una por parámetro, y cada fila lleva tres cosas que importan: un Number (la posición desde cero — TState es 0, TResult es 1), un propietario (el método o tipo al que pertenecen) y un Name, un puntero al heap #Strings que contiene el texto literal TState o TResult. En ILDASM la cabecera del método se lee así:

.method public hidebysig static !!1 Map<(class) TState, class TResult>(
        !!0 state,
        class [System.Runtime]System.Func`2<!!0, !!1> f) cil managed

Lee con atención el cuerpo de esa firma, porque es todo el argumento. A los parámetros se les hace referencia como !!0 y !!1 — por índice, no por nombre. El argumento Func<TState, TResult> es Func2<!!0, !!1>: de nuevo, posicional. El tipo de retorno !!1significa «el segundo parámetro de tipo del método», se llame como se llame. Los nombresTStateyTResultaparecen exactamente en un lugar — las cadenasGenericParam.Name` — y en ningún sitio del código que los usa.

Los nombres viven en una tabla; cada uso es por índiceTabla GenericParamNumber=0 Name="TState"Number=1 Name="TResult"Firma + cuerpo (usos)arg: Func`2<!!0, !!1>ret: !!1Renombrar "TState"→"A", "TResult"→"B"Solo cambian las dos cadenas Name. !!0 y !!1 son índices — intactos.El CLR vincula argumentos por ranura, así que cada instanciación sigue resolviéndose. Coste del renombrado: cero.

Así que cuando un renombrador cambia TState por A y TResult por B, reescribe dos cadenas en #Strings y no toca nada más. Cada !!0 y !!1 en la firma y en el IL sigue apuntando a la ranura correcta, porque las ranuras están numeradas, no nombradas. El método vincula sus argumentos exactamente igual que antes. Por eso renombrar parámetros de tipo es el renombrado más seguro de toda la caja de herramientas — más seguro que renombrar un método privado, porque el nombre de un método privado todavía puede filtrarse en un stack trace, mientras que el nombre de un parámetro de tipo no tiene ningún consumidor en tiempo de ejecución bajo código normal.

La instanciación también es posicional

La misma historia posicional se cumple en el punto de llamada, que es el otro lugar que podría preocupar a un renombrador ingenuo. Cuando escribes Map<string, int>(...), el compilador no emite los nombres string e int contra parámetros llamados TState y TResult. Emite un MethodSpec — un blob de metadatos que dice «instancia el método Map con esta lista ordenada de tipos argumento» — y la lista es posicional: [string, int] significa que la ranura 0 es string y la ranura 1 es int. Igualmente, un tipo construido como List<string> es un blob TypeSpec que lleva la definición genérica y una lista ordenada de argumentos.

En ningún lugar de un MethodSpec o TypeSpec hay un nombre de parámetro. La vinculación es «el argumento i rellena el parámetro i». Por eso puedes renombrar todos los parámetros de tipo de tu ensamblado a la misma letra — o a nada con sentido — y cada instanciación genérica de todo el programa sigue resolviéndose correctamente. La maquinaria de resolución nunca tuvo los nombres en sus manos.

Donde el renombrado muerde de verdad: las restricciones

Ahora la parte que no es gratis. Nuestro método tenía where TState : notnull y where TResult : class. Esas restricciones son metadatos reales, y un ofuscador debe conservarlas: si las elimina, el ensamblado puede fallar la verificación o cambiar la resolución de sobrecargas. Viven en dos sitios: los flags de restricción especial de la fila GenericParam (los tipos class/struct/new() y notnull) y, para las restricciones de tipo, filas en la tabla GenericParamConstraint.

Una restricción notnull o class no nombra ningún tipo, así que no hay nada que renombrar. Pero en el momento en que una restricción nombra un tipo —

public sealed class Handler<T> where T : IPlugin, IDisposable, new()
{
    // ...
}

— la restricción se convierte en una referencia a IPlugin e IDisposable, almacenada como filas GenericParamConstraint que apuntan a esos tipos. Y aquí está la distinción clave: la relación de restricción es estructural y sobrevive, pero el tipo que nombra obedece las reglas ordinarias de renombrado. IDisposable es un tipo del framework: intocable, nunca se renombra. IPlugin es tuyo: si es interno y nada lo busca por nombre, renombrarlo a a.b está bien y la restricción simplemente apunta al tipo renombrado. Pero si IPlugin forma parte de un contrato público que tus clientes implementan, o si algo lo resuelve por nombre, entonces renombrarlo rompe ese contrato — y lo rompe independientemente de que la referencia aparezca dentro de una restricción genérica. La restricción es una pista falsa; lo que importa es si el tipo nombrado es renombrable, juzgado con la misma prueba que aplicas en todas partes.

Donde el renombrado muerde más fuerte: reflexión sobre argumentos de tipo

El segundo peligro es el que de verdad sorprende a la gente, porque solo falla en tiempo de ejecución. Los nombres de los parámetros de tipo no tienen consumidor bajo código normal — pero la reflexión puede leerlos de vuelta:

Type t = typeof(Dictionary<,>);          // open generic
foreach (Type p in t.GetGenericArguments())
    Console.WriteLine(p.Name);            // "TKey", "TValue" — or "A", "B" after renaming

GetGenericArguments() sobre un genérico abierto devuelve los parámetros de tipo, y .Name lee exactamente la cadena GenericParam.Name que un renombrador reescribe. A la mayoría del código no le importa: usa los objetos Type posicionalmente. Pero el código que registra en logs, serializa o bifurca según esos nombres verá ahora el texto renombrado. Si algo de eso cruza un límite — un nombre de parámetro escrito en un diagnóstico que lee un cliente, o en una clave de caché, o comparado con una cadena fija — el renombrado cambió el comportamiento en silencio.

La versión más afilada implica buscar un miembro genérico por nombre y luego construirlo:

// Find an open generic method by name, then instantiate it.
MethodInfo open = typeof(Repo)
    .GetMethod("LoadAll", BindingFlags.Public | BindingFlags.Instance)!;
MethodInfo closed = open.MakeGenericMethod(typeof(Customer));

La cadena vulnerable aquí no es el parámetro de tipo: es "LoadAll", el nombre del método que se pasa a GetMethod. Renombra LoadAll y este GetMethod devuelve null, y la línea siguiente lanza una excepción. Es el mismo problema de la-reflexión-rompe-el-renombrado que se aplica a cualquier miembro, pero los genéricos lo concentran: el código genérico cargado de reflexión (fábricas, serializadores, contenedores de DI que resuelven genéricos abiertos, despacho mediante MakeGenericType/MakeGenericMethod) tiende a buscar miembros por nombre y luego cerrarlos sobre tipos en tiempo de ejecución. Los argumentos de tipo son seguros; los nombres de miembro que alimentan GetMethod, GetType(string) y compañía no lo son.

Cómo lo acota Nebula

La regla que se desprende de los metadatos es limpia, y es exactamente la que aplica Nebula. Los parámetros de tipo se renombran agresivamente, porque sus nombres no tienen papel en tiempo de ejecución y renombrarlos no cuesta nada: que Map<TState, TResult> se convierta en Map<A, B> en el decompilador es ganancia pura. Los tipos de restricción se juzgan con la misma prueba de renombrabilidad que cualquier otro tipo: internos y no referenciados por nombre, se renombran; contrato público o resueltos por nombre, se excluyen. Y los nombres de miembro que la reflexión reconstruye a partir de cadenas se conservan, de modo que una fábrica que hace GetMethod("LoadAll") sigue encontrando su método.

En una configuración de Nebula, lo único específico de genéricos que sueles hacer es proteger la superficie de reflexión — los tipos y métodos genéricos abiertos que algo busca por nombre — mientras dejas que los parámetros de tipo y los tipos de restricción internos se renombren con normalidad:

<Rules>
  <!-- A factory resolves these open generics by method name at runtime: keep the names. -->
  <Rule pattern="method MyApp.Data.Repo::LoadAll" rename="false" />
  <Rule pattern="type MyApp.Plugins.IPlugin" rename="false" />
  <!-- Type parameters everywhere are renamed by default; nothing to configure. -->
</Rules>

El resultado se lee como quieres cuando lo decompilas: los métodos genéricos son tan opacos como todo lo demás, sus parámetros de tipo reducidos a letras sin sentido, la lógica interior renombrada y con el flujo de control ofuscado — mientras que el puñado de nombres que de verdad tienen un trabajo en tiempo de ejecución, porque la reflexión o un contrato público los lee, se dejan en pie para que el código siga funcionando.

La prueba, en una línea

Los nombres de los parámetros de tipo se pueden renombrar libremente; no tienen consumidor en tiempo de ejecución salvo la reflexión que tú controlas. Antes de dejar que un renombrado toque cualquier otra cosa a la que haga referencia un genérico, hazte la pregunta que te haces con cada nombre: ¿este nombre se lee de vuelta — por el CLR por nombre (nunca lo hace; usa índices), por reflexión o a través de un límite de contrato? Para un parámetro de tipo la respuesta es casi siempre no, así que renómbralo. Para el tipo que nombra una restricción, o el miembro al que apunta una cadena de GetMethod, la respuesta puede ser sí — y ahí, no en los corchetes angulares, es donde corresponde el cuidado.

Prueba Nebula.NET

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