Skip to content
← Todas las publicaciones
· Delta1 Labs DecompilationGlass

Decompilar la disposición explícita de structs y las uniones al estilo C: reconstruir [StructLayout] y [FieldOffset] desde los metadatos

La disposición explícita de structs es una de las pocas características de C# que un decompilador no puede recuperar a partir de las formas del código, porque no deja código: vive por completo en los metadatos. El LayoutKind (Sequential/Explicit/Auto) es un flag en la definición del tipo, Pack y Size están en la tabla ClassLayout, y el desplazamiento en bytes de cada campo está en la tabla FieldLayout. Cuando dos campos comparten un desplazamiento tienes una unión al estilo C: bytes aliasados, un int que ve los mismos cuatro bytes que un float. Equivócate en un solo desplazamiento y el struct decompilado corrompe en silencio cada llamada de interop y de marshalling que lo toca. Este artículo muestra dónde viven los datos de disposición, cómo Glass.NET lee las tablas ClassLayout y FieldLayout y vuelve a emitir [StructLayout(LayoutKind.Explicit, Pack = ..., Size = ...)] en el tipo y [FieldOffset(n)] en cada campo, cómo reconoce una unión a partir de desplazamientos solapados, y por qué preservar los desplazamientos exactos no es negociable.

La mayoría de las características del lenguaje C# que un decompilador recupera son azúcar del compilador: el compilador bajó un foreach a un try/finally alrededor de un enumerador, o un lock a Monitor.Enter/Exit, y el decompilador reconoce la forma del código emitido y lo vuelve a plegar. La disposición explícita de structs no es así. No hay código que reconocer, porque la disposición no es ejecutable. Un struct [StructLayout(LayoutKind.Explicit)] con [FieldOffset] en cada campo compila a metadatos —filas en dos tablas laterales y un par de bits de flag— y nada en ningún cuerpo de método lo menciona. Un decompilador que solo reconstruye IL en sentencias emitirá un struct perfectamente plausible que está dispuesto completamente mal.

Esto importa aquí más que en casi cualquier otro sitio, porque la disposición explícita existe para honrar un contrato de memoria del que depende algo fuera de tu ensamblado: un struct nativo marshalleado a través de P/Invoke, un bloque de registros de dispositivo, una cabecera de paquete, una superposición de unión etiquetada. Los desplazamientos son la interfaz. Este artículo recorre exactamente dónde vive la disposición en los metadatos, cómo Glass la lee de vuelta, y por qué un solo desplazamiento perdido es uno de los errores más peligrosos que puede cometer un decompilador.

Un struct unión en la fuente

Aquí está el caso canónico: un valor que puede verse como un int, un float o cuatro bytes individuales, todos compartiendo el mismo almacenamiento:

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
    [FieldOffset(0)] public int AsInt;
    [FieldOffset(0)] public float AsFloat;
    [FieldOffset(0)] public byte Byte0;
    [FieldOffset(1)] public byte Byte1;
    [FieldOffset(2)] public byte Byte2;
    [FieldOffset(3)] public byte Byte3;
}

AsInt y AsFloat empiezan ambos en el byte 0. Escribir AsInt y luego leer AsFloat reinterpreta los mismos cuatro bytes —un cast de bits de int a float sin conversión—, exactamente el truco al que recurren los programadores de C con una union. Los cuatro campos byte superponen la misma palabra para que puedas desmenuzarla byte a byte. No hay palabra clave union en C#; los atributos [FieldOffset] solapados son la unión.

La disposición es todo el objetivo. Si los campos estuvieran dispuestos uno tras otro, Register32 tendría siete bytes de ancho y la reinterpretación se rompería. El solape es deliberado, y no queda registrado en ningún método, solo en los metadatos.

Dónde vive realmente la disposición

Tres piezas independientes de metadatos lo portan, y un decompilador debe leer las tres.

El tipo de disposición es un flag en el tipo. En la tabla TypeDef, la columna Flags (TypeAttributes) contiene una LayoutMask de dos bits:

  • 0x00000000 — AutoLayout (el runtime puede reordenar campos)
  • 0x00000008 — SequentialLayout (el cargador empaqueta en orden de declaración)
  • 0x00000010 — ExplicitLayout (cada desplazamiento se especifica a mano)

No hay opcode de IL que establezca esto. Es una directiva del cargador, leída cuando se carga el tipo, invisible para cualquier cosa que solo mire los cuerpos de los métodos.

