Obfusquer un hôte de plugins sans casser le contrat des plugins
Un hôte de plugins a une personnalité double que l'obfuscation doit respecter à la lettre. L'assembly de contrat — les interfaces, les classes de base abstraites et les attributs contre lesquels les plugins tiers compilent — est une ABI publiée : renommez un de ses membres et tout plugin déjà livré contre les anciens noms échoue à se lier, exactement comme renommer une API publique casse ses appelants. Mais l'hôte qui charge ces plugins est votre code le plus précieux et ce que vous voulez le plus renommer. Les deux vivent dans une seule solution et une passe d'obfuscation ordinaire ne peut pas les distinguer. Ce billet lit les points de liaison exacts — les noms de type d'interface et de méthode qu'un AssemblyLoadContext résout au chargement, les noms de contrat [Export]/[Import] que MEF apparie, et les noms de type d'attribut qu'un hôte scanne —, montre pourquoi chacun est sur l'ABI et doit survivre, et comment Nebula épingle la surface du contrat par sa forme tout en aplatissant l'hôte derrière elle.
Un hôte de plugins, c’est deux programmes qui portent une seule solution. L’un d’eux est le contrat : les interfaces, les classes de base abstraites et les attributs contre lesquels les auteurs tiers compilent leurs plugins. L’autre est l’hôte : le chargeur, la logique de composition, le code métier qui décide quoi faire des plugins qu’il trouve. Ces deux-là ont des règles d’obfuscation opposées, et la raison est la même que celle pour laquelle une API publique et une méthode privée ont des règles opposées — l’une est nommée par du code que vous ne contrôlez pas, l’autre seulement par du code que vous contrôlez.
Le contrat est une ABI publiée. Un plugin livré l’an dernier détient une référence à IPlugin.Execute par nom, cuite dans ses métadonnées. Quand votre hôte charge ce plugin et demande « ce type implémente-t-il IPlugin ? », la réponse est résolue contre l’identité de l’assembly de contrat et le nom complet de l’interface. Renommez IPlugin en a et la vérification « implémente » du plugin pointe vers un type qui n’existe plus sous ce nom — le plugin ne se lie à rien et votre hôte l’ignore silencieusement. Ce n’est pas un échec que vous verrez dans vos propres tests, car vos propres plugins sont recompilés ; c’est un échec qui n’apparaît que pour les plugins que vous n’avez pas construits.
L’hôte est l’inverse. Aucun plugin ne référence PluginCatalog ni CompositionCoordinator ni votre logique métier sous licence. Ces noms n’existent qu’à l’intérieur de votre processus, et ce sont les noms qui valent le plus la peine d’être aplatis en a, b, c. Toute la tâche d’obfusquer un hôte de plugins consiste à tracer la ligne entre ces deux-là — et l’obfuscateur ne peut pas la tracer pour vous à partir des seuls noms, car les deux côtés ne sont que des types publics dans votre solution.
Le contrat : trois sortes de nom sur l’ABI
Placez le contrat dans son propre assembly. Ce n’est pas une préférence de style ; cela transforme l’ABI en une frontière physique vers laquelle vous pouvez pointer un profil d’obfuscation. Un contrat minimal a trois sortes de nom, et les trois sont porteuses.
// Contract.dll — referenced by every plugin, never renamed.
namespace MyApp.Contracts;
public interface IPlugin
{
string Name { get; }
void Execute(IHostContext context);
}
public abstract class PluginBase : IPlugin
{
public abstract string Name { get; }
public abstract void Execute(IHostContext context);
}
[AttributeUsage(AttributeTargets.Class)]
public sealed class PluginAttribute : Attribute
{
public PluginAttribute(string id) => Id = id;
public string Id { get; }
}
Chacun de ceux-ci est apparié par nom quelque part que le plugin ne peut pas recompiler pour suivre :
- L’interface
IPluginet ses membres. L’hôte résout un type de plugin en demandant s’il implémenteIPlugin. Cette vérification compare la liste d’interfaces du type du plugin contre l’identité du typeIPlugindansContract.dll— nom complet plus identité d’assembly. Renommez l’interface et la comparaison échoue. RenommezExecuteet même un type qui implémente encore l’interface expose une méthode que l’hôte appelle sous un nom qui n’existe plus. - La classe de base abstraite
PluginBase. Les plugins qui en dérivent portent une référence au type de baseMyApp.Contracts.PluginBasepar nom. Renommez-la et le type de base du plugin pend dans le vide. - L’attribut
PluginAttribute. L’hôte scanne les assemblies de plugins à la recherche de types portant cet attribut. Ce scan apparie le nom de type de l’attribut. Renommez-le et le[Plugin("...")]de chaque plugin pointe vers un type que l’hôte ne reconnaît plus, donc le scan ne trouve rien.
Aucun de ces noms ne peut être aplati, car l’entité qui effectue la recherche — une DLL de plugin sur le disque d’un client — a été compilée contre les noms originaux et vous ne la reconstruirez jamais.
L’hôte : comment la liaison se produit vraiment au chargement
Voici le chargeur, dans la forme qu’utilisent la plupart des hôtes sur le .NET moderne : un AssemblyLoadContext collectable par plugin, un scan de l’attribut de contrat, et une porte is IPlugin avant l’activation.
// Host.dll — every identifier here is free to rename.
public sealed class PluginLoader
{
public IReadOnlyList<IPlugin> Load(string pluginDll)
{
var alc = new PluginLoadContext(pluginDll); // collectible, isolates deps
var asm = alc.LoadFromAssemblyPath(pluginDll);
var found = new List<IPlugin>();
foreach (var type in asm.GetTypes())
{
// (1) the attribute scan — matches PluginAttribute BY NAME
if (type.GetCustomAttribute<PluginAttribute>() is null)
continue;
// (2) the interface gate — matches IPlugin BY IDENTITY
if (!typeof(IPlugin).IsAssignableFrom(type))
continue;
// (3) activation — the plugin's parameterless ctor, a convention
found.Add((IPlugin)Activator.CreateInstance(type)!);
}
return found;
}
}
Regardez de quoi dépendent les trois lignes numérotées. GetCustomAttribute<PluginAttribute>() est une référence à la compilation au type d’attribut — si l’obfuscateur renomme PluginAttribute dans Contract.dll, cette ligne dans Host.dll est recompilée vers le nouveau nom et fonctionne toujours pour l’hôte, mais le plugin sur le disque porte encore l’ancien nom d’attribut, donc l’appariement échoue. typeof(IPlugin).IsAssignableFrom(type) compare contre l’identité de IPlugin ; renommez IPlugin et le plugin déjà livré implémente une interface au nom différent. Activator.CreateInstance a besoin du constructeur du plugin, qui est le code propre du plugin — sûr — mais la conversion vers IPlugin est, encore une fois, une vérification d’identité contre le contrat.
La leçon est que renommer le contrat ne casse pas la vue que l’hôte en a — l’hôte recompile. Cela casse la vue du plugin, qui ne peut pas recompiler. Cette asymétrie est tout le piège : votre suite de tests, construite à partir de plugins que vous reconstruisez à chaque exécution de CI, passe. Le terrain, plein de plugins que vous ne reconstruisez pas, échoue.
MEF rend la dépendance au nom explicite
Si vous composez avec MEF au lieu de bricoler le chargeur à la main, la dépendance au nom est la même mais plus littérale, car MEF apparie contre une chaîne de nom de contrat.
// Plugin side
[Export(typeof(IPlugin))]
[ExportMetadata("Id", "widget")]
public sealed class Widget : IPlugin { /* ... */ }
// Host side
[ImportMany]
public IEnumerable<IPlugin> Plugins { get; set; } = default!;
[Export(typeof(IPlugin))] ne stocke pas « l’interface » — il stocke un nom de contrat que MEF dérive du type, qui pour un export de type est le nom de type complet, MyApp.Contracts.IPlugin. L’[ImportMany] de IPlugin dérive la même chaîne. La composition réussit parce que les deux chaînes correspondent. Renommez IPlugin en MyApp.Contracts.a et la chaîne d’export devient MyApp.Contracts.a tandis que les plugins déjà sur le terrain exportent encore MyApp.Contracts.IPlugin — les chaînes ne correspondent plus et l’import revient vide, sans erreur, car « aucun export correspondant » est un état normal de MEF.
Il y a un correctif ceinture-et-bretelles qui vaut la peine d’être fait dans le code source indépendamment de l’obfuscation : donnez au contrat un nom de chaîne explicite pour que la composition cesse complètement de dépendre de l’identifiant du type.
public static class Contracts { public const string Plugin = "MyApp.Plugin.v1"; }
[Export(Contracts.Plugin, typeof(IPlugin))] // explicit string contract
public sealed class Widget : IPlugin { /* ... */ }
[ImportMany(Contracts.Plugin)]
public IEnumerable<IPlugin> Plugins { get; set; } = default!;
Maintenant le nom de contrat est le littéral "MyApp.Plugin.v1", une constante de chaîne que l’obfuscation laisse tranquille à moins que vous ne la chiffriez, et vous l’excluriez du chiffrement de chaînes sur l’assembly de contrat. Même ainsi, vous préservez toujours le nom de type IPlugin, car le second argument de [Export] et le type d’élément de l’import sont des identités de type que MEF vérifie aussi. Les noms de contrat explicites rendent la liaison robuste ; ils ne rendent pas les noms de type libres.
La configuration de Nebula
Deux assemblies, deux profils. L’hôte reçoit tout ; au contrat on préserve sa surface publique.
<Nebula>
<!-- Host: full rename, control-flow, string encryption. Nothing here is on the ABI. -->
<Assembly file="Host.dll">
<Rename enabled="true" />
<ControlFlow enabled="true" />
<StringEncryption enabled="true" />
</Assembly>
<!-- Contract: preserve the published ABI. The host that consumes it is still
fully renamed above; only the names third-party plugins bind to are kept. -->
<Assembly file="Contract.dll">
<Rename enabled="true">
<!-- Keep all public types and members: this IS the ABI. -->
<Preserve names="public" members="all" />
</Rename>
<ControlFlow enabled="true" /> <!-- bodies are still free to harden -->
<StringEncryption enabled="true">
<Exclude member="MyApp.Contracts.Contracts::Plugin" /> <!-- MEF contract literal -->
</StringEncryption>
</Assembly>
</Nebula>
Deux points méritent d’être soulignés. D’abord, préserver la surface publique du contrat ne signifie pas laisser le contrat sans protection — les corps de méthode sur PluginBase, toute implémentation d’interface par défaut, et les helpers internes restent obfusqués en flux de contrôle et chiffrés en chaînes. Vous gardez les noms auxquels un plugin se lie et vous durcissez tout le reste. Ensuite, l’hôte est renommé sans aucune exclusion, car rien en lui n’est sur l’ABI. C’est la configuration qui vous donne la protection que vous vouliez vraiment — votre chargeur, votre logique de composition, votre porte de licence aplatis en lettres uniques — sans l’échec sur le terrain qu’une passe globale aurait livré silencieusement.
Si contrat et hôte sont coincés dans un seul assembly, la même idée s’exprime comme des exclusions de renommage délimitées à l’espace de noms du contrat plutôt que deux profils, et cela fonctionne — mais vous êtes désormais à une faute de frappe dans un filtre d’espace de noms d’exposer l’hôte ou de casser le terrain. Divisez l’assembly si vous le pouvez ; l’ABI est une frontière, et la façon la moins chère d’obfusquer à travers une frontière correctement est d’en faire une vraie.
Le test qui attrape l’échec sur le terrain
La raison pour laquelle cette classe de bug est livrée est que le test naturel — chargez vos propres plugins, vérifiez qu’ils tournent — reconstruit les plugins depuis le code source à chaque fois, donc ils suivent toujours le contrat renommé. Pour attraper le vrai échec vous devez tester contre un binaire de plugin construit avant l’obfuscation et jamais recompilé.
[Fact]
public void Plugin_built_against_unobfuscated_contract_still_loads()
{
// ThirdParty.Plugin.dll was compiled ONCE against the shipping Contract.dll
// and checked into the test fixtures. It is never rebuilt in CI.
var loader = new PluginLoader(); // from the OBFUSCATED Host.dll
var plugins = loader.Load("fixtures/ThirdParty.Plugin.dll");
Assert.Single(plugins); // fails loudly if the ABI moved
Assert.Equal("widget", plugins[0].Name);
}
Gelez un binaire de plugin comme fixture, exécutez-le contre l’hôte obfusqué, et le test échoue à l’instant où un renommage atteint le contrat — en CI, où vous pouvez le corriger, plutôt que dans le dossier de plugins d’un client, où vous ne le pouvez pas. Ce unique binaire gelé est la différence entre une ABI que vous croyez stable et une dont vous avez prouvé qu’elle survit à la passe d’obfuscation.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.