DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Obfuscating a plugin host without breaking the plugin contract

A plugin host is two programs wearing one solution. One of them is the contract: the interfaces, abstract base classes and attributes that third-party authors compile their plugins against. The other is the host: the loader, the composition logic, the business code that decides what to do with the plugins it finds. These two have opposite obfuscation rules, and the reason is the same reason a public API and a private method have opposite rules — one is named by code you do not control, the other is named only by code you do.

The contract is a published ABI. A plugin shipped last year holds a reference to IPlugin.Execute by name, baked into its metadata. When your host loads that plugin and asks "does this type implement IPlugin?", the answer is resolved against the contract assembly's identity and the interface's full name. Rename IPlugin to a and the plugin's implements check points at a type that no longer exists under that name — the plugin binds to nothing and your host silently skips it. That is not a crash you will see in your own tests, because your own plugins get recompiled; it is a failure that only appears for the plugins you did not build.

The host is the opposite. No plugin references PluginCatalog or CompositionCoordinator or your license-gated business logic. Those names exist only inside your process, and they are the names most worth flattening to a, b, c. The whole task of obfuscating a plugin host is drawing the line between these two — and the obfuscator cannot draw it for you from names alone, because both sides are just public types in your solution.

The contract: three kinds of name on the ABI

Put the contract in its own assembly. This is not a style preference; it turns the ABI into a physical boundary you can point an obfuscation profile at. A minimal contract has three kinds of name, and all three are load-bearing.

// 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; }
}
Enter fullscreen mode Exit fullscreen mode

Each of these is matched by name somewhere the plugin cannot be recompiled to follow:

  1. The interface IPlugin and its members. The host resolves a plugin type by asking whether it implements IPlugin. That check compares the plugin type's interface list against the IPlugin type identity in Contract.dll — full name plus assembly identity. Rename the interface and the comparison fails. Rename Execute and even a type that still implements the interface exposes a method the host calls under a name that no longer exists.
  2. The abstract base PluginBase. Plugins that derive from it carry a base-type reference to MyApp.Contracts.PluginBase by name. Rename it and the plugin's base type dangles.
  3. The attribute PluginAttribute. The host scans plugin assemblies for types carrying this attribute. That scan matches the attribute type name. Rename it and every plugin's [Plugin("...")] points at a type the host no longer recognizes, so the scan finds nothing.

None of these names can be flattened, because the entity doing the lookup — a plugin DLL on a customer's disk — was compiled against the original names and will never be rebuilt by you.

The host: how the binding actually happens at load time

Here is the loader, in the shape most hosts use on modern .NET: a collectible AssemblyLoadContext per plugin, a scan for the contract attribute, and an is IPlugin gate before 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Look at what the three numbered lines depend on. GetCustomAttribute<PluginAttribute>() is a compile-time reference to the attribute type — if the obfuscator renames PluginAttribute in Contract.dll, this line in Host.dll is recompiled to the new name and still works for the host, but the plugin on disk still carries the old attribute name, so the match fails. typeof(IPlugin).IsAssignableFrom(type) compares against the IPlugin identity; rename IPlugin and the shipped plugin implements a differently-named interface. Activator.CreateInstance needs the plugin's constructor, which is the plugin's own code — safe — but the cast to IPlugin is, again, an identity check against the contract.

The lesson is that renaming the contract does not break the host's view of it — the host recompiles. It breaks the plugin's view, which cannot recompile. That asymmetry is the entire trap: your test suite, built from plugins you rebuild every CI run, passes. The field, full of plugins you do not rebuild, fails.

MEF makes the name dependency explicit

If you compose with MEF instead of hand-rolling the loader, the name dependency is the same but more literal, because MEF matches on a contract name string.

// Plugin side
[Export(typeof(IPlugin))]
[ExportMetadata("Id", "widget")]
public sealed class Widget : IPlugin { /* ... */ }

// Host side
[ImportMany]
public IEnumerable<IPlugin> Plugins { get; set; } = default!;
Enter fullscreen mode Exit fullscreen mode

[Export(typeof(IPlugin))] does not store "the interface" — it stores a contract name that MEF derives from the type, which for a type export is the full type name, MyApp.Contracts.IPlugin. The [ImportMany] of IPlugin derives the same string. Composition succeeds because the two strings match. Rename IPlugin to MyApp.Contracts.a and the export string becomes MyApp.Contracts.a while plugins already in the field still export MyApp.Contracts.IPlugin — the strings no longer match and the import comes back empty, with no error, because "no matching export" is a normal MEF state.

There is a belt-and-suspenders fix worth doing in source regardless of obfuscation: give the contract an explicit string name so composition stops depending on the type identifier at all.

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!;
Enter fullscreen mode Exit fullscreen mode

Now the contract name is the literal "MyApp.Plugin.v1", a string constant that obfuscation leaves alone unless you encrypt it, and you would exclude it from string encryption on the contract assembly. Even so, you still preserve the IPlugin type name, because the second argument to [Export] and the element type of the import are type identities MEF also checks. Explicit contract names make the binding robust; they do not make the type names free.

The Nebula configuration

Two assemblies, two profiles. The host gets everything; the contract gets its public surface preserved.

<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>
Enter fullscreen mode Exit fullscreen mode

Two things are worth calling out. First, preserving the public surface of the contract does not mean leaving the contract unprotected — the method bodies on PluginBase, any default interface implementations, and the internal helpers are still control-flow-obfuscated and string-encrypted. You keep the names a plugin binds to and harden everything else. Second, the host is renamed with no exclusions at all, because nothing in it is on the ABI. This is the configuration that gives you the protection you actually wanted — your loader, your composition logic, your license gate flattened to single letters — without the field failure that a blanket pass would have shipped silently.

If contract and host are stuck in one assembly, the same idea expresses as rename exclusions scoped to the contract namespace rather than two profiles, and it works — but you are now one typo in a namespace filter away from either exposing the host or breaking the field. Split the assembly if you possibly can; the ABI is a boundary, and the cheapest way to obfuscate across a boundary correctly is to make it a real one.

The test that catches the field failure

The reason this class of bug ships is that the natural test — load your own plugins, check they run — rebuilds the plugins from source every time, so they always track the renamed contract. To catch the real failure you have to test against a plugin binary built before obfuscation and never recompiled.

[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);
}
Enter fullscreen mode Exit fullscreen mode

Freeze one plugin binary as a fixture, run it against the obfuscated host, and the test fails the instant a rename reaches the contract — in CI, where you can fix it, instead of in a customer's plugin folder, where you cannot. That single frozen binary is the difference between an ABI you believe is stable and one you have proven survives the obfuscation pass.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •
You need to verify your account.
Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to