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

Garder des noms obfusqués stables d'une version à l'autre avec la seed map de Nebula

Par défaut, chaque exécution d'obfuscation renomme à partir de zéro : une méthode qui s'appelait "a" en 1.4.0 devient "q" en 1.4.1, et chaque map de désobfuscation archivée pourrit à l'instant où vous publiez. Le seedMapFile de Nebula.NET fige la sortie en place : les membres inchangés conservent le nom obfusqué qu'ils avaient à la version précédente, seul le nouveau code reçoit de nouveaux noms. Voici comment fonctionne la symbol map, pourquoi des noms stables rendent la symbolication des crashs et les diffs binaires traitables, et comment câbler l'obfuscation incrémentale dans la CI.

Publiez une build .NET obfusquée et vous obtenez un mapping : MyApp.Billing.Invoice.Recalculate est devenu n.a.b, et vous rangez la map pour pouvoir décoder les rapports de crash qui arriveront. Publiez 1.4.1 une semaine plus tard avec un correctif d’une ligne, et cette même méthode s’appelle maintenant q.c.f. Rien en elle n’a changé, mais son nom obfusqué, si — parce que l’exécution d’obfuscation par défaut renomme l’assembly entier à partir de zéro, et que la numérotation du renommeur s’est décalée à l’instant où vous avez ajouté un champ trois classes plus loin.

Ce churn coûte cher en silence. Votre map 1.4.0 ne peut pas lire une stack trace 1.4.1. Un diff binaire entre les deux versions est une mer de renommages avec votre vrai correctif enfoui dedans. Et vous ne pouvez jamais regarder une trace obfusquée brute et penser « ah, c’est encore le bug des factures », parce que le nom est différent à chaque build. La seed map de Nebula.NET supprime ce churn : réinjectez la map de la version précédente, et les membres inchangés conservent les noms qu’ils avaient déjà. Cet article explique comment cela fonctionne et pourquoi cela vaut la peine de le câbler.

Ce qu’une exécution émet déjà

Vous n’avez rien à activer pour obtenir la matière première. Chaque exécution de Nebula écrit une symbol map à côté de l’assembly obfusqué — MyApp.dll produit MyApp.symbols.json — qui enregistre chaque renommage effectué :

{
  "tool": "Nebula.NET",
  "toolVersion": "1.1.6",
  "generatedUtc": "2026-10-10T09:00:00Z",
  "assembly": "MyApp",
  "entries": [
    { "kind": "Method", "original": "MyApp.Billing.Invoice.Recalculate", "obfuscated": "n.a.b", "token": "0x06000123" },
    { "kind": "Type",   "original": "MyApp.Billing.Invoice",              "obfuscated": "n.a",   "token": "0x02000011" },
    { "kind": "Field",  "original": "MyApp.Billing.Invoice._subtotal",    "obfuscated": "n.a.d", "token": "0x04000045" }
  ]
}

Chaque entrée porte quatre choses : le kind du symbole, le nom pleinement qualifié original, le nom obfuscated qu’il a reçu, et le token de métadonnées — le RID MethodDef/TypeDef/FieldDef dans le module de sortie. Ce token est ce qui fait de la map un index bidirectionnel précis plutôt qu’une liste de noms approximative, et c’est ce que nebula deobfuscate --map MyApp.symbols.json lit pour retransformer une stack trace obfusquée en vrais noms. Jusqu’ici, rien d’inhabituel — vous archivez ce fichier avec chaque version et décodez les traces par rapport à lui.

Le problème est que la map de la version suivante est un fichier différent, avec des valeurs obfuscated différentes pour les mêmes noms original. L’archive grossit d’une map incompatible par version.

Le seeding : reporter les noms

La seed map ferme la boucle. Pointez seedMapFile vers le .symbols.json de la version précédente et Nebula le consulte avant d’attribuer les noms :

{
  "preservePublicApi": true,
  "controlFlowObfuscation": true,
  "encryptStrings": true,
  "obfuscateConstants": true,
  "metadataHardening": true,
  "seedMapFile": "maps/MyApp-1.4.0.symbols.json"
}

Le renommeur s’exécute maintenant en deux phases. Pour chaque membre qui existe encore sous le même nom d’origine, il recherche le nom obfusqué que la seed lui avait attribué et le réutilise tel quel. Seuls les membres sans entrée dans la seed — du code véritablement nouveau, ou du code dont l’identité a changé — passent à la génération de noms frais, et ces noms frais sont choisis pour ne pas entrer en collision avec un nom que la seed a déjà distribué. Le résultat est un assembly où le renommage est incrémental : le diff par rapport à la version précédente est votre changement réel, pas une renumérotation globale.

map 1.4.0 (seed)Recalculate → n.a.bInvoice → n.a_subtotal → n.a.darchivée avec laversion 1.4.0seedMapFile1.4.1 (avec seed)Recalculate → n.a.bInvoice → n.a_subtotal → n.a.dApplyCredit → n.a.e (nouveau)noms inchangés réutilisés1.4.1 (sans seed)Recalculate → q.c.fInvoice → q.c_subtotal → q.c.hApplyCredit → q.c.jtous les noms ont bougé

