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

Obfusquer les enums : pourquoi les noms survivent au renommage, et comment ToString() et switch les divulguent

Renommez un enum et ses membres et un décompilateur imprime encore LicenseTier.Pro, pas a.b, car un enum est une classe scellée dont les membres sont des champs const littéraux publics, et dès que votre code appelle ToString() ou Enum.Parse le runtime lit ces noms de champ directement dans les métadonnées à l'exécution. Les renommer en a, b, c est légal et ne vous coûte rien sauf la seule chose que vous vouliez : un aller-retour par le nom casse, car "Pro" ne correspond plus à un champ appelé a. Cet article lit l'IL en lequel un enum se compile, montre où vivent les noms (la table Field et le heap #Strings, pas les valeurs), parcourt exactement ce qui casse au renommage — ToString, Enum.Parse, le formatage de [Flags], la sérialisation JSON par chaîne, Enum.GetNames — et comment Nebula cadre le traitement des enums pour que les valeurs qui pilotent votre logique soient protégées tandis que les noms dont dépend votre protocole restent intacts.

Renommez tout dans un assembly .NET — chaque type, chaque méthode, chaque champ — et décompilez le résultat, et presque tout s’effondre dans le mur illisible que vous vouliez : a.b(c, d). Puis vous arrivez à un enum, et le décompilateur imprime calmement LicenseTier.Pro. Le nom que vous tentiez de cacher est là, dans un build Release, sans PDB en vue.

Ce n’est pas un bug de votre obfuscateur et ce n’est pas le décompilateur qui lit des symboles qui ne devraient pas être là. C’est une conséquence de ce qu’un enum est réellement dans les métadonnées — et le comprendre vous dit à la fois pourquoi les noms sont si visibles et, plus important, pourquoi les renommer est souvent la seule transformation à ne pas appliquer.

En quoi se compile un enum

Un enum est du sucre syntaxique sur une classe. Ceci :

public enum LicenseTier
{
    Free = 0,
    Trial = 1,
    Pro = 2,
}

se compile en une classe scellée dérivant de System.Enum, avec un champ public const littéral par membre plus un champ d’instance spécial qui contient la valeur sous-jacente. Décompilé littéralement, c’est :

public sealed class LicenseTier : System.Enum
{
    public int value__;          // the instance field — the actual integer
    public const LicenseTier Free  = (LicenseTier)0;
    public const LicenseTier Trial = (LicenseTier)1;
    public const LicenseTier Pro   = (LicenseTier)2;
}

Deux choses différentes sont stockées ici, et la distinction est tout l’article. La valeur de chaque membre — 0, 1, 2 — est une constante, stockée dans la table de métadonnées Constant et rattachée au champ. Le nom de chaque membre — Free, Trial, Pro — est stocké exactement comme tout autre nom de champ : une ligne dans la table Field pointant vers une entrée du heap #Strings. Dans ILDASM les lignes de champ se lisent ainsi :

.field public static literal valuetype LicenseTier Free  = int32(0x00000000)
.field public static literal valuetype LicenseTier Trial = int32(0x00000001)
.field public static literal valuetype LicenseTier Pro   = int32(0x00000002)
.field public specialname rtspecialname int32 value__

Les noms Free, Trial, Pro sont des littéraux de chaîne dans les métadonnées, aussi durables et aussi lisibles qu’un nom de type. Un décompilateur les imprime pour la même raison qu’il imprime LicenseTier lui-même, et il n’a besoin d’aucun symbole pour cela. Jusqu’ici ce n’est que « les noms d’enum sont des métadonnées », ce qui explique pourquoi un renommeur peut les atteindre. La raison pour laquelle il ne devrait en général pas est ce que le runtime fait de ces noms.

Les noms sont porteurs à l’exécution

Les valeurs d’enum se comparent comme des entiers — un switch sur un enum est un switch sur value__, et tier == LicenseTier.Pro est ldc.i4.2; ceq. Rien de cela ne touche le nom. Si votre code ne faisait que comparer et switcher, les noms seraient du poids mort que vous pourriez renommer librement.

Mais dès qu’une valeur devient texte, ou que du texte devient une valeur, le runtime va chercher les noms. Considérez la chose la plus courante que l’on fait avec les enums :

Console.WriteLine($"Current tier: {tier}");   // calls tier.ToString()

Enum.ToString() sans format n’a pas de table de correspondance compilée d’avance. Il réfléchit sur le type enum à l’exécution : il énumère les champs littéraux, lit la valeur constante de chacun dans les métadonnées, trouve le champ dont la valeur égale l’instance boxée et renvoie le nom de ce champ. L’IL au site d’appel n’est qu’un appel virtuel boxé —

ldloca.s   tier
constrained. LicenseTier
callvirt   instance string [System.Runtime]System.Object::ToString()

— et tout le travail se passe dans Enum.ToString, en parcourant les mêmes lignes Field que le décompilateur a lues. Donc si vous avez renommé Pro en a, cette ligne imprime désormais Current tier: a. Vos logs, vos diagnostics, vos tickets de support disent maintenant a. Vous n’avez pas cassé la compilation ; vous avez cassé la sortie.

Le sens inverse est pire, car il échoue au lieu de juste mal paraître :

var tier = Enum.Parse<LicenseTier>(config["tier"]);   // config says "Pro"

Enum.Parse compare le texte fourni aux noms de champ. Si le champ s’appelle désormais a, alors parser "Pro" lève ArgumentException — et parser "a" réussit. Donc un fichier de configuration, un document enregistré, une colonne de base de données ou une requête d’API portant la chaîne "Pro" — écrite par une version précédente de votre propre app, ou par un client que vous ne contrôlez pas — cesse de parser à l’instant où vous renommez le membre. Le nom n’est pas décoratif ; c’est le format de fil.

membre d'enumvaleur 2 · nom "Pro"utilise la VALEUR (int)== · switch · &/| bit à bit · arithmétiqueutilise le NOM (#Strings)ToString · Parse · GetName/GetNamessûr à renommer en a, b, cformat [Flags] · enum JSON par chaîne · configle renommage casse l'aller-retour

Là où ça mord le plus fort

Trois motifs transforment cela d’une note de bas de page en un bug livré.

Formatage de [Flags]. Un enum de flags formate une valeur combinée en la décomposant en bits activés et en joignant les noms correspondants par ", " :

[Flags] public enum Caps { None = 0, Read = 1, Write = 2, Admin = 4 }

(Caps.Read | Caps.Admin).ToString();   // "Read, Admin"

Renommez les membres et cela devient "a, c". Si quelque chose persiste ou parse cette chaîne — et les enums de flags sont souvent persistés précisément parce qu’ils se compactent proprement — le renommage change silencieusement votre format.

Sérialisation d’enum par chaîne. System.Text.Json avec JsonStringEnumConverter écrit et lit le nom du membre, pas le nombre :

var opts = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } };
JsonSerializer.Serialize(LicenseTier.Pro, opts);   // "\"Pro\""

