Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETGuide

Obfusquer une solution .NET multi-projets : une stratégie de protection cohérente

Une solution avec une douzaine de bibliothèques de classes et une application, ce n'est pas une douzaine de problèmes d'obfuscation : c'en est un seul. Protégez chaque projet isolément et vous dédoublez la protection des copies transitives, cassez InternalsVisibleTo entre vos propres assemblys et finissez avec un tas de maps de renommage qui ne concordent pas. Voici le mode d'emploi : décidez ce que vous livrez vraiment et ne protégez que cela, lancez une seule passe sur la sortie de publication, pilotez chaque assembly depuis un unique nebula.config.json partagé, renommez toute la solution pour que l'accès ami et les références croisées restent synchronisés, gardez une map amorcée par version, et câblez le tout dans la CI pour que chaque build livrée soit protégée à l'identique.

Une solution avec dix bibliothèques de classes et une application a l’air de dix problèmes d’obfuscation. C’en est un. Les projets se référencent les uns les autres, s’exposent des types mutuellement, partagent des internes via [InternalsVisibleTo], et se livrent ensemble comme une unité unique que le client installe ou restaure en un seul paquet. Protégez-les un par un et vous obtenez le pire de chaque monde : des copies transitives obfusquées deux fois, des références entre assemblys qui ne se résolvent plus, l’accès ami cassé entre vos propres DLL, et un tiroir de maps de renommage qui ne concordent pas quand un rapport de plantage traverse trois d’entre elles. Cet article est le mode d’emploi pour le traiter comme l’unique problème qu’il est : décider quoi protéger, où lancer la passe, comment piloter chaque assembly depuis une seule politique, et comment prouver le résultat sur tout l’ensemble dans Nebula.NET.

Étape 1 — Protégez ce que vous livrez, et cela seul

Avant toute configuration, fixez le périmètre. Votre solution compile bien plus qu’elle ne livre : projets de tests, bancs de performance, utilitaires de générateurs de source, applications d’exemple. Rien de cela n’arrive chez un client, donc rien de cela n’a besoin de protection — obfusquer un assembly de tests que vous ne distribuez jamais est un coût pur. Partez de la liste des assemblys qui sortent vraiment du bâtiment, qui est exactement ce que contient votre dotnet publish (ou votre .nupkg).

Le piège plus subtil, ce sont les copies transitives. Quand votre application se publie, chaque dépendance qu’elle tire atterrit dans le dossier de sortie — y compris les paquets tiers et, si vous n’y prenez pas garde, des copies de bibliothèques d’autres solutions. Vous protégez les assemblys que vous avez écrits et livrez ; vous ne réobfusquez pas une dépendance que quelqu’un d’autre a déjà livrée (vous n’en avez pas la map, pas le droit de la modifier, et rien à y gagner). La liste d’entrées nomme donc vos propres assemblys par leur nom, pas « tout ce qu’il y a dans le dossier ». Énumérez ceux qui portent votre espace de noms et laissez le reste tranquille.

Étape 2 — Lancez la passe une fois, sur la sortie de publication

L’erreur multi-projets la plus courante est de lancer l’obfuscation par projet, dans le bin/Debug de chacun, à chaque build de développement. C’est faux sur deux axes. Elle protège le mauvais artefact — des binaires Debug que personne ne livre — et les protège isolément, avant que la solution ait été assemblée en une sortie cohérente unique. Les développeurs perdent en plus ce dont ils ont le plus besoin : un build lisible et débogable.

Lancez la passe une fois, tard, sur la sortie de publication assemblée, sur le serveur de build seulement. Publiez toute la solution, puis donnez à Nebula les assemblys publiés comme une seule exécution. C’est la même discipline que n’obfusquer que sur votre serveur de build : les machines de développement compilent propre et débogable ; l’agent qui produit le paquet livrable est le seul endroit où la protection se produit, si bien que le binaire que vous livrez est le seul binaire jamais obfusqué.

projets de la solutionAcme.AppAcme.CoreAcme.RulesAcme.Plugins.AbstractionsAcme.Tests (non livré)une exécution Nebula4 assemblys livréscrossAssemblyRenameune configuration partagéeassemblys protégés(publish/protected)un jeu de mapspar version

Étape 3 — Une politique partagée, pas une configuration par projet

