Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETAnalyse approfondie

Obfusquer un assembly .NET visible pour COM : le GUID est le contrat, le DispId est le contrat, et le nom de la classe est le ProgID à moins que vous ne l'épingliez

Un assembly .NET visible pour COM est consommé par du code qui ne sait pas qu'il s'agit de .NET — une macro VBA, un VBScript, un hôte C++ hérité — et ce consommateur s'y lie par un contrat fait de trois choses qui ne se trouvent pas là où un développeur C# garde l'API publique : un GUID sur la classe et l'interface, un DispId sur chaque membre, et un ProgID qui, par défaut, est le nom du type qualifié par l'espace de noms. Obfusquez-le comme vous obfusqueriez une bibliothèque et les GUID survivent (ce sont des constantes d'attribut), mais le ProgID se déplace à l'instant où vous renommez la classe, et un client à liaison tardive qui fait CreateObject("Acme.Widget") et appelle les membres par leur nom via IDispatch se brise à la fois sur le ProgID déplacé et sur les noms de membres renommés. Cet article lit ce qu'un client COM résout réellement à chaque étape — du ProgID au CLSID au membre IDispatch —, trace la frontière entre ce que Nebula renomme (l'implémentation, le moteur) et ce qui est épinglé (les GUID, les DispId, les noms de membres qu'utilise la liaison tardive, le ProgID), montre la disposition avec [ProgId] et interface explicite qui réduit la surface épinglée, fournit la configuration, et se termine par la vérification par enregistrement-et-appel dans un script à liaison tardive comme dans un client tlb à liaison précoce.

Un assembly .NET visible pour COM est une chose étrange à obfusquer, car son consommateur le plus important ne sait pas qu’il s’agit de .NET. Une macro VBA, un VBScript dans une tâche planifiée, un hôte C++ vieux de vingt ans — tous atteignent votre assembly via COM, et le contrat auquel ils se lient n’est pas l’API publique C# qu’un développeur protégerait. Ce sont trois morceaux de métadonnées qui vivent à trois endroits différents : un GUID sur la classe et l’interface, un DispId sur chaque membre, et un ProgID qui, sauf indication contraire, n’est que le nom de votre type qualifié par l’espace de noms. Obfusquez-le comme une bibliothèque ordinaire et les GUID passent intacts — mais le ProgID se déplace à l’instant où vous renommez la classe, et un client à liaison tardive se brise à la fois sur le ProgID déplacé et sur les noms de membres renommés.

Cet article lit ce qu’un client COM résout à chaque saut, trace la frontière pour Nebula, et montre la disposition qui maintient la surface épinglée minuscule.

L’assembly tel que COM le voit

Voici la forme que livrent la plupart des composants visibles pour COM : une interface IDispatch explicite et une coclasse qui l’implémente :

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

L’enregistrement transforme cela en entrées de registre avec regasm :

regasm Acme.Automation.dll /tlb:Acme.Automation.tlb /codebase

regasm écrit trois choses qui comptent : une correspondance ProgID → CLSID, une correspondance CLSID → assembly, et (avec /tlb) une bibliothèque de types décrivant l’interface. Chacune d’elles est indexée par les GUID, ce qui explique qu’elles survivent au renommage — un GUID d’attribut est une constante chaîne, et le renommage ne touche jamais une constante. Les parties qui ne sont pas indexées par GUID sont le ProgID et les noms de membres, et ce sont là tout le travail.

Comment un client à liaison tardive se lie — et où il se brise

Suivons un script à travers les sauts. CreateObject("Acme.Automation.Widget") fait ceci :

  1. Recherche le ProgID Acme.Automation.Widget dans le registre → obtient un CLSID (le GUID de la coclasse).
  2. Instancie le CLSID → obtient un pointeur IDispatch.
  3. Pour chaque appel, GetIDsOfNames("Resize") → un DispId ; puis Invoke(dispId, args).

Trois recherches fondées sur des noms, et l’obfuscation peut en empoisonner deux. Le ProgID de l’étape 1 est par défaut le nom du type qualifié par l’espace de noms — Acme.Automation.Widget. Renommez la classe en a et l’espace de noms en b, et regasm enregistrerait désormais b.a ; le script demande toujours Acme.Automation.Widget, ne trouve rien, et meurt avec ActiveX component can’t create object. Le nom de membre de l’étape 3 est le nom de votre méthode CLR ; renommez Resize en a et GetIDsOfNames("Resize") renvoie DISP_E_UNKNOWNNAME — un échec à l’exécution sans erreur de compilation, exactement comme un paramètre PowerShell renommé.

CreateObject("Acme.Automation.Widget") — trois recherches par nom1. ProgID → CLSIDpar défaut : namespace.type2. CLSID → IDispatchindexé par constante GUID3. GetIDsOfNames("Resize")nom de membre → DispIdpuis Invoke(dispId, args)client à liaison précoce (tlb)se lie par GUID + DispId,ordre de vtable — sans nomsRenommable sans risque• intérieur de la classe Widget• champs privés (_engine)• corps de méthodes• tout l'espace de noms EngineÉpinglé• [Guid] CLSID + IID• valeurs [DispId]• noms de membres de l'interface (GetIDsOfNames à liaison tardive)• la chaîne ProgID (épinglez avec [ProgId])PourquoiLes GUID sont des constantesd'attribut, les clés de registresurvivent donc intactes au renommage.Le ProgID dérive du nom dutype, il SE DÉPLACE donc quand laclasse se renomme — sauf si [ProgId]l'épingle à une constante.Un client à liaison tardive passeles noms de membres en chaînes, cesnoms sont donc le contrat.Un client à liaison précoce les arésolus à SA compilation → DispId.

La disposition qui réduit la surface épinglée

Deux gestes font que la majeure partie de l’assembly se renomme librement.

Épinglez le ProgID avec [ProgId]. Le ProgID par défaut est le nom du type qualifié par l’espace de noms, ce qui enchaîne la chaîne CreateObject du client à un nom que vous voulez obfusquer. Placez [ProgId("Acme.Automation.Widget")] sur la coclasse — comme dans l’exemple ci-dessus — et le ProgID devient une constante d’attribut épinglée qui ne dépend plus du nom de la classe. Nebula peut désormais renommer Widget en n’importe quoi, et l’espace de noms avec ; regasm lit le ProgID sur l’attribut, et le CreateObject du script se résout toujours.

Mettez le contrat sur une interface explicite, la logique dans un moteur. Avec [ClassInterface(ClassInterfaceType.None)], COM ne voit que les membres de IWidget — donc les seuls noms de membres qui sont contrat sont ceux de l’interface. La coclasse peut détenir des champs privés et des assistants qui se renomment librement, et le vrai travail vit dans un espace de noms Acme.Automation.Engine distinct, sans aucun attribut COM, qui se renomme, chiffre ses chaînes et aplatit son flux de contrôle comme n’importe quelle autre bibliothèque :

// 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 surface conservée est désormais exactement : les noms de membres de l’interface, leurs DispId, les deux GUID et la chaîne ProgID. Tout ce qui mérite d’être protégé se trouve de l’autre côté de l’interface.

La configuration

Conservez l’interface par son nom (afin que ses noms de membres survivent), conservez le type de la coclasse si vous dépendez du ProgID par défaut — mais avec [ProgId] épinglé vous pouvez le renommer —, préservez les attributs d’interopérabilité, et renommez tout le reste :

{
  "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" ] }
}

