Skip to content
← Todas las publicaciones
· Delta1 Labs DecompilationGlass

Cómo Glass sigue el reenvío de tipos y la tabla ExportedType

Cuando un tipo se traslada de mscorlib a System.Runtime, el ensamblado antiguo conserva un reenviador: una fila ExportedType con el indicador de reenviador que apunta a un nuevo AssemblyRef. Un reenviador no tiene miembros, así que un descompilador no debe representarlo como un tipo: tiene que seguir el reenvío hasta la definición real. Este artículo recorre dónde vive el reenvío en los metadatos, cómo Glass.NET resuelve una cadena de reenvíos, cómo se degrada con elegancia cuando falta el ensamblado destino y cómo vuelve a emitir [assembly: TypeForwardedTo(...)] cuando descompilas la propia fachada.

Pocas piezas de metadatos de .NET causan tanta confusión como el reenviador de tipos. Abres un ensamblado antiguo —mscorlib, pongamos, o una fachada netstandard—, pides a tus herramientas System.ValueTuple, y el tipo aparece listado pero no tiene código. No es un esbozo que el compilador olvidó rellenar. Es un reenviador: una señal de una línea que dice que el tipo ahora vive físicamente en otro sitio. Un descompilador que no lo entienda te mostrará una clase vacía, o una referencia sin resolver, y en ambos casos ha fallado en su única tarea. Esto trata de cómo Glass.NET maneja el caso correctamente: dónde vive el reenvío en los metadatos, cómo Glass lo sigue (incluso a través de una cadena), qué hace cuando el destino no aparece por ningún lado y cómo vuelve a emitir el reenvío cuando descompilas el propio ensamblado de reenvío.

Glass.NET es un descompilador e inspector de ensamblados de .NET gratuito, con una interfaz Avalonia nativa de Visual Studio y una herramienta de línea de comandos glass. Todo lo que aquí se describe es análisis estático: Glass lee las tablas de metadatos y los cuerpos de los métodos de ensamblados que ya tienes y que tienes derecho a inspeccionar. Nunca carga ni ejecuta el objetivo.

Por qué existen los reenviadores

A lo largo de la vida de .NET, la Biblioteca de Clases Base se ha reorganizado más de una vez. Los tipos que vivían en mscorlib o System.Core bajo .NET Framework se dividieron, durante el paso a .NET Core y System.Runtime, en un conjunto de ensamblados de implementación de grano fino —System.Runtime, System.Collections, System.Linq, etc.—. System.ValueTuple es un buen caso concreto: se distribuyó primero en un paquete NuGet independiente System.ValueTuple.dll, y más tarde se incorporó a la plataforma, de modo que el tipo System.ValueTuple ahora vive en System.Runtime.

El problema es todo el código ya compilado contra la ubicación antigua. Un método compilado contra System.ValueTuple.dll tiene un TypeRef a ese ensamblado grabado en sus metadatos. Si el tipo simplemente desapareciera de allí, todas esas referencias se romperían en tiempo de carga. La solución es dejar atrás un reenviador: el ensamblado antiguo conserva una entrada para el tipo, pero en lugar de una definición guarda un puntero al ensamblado al que se trasladó el tipo. Las referencias antiguas siguen resolviéndose contra el ensamblado antiguo; el runtime sigue el reenvío en silencio y se enlaza al tipo real. El ensamblado fachada se convierte en una fina capa compuesta solo de reenviadores.

El código fuente que produce un reenviador es un único atributo de nivel de ensamblado:

[assembly: TypeForwardedTo(typeof(System.ValueTuple))]

Lo pones en el ensamblado antiguo (el que ya no contiene el tipo), haciendo referencia al nuevo ensamblado que sí lo contiene. El compilador lo convierte en metadatos.

Dónde vive el reenvío: la tabla ExportedType

