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

Cómo funcionan las tablas de metadatos de .NET — y cómo las lee un decompilador

Cada ensamblado .NET lleva una pequeña base de datos relacional de su propia estructura: las tablas de metadatos. Aquí tienes qué contienen realmente TypeDef, MethodDef y los índices codificados, cómo un token de metadatos direcciona una fila, y cómo un decompilador recorre las tablas para convertir una DLL de vuelta en tipos y métodos.

Abre una DLL de .NET en un editor hexadecimal y la mayor parte no es código. Antes del IL, antes de los recursos, hay una base de datos relacional compacta que describe la propia estructura del ensamblado: sus tipos, métodos, campos, los miembros externos que llama, las cadenas y firmas que usa. Esto son los metadatos, y son lo primero que lee el runtime, lo primero que lee un decompilador, y la razón por la que un ensamblado .NET puede desmontarse de forma tan limpia. Entender las tablas explica qué está haciendo realmente una herramienta como Glass.NET cuando convierte un binario de vuelta en tipos y métodos.

Una base de datos dentro del ensamblado

Los metadatos viven en un conjunto de tablas, cada una con un esquema fijo definido por ECMA-335. Cada tabla es una lista de filas, y cada fila es una tupla de columnas. Una columna contiene o bien un entero pequeño, o un índice a un heap (donde viven los datos de longitud variable como nombres y firmas), o un índice a otra tabla. Ese último tipo es lo que hace que los metadatos sean relacionales: las filas apuntan a filas.

Los heaps son cuatro streams que se sitúan junto a las tablas:

  • #Strings — todo el texto de identificadores (nombres de tipo, nombres de método, espacios de nombres), cada uno una secuencia UTF-8.
  • #Blob — datos binarios, sobre todo firmas (los tipos de parámetro y retorno de un método son un blob).
  • #GUID — identificadores de módulo.
  • #US — cadenas de usuario, los literales que tu código carga con ldstr.

Una columna nunca almacena un nombre directamente; almacena un índice al #Strings. Por eso renombrar durante la ofuscación es barato — solo cambia el texto del heap, no la estructura de la tabla.

Las tablas más relevantes para la reconstrucción:

TablaIdUna fila por
Module0x00el propio módulo
TypeRef0x01un tipo referenciado (externo)
TypeDef0x02un tipo definido aquí
Field0x04un campo
MethodDef0x06un método definido aquí
MemberRef0x0Aun miembro externo referenciado
TypeSpec0x1Bun tipo genérico instanciado

Qué contiene una fila TypeDef

Una fila TypeDef es el ancla de un tipo. Sus columnas son:

TypeDef:
  Flags      : UInt32          // visibility, abstract/sealed, layout
  Name       : #Strings index  // "InvoiceService"
  Namespace  : #Strings index  // "Billing"
  Extends    : TypeDefOrRef     // base type (a coded index)
  FieldList  : Field  RID       // first field; runs to next row's FieldList
  MethodList : MethodDef RID    // first method; same run trick

Dos ideas hacen mucho trabajo aquí. Primero, Name y Namespace son índices al heap, así que la identidad legible está a una desreferencia de distancia. Segundo, FieldList y MethodList están codificados por rango: los métodos de un tipo son las filas MethodDef desde el MethodList de esta fila hasta (sin incluir) el MethodList del siguiente TypeDef. No hay un conteo explícito — el límite es el inicio del siguiente tipo. Un lector que ignore esto y trate MethodList como un único método se equivoca en todo tras el primer tipo, lo cual es un error clásico de un analizador primerizo.

Índices codificados: apuntar a «una de varias tablas»

Extends puede ser un tipo definido en este ensamblado (TypeDef), un tipo referenciado desde otro (TypeRef), o un TypeSpec. La columna debe, por tanto, apuntar a una de varias tablas. Los metadatos resuelven esto con un índice codificado: unos pocos bits bajos seleccionan qué tabla, y el resto es el RID. Para TypeDefOrRef, 2 bits eligen entre TypeDef/TypeRef/TypeSpec y los bits restantes son la fila. Así que decodificar Extends significa enmascarar los bits de etiqueta para saber la tabla, y luego desplazar para obtener la fila. Los índices codificados están por todas partes en los metadatos y son la razón principal por la que un enfoque ingenuo de «leerlo como una struct plana» falla; debes decodificar cada columna según su tipo definido.

Tokens: cómo el IL nombra una fila

Cuando el IL necesita referirse a un método, tipo o campo, usa un token de metadatos — un valor de 4 bytes donde el byte alto es el id de tabla y los tres bytes bajos son el RID (base 1):

0x060x00 00 04id de tablaid de fila (RID)MethodDeffila 4token = 0x06000004

Así, 0x02000003 es «TypeDef, fila 3»; 0x0A000011 es «MemberRef, fila 17». El operando de una instrucción call es exactamente uno de esos tokens. Para saber qué invoca call 0x0A000011, el lector salta a la fila 17 de MemberRef, lee sus columnas Class (sobre qué está el miembro) y Name/Signature, y las resuelve a través de los heaps y otras tablas. Este único mecanismo — token adentro, fila afuera — es cómo se siguen todas las referencias cruzadas del IL.

Recorrer las tablas para reconstruir un método

Júntalo todo y el paso estructural del decompilador es una secuencia de recorridos de tabla:

// Pseudocode over the raw tables (names/sigs via the heaps):
foreach (var typeRow in tables.TypeDef)              // 0x02
{
    var typeName = strings[typeRow.Name];            // #Strings
    var ns       = strings[typeRow.Namespace];
    var baseType = Resolve(typeRow.Extends);         // decode coded index

    foreach (var m in MethodsOf(typeRow))            // run-encoded MethodList
    {
        var name = strings[m.Name];
        var sig  = blob[m.Signature];                // #Blob → params/return
        var il   = ReadBodyAt(m.Rva);                // RVA → IL bytes
        // tokens inside `il` resolve back through TypeRef/MemberRef/TypeDef
    }
}

ReadBodyAt(m.Rva) es el puente de los metadatos al código: la columna RVA de la fila MethodDef es una dirección virtual relativa que apunta al cuerpo IL del método en el archivo PE. El cuerpo tiene una cabecera diminuta (que da el tamaño del código y, para cabeceras gruesas, un token para la firma de variables locales), y luego el IL crudo. El decompilador decodifica ese IL, y cada token operando lo envía de vuelta a las tablas. Los nombres vienen de #Strings, las formas de tipo de las firmas #Blob. Solo después de esta recuperación estructural comienza el trabajo más difícil y heurístico — convertir IL y metadatos verificados en C# legible, reconstruir bucles, bloques using y lo demás.

Por qué esto importa cuando lees un binario

Como la estructura es una base de datos estandarizada, un decompilador puede mostrarte la forma exacta de un tipo — miembros, tipo base, firmas, atributos — con certeza, incluso cuando el cuerpo es difícil de reconstruir o está protegido deliberadamente. También explica qué puede y qué no puede tocar la ofuscación: renombrar reescribe las entradas de #Strings pero deja intacto el grafo de tablas, así que la estructura sigue siendo completamente legible; son los cuerpos IL y el flujo de control los que una protección más fuerte remodela. Cuando abres un ensamblado en Glass.NET y bajas de un tipo a sus métodos al IL crudo, estás recorriendo exactamente estas tablas — TypeDef a MethodDef a RVA a IL, resolviendo tokens todo el camino hacia abajo. Los metadatos son el mapa; el decompilador solo los lee en voz alta.

Prueba Nebula.NET

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