Les transformations que vous lancez, ce que vous gardez public à la frontière, où vit la map d’amorçage : ce sont des propriétés du produit, pas d’un projet. Elles appartiennent donc à un unique nebula.config.json au niveau de la solution, lu par l’unique exécution sur la sortie de publication. Un fragment de configuration par projet, c’est la façon dont une solution dérive vers une protection incohérente : une bibliothèque finit avec le chiffrement de chaînes, la suivante sans, et personne ne le remarque avant qu’un décompilateur ne le fasse.

Voici la politique entière d’un produit de quatre assemblys, dans un seul fichier. Elle nomme les assemblys livrés en entrée, les renomme en tant qu’ensemble, garde l’unique espace de noms qui est un contrat public, et amorce depuis la map de la version précédente :

{
  "schemaVersion": 1,
  "inputs": [
    "publish/Acme.App.dll",
    "publish/Acme.Core.dll",
    "publish/Acme.Rules.dll",
    "publish/Acme.Plugins.Abstractions.dll"
  ],
  "outputDirectory": "publish/protected",
  "crossAssemblyRename": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "obfuscateConstants": true,
  "seedMapFile": "maps/Acme-prev.symbols.json",
  "renaming": {
    "rules": [
      { "pattern": "Acme.Plugins.Abstractions.*", "action": "keep" },
      { "pattern": "Acme.*", "action": "rename" }
    ]
  }
}

crossAssemblyRename, le chiffrement de chaînes illimité, l’obfuscation de flux de contrôle et le masquage de constantes sont des fonctions Pro ; le renommage d’identifiants est dans toutes les éditions. Les règles de renommage s’évaluent de haut en bas, la première correspondance l’emporte : le keep étroit du contrat de plugins passe au-dessus du large Acme.* qui ramasse le reste, exactement comme dans construire une keep-list Nebula. Un fichier, une politique, chaque assembly traité de la même façon.

Étape 4 — Renommez la solution, pas par projet

crossAssemblyRename: true est la ligne qui fait fonctionner une solution. Vos assemblys se référencent sans cesse : Acme.App appelle un type de Acme.Core, qui en appelle un de Acme.Rules. Quand Nebula renomme Acme.Core.PricingEngine en a, il doit réécrire les sites d’appel dans Acme.App et Acme.Rules en a aussi, sinon ces références pendent dans le vide. Une seule exécution avec le renommage entre assemblys activé fait exactement cela : elle traite l’ensemble d’entrées comme un seul espace de noms à renommer et réécrit chaque référence sur l’ensemble.

Le tranchant, c’est [InternalsVisibleTo] entre vos propres assemblys — les types internal qu’un de vos projets expose à un autre (le plus souvent un projet de tests, mais aussi un frère découpé par couches). Ces noms internes sont un contrat à la compilation ; renommez le producteur seul et le consommateur référence encore les anciens noms et échoue à se lier. Parce que chaque assembly qui référence les internes renommés est une entrée de la même exécution, le renommage entre assemblys garde les deux côtés au pas : le type interne reçoit le même nouveau nom à sa définition et à chaque référence ami. La mécanique complète, y compris la variante de réflexion à l’exécution dont aucun compilateur ne vous avertit, est dans obfusquer InternalsVisibleTo et les assemblys amis. La règle qui en découle est la règle de tout cet article : si deux de vos assemblys partagent des noms, ils partagent une exécution.

Étape 5 — Une map amorcée par version

Une obfuscation par projet vous laisse une map par projet, générée contre les décisions de renommage isolées de ce projet, et rien ne garantit qu’elles s’accordent. Une trace de pile du terrain reste rarement dans un seul assembly — elle part de Acme.App, traverse Acme.Core, entre dans Acme.Rules — et la décoder exige que la map de chaque cadre ait été produite par la même exécution. Exécuter toute la solution une fois donne exactement cela : un jeu cohérent de maps .symbols.json pour la version, toutes générées contre les mêmes décisions.

Puis portez les noms en avant. Pointez seedMapFile vers les maps de la version précédente pour que les membres inchangés gardent le nom obfusqué qu’ils avaient déjà ; seul le code neuf reçoit des noms neufs. Sur une série de versions, cela signifie qu’une map archivée continue de décoder la même méthode, et qu’un diff binaire montre votre vrai changement au lieu d’une renumérotation globale. Le traitement complet — pourquoi des noms stables rendent la symbolisation de plantages et le patch delta praticables, et pourquoi l’amorçage est un outil opérationnel et non un levier de sécurité — est dans garder stables les noms obfusqués avec une map d’amorçage. Archivez une copie estampillée de la version des maps pour chaque release ; c’est ce qui décode pour toujours les rapports de plantage de ce build.

