Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierung.NETTiefenanalyse

Einen Plugin-Host obfuskieren, ohne den Plugin-Vertrag zu brechen

Ein Plugin-Host hat eine gespaltene Persönlichkeit, die die Obfuskierung genau respektieren muss. Die Vertrags-Assembly — die Schnittstellen, abstrakten Basisklassen und Attribute, gegen die Drittanbieter-Plugins kompilieren — ist eine veröffentlichte ABI: benenne eines ihrer Mitglieder um, und jedes bereits ausgelieferte Plugin gegen die alten Namen scheitert beim Binden, genauso wie das Umbenennen einer öffentlichen API ihre Aufrufer bricht. Aber der Host, der diese Plugins lädt, ist dein wertvollster Code und das, was du am meisten umbenennen willst. Beide leben in einer Lösung, und ein gewöhnlicher Obfuskierungslauf kann sie nicht auseinanderhalten. Dieser Beitrag liest die genauen Bindungspunkte — die Schnittstellen- und Methodentypnamen, die ein AssemblyLoadContext beim Laden auflöst, die [Export]/[Import]-Vertragsnamen, die MEF abgleicht, und die Attributtypnamen, nach denen ein Host scannt —, zeigt, warum jeder auf der ABI liegt und überleben muss, und wie Nebula die Vertragsoberfläche über ihre Form fixiert, während es den Host dahinter plattmacht.

Ein Plugin-Host sind zwei Programme, die eine Lösung tragen. Eines davon ist der Vertrag: die Schnittstellen, abstrakten Basisklassen und Attribute, gegen die Drittanbieter ihre Plugins kompilieren. Das andere ist der Host: der Lader, die Kompositionslogik, der Geschäftscode, der entscheidet, was mit den gefundenen Plugins zu tun ist. Diese beiden haben gegensätzliche Obfuskierungsregeln, und der Grund ist derselbe, aus dem eine öffentliche API und eine private Methode gegensätzliche Regeln haben — die eine wird von Code benannt, den du nicht kontrollierst, die andere nur von Code, den du kontrollierst.

Der Vertrag ist eine veröffentlichte ABI. Ein letztes Jahr ausgeliefertes Plugin hält eine Referenz auf IPlugin.Execute per Name, in seine Metadaten eingebacken. Wenn dein Host dieses Plugin lädt und fragt „implementiert dieser Typ IPlugin?”, wird die Antwort gegen die Identität der Vertrags-Assembly und den vollständigen Namen der Schnittstelle aufgelöst. Benenne IPlugin in a um, und die „implementiert”-Prüfung des Plugins zeigt auf einen Typ, der unter diesem Namen nicht mehr existiert — das Plugin bindet an nichts und dein Host überspringt es stillschweigend. Das ist kein Fehler, den du in deinen eigenen Tests sehen wirst, denn deine eigenen Plugins werden neu kompiliert; es ist ein Fehler, der nur für die Plugins auftaucht, die du nicht gebaut hast.

Der Host ist das Gegenteil. Kein Plugin referenziert PluginCatalog oder CompositionCoordinator oder deine lizenzierte Geschäftslogik. Diese Namen existieren nur innerhalb deines Prozesses, und sie sind die Namen, die am meisten lohnend auf a, b, c plattzumachen sind. Die ganze Aufgabe, einen Plugin-Host zu obfuskieren, besteht darin, die Linie zwischen diesen beiden zu ziehen — und der Obfuskierer kann sie nicht allein aus den Namen für dich ziehen, denn beide Seiten sind nur öffentliche Typen in deiner Lösung.

Contract.dll — BEWAHRENpublic interface IPlugin string Name { get; } void Execute(IHostCtx c)public abstract PluginBase[AttributeUsage]public PluginAttributeNamen beim Laden aufgelöstvom Host + von MEF —Umbenennen bricht ausgelieferte PluginsHost.dll — ALLES UMBENENNENPluginCatalog → aCompositionCoord… → bLicenseGate → cLoadContext mgmt → dGeschäftslogik → …kein Plugin benennt dies;es ist intern und der Code, der ammeisten schützenswert ist —das ganze Modul plattmachenThirdParty.Plugin.dllclass Widget : PluginBase [Plugin] override Execute(...)referenziert Contract.dllNUR — über die ursprünglichenNamen. Du baust dies nichtneu; es wird separat geliefert.

