Ofuscar un ensamblado .NET visible para COM: el GUID es el contrato, el DispId es el contrato, y el nombre de la clase es el ProgID a menos que lo fijes
Un ensamblado .NET visible para COM lo consume código que no sabe que es .NET —una macro de VBA, un VBScript, un host de C++ heredado— y ese consumidor se enlaza a él mediante un contrato hecho de tres cosas que no están donde un desarrollador de C# guarda la API pública: un GUID en la clase y en la interfaz, un DispId en cada miembro, y un ProgID que por defecto es el nombre del tipo cualificado con su espacio de nombres. Ofúscalo como ofuscarías una biblioteca y los GUID sobreviven (son constantes de atributo), pero el ProgID se mueve en el momento en que renombras la clase, y un cliente de enlace tardío que hace CreateObject("Acme.Widget") e invoca los miembros por nombre mediante IDispatch se rompe tanto por el ProgID desplazado como por los nombres de miembro renombrados. Esta entrada lee qué resuelve realmente un cliente COM en cada etapa —de ProgID a CLSID a miembro de IDispatch—, traza la línea entre lo que Nebula renombra (la implementación, el motor) y lo que queda fijado (los GUID, los DispId, los nombres de miembro que usa el enlace tardío, el ProgID), muestra la disposición con [ProgId] e interfaz explícita que reduce la superficie fijada, da la configuración y termina con la comprobación de registrar e invocar tanto en un script de enlace tardío como en un cliente tlb de enlace temprano.
Un ensamblado .NET visible para COM es algo extraño de ofuscar porque su consumidor más importante no sabe que es .NET. Una macro de VBA, un VBScript en una tarea programada, un host de C++ de veinte años —todos llegan a tu ensamblado a través de COM, y el contrato al que se enlazan no es la API pública de C# que un desarrollador protegería. Son tres piezas de metadatos que viven en tres lugares distintos: un GUID en la clase y en la interfaz, un DispId en cada miembro, y un ProgID que, salvo que digas lo contrario, no es más que el nombre de tu tipo cualificado con su espacio de nombres. Ofúscalo como una biblioteca corriente y los GUID pasan intactos —pero el ProgID se mueve en el instante en que renombras la clase, y un cliente de enlace tardío se rompe tanto por el ProgID desplazado como por los nombres de miembro renombrados.
Esta entrada lee qué resuelve un cliente COM en cada salto, traza la línea para Nebula y muestra la disposición que mantiene minúscula la superficie fijada.
El ensamblado tal como lo ve COM
Esta es la forma que distribuyen la mayoría de los componentes visibles para COM: una interfaz IDispatch explícita y una coclase que la implementa:
using System.Runtime.InteropServices;
using Acme.Automation.Engine; // the real logic — renames wholesale
namespace Acme.Automation;
[ComVisible(true)]
[Guid("8d9b3a2e-6c41-4f7a-9b21-2f0c7e5a1d44")] // IID — pinned, survives anyway
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
public interface IWidget
{
[DispId(1)] string Caption { get; set; } // member name + DispId = contract
[DispId(2)] int Resize(int width, int height);
}
[ComVisible(true)]
[Guid("1f6e0c74-3a8b-4d2e-8c15-9a7b4e3f0c21")] // CLSID — pinned, survives anyway
[ClassInterface(ClassInterfaceType.None)] // no autogenerated interface
[ProgId("Acme.Automation.Widget")] // pin the ProgID — see below
public sealed class Widget : IWidget
{
private readonly WidgetEngine _engine = WidgetEngine.Open(); // private: renames freely
public string Caption { get => _engine.Caption; set => _engine.Caption = value; }
public int Resize(int width, int height) => _engine.Resize(width, height);
}
El registro convierte eso en entradas del registro con regasm:
regasm Acme.Automation.dll /tlb:Acme.Automation.tlb /codebase
regasm escribe tres cosas que importan: una asignación ProgID → CLSID, una asignación CLSID → assembly, y (con /tlb) una biblioteca de tipos que describe la interfaz. Todas ellas están indexadas por los GUID, que es la razón por la que sobreviven al renombrado: un GUID de atributo es una constante de cadena, y el renombrado nunca toca una constante. Las partes que no están indexadas por GUID son el ProgID y los nombres de miembro, y esas son todo el trabajo.
Cómo se enlaza un cliente de enlace tardío, y dónde se rompe
Sigamos un script a través de los saltos. CreateObject("Acme.Automation.Widget") hace esto:
- Busca el ProgID
Acme.Automation.Widgeten el registro → obtiene un CLSID (el GUID de la coclase). - Instancia el CLSID → obtiene un puntero
IDispatch. - Para cada llamada,
GetIDsOfNames("Resize")→ un DispId; luegoInvoke(dispId, args).
Tres búsquedas basadas en nombres, y la ofuscación puede envenenar dos de ellas. El ProgID del paso 1 es, por defecto, el nombre del tipo cualificado con su espacio de nombres —Acme.Automation.Widget—. Renombra la clase a a y el espacio de nombres a b, y regasm registraría ahora b.a; el script sigue pidiendo Acme.Automation.Widget, no encuentra nada, y muere con ActiveX component can’t create object. El nombre del miembro del paso 3 es el nombre de tu método del CLR; renombra Resize a a y GetIDsOfNames("Resize") devuelve DISP_E_UNKNOWNNAME: un fallo en tiempo de ejecución sin error de compilación, exactamente igual que un parámetro de PowerShell renombrado.
La disposición que reduce la superficie fijada
Dos movimientos hacen que la mayor parte del ensamblado se renombre con libertad.
Fija el ProgID con [ProgId]. El ProgID por defecto es el nombre del tipo cualificado con su espacio de nombres, lo que encadena la cadena CreateObject del cliente a un nombre que quieres ofuscar. Pon [ProgId("Acme.Automation.Widget")] en la coclase —como en el ejemplo de arriba— y el ProgID se convierte en una constante de atributo fijada que ya no depende del nombre de la clase. Ahora Nebula puede renombrar Widget a cualquier cosa, y el espacio de nombres con él; regasm lee el ProgID del atributo, y el CreateObject del script sigue resolviéndose.
Pon el contrato en una interfaz explícita, la lógica en un motor. Con [ClassInterface(ClassInterfaceType.None)], COM solo ve los miembros de IWidget, así que los únicos nombres de miembro que son contrato son los de la interfaz. La coclase puede contener campos privados y auxiliares que se renombran con libertad, y el trabajo real vive en un espacio de nombres Acme.Automation.Engine aparte, sin ningún atributo de COM, que se renombra, se le cifran las cadenas y se le aplana el flujo de control como a cualquier otra biblioteca:
// Acme.Automation.Engine — no COM, no public contract, renames wholesale.
namespace Acme.Automation.Engine;
internal sealed class WidgetEngine
{
internal static WidgetEngine Open() { /* licensing gate, state load */ }
internal string Caption { get; set; } = "";
internal int Resize(int width, int height) { /* the real algorithm */ }
}
La superficie conservada es ahora exactamente: los nombres de miembro de la interfaz, sus DispId, los dos GUID y la cadena ProgID. Todo lo que merece la pena proteger está al otro lado de la interfaz.
La configuración
Conserva la interfaz por nombre (para que sobrevivan sus nombres de miembro), conserva el tipo de la coclase si dependes del ProgID por defecto —pero con [ProgId] fijado puedes renombrarlo—, preserva los atributos de interoperabilidad, y renombra todo lo demás:
{
"inputs": [ "bin/Release/net48/Acme.Automation.dll" ],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Acme.Automation.IWidget", "action": "keep" },
{ "pattern": "Acme.Automation.Widget", "action": "rename" },
{ "pattern": "Acme.Automation.Engine.*", "action": "rename" }
],
"preserveAttributes": [ "System.Runtime.InteropServices.*" ]
},
"controlFlow": { "exclude": [ "Acme.Automation.IWidget" ] }
}
Tres notas. La regla keep está en la interfaz, no en la coclase: los nombres de miembro de la interfaz son lo que resuelve el enlace tardío, y conservar la interfaz conserva Caption y Resize. La coclase está puesta en rename a propósito, lo cual solo es seguro porque [ProgId] está fijado; quita ese atributo y tendrás que cambiar esta regla a keep o CreateObject se rompe. preserveAttributes protege [Guid], [DispId], [ComVisible], [InterfaceType], [ClassInterface] y [ProgId] frente a cualquier pasada que elimine atributos: perder [ComVisible] hace que el tipo sea invisible para regasm sin ningún error, y perder [DispId] hace que los miembros se renumeren, lo que reordena en silencio la vtable de un cliente de enlace temprano. El cifrado de cadenas es seguro en todo momento: el ProgID y el GUID son argumentos de atributo y no se tocan, y GetIDsOfNames reflexiona sobre los nombres de miembro reales en los metadatos, no sobre ningún literal de cadena.
Verifica registrando y llamando, en ambos estilos de enlace
Aquí todo fallo es un fallo de enlace en tiempo de ejecución, así que registra el ensamblado protegido y ejercítalo de ambas formas. De enlace tardío, a través del ProgID y los nombres de IDispatch:
' late-bound.vbs — exercises the ProgID (step 1) and member names (step 3)
Set w = CreateObject("Acme.Automation.Widget") ' ProgID must still resolve
w.Caption = "Report" ' GetIDsOfNames("Caption")
WScript.Echo w.Resize(800, 600) ' GetIDsOfNames("Resize")
De enlace temprano, a través de la biblioteca de tipos: esto ejercita los GUID y los DispId, enlazando por vtable sin nombres en tiempo de ejecución:
// Reference the generated interop (tlbimp / COM reference), then:
var w = new Acme.Automation.Widget(); // CLSID GUID
w.Caption = "Report"; // DispId 1 via vtable
int h = w.Resize(800, 600); // DispId 2 via vtable
Registra la DLL protegida con regasm protected\Acme.Automation.dll /tlb /codebase, ejecuta ambos clientes en la arquitectura que distribuyes (un host de 32 bits necesita el regasm de 32 bits y la vista de 32 bits del registro), y anula el registro con regasm /u después para no estar nunca probando contra entradas obsoletas. Si el ensamblado tiene nombre seguro, vuelve a firmarlo tras la ofuscación. Un ensamblado que simplemente se registra no está demostrado; uno que hace CreateObject y despacha en ambos estilos, sí.
La conclusión
El contrato de un ensamblado visible para COM no es su superficie de C#. Los GUID de la clase y de la interfaz sobreviven a la ofuscación porque son constantes de atributo, y el registro está indexado por ellos: eso sale gratis. Lo que no sale gratis es el ProgID, que por defecto es el nombre de la clase cualificado con su espacio de nombres y por tanto se mueve en el momento en que renombras la clase, y los nombres de miembro que un cliente IDispatch de enlace tardío pasa a GetIDsOfNames, que son los nombres de tus métodos y propiedades del CLR. Fija el ProgID con [ProgId] para que la clase pueda renombrarse, conserva la interfaz para que sobrevivan sus nombres de miembro, fija los DispId para que las vtables de enlace temprano nunca se renumeren, preserva los atributos de InteropServices, y empuja el trabajo real a un espacio de nombres de motor que se renombre por completo. Luego demuéstralo registrando el ensamblado protegido y llamándolo tanto de enlace tardío a través del ProgID como de enlace temprano a través de la biblioteca de tipos, porque la compilación no te dirá qué nombre rompiste.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.