Cifrado de constantes en .NET: ocultar los números que los decompiladores leen gratis
El cifrado de cadenas se lleva la atención, pero los literales numéricos de tu código — umbrales, opcodes, números mágicos, desplazamientos — compilan a instrucciones ldc que un decompilador lee al instante. Aquí tienes cómo las constantes numéricas filtran la lógica, cómo el cifrado de constantes las reemplaza por valores decodificados en tiempo de ejecución, y dónde encaja junto al cifrado de cadenas y la protección del flujo de control.
El cifrado de cadenas se lleva toda la atención en la protección de .NET, y con razón — la lista de cadenas de un decompilador es un mapa. Pero las cadenas son solo la mitad de los literales de tu código. La otra mitad son números, y los números concretos muy a menudo son la lógica misma: el umbral contra el que compara una comprobación de licencia, el opcode en un switch de protocolo, la máscara de bits que selecciona una función, el valor mágico que valida una cabecera. Todos ellos compilan a instrucciones de carga llanas y legibles al instante. El cifrado de constantes es el equivalente numérico del cifrado de cadenas, y en código sensible a la seguridad importa igual.
Cómo compila un literal numérico
Cuando escribes un literal numérico, el compilador de C# emite una de las instrucciones ldc («load constant»), con el valor ahí mismo en el IL. Considera una comprobación trivial:
if (attempts > 3 && headerMagic == 0x5A4D)
Reject();
El IL lleva ambos números en claro:
ldloc.0 // attempts
ldc.i4.3 // the literal 3
ble.s IL_0014
ldloc.1 // headerMagic
ldc.i4 0x5A4D // the literal "MZ" PE signature
bne.un.s IL_0014
call Reject
Un decompilador reconstruye esto exactamente como el código que escribiste — 3 y 0x5A4D y todo. La segunda constante es especialmente reveladora: 0x5A4D es la firma MZ de la cabecera DOS, así que un lector sabe al instante que este código está olfateando un archivo PE, sin un solo comentario ni cadena que le ayude. Los enteros pequeños tienen sus propios opcodes cortos (ldc.i4.0 a ldc.i4.8, ldc.i4.s para un byte), los mayores usan el ldc.i4/ldc.i8/ldc.r8 completo, pero en todos los casos el valor es un operando literal que cualquiera puede leer.
Qué hace el cifrado de constantes
El cifrado de constantes reescribe esas cargas literales para que el valor real nunca aparezca en el IL. Cada constante protegida se almacena en forma codificada y se reemplaza en el sitio de llamada por una pequeña rutina de decodificación que la reconstruye en tiempo de ejecución. Conceptualmente, el ldc se convierte en una llamada:
// before:
ldc.i4 0x5A4D
// after (conceptually):
ldc.i4 0x9C31 // an encoded token, not the real value
call int32 <Decode>::N(int32) // returns 0x5A4D at runtime
La rutina de decodificación la genera el ofuscador y está ella misma ofuscada — no es un único xor obvio con una clave visible junto a los datos. El efecto es que un decompilador estático ve Decode.N(0x9C31) donde el código tenía 0x5A4D. El número ha desaparecido del listado; recuperarlo significa ejecutar realmente (o emular fielmente) la lógica de decodificación, que es exactamente el trabajo que el análisis estático intenta evitar.
Los números que vale la pena ocultar
No todo entero es interesante — un bucle que cuenta hasta arr.Length no revela nada. Lo que el cifrado de constantes protege es la clase de literales que son el comportamiento:
- Umbrales y límites en comprobaciones — el tope de reintentos
> 3, un conteo de días de prueba, un límite de asientos. Ver el número le dice al atacante con precisión qué frontera presionar. - Números mágicos y firmas — bytes de cabecera como
0x5A4D, marcadores de formato, semillas de checksum. Estos etiquetan exactamente qué está analizando o validando el código. - Opcodes de protocolo y de estado — los casos enteros de un
switchque impulsa una máquina de estados o un protocolo de cable. En claro, todo el protocolo es enumerable desde la tabla de saltos. - Máscaras de bits de banderas de funciones — un valor como
0x04aplicado con AND contra un campo de derechos nombra el bit exacto que desbloquea una función, lo cual es una invitación a forzarlo. - Códigos de estado y resultado — el valor concreto que una rutina de seguridad devuelve al tener éxito, que un atacante encantaría hacer que el código produzca incondicionalmente.
En cada caso el número porta el significado, y quitarlo del listado estático eleva el coste de entender o manipular la comprobación.
Dónde encaja con las otras transformaciones
El cifrado de constantes es una capa, y es más fuerte en combinación. Por sí solo oculta valores pero deja intacta la forma circundante; junto al cifrado de cadenas cierra la otra mitad de la fuga de literales, y junto a la ofuscación del flujo de control oculta no solo cuáles son las constantes sino dónde ocurren las comparaciones. Una comprobación de licencia protegida por las tres ya no presenta al lector un pulcro if (days > 30) contra un literal visible — el valor se decodifica en tiempo de ejecución, los mensajes de cadena están cifrados, y la propia rama está enterrada en un grafo de control aplanado.
También se combina con la protección a nivel de método: una constante que solo se decodifica dentro de un método cifrado o virtualizado nunca existe en forma llana en el ensamblado distribuido, porque el cuerpo del método que lleva la llamada de decodificación está él mismo protegido hasta la ejecución.
Una nota sobre coste y alcance
No hay protección gratis. Un ldc.i4 llano es una única instrucción; una constante decodificada es una llamada de método, así que cada literal protegido cambia una carga directa por una pequeña cantidad de trabajo en tiempo de ejecución. Para el código ordinario esto es invisible. El único lugar donde pensarlo es un bucle numérico genuinamente caliente que lee la misma constante millones de veces — ahí, el cifrado de constantes en masa puede aparecer en un perfilado. La respuesta es el alcance, no la evitación: aplica el cifrado de constantes al código donde los números son secretos que vale la pena guardar — licenciamiento, manejo de protocolos, validación, comprobaciones de derechos — y deja en paz los núcleos aritméticos que no contienen literales sensibles. Nebula te permite dirigir la protección a los tipos y métodos que lo merecen, de modo que las constantes relevantes para la seguridad se ocultan mientras la matemática crítica para el rendimiento sigue siendo una carga llana.
La conclusión es simple: un decompilador lee tus números con tanta facilidad como tus cadenas, y en código sensible a la seguridad los números son con frecuencia toda la historia. Cifra las constantes que codifican tu lógica, combina la transformación con la protección de cadenas y de flujo de control, y acótala a donde se gana su coste — y los literales pulcros y autoexplicativos que solían entregar al lector tus comprobaciones desaparecen del listado.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.