El atributo compila a una fila en la tabla de metadatos ExportedType (0x27). Una fila ExportedType tiene cuatro columnas que importan aquí:

  • Flags — un valor TypeAttributes. Un reenviador activa el bit IsTypeForwarder, 0x00200000. Ese bit es lo que distingue un reenvío del otro uso de ExportedType (reexportar un tipo desde un módulo secundario de un ensamblado multimódulo).
  • TypeName / TypeNamespace — el nombre del tipo reenviado, p. ej. ValueTuple en el espacio de nombres System.
  • Implementation — un índice codificado. Para un reenviador apunta a una fila AssemblyRef: el ensamblado en el que vive ahora el tipo.

Es crucial que no haya ninguna fila en la tabla TypeDef para un tipo reenviado. La fila ExportedType solo lleva identidad —nombre, espacio de nombres, destino— y nada más. Sin campos, sin métodos, sin tipo base, sin diseño. Ese es precisamente el objetivo: un reenviador es una dirección, no una casa.

Representados en una vista al estilo de ILDasm, el reenviador y el AssemblyRef del que depende se ven así:

.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A)
  .ver 8:0:0:0
}

.class extern forwarder System.ValueTuple
{
  .assembly extern System.Runtime
}

La palabra clave forwarder en .class extern es exactamente el indicador IsTypeForwarder; el .assembly extern System.Runtime anidado es la columna Implementation resuelta al AssemblyRef. No hay ningún cuerpo .class en ningún lugar de este ensamblado, solo esta señal externa.

Qué hace mal un descompilador ingenuo

Dale ese ensamblado a una herramienta que recorra la tabla ExportedType sin tratar de forma especial el bit de reenviador y ocurrirá una de dos cosas malas.

Si trata la fila ExportedType como un tipo que mostrar, obtienes un cascarón vacío:

// INCORRECTO — un reenviador representado como si fuera una definición
namespace System
{
    public struct ValueTuple
    {
        // (sin miembros — este ensamblado no tiene definición para el tipo)
    }
}

Ese cuerpo es una ficción. ValueTuple tiene ocho y más constructores, sobrecargas de Create, IComparable, ITuple —ninguno de los cuales está aquí, porque ninguno está en este ensamblado—. Mostrar una struct vacía implica que el tipo no tiene miembros, lo cual es rotundamente falso.

El otro modo de fallo es resolver una referencia a System.ValueTuple directamente contra la fachada, no encontrar ningún TypeDef y sacarlo a la superficie como un tipo sin resolver: un subrayado rojo en la interfaz, o /* unresolved */ ValueTuple. También incorrecto: el tipo se resuelve perfectamente, solo que a un salto de distancia.

Cómo sigue Glass el reenvío

Glass trata de forma especial el bit de reenviador. Cuando resuelve una referencia a un tipo, o cuando le pides que descompile un tipo por nombre, no se detiene en la fila ExportedType. Lee la Implementation, resuelve el AssemblyRef contra el conjunto de ensamblados cargados (el mismo resolutor que Glass ya usa para las referencias ordinarias —directorios del framework, la carpeta del ensamblado que abriste y cualquier ruta de búsqueda que hayas añadido—), carga el ensamblado destino y busca el TypeDef real allí. Descompila ese.

El flujo, incluido el caso encadenado en el que un reenviador apunta a un ensamblado que a su vez reenvía el tipo más adelante:

código que referenciaTypeRef → mscorlibenlazado a fachada antiguamscorlib.dllExportedTypeforwarder → AssemblyRefsin TypeDef aquínetstandard.dllExportedTypeforwarder → AssemblyRefreenvío encadenadoSystem.Runtime.dllTypeDefSystem.ValueTupledefinición real + miembrosGlass sigue cada salto de reenvío hasta el TypeDef real y lo descompilaCada fachada solo lleva una señal; la definición —y todos sus miembros— vive a uno o más saltos.

Como los reenviadores pueden encadenarse —el ensamblado A reenvía a B, y B reenvía el mismo tipo más adelante a C—, Glass sigue la Implementation repetidamente, no solo una vez. Camina de reenviador en reenviador hasta aterrizar en un TypeDef real, manteniendo un conjunto de visitados para que un ciclo patológico de reenviadores no lo meta en un bucle infinito; un ciclo se informa como un reenvío roto en lugar de colgar la interfaz.

Descompilar por nombre desde la fachada

