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

Renombrar hasta la colisión: cómo Nebula reutiliza un mismo nombre entre miembros, y por qué el CLR los distingue por la firma

Un renombrador ingenuo da a cada miembro un nombre corto único (a, b, c, d) y ahí se queda. Está dejando valor sobre la mesa, porque .NET no identifica un miembro por su nombre. Un método se identifica por su nombre junto con su firma, un campo por su nombre y su tipo declarante, y el runtime enlaza cada llamada a través de un token de metadatos resuelto en tiempo de carga, no comparando cadenas de nuevo. Eso significa que puedes renombrar docenas de métodos sin relación entre sí al único nombre a, siempre que sus firmas difieran, y el ensamblado sigue cargando, verificándose y ejecutándose. Esta entrada lee los metadatos de MethodDef y del heap de cadenas para mostrar por qué el renombrado con colisiones es legal, recorre las reglas exactas que lo acotan (choques de firmas idénticas, cadenas de sobrescritura virtual, implementaciones explícitas de interfaz) y explica cómo Nebula comprime los nombres hasta un conjunto minúsculo y reutilizado que a la vez reduce el heap de cadenas y convierte la salida de un decompilador en un muro ilegible de miembros con nombres idénticos.

La mayoría de la gente se imagina el renombrado como un diccionario: cada identificador recibe su propio sustituto nuevo y sin significado. CalculateTax se convierte en a, ValidateLicense en b, _cachedResult en c, y así sucesivamente por todo el ensamblado. Funciona, y es lo que hace un renombrador de primera generación. Pero asume en silencio algo que no es cierto en .NET: que el nombre de un miembro es su identidad. No lo es. El CLR identifica un miembro por mucho más que su nombre, y en cuanto ves exactamente por qué, se vuelve posible una transformación más agresiva y más desorientadora: reutilizar el mismo nombre en muchos miembros.

Qué identifica realmente a un método

