Obfuscation des références en .NET : masquer le graphe d'appels
Renommez tout et un décompilateur lira encore votre intention dans les appels que vous faites : File.ReadAllText, RSA.Create, votre propre CheckLicense. L'obfuscation des références (appels par proxy) achemine ces appels via des proxys générés pour que le site d'appel ne nomme plus sa cible. Voici comment cela fonctionne en IL, ce que cela déjoue et ce que cela coûte.
Passez un décompilateur sur un assembly renommé mais par ailleurs non protégé et les corps de méthodes ressemblent à du bruit : a, b, c pour chaque variable locale, M0, M1 pour chaque méthode privée. Puis vous lisez les appels, et le brouillard se dissipe :
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);
}
Chaque identifiant que vous possédiez a été renommé, et pourtant M3 vérifie manifestement un fichier de licence signé. Rien de ce que vous avez renommé ne l’a trahi — les appels l’ont fait. File.ReadAllText, RSA.Create, VerifyData : ceux-ci nomment des membres de la bibliothèque de classes de base, et vous ne pouvez pas renommer la BCL. Le motif d’appels est l’intention, et le renommage n’y touche jamais.
C’est la faille que comble l’obfuscation des références. Le renommage masque les noms des choses que vous possédez ; l’obfuscation des références masque quel code appelle quoi — le graphe d’appels — y compris les appels au framework que le renommage ne peut atteindre.
À quoi ressemble un appel en IL
Un appel de méthode en .NET est une instruction call ou callvirt portant un unique jeton de métadonnées qui pointe vers une ligne MemberRef ou MethodDef. Ce jeton se résout en un nom pleinement qualifié. L’appel à File.ReadAllText ci-dessus est littéralement :
call string [System.Runtime.IO.FileSystem]System.IO.File::ReadAllText(string)
Le jeton est là, dans le flux d’instructions. Aucun renommage de vos propres types ne le retire, car il nomme un type dans l’assembly de quelqu’un d’autre. Un décompilateur, ou de4dot, ou une personne avec dnSpy, le lit directement.
Acheminer l’appel via un proxy
L’obfuscation des références remplace cette instruction par un appel à un proxy généré — une méthode sans marque que Nebula injecte — qui résout la vraie cible à l’exécution et l’invoque. Le site d’appel cesse de nommer File.ReadAllText :
// avant
call string System.IO.File::ReadAllText(string)
// après
call string <Module>:: (string) // un proxy ; la vraie cible est résolue à l'intérieur
À l’intérieur, le proxy conserve la cible d’origine sous forme de données — un jeton chiffré ou indirect résolu à la première utilisation puis mis en cache — et y dispatche. Conceptuellement, le proxy généré se comporte ainsi, bien que le vrai soit lui-même obfusqué et anonyme :
// Généré, un par cible distincte proxyfiée (les noms sont illustratifs).
static string Proxy_7(string path)
{
// La cible est stockée comme donnée, pas comme jeton au site d'appel :
var mi = TokenCache.Resolve(0x6F03A1); // → System.IO.File.ReadAllText(string)
return (string)mi.Invoke(null, new object[] { path });
}
Le changement clé est que l’identité de la cible s’est déplacée d’un opérande d’instruction — que voit chaque lecteur de l’IL — vers des données résolues à l’exécution. Une lecture statique du site d’appel montre désormais un saut vers <Module>::, pas un appel à File. Pour savoir ce qu’appelle réellement Proxy_7, vous devez résoudre l’indirection comme le fait le runtime, ce qu’un décompilateur statique ne fait pas à votre place.
Nebula l’expose sous proxyReferences (Pro et supérieur) :
{
"rename": true,
"controlFlowObfuscation": "aggressive",
"proxyReferences": true,
"proxyReferencesInclude": [
"MyApp.Licensing.LicenseChecker.*",
"MyApp.Activation.*"
]
}
Comme pour toute transformation, vous la ciblez. La liste d’inclusion restreint le proxyfiage aux espaces de noms où masquer le graphe d’appels en vaut la peine — licence, activation, anti-altération — plutôt que de réécrire chaque appel de l’assembly.
Ce qu’elle masque, concrètement
Imaginez le graphe d’appels d’un module de licence. Avant, c’est une carte lisible : vos méthodes à gauche, la BCL et vos aides cryptographiques à droite, chaque arête un appel étiqueté.
Après, chaque arête se termine dans la même couche de proxy sans marque. La carte qui vous disait qu’Activate lit un fichier et que Verify appelle RSA a disparu : statiquement, les deux entrent seulement dans un proxy. L’information qu’un attaquant veut le plus tôt — où est la vérification de licence, et que touche-t-elle — n’est plus lisible depuis l’IL.
Cela compte surtout pour les appels que vous ne pouvez pas masquer autrement. Vous pouvez renommer CheckLicense en M3, mais un appel direct à RSA.Create() à côté de la lecture d’une clé publique embarquée est une vérification de signature quel que soit le nom des variables locales qui l’entourent. Le proxyfiage supprime cet indice.
Ce qu’elle ne fait pas
L’obfuscation des références est une défense contre l’analyse statique, et elle est honnête sur ses limites. Elle augmente le coût pour lire le graphe d’appels ; elle n’empêche pas un attaquant d’exécuter votre code et de l’observer. Quiconque attache un profileur ou pose un point d’arrêt peut voir un proxy se résoudre en File.ReadAllText à sa première exécution. C’est pourquoi c’est une couche, pas un mur :
- L’obfuscation du flux de contrôle brouille l’ordre dans lequel surviennent les appels proxyfiés, de sorte que même les appels observés ne révèlent pas la logique qui les séquence.
- Le chiffrement des chaînes masque les chemins et clés littéraux que ces appels consomment, de sorte qu’un appel résolu à
ReadAllTextne livre pas aussi le nom du fichier. - L’anti-débogage / anti-altération augmente le coût de l’analyse dynamique qui dénouerait autrement les proxys à l’exécution.
Chaque couche ferme un raccourci. Le renommage ferme le raccourci « lire les noms » ; l’obfuscation des références ferme le raccourci « lire le graphe d’appels » ; avec le flux de contrôle et le chiffrement, elles forcent l’attaquant à quitter la voie statique en un clic pour une analyse dynamique lente et manuelle — c’est tout l’objectif de l’obfuscation. Vous ne rendez pas la rétro-ingénierie impossible ; vous la rendez assez coûteuse pour qu’elle n’en vaille pas la peine.
Vous protégez une app .NET où le graphe d’appels trahit votre logique de licence ou d’activation ? Activez proxyReferences pour ces espaces de noms — voyez résister à la désobfuscation automatique pour savoir comment cela se combine avec les autres transformations, et quelle édition vous convient pour savoir où cela se débloque.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.