Ofuscar un host de plugins sin romper el contrato de los plugins
Un host de plugins tiene una personalidad dividida que la ofuscación debe respetar con exactitud. El ensamblado de contrato —las interfaces, las clases base abstractas y los atributos contra los que compilan los plugins de terceros— es una ABI publicada: renombra un miembro suyo y todo plugin ya distribuido contra los nombres antiguos falla al enlazar, igual que renombrar una API pública rompe a quienes la llaman. Pero el host que carga esos plugins es tu código más valioso y lo que más quieres renombrar. Los dos viven en una sola solución y una pasada de ofuscación corriente no puede distinguirlos. Esta entrada lee los puntos de enlace exactos —los nombres de tipo de interfaz y de método que un AssemblyLoadContext resuelve al cargar, los nombres de contrato [Export]/[Import] que MEF empareja, y los nombres de tipo de atributo que un host escanea—, muestra por qué cada uno está en la ABI y debe sobrevivir, y cómo Nebula fija la superficie del contrato por su forma mientras aplana el host detrás de ella.
Un host de plugins son dos programas que visten una sola solución. Uno de ellos es el contrato: las interfaces, las clases base abstractas y los atributos contra los que los autores de terceros compilan sus plugins. El otro es el host: el cargador, la lógica de composición, el código de negocio que decide qué hacer con los plugins que encuentra. Estos dos tienen reglas de ofuscación opuestas, y la razón es la misma por la que una API pública y un método privado tienen reglas opuestas: uno es nombrado por código que no controlas, el otro solo por código que sí.
El contrato es una ABI publicada. Un plugin distribuido el año pasado guarda una referencia a IPlugin.Execute por nombre, horneada en sus metadatos. Cuando tu host carga ese plugin y pregunta “¿este tipo implementa IPlugin?”, la respuesta se resuelve contra la identidad del ensamblado de contrato y el nombre completo de la interfaz. Renombra IPlugin a a y la comprobación de “implementa” del plugin apunta a un tipo que ya no existe bajo ese nombre — el plugin no enlaza con nada y tu host lo omite en silencio. Ese no es un fallo que verás en tus propias pruebas, porque tus propios plugins se recompilan; es un fallo que solo aparece para los plugins que no construiste tú.
El host es lo contrario. Ningún plugin referencia PluginCatalog ni CompositionCoordinator ni tu lógica de negocio con licencia. Esos nombres existen solo dentro de tu proceso, y son los nombres que más vale la pena aplanar a a, b, c. Toda la tarea de ofuscar un host de plugins es trazar la línea entre estos dos — y el ofuscador no puede trazarla por ti a partir de los nombres solos, porque ambos lados son simplemente tipos públicos en tu solución.
El contrato: tres clases de nombre en la ABI
Pon el contrato en su propio ensamblado. Esto no es una preferencia de estilo; convierte la ABI en una frontera física a la que puedes apuntar un perfil de ofuscación. Un contrato mínimo tiene tres clases de nombre, y las tres son portantes.
// 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; }
}
Cada uno de estos se empareja por nombre en algún lugar que el plugin no puede recompilarse para seguir:
- La interfaz
IPluginy sus miembros. El host resuelve un tipo de plugin preguntando si implementaIPlugin. Esa comprobación compara la lista de interfaces del tipo del plugin contra la identidad del tipoIPluginenContract.dll— nombre completo más identidad de ensamblado. Renombra la interfaz y la comparación falla. RenombraExecutee incluso un tipo que aún implemente la interfaz expone un método que el host llama bajo un nombre que ya no existe. - La clase base abstracta
PluginBase. Los plugins que derivan de ella llevan una referencia al tipo baseMyApp.Contracts.PluginBasepor nombre. Renómbrala y el tipo base del plugin queda colgando. - El atributo
PluginAttribute. El host escanea los ensamblados de plugins en busca de tipos que lleven este atributo. Ese escaneo empareja el nombre de tipo del atributo. Renómbralo y el[Plugin("...")]de cada plugin apunta a un tipo que el host ya no reconoce, así que el escaneo no encuentra nada.
Ninguno de estos nombres puede aplanarse, porque la entidad que hace la búsqueda — una DLL de plugin en el disco de un cliente — se compiló contra los nombres originales y nunca la reconstruirás tú.
El host: cómo ocurre realmente el enlace al cargar
Aquí está el cargador, con la forma que usan la mayoría de los hosts en .NET moderno: un AssemblyLoadContext coleccionable por plugin, un escaneo del atributo de contrato, y una puerta is IPlugin antes de activar.
// 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;
}
}
Mira de qué dependen las tres líneas numeradas. GetCustomAttribute<PluginAttribute>() es una referencia en tiempo de compilación al tipo de atributo — si el ofuscador renombra PluginAttribute en Contract.dll, esta línea en Host.dll se recompila al nuevo nombre y sigue funcionando para el host, pero el plugin en disco todavía lleva el nombre de atributo antiguo, así que el emparejamiento falla. typeof(IPlugin).IsAssignableFrom(type) compara contra la identidad de IPlugin; renombra IPlugin y el plugin ya enviado implementa una interfaz con nombre distinto. Activator.CreateInstance necesita el constructor del plugin, que es el código propio del plugin — seguro — pero la conversión a IPlugin es, otra vez, una comprobación de identidad contra el contrato.
La lección es que renombrar el contrato no rompe la vista que el host tiene de él — el host se recompila. Rompe la vista del plugin, que no puede recompilarse. Esa asimetría es toda la trampa: tu suite de pruebas, construida a partir de plugins que reconstruyes en cada ejecución de CI, pasa. El campo, lleno de plugins que no reconstruyes, falla.
MEF hace explícita la dependencia del nombre
Si compones con MEF en lugar de hacer el cargador a mano, la dependencia del nombre es la misma pero más literal, porque MEF empareja contra una cadena de nombre de contrato.
// 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))] no almacena “la interfaz” — almacena un nombre de contrato que MEF deriva del tipo, que para una exportación de tipo es el nombre de tipo completo, MyApp.Contracts.IPlugin. El [ImportMany] de IPlugin deriva la misma cadena. La composición tiene éxito porque las dos cadenas coinciden. Renombra IPlugin a MyApp.Contracts.a y la cadena de exportación pasa a ser MyApp.Contracts.a mientras los plugins ya en el campo siguen exportando MyApp.Contracts.IPlugin — las cadenas ya no coinciden y la importación vuelve vacía, sin error, porque “sin exportación coincidente” es un estado normal de MEF.
Hay un arreglo de cinturón y tirantes que vale la pena hacer en el código fuente independientemente de la ofuscación: dale al contrato un nombre de cadena explícito para que la composición deje de depender del identificador del tipo por completo.
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!;
Ahora el nombre de contrato es el literal "MyApp.Plugin.v1", una constante de cadena que la ofuscación deja en paz salvo que la cifres, y lo excluirías del cifrado de cadenas en el ensamblado de contrato. Aun así, sigues preservando el nombre de tipo IPlugin, porque el segundo argumento de [Export] y el tipo de elemento de la importación son identidades de tipo que MEF también comprueba. Los nombres de contrato explícitos hacen el enlace robusto; no hacen libres los nombres de tipo.
La configuración de Nebula
Dos ensamblados, dos perfiles. El host recibe todo; al contrato se le preserva su superficie pública.
<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>
Vale la pena destacar dos cosas. Primero, preservar la superficie pública del contrato no significa dejar el contrato sin proteger — los cuerpos de método en PluginBase, cualquier implementación de interfaz por defecto, y los ayudantes internos siguen ofuscados en flujo de control y con cadenas cifradas. Mantienes los nombres a los que un plugin se enlaza y endureces todo lo demás. Segundo, el host se renombra sin exclusiones en absoluto, porque nada en él está en la ABI. Esta es la configuración que te da la protección que de verdad querías — tu cargador, tu lógica de composición, tu puerta de licencia aplanados a letras sueltas — sin el fallo en el campo que una pasada general habría enviado en silencio.
Si contrato y host están atascados en un solo ensamblado, la misma idea se expresa como exclusiones de renombrado delimitadas al espacio de nombres del contrato en lugar de dos perfiles, y funciona — pero ahora estás a un error de tecleo en un filtro de espacio de nombres de exponer el host o romper el campo. Divide el ensamblado si puedes; la ABI es una frontera, y la forma más barata de ofuscar a través de una frontera correctamente es hacerla real.
La prueba que atrapa el fallo en el campo
La razón por la que esta clase de error se envía es que la prueba natural — carga tus propios plugins, comprueba que corren — reconstruye los plugins desde el código fuente cada vez, así que siempre siguen el contrato renombrado. Para atrapar el fallo real tienes que probar contra un binario de plugin construido antes de la ofuscación y nunca recompilado.
[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);
}
Congela un binario de plugin como fixture, ejecútalo contra el host ofuscado, y la prueba falla en el instante en que un renombrado alcanza el contrato — en CI, donde puedes arreglarlo, en lugar de en la carpeta de plugins de un cliente, donde no puedes. Ese único binario congelado es la diferencia entre una ABI que crees estable y una que has probado que sobrevive a la pasada de ofuscación.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.