Obfusquer les membres virtuels et d'interface : chaînes d'override, implémentations explicites, et la table MethodImpl qui les relie
Renommer une méthode privée est une modification locale. Renommer une méthode virtuelle ne l'est pas, parce que son nom fait partie de la façon dont le CLR câble la répartition des appels : un override réutilise son slot de base en faisant correspondre nom et signature au chargement du type, une implémentation d'interface implicite est trouvée de la même façon, et seule une implémentation explicite est liée par une ligne MethodImpl qui porte des jetons au lieu de texte. Ratez le regroupement et vous obtenez soit une TypeLoadException dès que le type se charge — soit, pire, rien du tout, pendant que l'override se détache silencieusement et que l'implémentation de base s'exécute à sa place. Cet article lit l'IL de chaque liaison, montre lesquelles sont par nom et lesquelles sont par table, parcourt les trois façons dont un renommeur naïf les casse, et explique comment Nebula regroupe une méthode virtuelle avec chaque override et chaque implémentation auxquels elle est connectée — à travers les types et les interfaces — pour que le slot entier soit renommé vers un seul nom ou conservé intact quand l'un de ses membres franchit la frontière vers un assembly que vous ne contrôlez pas.
Renommez un helper privé et vous avez modifié une chaîne dans le heap #Strings ; rien d’autre dans l’assembly ne s’en soucie. Renommez une méthode virtual et vous avez mis la main dans le mécanisme que le runtime utilise pour décider sur quelle méthode un appel atterrit réellement. Ce mécanisme est en partie par nom et en partie par table, et la différence entre les deux décide si un renommage est gratuit, fatal, ou — le pire résultat — silencieusement faux.
Cet article lit ce que le CLR fait réellement d’une méthode virtuelle, d’un override, d’une implémentation d’interface implicite et d’une explicite ; montre lesquelles de ces liaisons un renommeur peut toucher ; et explique comment Nebula les regroupe pour que le slot entier bouge ensemble ou pas du tout.
Trois façons pour une méthode de se retrouver dans un slot
Commencez par une petite hiérarchie qui exerce les trois liaisons :
public interface IJob
{
void Run();
string Describe();
}
public abstract class JobBase : IJob
{
public virtual void Run() { } // implicit interface implementation, and a base slot
string IJob.Describe() => GetType().Name; // explicit interface implementation
}
public sealed class ExportJob : JobBase
{
public override void Run() { /* ... */ } // implicit override of JobBase.Run
}
Compilez-la et regardez les en-têtes de méthode qu’imprime ILDASM, parce que les drapeaux et les directives sont toute l’histoire.
// JobBase
.method public hidebysig newslot virtual instance void Run() cil managed
.method private hidebysig newslot virtual final instance string
'IJob.Describe'() cil managed
{
.override Acme.Jobs.IJob::Describe
...
}
// ExportJob
.method public hidebysig virtual instance void Run() cil managed // note: no newslot
Trois choses sont visibles ici.
JobBase.Run est newslot virtual : elle introduit un slot de vtable. Elle implémente aussi IJob.Run, mais rien dans ses métadonnées ne le dit — pas de directive, pas d’attribut, aucune référence à IJob sur la méthode. Le CLR le détermine au chargement du type en parcourant les méthodes de l’interface et en trouvant, sur la classe, une méthode virtuelle publique avec les mêmes nom et signature. C’est une implémentation d’interface implicite, et elle est liée par nom.
ExportJob.Run est virtual sans newslot. Ce drapeau — ou plutôt son absence, « reuse slot » — dit au chargeur « trouve une méthode virtuelle sur un type de base avec mes nom et signature, et reprends son slot ». C’est un override implicite, et il est lui aussi lié par nom. Si le chargeur ne trouve aucune correspondance, il n’échoue pas ; il alloue discrètement un nouveau slot, exactement comme si la méthode avait dit newslot.
IJob.Describe sur JobBase est d’une autre nature. Elle est privée, newslot virtual final, nommée avec la chaîne pointée 'IJob.Describe' — et elle porte une directive .override. Cette directive est une ligne de la table de métadonnées MethodImpl à deux colonnes : MethodBody (le jeton de cette méthode) et MethodDeclaration (le jeton de IJob::Describe). Le chargeur lit la ligne et câble le slot à partir des jetons. Le nom de la méthode n’est jamais consulté ; elle pourrait s’appeler x et l’override se résoudrait à l’identique. C’est une implémentation explicite, liée par table.
Ce que cela signifie pour un renommeur
Mettez les trois liaisons côte à côte et la règle en découle.
Les implémentations explicites sont libres. La ligne MethodImpl porte des jetons. Renommez 'IJob.Describe' en a et la ligne dit toujours « le corps 0x06000012 implémente la déclaration 0x06000003 » ; le chargeur ne regarde jamais la chaîne. Un décompilateur affichera string IJob.a() ou, si la méthode d’interface a aussi été renommée, string b.a() — mais rien n’a changé dans la répartition. (Il en va de même pour un override C# que le compilateur a choisi d’émettre avec un .override explicite, ce qu’il fait pour certains cas de retour covariant et de variance générique.)
Les liaisons implicites sont porteuses, dans les deux sens. ExportJob.Run trouve JobBase.Run par nom. JobBase.Run est trouvée par IJob.Run par nom. Renommez l’une quelconque des trois et la chaîne se rompt à ce maillon. Elles ne peuvent donc pas être renommées individuellement — seulement ensemble, vers le même nouveau nom. Et « ensemble » est transitif : si JobBase.Run implémentait aussi, par hasard, IRunnable.Run d’une seconde interface, la méthode de cette interface rejoint l’ensemble, tout comme chaque autre classe qui implémente IRunnable.Run implicitement, et chaque override de celles-ci. L’unité de renommage n’est pas une méthode ; c’est la composante connexe des méthodes reliées par overrides et implémentations.
Si un membre quelconque de la composante est externe, toute la composante est gelée. Supposons que JobBase redéfinisse aussi object.ToString(). Cet override est lié par nom à une méthode de System.Private.CoreLib, que vous ne renommez pas. Renommez l’override et il se détache ; il doit donc garder son nom — et, par la transitivité ci-dessus, tout ce qui lui est relié aussi. Il en va de même pour IDisposable.Dispose, Stream.Read, une interface d’un package NuGet qui n’est pas une entrée de la passe, ou une interface publique d’une bibliothèque dont vous préservez l’API. La frontière de l’assembly (ou de la passe multi-assembly) est la frontière de ce qui peut bouger.
Les trois modes de défaillance, concrètement
La raison pour laquelle cela demande du soin est que les trois façons de se tromper échouent très différemment, et que la plus silencieuse est la plus dangereuse.
Interface renommée, pas l’implémentation → TypeLoadException. Renommez IJob.Run en a mais laissez JobBase.Run tranquille. Quand quelque chose touche JobBase pour la première fois, le chargeur construit sa table d’interfaces, cherche une méthode correspondant à a() avec la bonne signature, n’en trouve aucune, et refuse de charger le type :
System.TypeLoadException: Method 'a' in type 'Acme.Jobs.JobBase' from assembly
'Acme.Jobs, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' does not have an implementation.
Bruyant, immédiat, et facile à attribuer — c’est la bonne défaillance. Notez qu’elle est levée par le chargeur, pas par un appel, elle se déclenche donc même si Run n’est jamais invoquée, et elle fait tomber le type entier, pas une seule méthode.
Override renommé, pas la base → détachement silencieux. Renommez ExportJob.Run en a mais laissez JobBase.Run. ExportJob.a est toujours virtual sans newslot, le chargeur cherche donc une méthode virtuelle de base nommée a avec cette signature. Il n’y en a pas. Le chargeur ne lève pas d’exception ; la spécification dit qu’une méthode virtuelle reuse-slot sans base correspondante obtient simplement un nouveau slot. ExportJob se charge sans problème. Et chaque appel à job.Run() via une référence IJob ou JobBase est désormais réparti vers JobBase.Run — le corps de base vide — parce que c’est ce qui occupe le slot hérité. L’override a été transformé en une méthode sans rapport que rien n’appelle. Pas d’exception, pas d’avertissement, pas de ligne de log ; l’export cesse simplement, silencieusement, de se produire. C’est le mode de défaillance qui part en production.
Implémentation d’une interface externe renommée → TypeLoadException, parfois loin du renommage. Renommez Dispose sur une classe qui implémente IDisposable implicitement, et vous obtenez la défaillance du chargeur ci-dessus — mais la méthode « manquante » est System.IDisposable.Dispose, définie dans un assembly que vous n’avez jamais touché, et le type qui échoue peut être un minuscule helper internal très loin du code auquel vous pensiez. La variante qui mord en pratique est l’interface issue d’un package : votre type implémente IHostedService, IValueConverter ou IEqualityComparer<T>, l’assembly du package n’est pas une entrée de la passe d’obfuscation, et l’implémentation implicite est renommée parce que rien dans le module ne l’a épinglée.
Comment Nebula regroupe les slots
Le renommeur de Nebula ne renomme pas des méthodes ; il renomme des groupes. Avant qu’un seul nom soit attribué, une analyse de groupes de méthodes construit un union-find sur chaque méthode du module en utilisant exactement les trois relations ci-dessus :
- Overrides explicites — chaque enregistrement
.overriderelie le corps à la déclaration qu’il implémente, si cette déclaration vit dans ce module. - Overrides de base implicites — chaque méthode virtuelle reuse-slot est reliée à la méthode virtuelle du type de base ayant les mêmes nom et signature, en remontant la chaîne de base.
- Implémentations d’interface implicites — pour chaque interface que le type implémente (en suivant l’héritage d’interfaces et la chaîne de base), chaque méthode d’interface est reliée à la méthode virtuelle correspondante de la classe.
Le résultat est un représentant par composante connexe. Puis la règle de frontière est appliquée : un groupe est marqué préservé quand l’un quelconque de ses membres redéfinit ou implémente quelque chose hors du module — la chaîne de base qui atteint System.Object, un .override dont la déclaration se résout vers un autre assembly, une interface que le résolveur peut voir mais ne possède pas. La préservation se propage aussi dans l’autre sens : si un seul membre d’un groupe doit garder son nom pour quelque raison que ce soit — il est public sous preservePublicApi, il est listé dans exclude, l’auto-détection a trouvé de la réflexion sur lui — tout le groupe garde son nom, parce qu’un slot à moitié renommé est la défaillance par détachement silencieux ci-dessus.
Chaque groupe qui survit est renommé vers un seul nom. IJob.Run, JobBase.Run et ExportJob.Run deviennent tous, disons, a ; la correspondance par nom du chargeur trouve a sur chaque type exactement comme elle trouvait Run, et la méthode explicite 'IJob.Describe' est renommée dans le même groupe que IJob.Describe puisque sa ligne .override les relie.
Il y a un cas de plus qu’il vaut la peine de connaître parce qu’il produit une ligne de log. Si un type implémente une interface qui ne peut pas être résolue au moment de l’obfuscation — l’assembly référencé n’est pas à côté de l’entrée, ou c’est un package que Nebula ne peut pas localiser — Nebula ne peut pas voir les membres de cette interface, et ne peut donc pas dire lesquelles des méthodes virtuelles du type l’implémentent implicitement. La réponse sûre est de préserver chaque méthode virtuelle de ce type, et de le dire :
warn: preserved 4 virtual member(s) of 'Acme.Jobs.ExportJob' (implements an unresolved interface; names may be part of that contract).
Si vous voyez cet avertissement, le correctif consiste généralement à rendre l’assembly référencé résolvable (gardez le dossier de sortie du build intact, ou pointez Nebula dessus), après quoi l’interface se résout comme externe, seules les méthodes qui l’implémentent réellement sont épinglées, et le reste du type se renomme normalement.
Méthodes d’interface par défaut, génériques et variance
Quelques fonctionnalités plus récentes du langage reposent sur la même machinerie et héritent de ses règles.
Une méthode d’interface par défaut (C# 8) est simplement une méthode virtuelle avec un corps déclarée sur l’interface. Une classe qui ne la redéfinit pas reçoit l’implémentation de l’interface via la table d’interfaces ; une classe qui la redéfinit le fait avec la même liaison implicite par nom ou un .override explicite. Dans les deux cas, la méthode appartient au groupe de la méthode d’interface, et le renommage suit le groupe.
Une interface générique comme IRepository<T> n’ajoute rien de nouveau : implémenter IRepository<Customer> relie le Load de la classe à IRepository<T>.Load par nom et signature avec T substitué, et la méthode du générique ouvert est le membre du groupe. Obfusquer les génériques explique pourquoi les paramètres de type eux-mêmes sont libres.
Les retours covariants (C# 9) et certains cas de variance font émettre au compilateur un .override même pour ce que vous avez écrit comme un override ordinaire, plus un attribut [PreserveBaseOverrides] — ce qui n’est à nouveau que la liaison explicite, par jeton. Nebula lit les lignes MethodImpl, ces cas sont donc traités à l’identique.
L’exception de la réflexion
Tout ce qui précède concerne ce que le chargeur lit. Il existe un second lecteur des noms de membres d’interface, et c’est votre propre code. Une bibliothèque de proxys dynamiques, un décorateur DI, un framework d’interception — tout ce qui construit des types à l’exécution ou résout des membres avec GetMethod("Run") ou GetInterfaceMap puis compare les noms — dépend de la survie des noms :
// A decorator that forwards by name: breaks if IJob.Run is renamed.
MethodInfo target = typeof(IJob).GetMethod("Run")!;
L’auto-détection de Nebula attrape les motifs courants (littéraux de chaîne qui correspondent à des noms de membres sur des types réfléchis, conventions de sérialisation et de DI), et parce que la préservation se propage à travers le groupe, épingler IJob.Run épingle automatiquement chacune de ses implémentations. Pour les cas qu’elle ne peut pas voir — un nom assemblé à l’exécution, une convention dans une bibliothèque tierce — excluez explicitement l’interface et le groupe suit.
La configuration
Pour une bibliothèque dont les interfaces sont le contrat public, preservePublicApi conserve à lui seul les interfaces publiques et les implémentations publiques, et le regroupement s’assure qu’aucun override interne d’une méthode virtuelle publique n’est renommé sous ses pieds. Pour une application, vous renommez tout et n’excluez que les types de contrat auxquels la réflexion ou des plugins externes se lient par nom :
{
"inputs": ["out/Acme.Jobs.dll", "out/Acme.Host.dll"],
"outputDirectory": "out/obf",
"preservePublicApi": false,
"crossAssemblyRename": true,
"exclude": [
"Acme.Jobs.IPlugin"
],
"controlFlowObfuscation": true,
"encryptStrings": true
}
Deux remarques à ce sujet. crossAssemblyRename avec les deux assemblies en entrée est ce qui permet à un slot virtuel qui s’étend sur Acme.Jobs et Acme.Host — une classe de base dans l’un, des overrides dans l’autre — de se renommer comme un seul groupe au lieu d’être gelé à la frontière de l’assembly ; avec un seul des deux en entrée, le slot est externe à la passe et reste tel quel. Et exclure IPlugin par nom de type préserve ses membres, ce qui, par propagation, préserve chaque classe qui les implémente, sans avoir à lister les implémenteurs.
Décompilez le résultat et la hiérarchie se lit comme vous le souhaitez : interface a { void b(); string c(); }, abstract class d : a { public virtual void b() {} string a.c() ... }, sealed class e : d { public override void b() ... } — chaque slot intact, chaque nom disparu, et ToString() et Dispose() toujours exactement là où le runtime les attend.
La règle, en une ligne
Le nom d’une méthode virtuelle est partagé avec chaque override et chaque implémentation auxquels elle est connectée, elle est donc renommée en groupe ou pas du tout — et le groupe est gelé dès que l’un de ses membres se lie à quelque chose hors des assemblies que vous renommez. Les implémentations explicites sont liées par la table MethodImpl et ne coûtent rien à renommer ; les implicites sont liées par nom et coûtent tout si vous les renommez seules. Le travail de l’obfuscateur est de savoir laquelle est laquelle, et de ne jamais laisser un slot à moitié renommé.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.