Der Vertrag: drei Arten von Name auf der ABI

Lege den Vertrag in seine eigene Assembly. Das ist keine Stilpräferenz; es verwandelt die ABI in eine physische Grenze, auf die du ein Obfuskierungsprofil richten kannst. Ein minimaler Vertrag hat drei Arten von Name, und alle drei sind tragend.

// 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; }
}

Jeder davon wird irgendwo per Name abgeglichen, wohin das Plugin nicht neu kompiliert werden kann, um zu folgen:

  1. Die Schnittstelle IPlugin und ihre Mitglieder. Der Host löst einen Plugin-Typ auf, indem er fragt, ob er IPlugin implementiert. Diese Prüfung vergleicht die Schnittstellenliste des Plugin-Typs gegen die Typidentität von IPlugin in Contract.dll — vollständiger Name plus Assembly-Identität. Benenne die Schnittstelle um, und der Vergleich scheitert. Benenne Execute um, und selbst ein Typ, der die Schnittstelle noch implementiert, stellt eine Methode bereit, die der Host unter einem Namen aufruft, der nicht mehr existiert.
  2. Die abstrakte Basisklasse PluginBase. Plugins, die davon ableiten, tragen eine Basistyp-Referenz auf MyApp.Contracts.PluginBase per Name. Benenne sie um, und der Basistyp des Plugins hängt in der Luft.
  3. Das Attribut PluginAttribute. Der Host scannt Plugin-Assemblies nach Typen, die dieses Attribut tragen. Dieser Scan gleicht den Typnamen des Attributs ab. Benenne ihn um, und das [Plugin("...")] jedes Plugins zeigt auf einen Typ, den der Host nicht mehr erkennt, also findet der Scan nichts.

Keiner dieser Namen kann plattgemacht werden, denn die Entität, die den Nachschlag durchführt — eine Plugin-DLL auf der Platte eines Kunden — wurde gegen die ursprünglichen Namen kompiliert und wird nie von dir neu gebaut.

Der Host: wie die Bindung beim Laden tatsächlich geschieht

Hier ist der Lader, in der Form, die die meisten Hosts auf modernem .NET verwenden: ein sammelbarer AssemblyLoadContext pro Plugin, ein Scan des Vertragsattributs und ein is IPlugin-Tor vor der Aktivierung.

// 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;
    }
}

Sieh dir an, wovon die drei nummerierten Zeilen abhängen. GetCustomAttribute<PluginAttribute>() ist eine Referenz zur Kompilierzeit auf den Attributtyp — wenn der Obfuskierer PluginAttribute in Contract.dll umbenennt, wird diese Zeile in Host.dll auf den neuen Namen neu kompiliert und funktioniert weiterhin für den Host, aber das Plugin auf der Platte trägt noch den alten Attributnamen, also scheitert der Abgleich. typeof(IPlugin).IsAssignableFrom(type) vergleicht gegen die Identität von IPlugin; benenne IPlugin um, und das bereits ausgelieferte Plugin implementiert eine anders benannte Schnittstelle. Activator.CreateInstance braucht den Konstruktor des Plugins, der der eigene Code des Plugins ist — sicher — aber die Umwandlung in IPlugin ist, wieder, eine Identitätsprüfung gegen den Vertrag.

Die Lehre ist, dass das Umbenennen des Vertrags nicht die Sicht des Hosts darauf bricht — der Host kompiliert neu. Es bricht die Sicht des Plugins, das nicht neu kompilieren kann. Diese Asymmetrie ist die ganze Falle: deine Testsuite, aus Plugins gebaut, die du bei jedem CI-Lauf neu baust, besteht. Das Feld, voller Plugins, die du nicht neu baust, scheitert.

MEF macht die Namensabhängigkeit explizit

Wenn du mit MEF komponierst, statt den Lader von Hand zu bauen, ist die Namensabhängigkeit dieselbe, aber wörtlicher, denn MEF gleicht gegen einen Vertragsnamen-String ab.