Desde la línea de comandos puedes apuntar glass directamente a la fachada antigua y pedir el tipo. Glass hace el seguimiento del reenvío por ti:

glass decompile mscorlib.dll --type System.ValueTuple
// forwarded from mscorlib.dll → System.Runtime
// definition in System.Runtime, Version=8.0.0.0
namespace System
{
    public struct ValueTuple : IEquatable<ValueTuple>,
        IStructuralEquatable, IStructuralComparable, IComparable,
        IComparable<ValueTuple>, ITuple
    {
        public static ValueTuple Create() => default;
        public override int GetHashCode() => 0;
        // ... the real members, decompiled from System.Runtime
    }
}

El cartel // forwarded from te dice que Glass cruzó una frontera de ensamblado para llegar aquí; el cuerpo es la definición genuina de System.Runtime, miembros incluidos. Si quieres quedarte en la fachada e inspeccionar el reenvío en lugar de perseguirlo —por ejemplo para auditar exactamente qué reexporta un envoltorio netstandard—, --no-follow-forwarders detiene a Glass en la fila ExportedType e imprime el reenvío:

glass decompile mscorlib.dll --type System.ValueTuple --no-follow-forwarders
.class extern forwarder System.ValueTuple -> System.Runtime

Descompilar el propio ensamblado de reenvío

Cuando abres el ensamblado fachada como un todo —no un tipo, sino el ensamblado—, la salida correcta no es un montón de tipos vacíos. Es el conjunto de atributos de reenvío que produjeron esas filas ExportedType. Glass los reconstruye en la vista de atributos del ensamblado:

// Assembly attributes for mscorlib.dll (facade)
[assembly: TypeForwardedTo(typeof(System.ValueTuple))]
[assembly: TypeForwardedTo(typeof(System.Collections.Generic.List<>))]
[assembly: TypeForwardedTo(typeof(System.Threading.Tasks.Task))]
// ... one per forwarder

Ese es el C# que, recompilado, regeneraría la misma tabla ExportedType. Una fachada se descompila de vuelta a una lista de declaraciones TypeForwardedTo, que es exactamente lo que una fachada es.

Cuando falta el destino

La resolución puede fallar. Puede que tengas la fachada en disco pero no System.Runtime, o un ensamblado puede reenviar hacia algo que simplemente no tienes. Glass no falla ni descarta el tipo en silencio. Informa del reenvío en sí, marcado como no resuelto, para que aun así sepas adónde fue el tipo:

glass decompile oldlib.dll --type Contoso.Legacy.Widget
// unresolved forward
// Contoso.Legacy.Widget is forwarded to:
//   Contoso.Runtime, Version=3.0.0.0, PublicKeyToken=a1b2c3d4e5f60708
// target assembly not found on any search path — showing the forward only
.class extern forwarder Contoso.Legacy.Widget -> Contoso.Runtime

El resto del ensamblado se descompila con normalidad; solo los tipos cuyos destinos están ausentes recurren a esta vista de solo reenvío. Obtienes el nombre completo del ensamblado de destino —suficiente para ir a buscarlo y apuntar a Glass hacia él con una ruta de búsqueda añadida— en lugar de una excepción o un espacio en blanco.

Por qué esto importa cuando lees código real

El reenvío de tipos es invisible hasta que deja de serlo. La mayor parte del tiempo el runtime sigue los reenvíos por ti y nunca piensas en ello. Pero en el momento en que abres un ensamblado fachada en un descompilador —rastreando un método de la BCL, depurando por qué una referencia se enlaza al ensamblado que lo hace, entendiendo un envoltorio netstandard— los reenviadores pasan de pronto a ser toda la historia, y una herramienta que los representa como tipos vacíos o referencias sin resolver te induce activamente a error. Glass trata la tabla ExportedType como lo que es: un conjunto de señales. Las sigue hasta las definiciones reales, cadenas incluidas; las vuelve a emitir fielmente cuando descompilas la fachada; y te dice con honestidad adónde fue un tipo cuando no puede seguirlo. Esa es la diferencia entre un descompilador que entiende los metadatos y uno que se limita a listarlos.

Prueba Nebula.NET

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