Ofuscación de referencias en .NET: ocultar el grafo de llamadas
Renombra todo y un decompilador aún leerá tu intención a partir de las llamadas que haces: File.ReadAllText, RSA.Create, tu propio CheckLicense. La ofuscación de referencias (llamadas por proxy) enruta esas llamadas a través de proxies generados para que el sitio de llamada ya no nombre su destino. Aquí tienes cómo funciona en IL, qué derrota y qué cuesta.
Pasa un decompilador sobre un ensamblado renombrado pero por lo demás sin proteger y los cuerpos de los métodos parecen ruido: a, b, c para cada local, M0, M1 para cada método privado. Luego lees las llamadas y la niebla se disipa:
private static bool M3(string a)
{
byte[] b = File.ReadAllText(a).Split(',').Select(Convert.FromBase64String).First();
using var c = RSA.Create();
c.ImportSubjectPublicKeyInfo(Nebula_Helpers.K, out _);
return c.VerifyData(b, /* … */, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
Cada identificador que poseías fue renombrado, y aun así M3 está obviamente verificando un archivo de licencia firmado. Nada de lo que renombraste lo delató: lo hicieron las llamadas. File.ReadAllText, RSA.Create, VerifyData: estas nombran miembros de la biblioteca de clases base, y no puedes renombrar la BCL. El patrón de llamadas es la intención, y el renombrado nunca lo toca.
Esta es la brecha que cierra la ofuscación de referencias. El renombrado oculta los nombres de las cosas que posees; la ofuscación de referencias oculta qué código llama a qué —el grafo de llamadas— incluidas las llamadas al framework que el renombrado no puede alcanzar.
Cómo se ve una llamada en IL
Una llamada a método en .NET es una instrucción call o callvirt que lleva un único token de metadatos que apunta a una fila MemberRef o MethodDef. Ese token se resuelve a un nombre totalmente cualificado. La llamada a File.ReadAllText de arriba es literalmente:
call string [System.Runtime.IO.FileSystem]System.IO.File::ReadAllText(string)
El token está ahí mismo en el flujo de instrucciones. Ninguna cantidad de renombrado de tus propios tipos elimina ese token, porque nombra un tipo en el ensamblado de otra persona. Un decompilador, o de4dot, o una persona con dnSpy, lo lee directamente.
Enrutar la llamada a través de un proxy
La ofuscación de referencias reemplaza esa instrucción por una llamada a un proxy generado —un método sin marca que Nebula inyecta— que resuelve el destino real en tiempo de ejecución y lo invoca. El sitio de llamada deja de nombrar File.ReadAllText:
// antes
call string System.IO.File::ReadAllText(string)
// después
call string <Module>:: (string) // un proxy; el destino real se resuelve dentro
Dentro, el proxy guarda el destino original como datos —un token cifrado o indirecto que se resuelve en el primer uso y se cachea— y despacha hacia él. Conceptualmente el proxy generado se comporta así, aunque el real está a su vez ofuscado y sin nombre:
// Generado, uno por cada destino distinto proxyado (los nombres son ilustrativos).
static string Proxy_7(string path)
{
// El destino se almacena como datos, no como un token en el sitio de llamada:
var mi = TokenCache.Resolve(0x6F03A1); // → System.IO.File.ReadAllText(string)
return (string)mi.Invoke(null, new object[] { path });
}
El cambio clave es que la identidad del destino se ha movido de un operando de instrucción —que ve cada lector del IL— a datos que se resuelven en tiempo de ejecución. Una lectura estática del sitio de llamada ahora muestra un salto a <Module>::, no una llamada a File. Para saber qué llama realmente Proxy_7, tienes que resolver la indirección como lo hace el runtime, algo que un decompilador estático no hace por ti.
Nebula lo expone como proxyReferences (Pro y superior):
{
"rename": true,
"controlFlowObfuscation": "aggressive",
"proxyReferences": true,
"proxyReferencesInclude": [
"MyApp.Licensing.LicenseChecker.*",
"MyApp.Activation.*"
]
}
Como con toda transformación, la diriges. La lista de inclusión restringe el proxyado a los espacios de nombres donde ocultar el grafo de llamadas vale la pena —licenciamiento, activación, anti-manipulación— en lugar de reescribir cada llamada del ensamblado.
Qué oculta, en concreto
Imagina el grafo de llamadas de un módulo de licenciamiento. Antes, es un mapa legible: tus métodos a la izquierda, la BCL y tus ayudantes criptográficos a la derecha, cada arista una llamada etiquetada.
Después, cada arista termina en la misma capa de proxy sin marca. El mapa que te decía que Activate lee un archivo y Verify llama a RSA ha desaparecido: estáticamente, ambos solo entran en un proxy. La información que un atacante más desea pronto —dónde está la comprobación de licencia y qué toca— ya no es legible desde el IL.
Esto importa más para las llamadas que de otro modo no puedes ocultar. Puedes renombrar CheckLicense a M3, pero una llamada directa a RSA.Create() junto a la lectura de una clave pública incrustada es una verificación de firma sin importar cómo se llamen los locales que la rodean. El proxyado elimina ese indicio.
Qué no hace
La ofuscación de referencias es una defensa frente al análisis estático, y es honesta sobre sus límites. Eleva el coste de leer el grafo de llamadas; no impide que un atacante ejecute tu código y lo observe. Cualquiera que adjunte un profiler o ponga un punto de interrupción puede ver a un proxy resolverse a File.ReadAllText la primera vez que se ejecuta. Por eso es una capa, no un muro:
- La ofuscación del flujo de control mezcla el orden en que ocurren las llamadas proxyadas, de modo que incluso las llamadas observadas no revelan la lógica que las secuencia.
- El cifrado de cadenas oculta las rutas y claves literales que esas llamadas consumen, de modo que una llamada resuelta a
ReadAllTextno entregue también el nombre del archivo. - El anti-depuración / anti-manipulación eleva el coste del análisis dinámico que de otro modo desenredaría los proxies en tiempo de ejecución.
Cada capa cierra un atajo. El renombrado cierra el atajo de «leer los nombres»; la ofuscación de referencias cierra el atajo de «leer el grafo de llamadas»; junto con el flujo de control y el cifrado fuerzan al atacante a salir de la ruta estática de un clic hacia un análisis dinámico lento y manual, que es todo el objetivo de la ofuscación. No estás haciendo la ingeniería inversa imposible; la estás haciendo lo bastante cara como para que no valga la pena.
¿Proteges una app .NET donde el grafo de llamadas delata tu lógica de licenciamiento o activación? Activa proxyReferences para esos espacios de nombres —consulta resistir la desofuscación automática para ver cómo se combina con las demás transformaciones, y qué edición es la adecuada para ti para saber dónde se desbloquea.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.