// 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))] speichert nicht „die Schnittstelle” — es speichert einen Vertragsnamen, den MEF aus dem Typ ableitet, der für einen Typexport der vollständige Typname ist, MyApp.Contracts.IPlugin. Das [ImportMany] von IPlugin leitet denselben String ab. Die Komposition gelingt, weil die beiden Strings übereinstimmen. Benenne IPlugin in MyApp.Contracts.a um, und der Export-String wird MyApp.Contracts.a, während Plugins, die bereits im Feld sind, noch MyApp.Contracts.IPlugin exportieren — die Strings stimmen nicht mehr überein und der Import kommt leer zurück, ohne Fehler, denn „kein passender Export” ist ein normaler MEF-Zustand.

Es gibt eine Doppelt-gesichert-Lösung, die sich unabhängig von der Obfuskierung im Quellcode lohnt: gib dem Vertrag einen expliziten String-Namen, damit die Komposition vollständig aufhört, vom Typidentifikator abzuhängen.

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!;

Jetzt ist der Vertragsname das Literal "MyApp.Plugin.v1", eine String-Konstante, die die Obfuskierung in Ruhe lässt, sofern du sie nicht verschlüsselst, und du würdest sie von der String-Verschlüsselung auf der Vertrags-Assembly ausnehmen. Dennoch bewahrst du weiterhin den Typnamen IPlugin, denn das zweite Argument von [Export] und der Elementtyp des Imports sind Typidentitäten, die MEF ebenfalls prüft. Explizite Vertragsnamen machen die Bindung robust; sie machen die Typnamen nicht frei.

Die Nebula-Konfiguration

Zwei Assemblies, zwei Profile. Der Host bekommt alles; dem Vertrag wird seine öffentliche Oberfläche bewahrt.

<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>

Zwei Dinge sind hervorzuheben. Erstens bedeutet das Bewahren der öffentlichen Oberfläche des Vertrags nicht, den Vertrag ungeschützt zu lassen — die Methodenkörper auf PluginBase, jede Standard-Schnittstellenimplementierung und die internen Helfer sind weiterhin kontrollfluss-obfuskiert und string-verschlüsselt. Du behältst die Namen, an die ein Plugin bindet, und härtest alles andere. Zweitens wird der Host ohne jegliche Ausschlüsse umbenannt, denn nichts in ihm liegt auf der ABI. Dies ist die Konfiguration, die dir den Schutz gibt, den du tatsächlich wolltest — deinen Lader, deine Kompositionslogik, dein Lizenztor auf einzelne Buchstaben plattgemacht — ohne den Feldfehler, den ein pauschaler Lauf stillschweigend ausgeliefert hätte.

Wenn Vertrag und Host in einer Assembly feststecken, drückt sich dieselbe Idee als auf den Vertragsnamensraum beschränkte Umbenennungsausschlüsse statt zweier Profile aus, und es funktioniert — aber du bist jetzt einen Tippfehler in einem Namensraumfilter davon entfernt, entweder den Host freizulegen oder das Feld zu brechen. Teile die Assembly, wenn du irgend kannst; die ABI ist eine Grenze, und der billigste Weg, über eine Grenze korrekt zu obfuskieren, ist, sie zu einer echten zu machen.

Der Test, der den Feldfehler fängt

Der Grund, aus dem diese Fehlerklasse ausgeliefert wird, ist, dass der natürliche Test — lade deine eigenen Plugins, prüfe, dass sie laufen — die Plugins jedes Mal aus dem Quellcode neu baut, also folgen sie immer dem umbenannten Vertrag. Um den echten Fehler zu fangen, musst du gegen ein Plugin-Binary testen, das vor der Obfuskierung gebaut und nie neu kompiliert wurde.

[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);
}

Friere ein Plugin-Binary als Fixture ein, führe es gegen den obfuskierten Host aus, und der Test scheitert in dem Moment, in dem ein Umbenennen den Vertrag erreicht — in CI, wo du es beheben kannst, statt im Plugin-Ordner eines Kunden, wo du es nicht kannst. Dieses eine eingefrorene Binary ist der Unterschied zwischen einer ABI, die du für stabil hältst, und einer, von der du bewiesen hast, dass sie den Obfuskierungslauf überlebt.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.