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

Decompilar un inicializador de array: los valores viven en un blob de datos y RuntimeHelpers.InitializeArray es la única pista

Escribe int[] x = { 1, 2, 3, 4, 5 } y podrías esperar cinco instrucciones stelem, una por elemento. Para un array pequeño eso es aproximadamente lo que obtienes, pero pasado un umbral el compilador de C# deja de almacenar los elementos uno a uno y hace algo más extraño: asigna el array y después llama a RuntimeHelpers.InitializeArray con un handle a un campo estático cuyos bytes son el array completo empaquetado de principio a fin, y ese campo no tiene ningún inicializador en el IL: sus datos viven en una sección de datos del PE, apuntada por una fila de metadatos FieldRVA, tipada como un struct sintético en una clase oculta cuya única propiedad es su longitud en bytes. Un decompilador no puede leer el literal del IL, porque el IL solo contiene un memcpy; tiene que reconocer el patrón InitializeArray, resolver el token del campo, seguir el FieldRVA hasta los bytes en bruto, tomar el tipo de elemento del newarr que lo precedió y dividir el blob little-endian de vuelta en { 1, 2, 3, 4, 5 }. Esta entrada recorre el IL y los metadatos de un array inicializado por blob, muestra lo que Glass.NET reconstruye, explica por qué un array de cadenas nunca toma este camino, y cubre el caso ReadOnlySpan de bytes en el que no hay array en absoluto: solo un handle de campo y un CreateSpan.

Escribe esto:

int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };

y la suposición natural es que el compilador emite nueve almacenamientos: cargar el array, cargar un índice, cargar un valor, stelem.i4, nueve veces. Para un array corto hace aproximadamente eso. Pero pasado un puñado de elementos, el compilador de C# cambia a algo que no se parece en nada al código fuente: asigna el array y después llama a RuntimeHelpers.InitializeArray, entregándole un puntero a un campo estático cuyos bytes son el array completo. Los valores ya no están en el código en absoluto. Se han empaquetado en una sección de datos del ensamblado, y lo único que queda en el IL es una copia en bloque.

Eso es un problema genuino para un decompilador, porque no hay ningún literal que leer. El IL contiene un memcpy, no { 2, 3, 5, ... }. Recuperar el inicializador significa reconocer el patrón, perseguir un token de campo fuera del flujo de instrucciones hasta los bytes en bruto del fichero, y reinterpretar esos bytes con un tipo que el IL solo menciona en otro sitio. Esta entrada recorre esa persecución para Glass.NET.

Lo que el compilador emite realmente

Este es el IL para el array anterior. Los valores brillan por su ausencia:

IL_0000:  ldc.i4.s   9
IL_0002:  newarr     [System.Runtime]System.Int32          // allocate int[9] — element type lives HERE
IL_0007:  dup
IL_0008:  ldtoken    field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=36' '<PrivateImplementationDetails>'::'2BCA...E6'
IL_000d:  call       void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_0012:  stloc.0

Cuatro cosas sostienen el inicializador completo, y ninguna de ellas basta por sí sola:

  • newarr [System.Runtime]System.Int32: el único lugar donde aparece el tipo de elemento. El blob que sigue no tiene tipo; esta instrucción es la que dice que los bytes son Int32.
  • ldc.i4.s 9: el número de elementos, que el decompilador puede contrastar con la longitud del blob.
  • ldtoken ... '__StaticArrayInitTypeSize=36': un handle a un campo estático sintético. El nombre codifica la longitud en bytes: 9 ints x 4 bytes = 36. El segundo nombre (2BCA...E6) es un hash del contenido, de modo que dos inicializadores idénticos en cualquier parte del ensamblado comparten un único campo.
  • call RuntimeHelpers::InitializeArray: la señal. Esta llamada exacta, que recibe un Array y un RuntimeFieldHandle, es el patrón que un decompilador vigila.

Los tres saltos hasta los bytes

El campo nombrado por ese ldtoken no tiene inicializador gestionado. Es un campo estático de un tipo valor sintético —__StaticArrayInitTypeSize=36, un struct cuyo único atributo significativo es [StructLayout(Size = 36)]— que vive en el tipo oculto <PrivateImplementationDetails> que el compilador crea exactamente para este propósito. Lo que da al campo su valor es una fila de la tabla de metadatos FieldRVA, que mapea el token del campo a una dirección virtual relativa dentro del PE. En esa dirección están los 36 bytes:

02 00 00 00  03 00 00 00  05 00 00 00  07 00 00 00  0B 00 00 00
0D 00 00 00  11 00 00 00  13 00 00 00  17 00 00 00

Cada grupo de cuatro es un Int32, little-endian: 02 00 00 00 es 2, 0B 00 00 00 es 11, 17 00 00 00 es 23. Así que para recuperar el literal un decompilador da tres saltos —token a campo, campo a fila FieldRVA, FieldRVA a bytes en bruto— y después trocea los bytes usando el tipo que leyó del newarr.

