Renaming to collisions: how Nebula reuses one name across members, and why the CLR tells them apart by signature
A naive renamer gives every member a unique short name — a, b, c, d — and stops there. It is leaving value on the table, because .NET does not identify a member by its name. A method is identified by its name together with its signature, a field by its name and its declaring type, and the runtime binds every call through a metadata token resolved at load time, not by re-matching strings. That means you can rename dozens of unrelated methods to the single name a, so long as their signatures differ, and the assembly still loads, verifies and runs. This post reads the MethodDef and String heap metadata to show why collision renaming is legal, walks the exact rules that bound it — same-signature clashes, virtual override chains, explicit interface implementations — and explains how Nebula packs names down to a tiny reused set that both shrinks the string heap and turns a decompiler’s output into an unreadable wall of identically named members.
Most people picture renaming as a dictionary: every identifier gets its own fresh, meaningless replacement. CalculateTax becomes a, ValidateLicense becomes b, _cachedResult becomes c, and so on down the assembly. That works, and it is what a first-generation renamer does. But it quietly assumes something that is not true of .NET: that a member’s name is its identity. It is not. The CLR identifies a member by far more than its name, and once you see exactly what, a more aggressive and more confusing transform becomes available — reusing the same name across many members.
What actually identifies a method
A method in a .NET assembly is a row in the MethodDef metadata table. The name is one column — a offset into the #Strings heap — but it is not what makes the method distinct. Within its declaring type, a method is identified by its name together with its signature: the calling convention, the return type, and the ordered parameter types, all encoded as a blob in the #Blob heap and referenced by the row’s Signature column.
This is not an obfuscation trick; it is the everyday mechanism behind overloading. Consider two ordinary overloads:
public void Write(int value) { /* ... */ }
public void Write(string value) { /* ... */ }
These are two MethodDef rows that share one name string — both Name columns point at the same "Write" in the #Strings heap — and differ only in their signature blobs. The runtime has always distinguished them. Overloading is, in metadata terms, precisely name reuse with distinct signatures, and it has worked since v1. A renamer that understands this can create overloads where the source never had any.
// Before — three unrelated methods, three distinct names.
public decimal CalculateTax(decimal amount) { /* ... */ }
public bool ValidateLicense(string key) { /* ... */ }
public void FlushCache() { /* ... */ }
// After — all three renamed to 'a'. Signatures differ, so the metadata is valid.
public decimal a(decimal amount) { /* ... */ }
public bool a(string key) { /* ... */ }
public void a() { /* ... */ }
Three methods, one name, and the assembly loads, verifies, and runs exactly as before — because each resulting (name, signature) pair is still unique within the type.
Why the call still finds the right method
The obvious worry is that if names collide, calls become ambiguous. They do not, because nothing resolves a call by name at runtime. When the C# compiler emits a call, it writes a call or callvirt instruction whose operand is a metadata token — a numeric handle pointing at one specific MethodDef or MemberRef row, with the full signature recorded alongside.
// A call site targeting CalculateTax(decimal), before and after renaming.
ldarg.0
ldloc.0
callvirt instance valuetype Decimal Acme.Billing::a(valuetype Decimal)
// ^name is "a" ^signature is still (decimal)->decimal
The token and the embedded signature are what the loader binds, once, to exactly one row. Renaming rewrote the Name column of that row and rewrote the name spelled at the call site — but it did not change which row the token selects, and it did not change the signature. So a call that targeted CalculateTax(decimal) before targets that identical method after, even though a dozen other methods now also answer to a. The name is a convenience for humans reading metadata; the runtime navigates by token and signature and never notices the collision.
Across different types the constraint is looser still. The declaring type is part of a method’s identity, so every type may freely have its own method named a with any signature — including a signature identical to another type’s a. Fields behave the same way: a field is identified by its name and declaring type, so each type gets its own a field, and a type can even hold a field named a and a method named a at once, because fields and methods live in different tables.
The rules that bound it
Collision renaming is aggressive, not unlimited. A correct renamer has to respect the same rules the metadata and the type system impose, or it produces an assembly that fails to load.
- Same signature, same type. Two methods in one type cannot share both name and signature — that is a duplicate row the runtime rejects with a
TypeLoadException. So collisions may only be assigned among methods whose signatures already differ. The renamer computes, per type, which members can safely share a name. - Virtual override chains. An override must carry the same name and signature as the base method it overrides, all the way down the hierarchy, or the vtable slot no longer matches and the override silently stops dispatching. Virtual methods and every override of them are therefore renamed together, in lockstep, to one shared name.
- Explicit interface implementations. A member like
void IDisposable.Dispose()records the interface method it implements by token; its name in metadata is decorated (IDisposable.Dispose) and the mapping must stay consistent. These are renamed only in coordination with the interface, or preserved. - Anything resolved by string. Members a serializer matches by name, a framework discovers by convention, or your own code looks up via reflection or
nameofmust be excluded — the same preservation rules that apply to any renaming apply here, unchanged.
What the attacker sees
Unique renaming still leaves a usable map. If a always means one thing, an analyst can follow a through the assembly and slowly build understanding — the names are opaque, but they are consistent. Collision renaming takes that away. Reuse one name across many unrelated methods and fields and the decompiled output becomes a wall of a.a(a) where each a denotes something different depending on the surrounding type and the argument signature. There is no landmark left; the one tool an analyst has for free — “this name is that thing” — stops working.
// What the decompiler faithfully emits after collision renaming.
// Every 'a' here is a different member, told apart only by signature.
a a = this.a.a(a.a);
if (a.a((string)a))
a.a();
That is not a contrived example; it is the honest output of a decompiler reproducing name reuse that only the signatures made legal. In the sharper cases the emitted C# is not even directly recompilable without inserting explicit casts to steer overload resolution — the decompiler recovered valid IL into source that the C# language rules would call ambiguous. The attacker has to reconstruct, by signature, the distinctions the names used to carry for free.
There is a mechanical dividend too: the #Strings heap shrinks. A naive renamer still needs thousands of distinct short strings; collision renaming references one "a" from hundreds of rows, so the heap holds a handful of entries instead. Smaller metadata is a minor, welcome side effect of the main goal, which is to make orientation — the first thing any reverse-engineer does — cost real time. Nebula does not collapse names blindly; it collapses exactly as far as the signature rules, the override chains, and your preservation list allow, and no further.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.