Renommer jusqu'à la collision : comment Nebula réutilise un seul nom pour plusieurs membres, et pourquoi le CLR les distingue par leur signature
Un renommeur naïf donne à chaque membre un nom court unique — a, b, c, d — et s'arrête là. Il laisse de la valeur sur la table, parce que .NET n'identifie pas un membre par son nom. Une méthode est identifiée par son nom joint à sa signature, un champ par son nom et son type déclarant, et le runtime lie chaque appel via un jeton de métadonnées résolu au chargement, pas en comparant des chaînes. Cela signifie que vous pouvez renommer des dizaines de méthodes sans rapport entre elles vers le seul nom a, tant que leurs signatures diffèrent, et l'assembly se charge, se vérifie et s'exécute toujours. Cet article lit les métadonnées MethodDef et le tas de chaînes pour montrer pourquoi le renommage par collision est légal, parcourt les règles exactes qui le bornent — conflits de signatures identiques, chaînes de redéfinitions virtuelles, implémentations d'interface explicites — et explique comment Nebula compacte les noms jusqu'à un minuscule ensemble réutilisé qui réduit le tas de chaînes et transforme la sortie d'un décompilateur en un mur illisible de membres au nom identique.
La plupart des gens se représentent le renommage comme un dictionnaire : chaque identifiant reçoit son propre remplaçant, nouveau et dénué de sens. CalculateTax devient a, ValidateLicense devient b, _cachedResult devient c, et ainsi de suite dans tout l’assembly. Cela fonctionne, et c’est ce que fait un renommeur de première génération. Mais cela suppose silencieusement quelque chose qui n’est pas vrai de .NET : que le nom d’un membre est son identité. Ce n’est pas le cas. Le CLR identifie un membre par bien plus que son nom, et une fois que l’on voit exactement par quoi, une transformation plus agressive et plus déroutante devient possible — réutiliser le même nom pour de nombreux membres.
Ce qui identifie réellement une méthode
Une méthode dans un assembly .NET est une ligne de la table de métadonnées MethodDef. Le nom est une colonne — un décalage dans le tas #Strings — mais ce n’est pas ce qui rend la méthode distincte. Au sein de son type déclarant, une méthode est identifiée par son nom joint à sa signature : la convention d’appel, le type de retour et les types ordonnés des paramètres, le tout encodé en blob dans le tas #Blob et référencé par la colonne Signature de la ligne.
Ce n’est pas une astuce d’obfuscation ; c’est le mécanisme quotidien derrière la surcharge. Considérez deux surcharges ordinaires :
public void Write(int value) { /* ... */ }
public void Write(string value) { /* ... */ }
Ce sont deux lignes MethodDef qui partagent une seule chaîne de nom — les deux colonnes Name pointent vers le même "Write" dans le tas #Strings — et ne diffèrent que par leurs blobs de signature. Le runtime les a toujours distinguées. La surcharge est, en termes de métadonnées, précisément une réutilisation de nom avec signatures distinctes, et elle fonctionne depuis la v1. Un renommeur qui comprend cela peut créer des surcharges là où la source n’en avait jamais eu.
// Before — three unrelated methods, three distinct names.
public decimal CalculateTax(decimal amount) { /* ... */ }
public bool ValidateLicense(string key) { /* ... */ }
public void FlushCache() { /* ... */ }
// After — all three renamed to 'a'. Signatures differ, so the metadata is valid.
public decimal a(decimal amount) { /* ... */ }
public bool a(string key) { /* ... */ }
public void a() { /* ... */ }
Trois méthodes, un nom, et l’assembly se charge, se vérifie et s’exécute exactement comme avant — parce que chaque paire (nom, signature) résultante est toujours unique dans le type.
Pourquoi l’appel trouve toujours la bonne méthode
L’inquiétude évidente est que si les noms entrent en collision, les appels deviennent ambigus. Ce n’est pas le cas, parce que rien ne résout un appel par nom à l’exécution. Quand le compilateur C# émet un appel, il écrit une instruction call ou callvirt dont l’opérande est un jeton de métadonnées — un handle numérique pointant vers une ligne précise MethodDef ou MemberRef, avec la signature complète enregistrée à côté.
// A call site targeting CalculateTax(decimal), before and after renaming.
ldarg.0
ldloc.0
callvirt instance valuetype Decimal Acme.Billing::a(valuetype Decimal)
// ^name is "a" ^signature is still (decimal)->decimal
Le jeton et la signature embarquée sont ce que le chargeur lie, une seule fois, à exactement une ligne. Le renommage a réécrit la colonne Name de cette ligne et réécrit le nom épelé au site d’appel — mais il n’a pas changé quelle ligne le jeton sélectionne, et il n’a pas changé la signature. Ainsi un appel qui visait CalculateTax(decimal) avant vise cette méthode identique après, même si une douzaine d’autres méthodes répondent désormais aussi au nom a. Le nom est une commodité pour les humains qui lisent les métadonnées ; le runtime navigue par jeton et signature et ne remarque jamais la collision.
Entre types différents la contrainte est encore plus lâche. Le type déclarant fait partie de l’identité d’une méthode, donc chaque type peut librement avoir sa propre méthode nommée a avec n’importe quelle signature — y compris une signature identique au a d’un autre type. Les champs se comportent de la même façon : un champ est identifié par son nom et son type déclarant, donc chaque type a son propre champ a, et un type peut même contenir à la fois un champ nommé a et une méthode nommée a, parce que champs et méthodes vivent dans des tables différentes.
Les règles qui le bornent
Le renommage par collision est agressif, pas illimité. Un renommeur correct doit respecter les mêmes règles que les métadonnées et le système de types imposent, sous peine de produire un assembly qui échoue au chargement.
- Même signature, même type. Deux méthodes d’un même type ne peuvent pas partager à la fois le nom et la signature — ce serait une ligne dupliquée que le runtime rejette avec une
TypeLoadException. Les collisions ne peuvent donc être assignées qu’entre méthodes dont les signatures diffèrent déjà. Le renommeur calcule, par type, quels membres peuvent sûrement partager un nom. - Chaînes de redéfinitions virtuelles. Une redéfinition doit porter les mêmes nom et signature que la méthode de base qu’elle redéfinit, tout au long de la hiérarchie, sinon l’emplacement de la vtable ne correspond plus et la redéfinition cesse silencieusement d’être dispatchée. Les méthodes virtuelles et chacune de leurs redéfinitions sont donc renommées ensemble, de concert, vers un seul nom partagé.
- Implémentations d’interface explicites. Un membre comme
void IDisposable.Dispose()enregistre par jeton la méthode d’interface qu’il implémente ; son nom dans les métadonnées est décoré (IDisposable.Dispose) et la correspondance doit rester cohérente. Ceux-ci ne sont renommés qu’en coordination avec l’interface, ou préservés. - Tout ce qui est résolu par chaîne. Les membres qu’un sérialiseur fait correspondre par nom, qu’un framework découvre par convention, ou que votre propre code recherche via la réflexion ou
nameofdoivent être exclus — les mêmes règles de préservation qui s’appliquent à tout renommage s’appliquent ici, inchangées.
Ce que voit l’attaquant
Le renommage unique laisse encore une carte utilisable. Si a signifie toujours une seule chose, un analyste peut suivre a à travers l’assembly et construire lentement sa compréhension — les noms sont opaques, mais ils sont cohérents. Le renommage par collision retire cela. Réutilisez un nom pour de nombreuses méthodes et champs sans rapport, et la sortie décompilée devient un mur de a.a(a) où chaque a désigne quelque chose de différent selon le type environnant et la signature des arguments. Il ne reste aucun repère ; le seul outil gratuit dont dispose un analyste — « ce nom, c’est cette chose » — cesse de fonctionner.
// What the decompiler faithfully emits after collision renaming.
// Every 'a' here is a different member, told apart only by signature.
a a = this.a.a(a.a);
if (a.a((string)a))
a.a();
Ce n’est pas un exemple artificiel ; c’est la sortie honnête d’un décompilateur reproduisant une réutilisation de nom que seules les signatures rendaient légale. Dans les cas les plus aigus, le C# émis n’est même pas directement recompilable sans insérer des casts explicites pour orienter la résolution de surcharge — le décompilateur a récupéré de l’IL valide en une source que les règles du langage C# qualifieraient d’ambiguë. L’attaquant doit reconstruire, par signature, les distinctions que les noms portaient gratuitement.
Il y a aussi un dividende mécanique : le tas #Strings rétrécit. Un renommeur naïf a encore besoin de milliers de chaînes courtes distinctes ; le renommage par collision référence un seul "a" depuis des centaines de lignes, donc le tas ne contient qu’une poignée d’entrées. Des métadonnées plus petites sont un effet secondaire mineur et bienvenu de l’objectif principal, qui est de faire en sorte que l’orientation — la première chose que fait tout rétro-ingénieur — coûte du temps réel. Nebula ne réduit pas les noms aveuglément ; il les réduit exactement aussi loin que les règles de signature, les chaînes de redéfinitions et votre liste de préservation le permettent, et pas plus loin.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.