Renommez Pro et votre API émet désormais "a", et chaque client qui envoie "Pro" obtient un échec de désérialisation. Les binders de configuration, appsettings.json, YAML et la plupart des sérialiseurs qui favorisent des enums lisibles se comportent de même. Le nom fait partie de votre contrat public, que vous l’ayez déclaré ainsi ou non.

UI et validation pilotées par Enum.GetNames/GetValues. Tout code qui construit une liste déroulante, un listage d’aide CLI ou un message de validation à partir de Enum.GetNames<LicenseTier>() affichera désormais a, b, c à un humain. Les valeurs fonctionnent encore ; les mots ont disparu.

La règle : cadrez le renommage sur la valeur, jamais sur le contrat

Ce qui vaut la peine d’être protégé dans un enum n’est presque jamais l’orthographe de ses membres — c’est la logique que pilotent ses valeurs. Un attaquant lisant LicenseTier.Pro n’apprend rien qu’il ne pourrait deviner ; ce que vous ne voulez pas qu’il suive facilement, c’est la méthode qui décide ce que Pro débloque et le flot de contrôle qui l’applique. Et ce qui casse au renommage est précisément l’aller-retour par le nom. Ces deux faits pointent vers une règle :

Ne renommez les membres d’enum que là où le nom ne quitte jamais le processus et n’est jamais reconstruit depuis du texte. Excluez du renommage tout enum dont les noms traversent une frontière : sérialisé, parsé, comparé par chaîne, ou montré à une personne.

C’est exactement le genre de cadrage autour duquel Nebula est bâti. Vous laissez lisibles les enums dépendant du nom pour que ToString(), Enum.Parse et votre contrat JSON continuent de fonctionner, tandis que les méthodes et le flot de contrôle qui consomment ces enums — la porte de tier, le contrôle de capacité, l’arithmétique des sièges — reçoivent le traitement complet de renommage, de flot de contrôle et de chiffrement de chaînes. Dans une config Nebula, cela ressemble à exclure du renommage les membres d’un enum de contrat en laissant tout le reste dans le périmètre :

<Rules>
  <!-- Keep the names of enums that cross a boundary; their text is a contract. -->
  <Rule pattern="type MyApp.Contracts.LicenseTier" rename="false" />
  <Rule pattern="type MyApp.Contracts.Caps" rename="false" />
  <!-- Everything else, including the logic that reads these enums, is renamed as usual. -->
</Rules>

Le bénéfice est que rien de ce qui intéresse un attaquant n’est devenu plus facile à lire. Le nom de l’enum n’a jamais été le secret — Pro contre a ne lui dit rien sur comment Pro est appliqué, et laisser le nom intact ne vous coûte aucune protection sur le code qui compte. Ce que cela vous achète, c’est un build qui sérialise, parse et logue encore correctement, au lieu d’un build qui compile proprement puis échoue la première fois qu’un "Pro" enregistré revient par Enum.Parse.

Le test d’une ligne

Avant de laisser un renommeur toucher un enum, posez une question à chaque membre : son nom devient-il jamais du texte, ou vient-il de texte, où que ce soit que ce processus puisse atteindre — un log, un fichier de configuration, une charge sérialisée, une chaîne d’UI, une entrée parsée, une valeur stockée que vous relisez ? Si oui, le nom fait partie de votre contrat et doit rester. Si vraiment non — l’enum est privé, comparé seulement par valeur, jamais converti en chaîne — alors renommez sans crainte ; c’est aussi sûr que tout autre champ. L’erreur n’est jamais de renommer un enum ; c’est d’en renommer un dont les noms tenaient discrètement ensemble votre format de fil.

Essayez Nebula.NET

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