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

Decompilar una constante decimal: por qué 3.14m no es una constante IL, y el atributo [DecimalConstant] que la reconstruye

Cualquier otro literal numérico en C# —42, 3.14, 1_000_000L— acaba en el IL como una instrucción ldc o en la tabla Constant de los metadatos, un valor que el runtime carga directamente. decimal no puede: es un struct de 128 bits, no un primitivo para el que el CLR tenga un opcode de carga, así que un const decimal o un parámetro decimal por defecto no tiene ningún sitio nativo donde vivir, y el compilador lo resuelve con un truco que desconcierta a cualquiera que lea IL en bruto: codifica el número en un [DecimalConstantAttribute] cuyos cinco argumentos de constructor (escala, signo y tres enteros de 32 bits) deletrean el valor exacto. Esta entrada sigue a un decompilador a través de esa reconstrucción: por qué la tabla Constant rechaza decimal, cómo los argumentos del atributo se corresponden con los cuatro enteros de los que se construye un decimal, por qué un campo const decimal no tiene inicializador en ningún constructor, en qué se diferencia un parámetro decimal por defecto de uno int por defecto, y por qué confundir el atributo con una anotación ordinaria convierte 3.14m de vuelta en new decimal(314, 0, 0, false, 2) en lugar de en un literal.

Toma la línea de C# más corriente que se pueda imaginar:

public const decimal TaxRate = 0.075m;

Compílala, abre el ensamblado y busca dónde se almacena 0.075. No está en el IL: no hay ninguna instrucción ldc para él. No está en la tabla Constant de los metadatos, donde viven los const int y los valores por defecto de los parámetros. El número, aparentemente, se ha esfumado. No es así: ha sido desmontado en cinco enteros y escondido en un atributo, y un decompilador que no sepa mirar ahí mostrará tu tipo impositivo como un montón de argumentos de constructor. Es el único literal numérico de C# que un decompilador tiene que reconstruir en lugar de leer, y merece la pena ver el mecanismo completo.

Por qué decimal no tiene dónde vivir

Cualquier otro literal numérico tiene un hogar nativo. Un const int o un parámetro int por defecto se escribe en la tabla de metadatos Constant, cuyo formato de blob define una codificación para cada tipo de elemento primitivo: ELEMENT_TYPE_I4, ELEMENT_TYPE_R8, ELEMENT_TYPE_STRING, etcétera. Un literal en el cuerpo de un método se convierte en una instrucción: ldc.i4 42, ldc.r8 3.14. Ambos canales comparten una suposición: que el valor es de un tipo que el CLR representa de forma nativa.

decimal no lo es. System.Decimal es un struct de 128 bits definido en la biblioteca de clases, construido a partir de un entero de 96 bits más una escala y un signo. El CLR no tiene ningún opcode de carga para él y el formato del blob Constant no tiene ningún tipo de elemento para él. Así que cuando el lenguaje dice «este decimal es una constante», no existe ningún hueco en los metadatos que pueda contener la respuesta. El compilador tiene que codificar el valor de otra manera, y la manera que eligió es System.Runtime.CompilerServices.DecimalConstantAttribute.

El atributo es el número

Aquí está el campo de arriba tal como lo emite el compilador, en IL:

.field public static initonly valuetype [System.Runtime]System.Decimal TaxRate
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.DecimalConstantAttribute::.ctor(
      uint8, uint8, uint32, uint32, uint32) = ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )

Esos cinco argumentos de constructor no son metadatos sobre el número: son el número, exactamente en las piezas a partir de las cuales se ensambla System.Decimal. La firma del constructor es (byte scale, byte sign, uint hi, uint mid, uint low):

  • scale = 3 — el número de dígitos tras la coma decimal (dividir el entero entre 10³).
  • sign = 0 — positivo (1 significaría negativo).
  • hi / mid / low = 0 / 0 / 75 — las tres palabras de 32 bits del entero sin escalar de 96 bits; aquí solo 75 en la palabra baja.

Todo junto: el entero sin escalar 75, escalado por 10⁻³, positivo → 0.075. El decompilador ejecuta la misma aritmética que haría el constructor decimal(int lo, int mid, int hi, bool isNegative, byte scale) del runtime, y recupera el literal 0.075m. No se redondea nada y no se pierde nada: el atributo es una descripción completa y exacta del valor.