Un método en un ensamblado .NET es una fila de la tabla de metadatos MethodDef. El nombre es una columna (un desplazamiento dentro del heap #Strings), pero no es lo que hace distinto al método. Dentro de su tipo declarante, un método se identifica por su nombre junto con su firma: la convención de llamada, el tipo de retorno y los tipos de parámetros ordenados, todo codificado como un blob en el heap #Blob y referenciado por la columna Signature de la fila.

Esto no es un truco de ofuscación; es el mecanismo cotidiano detrás de la sobrecarga. Considera dos sobrecargas ordinarias:

public void Write(int value)    { /* ... */ }
public void Write(string value) { /* ... */ }

Son dos filas MethodDef que comparten una misma cadena de nombre (ambas columnas Name apuntan al mismo "Write" del heap #Strings) y difieren únicamente en sus blobs de firma. El runtime siempre las ha distinguido. La sobrecarga es, en términos de metadatos, precisamente reutilización de nombre con firmas distintas, y funciona desde la versión 1. Un renombrador que entiende esto puede crear sobrecargas donde el código fuente nunca tuvo ninguna.

// Before — three unrelated methods, three distinct names.
public decimal CalculateTax(decimal amount) { /* ... */ }
public bool    ValidateLicense(string key)  { /* ... */ }
public void    FlushCache()                  { /* ... */ }

// After — all three renamed to 'a'. Signatures differ, so the metadata is valid.
public decimal a(decimal amount) { /* ... */ }
public bool    a(string key)     { /* ... */ }
public void    a()               { /* ... */ }

Tres métodos, un nombre, y el ensamblado carga, se verifica y se ejecuta exactamente igual que antes, porque cada par (nombre, firma) resultante sigue siendo único dentro del tipo.

Tabla MethodDefName → "a" Sig → #1 (decimal)Name → "a" Sig → #2 (string)Name → "a" Sig → #3 (void)#Strings: "a"#Blob #1 decimal#Blob #2 string#Blob #3 voiduna sola cadena de nombre compartidatres blobs de firma distintos — esto es lo que los mantiene separados

Por qué la llamada sigue encontrando el método correcto

La preocupación obvia es que, si los nombres colisionan, las llamadas se vuelven ambiguas. No es así, porque nada resuelve una llamada por nombre en tiempo de ejecución. Cuando el compilador de C# emite una llamada, escribe una instrucción call o callvirt cuyo operando es un token de metadatos: un identificador numérico que apunta a una fila concreta MethodDef o MemberRef, con la firma completa registrada junto a ella.

// A call site targeting CalculateTax(decimal), before and after renaming.
ldarg.0
ldloc.0
callvirt   instance valuetype Decimal Acme.Billing::a(valuetype Decimal)
//                                              ^name is "a"  ^signature is still (decimal)->decimal

El token y la firma incrustada son lo que el cargador enlaza, una sola vez, a exactamente una fila. El renombrado reescribió la columna Name de esa fila y reescribió el nombre deletreado en el sitio de llamada, pero no cambió qué fila selecciona el token, y no cambió la firma. Así que una llamada que apuntaba a CalculateTax(decimal) antes apunta a ese mismo método idéntico después, aunque ahora una docena de métodos más también respondan a a. El nombre es una comodidad para los humanos que leen metadatos; el runtime navega por token y firma y nunca se entera de la colisión.

Entre tipos distintos la restricción es aún más laxa. El tipo declarante forma parte de la identidad de un método, así que cada tipo puede tener libremente su propio método llamado a con cualquier firma, incluida una firma idéntica a la a de otro tipo. Los campos se comportan igual: un campo se identifica por su nombre y su tipo declarante, así que cada tipo recibe su propio campo a, y un tipo puede incluso contener a la vez un campo llamado a y un método llamado a, porque campos y métodos viven en tablas distintas.

Las reglas que lo acotan

El renombrado con colisiones es agresivo, no ilimitado. Un renombrador correcto tiene que respetar las mismas reglas que imponen los metadatos y el sistema de tipos, o produce un ensamblado que no carga.

  • Misma firma, mismo tipo. Dos métodos de un mismo tipo no pueden compartir nombre y firma a la vez: sería una fila duplicada que el runtime rechaza con una TypeLoadException. Así que las colisiones solo pueden asignarse entre métodos cuyas firmas ya difieren. El renombrador calcula, por tipo, qué miembros pueden compartir un nombre sin riesgo.
  • Cadenas de sobrescritura virtual. Una sobrescritura debe llevar el mismo nombre y la misma firma que el método base al que sobrescribe, a lo largo de toda la jerarquía, o la ranura de la vtable deja de coincidir y la sobrescritura deja de despacharse en silencio. Los métodos virtuales y cada sobrescritura suya se renombran, por tanto, juntos y de forma coordinada a un único nombre compartido.
  • Implementaciones explícitas de interfaz. Un miembro como void IDisposable.Dispose() registra por token el método de interfaz que implementa; su nombre en los metadatos está decorado (IDisposable.Dispose) y la correspondencia debe mantenerse coherente. Estos se renombran solo en coordinación con la interfaz, o se preservan.
  • Todo lo que se resuelve por cadena. Los miembros que un serializador empareja por nombre, que un framework descubre por convención, o que tu propio código busca mediante reflexión o nameof deben excluirse: las mismas reglas de preservación que se aplican a cualquier renombrado se aplican aquí, sin cambios.
miembro a renombrar¿con qué puede compartir nombre?firma distinta en el tipo → colisionar a nombre comúncomprime el heap de cadenas, confunde al lectorcadena virtual / impl. explícita → renombrar en bloquenombres y firmas deben seguir coincidiendoresuelto por cadena → preservar, no tocar

Lo que ve el atacante

El renombrado único todavía deja un mapa utilizable. Si a siempre significa una sola cosa, un analista puede seguir a a por el ensamblado e ir construyendo comprensión poco a poco: los nombres son opacos, pero son coherentes. El renombrado con colisiones le quita eso. Reutiliza un nombre entre muchos métodos y campos sin relación y la salida decompilada se convierte en un muro de a.a(a) donde cada a denota algo distinto según el tipo que lo rodea y la firma de los argumentos. No queda ningún punto de referencia; la única herramienta que un analista tiene gratis («este nombre es aquella cosa») deja de funcionar.

// What the decompiler faithfully emits after collision renaming.
// Every 'a' here is a different member, told apart only by signature.
a a = this.a.a(a.a);
if (a.a((string)a))
    a.a();

No es un ejemplo forzado; es la salida honesta de un decompilador que reproduce una reutilización de nombres que solo las firmas hacían legal. En los casos más agudos el C# emitido ni siquiera es directamente recompilable sin insertar casts explícitos que guíen la resolución de sobrecargas: el decompilador recuperó IL válido en un código fuente que las reglas del lenguaje C# llamarían ambiguo. El atacante tiene que reconstruir, por firma, las distinciones que los nombres antes transportaban gratis.

También hay un dividendo mecánico: el heap #Strings se encoge. Un renombrador ingenuo sigue necesitando miles de cadenas cortas distintas; el renombrado con colisiones referencia una sola "a" desde cientos de filas, así que el heap contiene un puñado de entradas en lugar de miles. Unos metadatos más pequeños son un efecto secundario menor y bienvenido del objetivo principal, que es hacer que orientarse, lo primero que hace cualquier ingeniero inverso, cueste tiempo real. Nebula no colapsa los nombres a ciegas; los colapsa exactamente hasta donde lo permiten las reglas de firma, las cadenas de sobrescritura y tu lista de preservación, y ni un paso más.

Prueba Nebula.NET

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