Pack y Size viven en la tabla ClassLayout. Cada fila ata un tipo a un PackingSize (1, 2, 4, 8, 16, o 0 para “por defecto”) y un ClassSize (el Size total explícito, o 0 para dejar que decidan los campos). Un tipo solo obtiene una fila ClassLayout cuando necesita un pack no predeterminado o un tamaño explícito.

El desplazamiento de cada campo vive en la tabla FieldLayout. Para un tipo de disposición explícita, cada campo tiene una fila FieldLayout que empareja el campo con su Offset en bytes. Estos son los datos del solape: dos filas con el mismo desplazamiento significan dos campos aliasados.

Renderizado tal como lo muestra ILDasm, Register32 tiene este aspecto —fíjate en explicit en la cabecera de la clase, las directivas .size/.pack y el desplazamiento entre corchetes en cada campo—:

.class public explicit ansi sealed beforefieldinit Register32
    extends [System.Runtime]System.ValueType
{
    .pack 0
    .size 4
    .field [0] public int32 AsInt
    .field [0] public float32 AsFloat
    .field [0] public uint8 Byte0
    .field [1] public uint8 Byte1
    .field [2] public uint8 Byte2
    .field [3] public uint8 Byte3
}

Los [0], [0], [0], [1], [2], [3] delante de cada campo son los desplazamientos de FieldLayout. Tres campos en [0] es la unión. .size 4 es el ClassLayout.ClassSize; .pack 0 es el empaquetado por defecto. Nada de esto aparece en ningún .method: es metadato puramente declarativo.

Lo que produce un decompilador ingenuo

Un decompilador construido en torno a los cuerpos de métodos no tiene nada en lo que anclarse aquí, porque no hay cuerpos de métodos implicados en la disposición. Los modos de fallo son silenciosos y peligrosos:

// MAL — tipo de disposición eliminado, desplazamientos perdidos
public struct Register32
{
    public int AsInt;
    public float AsFloat;
    public byte Byte0;
    public byte Byte1;
    public byte Byte2;
    public byte Byte3;
}

Esto compila. Incluso tiene los nombres y tipos de campo correctos. Pero con el atributo eliminado vuelve al valor por defecto del compilador —Sequential— y los campos ahora quedan dispuestos uno tras otro: AsInt en 0, AsFloat en 4, los bytes en 8, 9, 10, 11. El struct tiene ahora 12 bytes de ancho en vez de 4, AsInt y AsFloat ya no se aliasan, y el cast de reinterpretación está muerto. Cada P/Invoke que pasaba este struct ahora marshalla un blob distinto y más grande. Nada te avisa: los tipos cuadran, el código compila, y lee y escribe los bytes equivocados para siempre.

Un fallo más sutil mantiene [StructLayout(LayoutKind.Explicit)] pero recalcula los desplazamientos a partir de los tamaños de campo en vez de leer FieldLayout. Eso da desplazamientos no solapados (0, 4, 8, 9, 10, 11), que destruyen la unión igual de a fondo pero con aspecto más convincente. La única fuente correcta de un desplazamiento es el desplazamiento de los metadatos. Debe copiarse, nunca derivarse.

La figura de abajo contrasta las dos disposiciones —la unión real, donde tres campos aliasan los mismos cuatro bytes, frente a la disposición secuencial que un atributo eliminado produce en silencio—:

Unión explícita — AsInt y AsFloat aliasan los bytes 0 a 3 byte 0 byte 1 byte 2 byte 3 AsInt @ desplazamiento 0 (4 bytes) AsFloat @ desplazamiento 0 (los mismos 4 bytes) Byte0..Byte3 en desplazamientos 0,1,2,3 Size = 4. Una palabra, vista de tres formas. Secuencial (atributo eliminado) — campos uno tras otro AsInt 0-3 AsFloat 4-7 B0 8 B1 9 B2 10 B3 11 12 bytes de ancho. Sin aliasing. Contrato de interop roto. Mismos nombres y tipos de campo en ambos. Solo los desplazamientos de los metadatos los distinguen, así que hay que leerlos, no adivinarlos.

Cómo lo reconstruye Glass