Étape 6 — Gardez la frontière de la solution, renommez librement à l’intérieur

Un produit multi-projets a exactement une surface qui doit survivre au renommage : ce à quoi le monde extérieur se lie par nom. Pour un SDK livré, c’est l’API publique ; pour une application à modèle de plugins, c’est le contrat de plugins — l’assembly d’abstractions contre lequel un tiers compile son plugin. Tout ce qui reste à l’intérieur de la frontière de la solution, quel que soit le nombre d’assemblys qu’il couvre, vous appartient pour le renommer librement, parce que rien à l’extérieur ne le référence.

C’est pourquoi la configuration ci-dessus garde Acme.Plugins.Abstractions.* en entier et renomme tout le reste, plutôt que d’activer preservePublicApi sur tout l’ensemble. preservePublicApi garde chaque type public de chaque entrée — mais à l’intérieur de votre propre solution, « public » n’est que le modificateur d’accès que vos assemblys emploient pour se voir ; ce n’est pas un contrat externe. Tout garder sacrifierait l’essentiel de votre protection sans raison. Délimitez le keep au seul assembly qui est vraiment une frontière, laissez le renommage entre assemblys renommer la surface publique-mais-interne-au-produit, et vous protégez le maximum tout en gardant intact le contrat dont dépendent les auteurs de vos plugins.

Étape 7 — Câblez-le dans la CI

Assemblez le tout en une étape de pipeline qui s’exécute sur l’agent et nulle part ailleurs : publiez la solution, récupérez les maps de la version précédente pour amorcer, lancez l’unique passe de protection, puis archivez les maps de ce build pour que la version suivante puisse en amorcer.

# Pipeline de release — s'exécute uniquement sur l'agent de build.
steps:
  - run: dotnet publish Acme.sln -c Release -o publish
  # Amorce depuis les maps de la dernière version (sans effet à la première release).
  - run: fetch-artifact Acme-latest.symbols/ -> maps/Acme-prev.symbols.json
  - run: nebula --config nebula.config.json      # une exécution, tous les assemblys livrés
  # Archive les maps de ce build pour amorcer la version suivante et décoder les plantages.
  - run: publish-artifact publish/protected/*.symbols.json as Acme-latest.symbols/
  - run: publish-artifact publish/protected/*.symbols.json as Acme-${VERSION}.symbols/
  - run: pack-and-sign publish/protected

Conditionnez-le à un signal qui n’existe que sur l’agent — la variable d’environnement NEBULA_OBFUSCATE du montage réservé-au-serveur-de-build — pour qu’un développeur qui lance dotnet publish en local obtienne toujours un build propre et débogable. L’étape d’empaquetage et de signature pointe vers publish/protected, jamais vers la sortie de publication brute, de sorte que l’artefact qui sort du pipeline est le protégé. Chaque build livrée exécute désormais la politique identique sur l’ensemble d’entrées identique : la protection est une propriété de la release, pas de celui qui l’a compilée.

Vérifiez sur l’ensemble des assemblys dans Glass.NET

Le mode de défaillance contre lequel ce mode d’emploi prémunit est invisible au build — l’application tourne, les tests passent, et un assembly a discrètement été livré en clair ou une référence croisée pend discrètement dans le vide. Alors vérifiez la sortie, et vérifiez-la toute. Glass.NET est le compagnon gratuit et en analyse statique seule pour cela : il lit les métadonnées et l’IL et n’exécute jamais le binaire, alors ouvrez tour à tour chaque assembly protégé de publish/protected.

Confirmez trois choses sur l’ensemble. D’abord, que chaque assembly livré est vraiment protégé : les internes se lisent comme a, b, c et vos chaînes sont chiffrées dans chacun, pas seulement dans le premier. Ensuite, qu’un type renommé dans un assembly l’est au même nom là où un autre assembly le référence — le renommage entre assemblys est cohérent, sans aucune référence pendante vers un ancien nom. Enfin, que la frontière a survécu : votre assembly de contrat de plugins se lit encore avec ses vrais noms publics. Puis lancez la suite d’intégration contre la sortie protégée, car une rupture par résolution de noms entre assemblys n’est visible qu’une fois un chemin exécuté. La procédure avant/après complète est dans votre obfuscation a-t-elle vraiment marché ?.

Traitez la solution comme une unité unique et le tout devient plus simple, pas plus difficile : une configuration, une exécution, un jeu de maps par version, une frontière gardée, une barrière de CI. Téléchargez Nebula.NET gratuitement et exécutez-le sur votre sortie de publication en une seule passe.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.