Chiffrer les ressources incorporées : les données que votre obfuscateur oublie
Vous avez renommé chaque type, chiffré chaque chaîne et virtualisé les méthodes chaudes — puis quelqu'un a ouvert votre assembly dans une visionneuse de ressources et a lu votre SQL incorporé, votre matériau de clés de licence et le texte intégral de vos messages d'erreur directement dans le blob .resources. Les ressources managées ne sont pas du code, donc la protection au niveau des méthodes ne les touche jamais ; elles se trouvent dans le manifeste sous forme de valeurs préfixées en longueur que n'importe qui peut extraire. Voici comment les ressources incorporées sont réellement stockées dans un PE, pourquoi le renommage les laisse intactes, et comment Nebula les chiffre au moment de la compilation et les déchiffre de façon transparente via un résolveur pour que vos appels à ResourceManager et GetManifestResourceStream continuent de fonctionner sans changement.
Une passe de protection tend à se concentrer sur la surface excitante : les corps de méthodes. Vous renommez le graphe de types, aplatissez le flux de contrôle, chiffrez les littéraux de chaîne, virtualisez peut-être les méthodes qui imposent la licence. Puis vous livrez, et une semaine plus tard quelqu’un vous envoie par e-mail le texte exact de vos messages d’erreur internes, le modèle d’une chaîne de connexion et un fichier de données que vous jugiez propriétaire — le tout extrait de l’assembly sans décompiler une seule méthode. Ils n’ont pas cassé votre obfuscation. Ils ont ouvert les ressources, que votre obfuscation n’a jamais touchées, car les ressources ne sont pas du code.
C’est l’angle mort. Les ressources incorporées sont stockées comme données dans une partie du PE que les transformations au niveau des méthodes n’ont aucune raison de visiter. Cet article porte sur ce qui se trouve réellement dans ce blob, pourquoi toute protection centrée sur le code le laisse en clair, et comment Nebula chiffre les ressources au moment de la compilation tout en gardant vos appels à ResourceManager et GetManifestResourceStream fonctionnant exactement comme vous les avez écrits.
Où vivent réellement les ressources
Une ressource managée n’est pas un membre. Lorsque vous marquez un fichier comme ressource incorporée, le compilateur écrit ses octets dans un blob de ressources et ajoute une ligne à la table de métadonnées ManifestResource qui la nomme et donne son décalage. Un fichier .resx est compilé en un flux binaire .resources et incorporé de la même façon. Rien dans ce processus n’implique d’IL, de corps de méthodes ou de noms de membres — les deux sous-systèmes se connaissent à peine.
Parce que le blob de ressources est une région séparée, chaque transformation de code que vous exécutez — renommage, flux de contrôle, chiffrement de chaînes, virtualisation — passe à côté. Il n’y a aucun nom à malmener ni d’IL à réécrire. Le blob que vous avez livré est, octet pour octet, le blob que le compilateur a produit.
Ce que voit un lecteur
Le format .resources est délibérément simple et la BCL livre le lecteur. Vous n’avez pas besoin d’un décompilateur ; vous avez besoin de System.Resources.ResourceReader, la classe même que le framework utilise en interne :
using var reader = new ResourceReader("MyApp.Strings.resources");
foreach (DictionaryEntry entry in reader)
Console.WriteLine($"{entry.Key} = {entry.Value}");
// ConnectionTemplate = Server={0};Database=acme;User Id={1};Password={2};
// ActivationSeed = 7f3a9c... (your "secret" byte[] in plain hex)
// Error_LicenseVoid = This build's licence was revoked on {0:d}.
Pour un fichier incorporé brut (pas un .resx), c’est encore plus direct — GetManifestResourceStream restitue les octets exacts :
var asm = Assembly.LoadFrom("MyApp.dll");
using var s = asm.GetManifestResourceStream("MyApp.Data.rules.bin");
using var ms = new MemoryStream();
s!.CopyTo(ms);
File.WriteAllBytes("rules.bin", ms.ToArray()); // your proprietary file, extracted
Rien ici ne touche vos méthodes protégées. Les noms des ressources sont listés dans l’index de hachage du flux, les valeurs de chaîne sont en UTF-8 avec un préfixe de longueur sur 7 bits, et les objets arbitraires reviennent via leur sérialiseur. Une visionneuse de ressources n’est qu’une interface graphique au-dessus de cette API. « Je l’ai incorporé dans la DLL » ne vous a rien acheté contre elle.
Chiffrer le blob au moment de la compilation
Nebula traite les ressources comme une cible de protection à part entière. Activée dans la configuration, la passe de chiffrement de ressources parcourt les entrées ManifestResource que vous avez sélectionnées, remplace les octets en texte clair par du texte chiffré et injecte un résolveur qui déchiffre à la demande. La configuration nomme ce qu’il faut protéger :
<Nebula>
<ResourceEncryption Enabled="true">
<!-- Raw embedded files -->
<Include Pattern="MyApp.Data.*" />
<!-- .resx-backed resource sets -->
<Include Pattern="MyApp.Strings.resources" />
<!-- Leave framework/designer resources that must stay plain -->
<Exclude Pattern="*.g.resources" />
</ResourceEncryption>
</Nebula>
Au moment de la compilation, la passe réécrit chaque ressource correspondante. Conceptuellement, la transformation est celle-ci — lire le texte clair, le chiffrer sous une clé que le code protégé peut reproduire, et réécrire le texte chiffré dans le blob :
// Build-time transform (inside the Nebula pass, shown for intuition)
foreach (var res in module.Resources.OfType<EmbeddedResource>())
{
if (!selector.Matches(res.Name)) continue;
byte[] plain = res.GetResourceData();
byte[] cipher = ResourceCrypto.Encrypt(plain, deriveKeyFor(res.Name));
module.Replace(res, new EmbeddedResource(res.Name, cipher));
}
La clé n’est pas écrite comme un champ constant. Elle est produite par une petite routine de déchiffrement que Nebula injecte puis soumet aux mêmes protections que le reste de votre code — chiffrement de chaînes sur ses constantes, aplatissement de flux de contrôle, éventuellement virtualisation — de sorte qu’un attaquant ne puisse pas chercher au grep une clé statique ni un identifiant d’algorithme reconnaissable. C’est bien là tout l’intérêt : le texte chiffré de la ressource n’est récupérable que dans la mesure où l’est le code obfusqué qui le déballe.
Le déchiffrement reste transparent pour votre code
La passe serait inutile si elle vous forçait à réécrire chaque appel à GetString et GetManifestResourceStream. Ce n’est pas le cas. Pour les fichiers incorporés bruts, Nebula installe un hook pour que les noms protégés renvoient un flux déchiffrant, et votre site d’appel existant est inchangé :
// Your code — unchanged. The resolver decrypts behind GetManifestResourceStream.
using var s = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MyApp.Data.rules.bin");
var rules = RuleSet.Parse(s!); // receives plaintext
Pour les recherches adossées à un .resx, Nebula fournit un ResourceManager dont l’ensemble de ressources déchiffre les valeurs à mesure qu’elles sont lues, de sorte que GetString et GetObject restituent toujours du texte clair à votre code :
// Your code — unchanged. The injected ResourceManager decrypts on read.
var rm = Strings.ResourceManager; // Nebula-supplied, decrypting
string msg = rm.GetString("Error_LicenseVoid")!; // plaintext at the call site
La mécanique qui rend cela possible, ce sont les mêmes points d’extension du framework que la localisation utilise déjà — un ensemble de ressources est construit par flux de ressources, et le lecteur derrière lui, c’est à vous de le fournir. Nebula glisse un lecteur déchiffrant dans ce pipeline. Comme les assemblies satellites (localisées) ne sont que des assemblies avec leurs propres flux .resources, le traitement identique les couvre : chaque .resources localisé est chiffré et résolu de la même façon, donc vos chaînes propres à une culture sont aussi protégées, pas seulement l’ensemble neutre.
Exécutez maintenant le lecteur de ressources sur l’assembly protégé et la différence est frappante :
using var reader = new ResourceReader("MyApp.Strings.resources");
foreach (DictionaryEntry entry in reader)
Console.WriteLine($"{entry.Key} = {entry.Value}");
// __nebula_enc_0 = System.Byte[] (opaque ciphertext; no names, no plaintext)
// __nebula_enc_1 = System.Byte[]
Les noms ont disparu, les valeurs sont du texte chiffré, et la seule chose qui peut les retransformer en chaînes est le chemin de code obfusqué à l’intérieur de votre assembly — c’est justement là où vous avez déjà dépensé l’effort pour rendre la récupération coûteuse.
À retenir
La protection du code protège le code. Les ressources sont des données, et elles se trouvent dans une région distincte de l’assembly que le renommage, l’obfuscation de flux de contrôle, le chiffrement de chaînes et la virtualisation ne visitent jamais — c’est pourquoi une visionneuse de ressources peut lire votre SQL incorporé, votre matériau de clés et votre texte d’erreur dans une DLL entièrement obfusquée en un clic. Traitez les ressources comme une cible de protection à part entière : chiffrez le blob au moment de la compilation, dérivez la clé via le même code protégé qui garde vos méthodes afin qu’il n’y ait pas de clé statique à chercher au grep, et déchiffrez de façon transparente via le hook de GetManifestResourceStream et un ResourceManager déchiffrant pour que vos sites d’appel ne changent jamais. Rien de ce qui est livré à un client n’est incassable, ressources comprises — mais vous pouvez supprimer la divulgation triviale et forcer toute récupération à passer par le code obfusqué, où elle coûte à l’attaquant un travail réel. Si les chaînes dans votre code sont l’autre moitié de ce problème, associez-le à le chiffrement de chaînes en .NET ; pour le tableau plus large de ce que l’obfuscation couvre et ne couvre pas, voyez l’obfuscation au-delà du renommage.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.