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

Obfuscating generics: renaming type parameters, and the constraints and reflection that pin the names down

A generic type parameter like TResult is a name in the metadata — a row in the GenericParam table with a string in #Strings — so a renamer can turn it into T0 and a decompiler will faithfully print T0, which is exactly the harmless outcome you want. But generics are where naive renaming quietly breaks things, because the CLR identifies an instantiation by position and arity, not by name, while your reflection code identifies a type parameter by its name. This post reads what a generic method and class actually compile to — the GenericParam and GenericParamConstraint tables, the MethodSpec and TypeSpec that record an instantiation by index — shows why renaming the parameter is free but renaming a constraint type is not, and walks the reflection calls (Type.GetGenericArguments().Name, MakeGenericType, GetMethod on an open generic) that read names back and break when a renamer touches the wrong thing.

Rename an assembly and decompile a non-generic method, and the signature collapses nicely: int a.b(string c). Do the same to a generic method and you often see something that looks half-finished — TResult a.b<TResult, TState>(TState c). The method name went, the parameter name went, but the type parameter names are still sitting there in angle brackets, perfectly readable.

That is not an obfuscation failure, and chasing it is a mistake. Type parameter names are the most harmless names in the whole assembly, and the interesting story about obfuscating generics is the opposite one: these names are so safe to rename that the real risk is renaming past them into the two things generics carry that are genuinely load-bearing — constraint types, and the names reflection reads back at runtime. To see where the line is, you have to look at what a generic actually compiles to.

What a generic compiles to

Take a small generic method with a couple of constraints:

public static TResult Map<TState, TResult>(TState state, Func<TState, TResult> f)
    where TState : notnull
    where TResult : class
{
    return f(state);
}

The two type parameters, TState and TResult, are not stored where you might expect — they are not part of the method’s name or baked into its IL. They are rows in a dedicated metadata table, GenericParam, one per parameter, and each row carries three things that matter: a Number (the zero-based position — TState is 0, TResult is 1), an owner (the method or type they belong to), and a Name, a pointer into the #Strings heap holding the literal text TState or TResult. In ILDASM the method header reads:

.method public hidebysig static !!1 Map<(class) TState, class TResult>(
        !!0 state,
        class [System.Runtime]System.Func`2<!!0, !!1> f) cil managed

Read the body of that signature carefully, because it is the whole argument. The parameters are referred to as !!0 and !!1 — by index, not by name. The Func<TState, TResult> argument is Func2<!!0, !!1>: again, positional. The !!1return type means "the second method type parameter," whatever it happens to be called. The namesTStateandTResultappear in exactly one place — theGenericParam.Name` strings — and nowhere in the code that uses them.

Names live in one table; every use is by indexGenericParam tableNumber=0 Name="TState"Number=1 Name="TResult"Signature + body (uses)arg: Func`2<!!0, !!1>ret: !!1Rename "TState"→"A", "TResult"→"B"Only the two Name strings change. !!0 and !!1 are indices — untouched.The CLR binds arguments by slot, so every instantiation still resolves. Cost of the rename: zero.

So when a renamer changes TState to A and TResult to B, it rewrites two strings in #Strings and touches nothing else. Every !!0 and !!1 in the signature and the IL keeps pointing at the right slot, because slots are numbered, not named. The method binds its arguments exactly as before. This is why renaming type parameters is the single safest rename in the toolbox — safer than renaming a private method, because a private method name can still leak through a stack trace, whereas a type parameter name has no runtime consumer at all under normal code.

Instantiation is positional too

The same positional story holds at the call site, which is the other place a naive renamer might worry about. When you write Map<string, int>(...), the compiler does not emit the names string and int against parameters called TState and TResult. It emits a MethodSpec — a metadata blob that says “instantiate method Map with this ordered list of argument types” — and the list is positional: [string, int] means slot 0 is string, slot 1 is int. Likewise a constructed type like List<string> is a TypeSpec blob carrying the generic definition and an ordered argument list.

Nowhere in a MethodSpec or TypeSpec is there a parameter name. The binding is “argument i fills parameter i.” That is why you can rename every type parameter in your assembly to the same single letter — or to nothing meaningful — and every generic instantiation across the whole program still resolves correctly. The resolution machinery never had the names in its hands to begin with.

Where renaming actually bites: constraints

Now the part that is not free. Our method had where TState : notnull and where TResult : class. Those constraints are real metadata, and an obfuscator must preserve them — strip them and the assembly may fail verification or change overload resolution. They live in two places: the special-constraint flags on the GenericParam row (the class/struct/new() kinds and notnull), and, for type constraints, rows in the GenericParamConstraint table.

A notnull or class constraint names no type, so there is nothing to rename. But the moment a constraint names a type —

public sealed class Handler<T> where T : IPlugin, IDisposable, new()
{
    // ...
}

— the constraint becomes a reference to IPlugin and IDisposable, stored as GenericParamConstraint rows pointing at those types. And here is the key distinction: the constraint relationship is structural and survives, but the type it names obeys the ordinary renaming rules. IDisposable is a framework type — off-limits, never renamed. IPlugin is yours: if it is internal and nothing looks it up by name, renaming it to a.b is fine and the constraint simply points at the renamed type. But if IPlugin is part of a public contract your customers implement, or if anything resolves it by name, then renaming it breaks that contract — and it breaks it regardless of the fact that the reference happens to appear inside a generic constraint. The constraint is a red herring; what matters is the renameability of the named type, judged by the same test you apply everywhere else.

Where renaming bites harder: reflection over type arguments

The second hazard is the one that actually surprises people, because it only fails at runtime. Type parameter names have no consumer under normal code — but reflection can read them back:

Type t = typeof(Dictionary<,>);          // open generic
foreach (Type p in t.GetGenericArguments())
    Console.WriteLine(p.Name);            // "TKey", "TValue" — or "A", "B" after renaming

GetGenericArguments() on an open generic returns the type parameters, and .Name reads the very GenericParam.Name string a renamer rewrites. Most code does not care — it uses the Type objects positionally. But code that logs, serializes, or branches on those names will now see the renamed text. If any of it crosses a boundary — a parameter name written into a diagnostic a customer reads, or into a cache key, or matched against a hard-coded string — renaming silently changed behavior.

The sharper version involves looking a generic member up by name and then constructing it:

// Find an open generic method by name, then instantiate it.
MethodInfo open = typeof(Repo)
    .GetMethod("LoadAll", BindingFlags.Public | BindingFlags.Instance)!;
MethodInfo closed = open.MakeGenericMethod(typeof(Customer));

The vulnerable string here is not the type parameter — it is "LoadAll", the method name passed to GetMethod. Rename LoadAll and this GetMethod returns null, and the next line throws. This is the same reflection-breaks-renaming problem that applies to any member, but generics concentrate it: reflection-heavy generic code (factories, serializers, DI containers that resolve open generics, MakeGenericType/MakeGenericMethod dispatch) tends to look members up by name and then close them over runtime types. The type arguments are safe; the member names feeding GetMethod, GetType(string) and friends are not.

How Nebula scopes it

The rule that falls out of the metadata is clean, and it is exactly the one Nebula applies. Type parameters are renamed aggressively, because their names have no runtime role and renaming them costs nothing — Map<TState, TResult> becoming Map<A, B> in the decompiler is pure upside. The constraint types are judged by the same renameability test as any other type: internal and unreferenced-by-name, they are renamed; public contract or name-resolved, they are excluded. And the member names that reflection reconstructs from strings are kept, so a factory that does GetMethod("LoadAll") still finds its method.

In a Nebula config, the only generics-specific thing you usually do is protect the reflection surface — the open generic types and methods something looks up by name — while leaving the type parameters and internal constraint types to be renamed normally:

<Rules>
  <!-- A factory resolves these open generics by method name at runtime: keep the names. -->
  <Rule pattern="method MyApp.Data.Repo::LoadAll" rename="false" />
  <Rule pattern="type MyApp.Plugins.IPlugin" rename="false" />
  <!-- Type parameters everywhere are renamed by default; nothing to configure. -->
</Rules>

The result reads the way you want when you decompile it: the generic methods are as opaque as everything else, their type parameters reduced to meaningless letters, the logic inside renamed and control-flow-obfuscated — while the handful of names that genuinely have a runtime job, because reflection or a public contract reads them, are left standing so the code still works.

The test, in one line

Type parameter names are free to rename; they have no runtime consumer except reflection you control. Before you let a rename touch anything else a generic references, ask the question you ask of every name: does this name get read back — by the CLR by name (it never does; it uses indices), by reflection, or across a contract boundary? For a type parameter the answer is almost always no, so rename it. For the type a constraint names, or the member a GetMethod string points at, the answer may be yes — and that, not the angle brackets, is where the care belongs.

Try Nebula.NET

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