DecimalConstantAttribute → literalcampo constconst decimal TaxRate[DecimalConstant(...)]scale = 3sign = 0hi = 0mid = 0low = 75reensamblar System.Decimalhi·mid·low → entero 75sign 0 → positivoscale 3 → ÷ 10³75 × 10⁻³reconstruido0.075mcontraste: una constante intconst int N = 75 vive directamente en la tabla Constant — ldc.i4 / una fila Constant.Solo decimal necesita el atributo, porque no tiene hueco en ninguno de los dos canales.

Un const decimal no tiene inicializador — a propósito

Hay una segunda sorpresa esperando a un decompilador que solo lea cuerpos de métodos. Podrías esperar que const decimal TaxRate = 0.075m se compile a un store en el constructor estático —el .cctor asigna el campo—, pero no es así. const en C# significa constante en tiempo de compilación: el valor tiene que poder usarse en otros contextos constantes, así que el compilador no puede implementarlo ejecutando código en la inicialización del tipo (eso lo convertiría en readonly, no en const). En su lugar:

  • el campo se emite como un campo static initonly que lleva el atributo [DecimalConstant], y
  • cada uso de TaxRate en el código fuente se reemplaza, en tiempo de compilación, por el valor reconstruido insertado en línea en el punto de llamada.

Así que el .cctor no contiene ningún store a TaxRate, y no hay inicializador de instancia. Un decompilador que recorra los constructores buscando el valor del campo no encuentra nada y reportaría erróneamente el campo como no inicializado. El valor está en el atributo, y recuperar la declaración original significa leerlo de ahí, y después presentar el campo como const, no como static readonly, porque el flag initonly más el atributo [DecimalConstant] son, juntos, la firma de un const decimal.

Un parámetro decimal por defecto va por otro camino que un int por defecto

El mismo hueco cambia la forma de los parámetros opcionales. Compara:

void A(int    rate = 5)      { }
void B(decimal rate = 0.075m) { }

Para A, el valor por defecto 5 va a la tabla Constant y el parámetro recibe el flag HasDefault; un decompilador lee el blob Constant y escribe = 5. Para B, la tabla Constant no puede contener un decimal, así que el compilador marca el parámetro como [opt] (el flag Optional) sin una entrada HasDefault, y adjunta [DecimalConstant(3, 0, 0, 0, 75)] al parámetro:

.method ... void B(valuetype System.Decimal rate)
{
  .param [1]
    .custom instance void ...DecimalConstantAttribute::.ctor(uint8,uint8,uint32,uint32,uint32)
      = ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )
}

Un decompilador que recupere valores por defecto necesita, por tanto, dos rutas de código: leer la tabla Constant para los primitivos, y comprobar si hay [DecimalConstant] en el parámetro para decimal. Si solo respeta la tabla Constant, el valor por defecto del decimal desaparece: el parámetro se muestra como si no tuviera ninguno. Peor aún, un decompilador que trate [DecimalConstant] como un atributo personalizado ordinario lo imprimirá literalmente en el código fuente:

// WRONG — the attribute is an encoding, not something the author wrote:
void B([DecimalConstant(3, 0, 0, 0, 75)] decimal rate) { }

Esa línea nunca existió en el código original. El atributo es la codificación privada que el compilador usa para = 0.075m, y un decompilador fiel lo consume y muestra lo que el programador realmente escribió.

Por qué Glass lo trata como un valor, no como una anotación

La regla que un decompilador tiene que codificar es: [DecimalConstant] nunca se muestra; siempre se consume. Allí donde aparezca —en un campo const, en un campo static readonly inicializado desde una constante, o en un parámetro opcional—, Glass.NET lee los cinco argumentos, reensambla el System.Decimal y emite el literal que el autor escribió: const decimal TaxRate = 0.075m;, decimal rate = 0.075m. El atributo en sí se suprime, exactamente igual que se suprime el atributo [Extension] de un método de extensión o el modreq [IsReadOnly] de un readonly struct: son la contabilidad interna del compilador, no el código fuente.

Es una característica pequeña con un modo de fallo desproporcionado. Hazlo mal y cada constante monetaria, tipo impositivo y umbral de una aplicación financiera decompilada vuelve como new decimal(75, 0, 0, false, 3) o, peor, como un atributo que el programador nunca escribió: técnicamente correcto, humanamente ilegible. Hazlo bien y 0.075m vuelve a leerse como 0.075m, lo que, para el único tipo numérico que no tiene hogar nativo en los metadatos, es todo el trabajo.

Mira cómo Glass maneja los otros valores que el compilador esconde en los metadatos en vez de en el IL en decompilar argumentos opcionales y con nombre y decompilar un inicializador de array.

Prueba Nebula.NET

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