Skip to content
← All posts
· Delta1 Labs Nebula.NETObfuscation.NETDeep Dive

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 consumed by code that does not know it is .NET — a VBA macro, a VBScript, a legacy C++ host — and that consumer binds to it through a contract made of three things that are not where a C# developer keeps the public API: a GUID on the class and the interface, a DispId on each member, and a ProgID that defaults to the namespace-qualified type name. Obfuscate it the way you would obfuscate a library and the GUIDs survive (they are attribute constants), but the ProgID moves the moment you rename the class, and a late-bound client that does CreateObject("Acme.Widget") and calls members by name through IDispatch breaks on both the moved ProgID and the renamed member names. This post reads what a COM client actually resolves at each stage — ProgID to CLSID to IDispatch member — draws the line between what Nebula renames (the implementation, the engine) and what is pinned (the GUIDs, the DispIds, the member names late binding uses, the ProgID), shows the [ProgId] and explicit-interface layout that shrinks the pinned surface, gives the config, and ends with the register-and-call check in both a late-bound script and an early-bound tlb client.

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

Registration turns that into registry entries with regasm:

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

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.

CreateObject("Acme.Automation.Widget") — three name lookups1. ProgID → CLSIDdefaults to namespace.type2. CLSID → IDispatchkeyed by GUID constant3. GetIDsOfNames("Resize")member name → DispIdthen Invoke(dispId, args)early-bound (tlb) clientbinds by GUID + DispId,vtable order — no namesRename-safe• Widget class internals• private fields (_engine)• method bodies• the whole Engine namespacePinned• [Guid] CLSID + IID• [DispId] values• interface member names (late-bound GetIDsOfNames)• the ProgID string (pin with [ProgId])WhyGUIDs are attribute constants,so the registry keys surviverenaming untouched.The ProgID is derived from thetype name, so it MOVES when theclass renames — unless [ProgId]pins it to a constant.A late-bound client passes membernames as strings at run time, sothose names are the contract.An early-bound client resolvedthem at ITS compile time → DispId.

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

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

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")

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

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.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.