Eine COM-sichtbare .NET-Assembly obfuskieren: Die GUID ist der Vertrag, der DispId ist der Vertrag, und der Klassenname ist die ProgID, sofern Sie sie nicht festnageln
Eine COM-sichtbare .NET-Assembly wird von Code konsumiert, der nicht weiß, dass sie .NET ist — ein VBA-Makro, ein VBScript, ein alter C++-Host — und dieser Konsument bindet sich an sie über einen Vertrag aus drei Dingen, die nicht dort liegen, wo ein C#-Entwickler die öffentliche API aufbewahrt: eine GUID auf der Klasse und der Schnittstelle, ein DispId auf jedem Mitglied und eine ProgID, die standardmäßig der namespace-qualifizierte Typname ist. Obfuskieren Sie sie wie eine Bibliothek, und die GUIDs überleben (sie sind Attributkonstanten), aber die ProgID verschiebt sich in dem Moment, in dem Sie die Klasse umbenennen, und ein spätgebundener Client, der CreateObject("Acme.Widget") macht und Mitglieder per Namen über IDispatch aufruft, bricht sowohl an der verschobenen ProgID als auch an den umbenannten Mitgliedsnamen. Dieser Beitrag liest, was ein COM-Client auf jeder Stufe tatsächlich auflöst — von ProgID zu CLSID zu IDispatch-Mitglied —, zieht die Linie zwischen dem, was Nebula umbenennt (die Implementierung, die Engine), und dem, was festgenagelt ist (die GUIDs, die DispIds, die von der Spätbindung genutzten Mitgliedsnamen, die ProgID), zeigt das Layout mit [ProgId] und expliziter Schnittstelle, das die festgenagelte Oberfläche verkleinert, liefert die Konfiguration und endet mit der Registrieren-und-Aufrufen-Prüfung sowohl in einem spätgebundenen Skript als auch in einem frühgebundenen tlb-Client.
Eine COM-sichtbare .NET-Assembly ist eine seltsame Sache zum Obfuskieren, weil ihr wichtigster Konsument nicht weiß, dass sie .NET ist. Ein VBA-Makro, ein VBScript in einer geplanten Aufgabe, ein zwanzig Jahre alter C++-Host — sie alle erreichen Ihre Assembly über COM, und der Vertrag, an den sie sich binden, ist nicht die öffentliche C#-API, die ein Entwickler schützen würde. Es sind drei Metadaten-Stücke, die an drei verschiedenen Orten leben: eine GUID auf der Klasse und der Schnittstelle, ein DispId auf jedem Mitglied und eine ProgID, die, sofern Sie nichts anderes sagen, schlicht der namespace-qualifizierte Name Ihres Typs ist. Obfuskieren Sie sie wie eine gewöhnliche Bibliothek, und die GUIDs kommen unangetastet durch — aber die ProgID verschiebt sich in dem Augenblick, in dem Sie die Klasse umbenennen, und ein spätgebundener Client bricht sowohl an der verschobenen ProgID als auch an den umbenannten Mitgliedsnamen.
Dieser Beitrag liest, was ein COM-Client auf jedem Sprung auflöst, zieht die Linie für Nebula und zeigt das Layout, das die festgenagelte Oberfläche winzig hält.
Die Assembly, wie COM sie sieht
Hier ist die Form, die die meisten COM-sichtbaren Komponenten ausliefern — eine explizite IDispatch-Schnittstelle und eine Coklasse, die sie implementiert:
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);
}
Die Registrierung verwandelt das mit regasm in Registry-Einträge:
regasm Acme.Automation.dll /tlb:Acme.Automation.tlb /codebase
regasm schreibt drei Dinge, die zählen: eine Zuordnung ProgID → CLSID, eine Zuordnung CLSID → assembly und (mit /tlb) eine Typbibliothek, die die Schnittstelle beschreibt. Jede davon ist über die GUIDs indexiert, weshalb sie das Umbenennen überleben — eine Attribut-GUID ist eine String-Konstante, und das Umbenennen rührt eine Konstante nie an. Die Teile, die nicht über GUID indexiert sind, sind die ProgID und die Mitgliedsnamen, und die sind die ganze Arbeit.
Wie ein spätgebundener Client bindet — und wo er bricht
Verfolgen wir ein Skript durch die Sprünge. CreateObject("Acme.Automation.Widget") tut Folgendes:
- Schlägt die ProgID
Acme.Automation.Widgetin der Registry nach → erhält einen CLSID (die Coklassen-GUID). - Instanziiert den CLSID → erhält einen
IDispatch-Zeiger. - Für jeden Aufruf
GetIDsOfNames("Resize")→ einen DispId; dannInvoke(dispId, args).
Drei namensbasierte Nachschlagevorgänge, und die Obfuskierung kann zwei davon vergiften. Die ProgID in Schritt 1 ist standardmäßig der namespace-qualifizierte Name des Typs — Acme.Automation.Widget. Benennen Sie die Klasse in a und den Namespace in b um, und regasm würde nun b.a registrieren; das Skript fragt weiterhin nach Acme.Automation.Widget, findet nichts und stirbt mit ActiveX component can’t create object. Der Mitgliedsname in Schritt 3 ist Ihr CLR-Methodenname; benennen Sie Resize in a um, und GetIDsOfNames("Resize") gibt DISP_E_UNKNOWNNAME zurück — ein Laufzeitfehler ohne Build-Fehler, genau wie ein umbenannter PowerShell-Parameter.
Das Layout, das die festgenagelte Oberfläche verkleinert
Zwei Züge sorgen dafür, dass sich der Großteil der Assembly frei umbenennt.
Nageln Sie die ProgID mit [ProgId] fest. Die Standard-ProgID ist der namespace-qualifizierte Name des Typs, was den CreateObject-String des Clients an einen Namen kettet, den Sie obfuskieren wollen. Setzen Sie [ProgId("Acme.Automation.Widget")] auf die Coklasse — wie im Beispiel oben — und die ProgID wird zu einer festgenagelten Attributkonstante, die nicht mehr vom Klassennamen abhängt. Nun kann Nebula Widget in alles Mögliche umbenennen, und den Namespace gleich mit; regasm liest die ProgID vom Attribut ab, und das CreateObject des Skripts löst weiterhin auf.
Legen Sie den Vertrag auf eine explizite Schnittstelle, die Logik in eine Engine. Mit [ClassInterface(ClassInterfaceType.None)] sieht COM nur die Mitglieder von IWidget — also sind die einzigen Mitgliedsnamen, die Vertrag sind, die der Schnittstelle. Die Coklasse kann private Felder und Helfer halten, die sich frei umbenennen, und die eigentliche Arbeit lebt in einem separaten Namespace Acme.Automation.Engine ganz ohne COM-Attribute, der sich wie jede andere Bibliothek umbenennt, die Strings verschlüsselt und den Kontrollfluss abflacht:
// 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 */ }
}
Die beibehaltene Oberfläche ist nun genau: die Mitgliedsnamen der Schnittstelle, ihre DispId, die zwei GUIDs und der ProgID-String. Alles, was des Schutzes wert ist, liegt auf der anderen Seite der Schnittstelle.
Die Konfiguration
Behalten Sie die Schnittstelle namentlich bei (damit ihre Mitgliedsnamen überleben), behalten Sie den Coklassen-Typ bei, falls Sie auf die Standard-ProgID angewiesen sind — aber mit festgenageltem [ProgId] können Sie ihn umbenennen —, bewahren Sie die Interop-Attribute und benennen Sie alles andere um:
{
"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" ] }
}
Drei Hinweise. Die keep-Regel liegt auf der Schnittstelle, nicht auf der Coklasse — die Mitgliedsnamen der Schnittstelle sind das, was die Spätbindung auflöst, und das Behalten der Schnittstelle behält Caption und Resize. Die Coklasse ist bewusst auf rename gesetzt, was nur sicher ist, weil [ProgId] festgenagelt ist; nehmen Sie dieses Attribut weg, und Sie müssen diese Regel auf keep ändern, sonst bricht CreateObject. preserveAttributes schützt [Guid], [DispId], [ComVisible], [InterfaceType], [ClassInterface] und [ProgId] vor jedem attribut-strippenden Durchlauf — der Verlust von [ComVisible] macht den Typ für regasm ohne Fehler unsichtbar, und der Verlust von [DispId] lässt die Mitglieder neu nummerieren, was die vtable eines frühgebundenen Clients stillschweigend umordnet. String-Verschlüsselung ist durchweg sicher: Die ProgID und die GUID sind Attributargumente und werden nicht angerührt, und GetIDsOfNames reflektiert über die echten Mitgliedsnamen in den Metadaten, nicht über irgendein String-Literal.
Verifizieren durch Registrieren und Aufrufen — in beiden Bindungsstilen
Jeder Fehlschlag hier ist ein Bindungs-Fehlgriff zur Laufzeit, also registrieren Sie die geschützte Assembly und üben Sie sie auf beide Arten aus. Spätgebunden, über die ProgID und die IDispatch-Namen:
' 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")
Frühgebunden, über die Typbibliothek — das übt die GUIDs und die DispId, indem es per vtable ohne Namen zur Laufzeit bindet:
// 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
Registrieren Sie die geschützte DLL mit regasm protected\Acme.Automation.dll /tlb /codebase, führen Sie beide Clients in der Bitbreite aus, die Sie ausliefern (ein 32-Bit-Host braucht das 32-Bit-regasm und die 32-Bit-Registry-Sicht), und heben Sie die Registrierung danach mit regasm /u auf, damit Sie nie gegen veraltete Einträge testen. Ist die Assembly starknamig, signieren Sie nach der Obfuskierung neu. Eine Assembly, die sich bloß registriert, ist nicht bewiesen; eine, die CreateObject macht und in beiden Stilen dispatcht, schon.
Das Fazit
Der Vertrag einer COM-sichtbaren Assembly ist nicht ihre C#-Oberfläche. Die GUIDs auf der Klasse und der Schnittstelle überleben die Obfuskierung, weil sie Attributkonstanten sind, und die Registry ist über sie indexiert — so viel ist gratis. Was nicht gratis ist, ist die ProgID, die standardmäßig der namespace-qualifizierte Klassenname ist und sich daher in dem Moment verschiebt, in dem Sie die Klasse umbenennen, und die Mitgliedsnamen, die ein spätgebundener IDispatch-Client an GetIDsOfNames übergibt und die Ihre CLR-Methoden- und Eigenschaftsnamen sind. Nageln Sie die ProgID mit [ProgId] fest, damit sich die Klasse umbenennen kann, behalten Sie die Schnittstelle, damit ihre Mitgliedsnamen überleben, nageln Sie die DispId fest, damit frühgebundene vtables nie neu nummerieren, bewahren Sie die InteropServices-Attribute und schieben Sie die eigentliche Arbeit in einen Engine-Namespace, der vollständig umbenennt. Beweisen Sie es dann, indem Sie die geschützte Assembly registrieren und sie sowohl spätgebunden über die ProgID als auch frühgebunden über die Typbibliothek aufrufen — denn der Build wird Ihnen nicht sagen, welchen Namen Sie kaputtgemacht haben.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.