IL → FieldRVA → sección de datos → literalILnewarr Int32 ↑ tipo de elemento + tamañoldtoken field ...call InitializeArrayfila FieldRVAtoken → RVA 0x2104sección .data02 00 00 00 03 00 00 0005 00 00 00 07 00 00 000B 00 00 00 ...literal reconstruidonew int[]{ 2, 3, 5, 7, 11, 13, 17, 19, 23 }trocear el blobEl blob son bytes sin tipo. newarr dice Int32, así que el decompiladorlo lee en trozos little-endian de 4 bytes: 02 00 00 00 → 2, 0B 00 00 00 → 11,17 00 00 00 → 23. Un char[] lee 2 bytes por elemento, un long[] odouble[] lee 8: el tamaño de elemento y el orden de bytes son estructurales.

Lo que Glass reconstruye

Glass.NET reconoce la llamada a InitializeArray, resuelve el RuntimeFieldHandle al campo de <PrivateImplementationDetails>, lee sus bytes FieldRVA, toma Int32 y su anchura de 4 bytes del newarr, y recorre los 36 bytes en cuádruples little-endian. Renderiza exactamente lo que se escribió:

int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };

El tamaño del elemento no es un detalle: es lo que hace que los números salgan bien. El mismo blob de 16 bytes 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 es { 1, 2, 3, 4 } cuando el newarr dice Int32, pero { 1, 2 } cuando dice Int64, porque entonces cada elemento son ocho bytes leídos en little-endian. Glass toma la anchura de la asignación, no de una suposición, así que un long[], un double[] (donde los ocho bytes son un patrón de bits IEEE-754) y un char[] (dos bytes de UTF-16 por elemento) se trocean correctamente cada uno. Los blobs compartidos también se manejan: como el campo se indexa por un hash del contenido, dos inicializadores idénticos en métodos distintos apuntan al mismo campo, y Glass renderiza cada punto de llamada como su propio literal completo en lugar de filtrar la compartición a la salida.

Cuando la forma de blob no aparece

Dos casos se mantienen elemento a elemento, y un decompilador los lee directamente del IL sin ningún salto por FieldRVA:

string[] names = { "ada", "alan", "grace" };   // references — cannot be memcpy'd
int[] sized    = new int[Count];                // not constant — no blob to copy

Un string[] contiene referencias a objetos, no caracteres; los punteros no existen hasta el tiempo de ejecución y no son constantes, así que no hay nada que empaquetar. El compilador emite dup / ldc.i4 / ldstr / stelem.ref por elemento, que Glass lee de vuelta directamente como los valores de cadena. Lo mismo ocurre con cualquier array de tipo referencia, y con cualquier array cuyos elementos no sean constantes de tiempo de compilación: no hay ningún blob constante, así que los almacenamientos siguen siendo explícitos. Por eso la forma de blob es específicamente una optimización de constantes primitivas blittables: es el único caso en el que el valor del array es sus bytes.

El caso del span: ningún array en absoluto

C# tiene un truco más que conviene conocer, porque su IL se parece casi a la forma de array pero no asigna nada:

ReadOnlySpan<byte> magic = [ 0x4D, 0x5A, 0x90, 0x00 ];   // PE header bytes, as a span

Un ReadOnlySpan<byte> construido a partir de una secuencia constante de bytes no crea un array en el heap. El compilador apunta el span directamente a los datos del FieldRVA:

IL_0000:  ldsflda    field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4' ...
IL_0005:  ldc.i4.4
IL_0006:  call       valuetype [System.Runtime]System.ReadOnlySpan`1<uint8> System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<uint8>(valuetype [System.Runtime]System.RuntimeFieldHandle)

No hay newarr ni InitializeArray; en su lugar, RuntimeHelpers.CreateSpan<byte> envuelve un span sobre los bytes del campo sin copiar nada, y por eso este patrón solo funciona para byte (y otros tipos de elemento de un solo byte o independientes del orden de bytes, donde la disposición en memoria es portable). Glass reconoce CreateSpan de la misma forma que reconoce InitializeArray: resuelve el campo, lee los bytes del FieldRVA y renderiza la expresión de colección [ 0x4D, 0x5A, 0x90, 0x00 ]. La pista que lo distingue de la forma de array es ldsflda + CreateSpan en lugar de newarr + InitializeArray: la misma sección de datos, una puerta de entrada distinta, y ninguna asignación detrás.

Conclusión

Un inicializador de array de constantes blittables no sobrevive en el IL como una lista de valores. El compilador asigna el array, empaqueta los elementos como bytes little-endian en bruto en un campo estático de <PrivateImplementationDetails>, da a ese campo sus datos a través de una fila de metadatos FieldRVA en lugar de una instrucción, y deja atrás una única llamada a RuntimeHelpers.InitializeArray que copia los bytes con memcpy. Así que un decompilador no puede leer el literal del código: tiene que reconocer la llamada, resolver el token del campo, seguir el FieldRVA hasta la sección de datos, tomar el tipo de elemento del newarr que asignó el array, y trocear el blob en fragmentos little-endian del tamaño de un elemento. Glass.NET hace exactamente eso, y acierta la anchura para que un long[] no se lea como el doble de int; lee los casos de cadenas y no constantes directamente de sus almacenamientos explícitos; y reconoce la variante CreateSpan en la que un ReadOnlySpan<byte> apunta a los mismos datos sin ningún array. Los valores siempre estuvieron ahí: solo a tres saltos del flujo de instrucciones, en una sección a la que el IL únicamente señala.

Prueba Nebula.NET

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