Glass trata la disposición como un asunto de metadatos de primera clase, separado de la decompilación de métodos. Para cada tipo ejecuta una pequeña canalización explícita:

  1. Leer el LayoutMask de los flags del TypeDef. Si es ExplicitLayout o AutoLayout, se requiere un atributo [StructLayout]. Si es SequentialLayout y no hay fila ClassLayout, la disposición es la predeterminada del compilador para un struct y se omite el atributo: emitirlo sería redundante.
  2. Leer la fila ClassLayout, si existe, para PackingSize y ClassSize. Un pack no nulo se convierte en Pack = n; un size no nulo se convierte en Size = n. Los valores cero se dejan fuera, de modo que el atributo porta solo lo que los metadatos especifican realmente.
  3. Leer cada fila FieldLayout y adjuntar el Offset exacto en bytes a cada campo como [FieldOffset(n)]. Estos desplazamientos se copian literalmente de los metadatos: Glass nunca los recalcula a partir de los tamaños de campo.
  4. Detectar uniones escaneando los desplazamientos recogidos: siempre que los rangos de bytes de dos campos (offset a offset + size) se solapan, ese solape se preserva exactamente como se escribió, que es lo que hace que la salida sea una unión fiel.

El resultado es la fuente con la que empezaste, equivalente byte a byte en la disposición:

[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
    [FieldOffset(0)] public int AsInt;
    [FieldOffset(0)] public float AsFloat;
    [FieldOffset(0)] public byte Byte0;
    [FieldOffset(1)] public byte Byte1;
    [FieldOffset(2)] public byte Byte2;
    [FieldOffset(3)] public byte Byte3;
}

Como los desplazamientos vinieron directos de FieldLayout y el Size directo de ClassLayout, el struct recompilado hace marshalling a la misma huella idéntica de cuatro bytes. La reinterpretación int/float funciona, cada accesor de byte cae donde caía, y cualquier P/Invoke que pasaba Register32 sigue pasando los mismos bytes.

Superposiciones de unión etiquetada

La misma maquinaria maneja el patrón más rico de “unión etiquetada”, donde un campo discriminador se sitúa en su propio desplazamiento y varios campos de carga útil se solapan más adentro:

[StructLayout(LayoutKind.Explicit)]
public struct Variant
{
    [FieldOffset(0)] public VariantKind Kind;   // discriminador
    [FieldOffset(8)] public long IntValue;      // carga útil, aliasada...
    [FieldOffset(8)] public double RealValue;   // ...con esta
    [FieldOffset(8)] public IntPtr Handle;      // ...y esta
}

Aquí Kind en el desplazamiento 0 se lee para decidir cuál de los tres campos de carga útil solapados en el desplazamiento 8 está activo. Glass lee cuatro filas FieldLayout, ve que tres de ellas comparten el desplazamiento 8, y preserva los cuatro desplazamientos. Esta vez no hay fila Size, así que no se emite Size =; el runtime calcula el total a partir de los campos. La reconstrucción se mantiene mínima —solo los atributos que los metadatos realmente justifican— manteniendo cada desplazamiento intacto.

Ejecutarlo

Apunta Glass al ensamblado y pide el único tipo:

glass decompile MyApp.dll --type MyApp.Interop.Register32

La salida es el struct con atributos de arriba, desplazamientos y tamaño incluidos. Para barrer todo un namespace de interop y confirmar que cada tipo de disposición explícita hace el round-trip con sus desplazamientos intactos:

glass decompile MyApp.dll --namespace MyApp.Interop --layout-check

--layout-check vuelve a leer las tablas ClassLayout y FieldLayout tras la reconstrucción y verifica que los valores emitidos de [FieldOffset]/Size/Pack coinciden con los metadatos exactamente, de modo que tienes una garantía —no una esperanza— de que la disposición decompilada es la real. Glass es un decompilador libre y abierto; esta es salida estándar, sin nada restringido.

Por qué vale la pena hacer esto exactamente bien

La disposición explícita es una promesa a algo del mundo no gestionado. La razón entera de que un struct lleve [StructLayout(LayoutKind.Explicit)] es que sus bytes tienen que cuadrar con una definición que no posee: una cabecera de C, un formato de cable, un mapa de registros. Un decompilador que renderiza los campos correctamente pero pierde los desplazamientos ha producido código que parece correcto, compila correctamente y es incorrecto exactamente de la forma más difícil de detectar: la corrupción de datos ocurre en la frontera del marshalling, lejos de cualquier línea que pensarías en inspeccionar, y ni el compilador ni el runtime dirán una palabra.

Así que la regla que sigue Glass es sencilla y estricta: la disposición es metadato, el metadato es verdad fundamental, y los desplazamientos se copian, nunca se infieren. Lee el LayoutMask, lee ClassLayout, lee cada fila FieldLayout, y vuelve a emitir con precisión lo que hay, hasta los desplazamientos solapados que hacen de una unión una unión. Cuando inspeccionas o depuras código de interop que tienes derecho a leer, esa fidelidad es la diferencia entre una fuente en la que puedes confiar y una fuente que miente en silencio sobre dónde viven sus bytes.

Prueba Nebula.NET

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