Pourquoi des noms stables rapportent

Symbolication des crashs entre versions. Le gain pratique est qu’une seule map archivée décode plus d’une build. Quand Recalculate est n.a.b en 1.4.0, 1.4.1 et 1.4.2, une trace qui atterrit dans n.a.b se résout par rapport à n’importe laquelle de ces maps, et vous apprenez à reconnaître le nom obfusqué à vue — n.a.b, c’est « le recalcul des factures », point final. Un pipeline de rapports de crash peut regrouper par frame obfusquée et vous montrer que la même méthode est responsable sur toute une plage de versions avant que quiconque ne lance nebula deobfuscate, parce que le symbole est l’identité stable sur laquelle vous collectez depuis le début.

Des diffs qui montrent le changement, pas le churn. Les patchers delta (ClickOnce, Squirrel, votre propre mécanisme de mise à jour par diff binaire) ne produisent de petites mises à jour que lorsque la plupart des octets de l’assembly sont inchangés. Un renommage à partir de zéro déplace presque chaque nom et donc presque chaque octet, si bien qu’un correctif d’une ligne se distribue comme un téléchargement quasi complet. L’obfuscation avec seed garde les membres inchangés comparables octet pour octet, donc le patch est proportionnel au changement réel. Il en va de même quand un humain passe en revue deux builds décompilées côte à côte pour confirmer qu’un hotfix n’a fait que ce qu’il prétendait : avec des noms stables le diff est lisible ; sans eux, c’est du bruit.

Un mapping reconnaissable sur lequel vous pouvez raisonner. Au fil d’une série de versions, la map devient un registre principalement en ajout. De nouvelles entrées apparaissent quand vous ajoutez du code ; les entrées existantes restent en place. Vous pouvez differ deux maps et lire, en termes clairs, exactement quels membres sont nouveaux dans cette version — un signal étonnamment utile en lui-même.

Le câbler dans la CI

La mécanique : builder, obfusquer avec pour seed la map de la dernière version, et archiver la map de cette build pour que la build suivante puisse s’en servir de seed. La map vit hors de l’arbre source parce qu’elle nomme vos symboles ; un dépôt d’artefacts ou une branche protégée en est le foyer habituel.

# Pseudo-code d'une étape de pipeline de release.
steps:
  - run: dotnet build -c Release
  # Récupère la map de la version précédente ; à la toute première version c'est un no-op
  # et Nebula renomme à partir de zéro (il n'y a encore rien pour servir de seed).
  - run: fetch-artifact MyApp-latest.symbols.json -> maps/MyApp-prev.symbols.json
  - run: nebula protect --config nebula.json   # la config définit seedMapFile: maps/MyApp-prev.symbols.json
  # Archive la map de CETTE build comme nouvelle « latest » pour que la version suivante s'en serve de seed,
  # et garde une copie estampillée de version pour décoder les rapports de crash de cette build.
  - run: publish-artifact MyApp.symbols.json as MyApp-latest.symbols.json
  - run: publish-artifact MyApp.symbols.json as MyApp-${VERSION}.symbols.json

Deux notes pratiques. D’abord, la toute première version n’a pas de seed — ce n’est pas grave ; Nebula renomme à partir de zéro et émet la première map, qui devient la seed de la version deux. Ensuite, gardez une copie estampillée de version de chaque map (MyApp-1.4.1.symbols.json) en plus de la latest glissante, parce que c’est elle qui décode les rapports de crash de cette build précise. La latest glissante sert à seeder la build suivante ; les copies estampillées servent à la symbolication pour toujours.

Ce que le seeding ne fait pas

Le seeding est un outil opérationnel, pas un bouton de sécurité, et il vaut la peine d’être précis là-dessus. Il fixe quel nom obfusqué un membre reçoit ; il n’affaiblit pas l’obfuscation d’une build individuelle. Un attaquant qui détient votre assembly 1.4.0 possède déjà un binaire entièrement protégé — chaînes chiffrées, flux de contrôle aplati, métadonnées durcies. Réutiliser les mêmes noms en 1.4.1 ne dit à cet attaquant rien que le binaire 1.4.0 ne lui disait déjà, parce que les noms sont opaques dans les deux. Le renommage reste déterministe et sans collision, chaque autre passe s’exécute exactement comme configurée, et il n’y a aucune fuite de nom d’origine dans la sortie — la map qui contient les originaux est la vôtre, archivée de votre côté, et ne part jamais dans l’assembly. Ce que vous gagnez est purement de votre côté de la clôture : des maps qui continuent de fonctionner, des diffs qui restent petits, et des rapports de crash que vous pouvez lire d’un coup d’œil sur toute une série de versions.

Essayez Nebula.NET

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