Trois remarques. La règle keep porte sur l’interface, pas sur la coclasse — les noms de membres de l’interface sont ce que la liaison tardive résout, et conserver l’interface conserve Caption et Resize. La coclasse est mise à rename délibérément, ce qui n’est sûr que parce que [ProgId] est épinglé ; retirez cet attribut et vous devez changer cette règle en keep sinon CreateObject se brise. preserveAttributes protège [Guid], [DispId], [ComVisible], [InterfaceType], [ClassInterface] et [ProgId] contre toute passe de suppression d’attributs — perdre [ComVisible] rend le type invisible à regasm sans aucune erreur, et perdre [DispId] fait renuméroter les membres, ce qui réordonne silencieusement la vtable d’un client à liaison précoce. Le chiffrement des chaînes est sans danger partout : le ProgID et le GUID sont des arguments d’attribut et ne sont pas touchés, et GetIDsOfNames réfléchit sur les vrais noms de membres dans les métadonnées, pas sur un quelconque littéral de chaîne.

Vérifiez en enregistrant et en appelant — dans les deux styles de liaison

Chaque échec ici est un raté de liaison à l’exécution, alors enregistrez l’assembly protégé et exercez-le des deux façons. À liaison tardive, à travers le ProgID et les noms 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")

À liaison précoce, à travers la bibliothèque de types — cela exerce les GUID et les DispId, en liant par vtable sans noms à l’exécution :

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

Enregistrez la DLL protégée avec regasm protected\Acme.Automation.dll /tlb /codebase, exécutez les deux clients dans l’architecture que vous livrez (un hôte 32 bits a besoin du regasm 32 bits et de la vue 32 bits du registre), et annulez l’enregistrement avec regasm /u ensuite afin de ne jamais tester contre des entrées périmées. Si l’assembly a un nom fort, resignez-le après l’obfuscation. Un assembly qui se contente de s’enregistrer n’est pas prouvé ; un qui fait CreateObject et dispatche dans les deux styles l’est.

À retenir

Le contrat d’un assembly visible pour COM n’est pas sa surface C#. Les GUID sur la classe et l’interface survivent à l’obfuscation parce que ce sont des constantes d’attribut, et le registre est indexé par eux — cela, c’est gratuit. Ce qui n’est pas gratuit, c’est le ProgID, qui est par défaut le nom de la classe qualifié par l’espace de noms et se déplace donc à l’instant où vous renommez la classe, et les noms de membres qu’un client IDispatch à liaison tardive passe à GetIDsOfNames, qui sont les noms de vos méthodes et propriétés CLR. Épinglez le ProgID avec [ProgId] pour que la classe puisse se renommer, conservez l’interface pour que ses noms de membres survivent, épinglez les DispId pour que les vtables à liaison précoce ne se renumérotent jamais, préservez les attributs InteropServices, et repoussez le vrai travail dans un espace de noms de moteur qui se renomme intégralement. Puis prouvez-le en enregistrant l’assembly protégé et en l’appelant à la fois à liaison tardive via le ProgID et à liaison précoce via la bibliothèque de types — car la compilation ne vous dira pas quel nom vous avez cassé.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.