DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Obfuscating a COM-visible .NET assembly: the GUID is the contract, the DispId is the contract, and the class name is the ProgID unless you pin it

A COM-visible .NET assembly is a strange thing to obfuscate because its most important consumer does not know it is .NET. A VBA macro, a VBScript in a scheduled task, a twenty-year-old C++ host — they reach your assembly through COM, and the contract they bind to is not the public C# API a developer would protect. It is three pieces of metadata living in three different places: a GUID on the class and the interface, a DispId on each member, and a ProgID that, unless you say otherwise, is just your type's namespace-qualified name. Obfuscate it like an ordinary library and the GUIDs come through untouched — but the ProgID moves the instant you rename the class, and a late-bound client breaks on both the moved ProgID and the renamed member names.

This post reads what a COM client resolves at each hop, draws the line for Nebula, and shows the layout that keeps the pinned surface tiny.

The assembly as COM sees it

Here is the shape most COM-visible components ship — an explicit IDispatch interface and a coclass that implements it:

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

Registration turns that into registry entries with regasm:

regasm Acme.Automation.dll /tlb:Acme.Automation.tlb /codebase
Enter fullscreen mode Exit fullscreen mode

regasm writes three things that matter: a ProgID → CLSID mapping, a CLSID → assembly mapping, and (with /tlb) a type library describing the interface. Every one of those is keyed by the GUIDs, which is why they survive renaming — an attribute GUID is a string constant, and renaming never touches a constant. The parts that are not keyed by GUID are the ProgID and the member names, and those are the whole job.

How a late-bound client binds — and where it breaks

Walk a script through the hops. CreateObject("Acme.Automation.Widget") does this:

  1. Look up the ProgID Acme.Automation.Widget in the registry → get a CLSID (the coclass GUID).
  2. Instantiate the CLSID → get an IDispatch pointer.
  3. For each call, GetIDsOfNames("Resize") → a DispId; then Invoke(dispId, args).

Three name-based lookups, and obfuscation can poison two of them. The ProgID in step 1 defaults to the type's namespace-qualified name — Acme.Automation.Widget. Rename the class to a and the namespace to b, and regasm would now register b.a; the script still asks for Acme.Automation.Widget, finds nothing, and dies with ActiveX component can't create object. The member name in step 3 is your CLR method name; rename Resize to a and GetIDsOfNames("Resize") returns DISP_E_UNKNOWNNAME — a run-time failure with no build error, exactly like a renamed PowerShell parameter.

The layout that shrinks the pinned surface

Two moves make most of the assembly rename freely.

Pin the ProgID with [ProgId]. The default ProgID is the type's namespace-qualified name, which chains the client's CreateObject string to a name you want to obfuscate. Put [ProgId("Acme.Automation.Widget")] on the coclass — as in the sample above — and the ProgID becomes a pinned attribute constant that no longer depends on the class name. Now Nebula can rename Widget to anything and the namespace with it; regasm reads the ProgID off the attribute, and the script's CreateObject still resolves.

Put the contract on an explicit interface, the logic in an engine. With [ClassInterface(ClassInterfaceType.None)], COM sees only the members of IWidget — so the only member names that are contract are the interface's. The coclass can hold private fields and helpers that rename freely, and the real work lives in a separate Acme.Automation.Engine namespace with no COM attributes at all, which renames, string-encrypts and control-flow-flattens like any other library:

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

The kept surface is now exactly: the interface member names, their DispIds, the two GUIDs, and the ProgID string. Everything worth protecting is on the other side of the interface.

The config

Keep the interface by name (so its member names survive), keep the coclass type if you rely on the default ProgID — but with [ProgId] pinned you can rename it — preserve the interop attributes, and rename everything else:

{
  "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" ] }
}
Enter fullscreen mode Exit fullscreen mode

Three notes. The keep rule is on the interface, not the coclass — the interface member names are what late binding resolves, and keeping the interface keeps Caption and Resize. The coclass is set to rename deliberately, which is only safe because [ProgId] is pinned; drop that attribute and you must change this rule to keep or CreateObject breaks. preserveAttributes guards [Guid], [DispId], [ComVisible], [InterfaceType], [ClassInterface] and [ProgId] against any attribute-stripping pass — losing [ComVisible] makes the type invisible to regasm with no error, and losing [DispId] makes the members renumber, which silently reorders an early-bound client's vtable. String encryption is safe throughout: the ProgID and GUID are attribute arguments and are not touched, and GetIDsOfNames reflects on the real member names in metadata, not on any string literal.

Verify by registering and calling — in both binding styles

Every failure here is a run-time bind miss, so register the protected assembly and exercise it both ways. Late-bound, through the ProgID and IDispatch names:

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

Early-bound, through the type library — this exercises the GUIDs and the DispIds, binding by vtable with no names at run time:

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

Register the protected DLL with regasm protected\Acme.Automation.dll /tlb /codebase, run both clients in the bitness you ship (a 32-bit host needs the 32-bit regasm and the 32-bit registry view), and unregister with regasm /u afterwards so you are never testing against stale entries. If the assembly is strong-named, re-sign after obfuscation. An assembly that merely registers is not proven; one that CreateObjects and dispatches in both styles is.

The takeaway

A COM-visible assembly's contract is not its C# surface. The GUIDs on the class and interface survive obfuscation because they are attribute constants, and the registry is keyed by them — that much is free. What is not free is the ProgID, which defaults to the namespace-qualified class name and therefore moves the moment you rename the class, and the member names a late-bound IDispatch client passes to GetIDsOfNames, which are your CLR method and property names. Pin the ProgID with [ProgId] so the class can rename, keep the interface so its member names survive, pin the DispIds so early-bound vtables never renumber, preserve the InteropServices attributes, and push the real work into an engine namespace that renames wholesale. Then prove it by registering the protected assembly and calling it both late-bound through the ProgID and early-bound through the type library — because the build will not tell you which name you broke.

Top comments (0)