Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierung.NETTiefenanalyse

Generics obfuskieren: Typparameter umbenennen, und die Constraints und die Reflection, die die Namen festnageln

Ein generischer Typparameter wie TResult ist ein Name in den Metadaten — eine Zeile in der GenericParam-Tabelle mit einem String in #Strings —, sodass ein Umbenenner ihn in T0 verwandeln kann und ein Decompiler getreu T0 ausgibt, was genau das harmlose Ergebnis ist, das Sie wollen. Aber Generics sind der Ort, an dem naives Umbenennen stillschweigend Dinge zerbricht, denn die CLR identifiziert eine Instanziierung über Position und Arität, nicht über den Namen, während Ihr Reflection-Code einen Typparameter über seinen Namen identifiziert. Dieser Beitrag liest, wozu eine generische Methode und Klasse tatsächlich kompilieren — die Tabellen GenericParam und GenericParamConstraint, die MethodSpec und TypeSpec, die eine Instanziierung per Index festhalten —, zeigt, warum das Umbenennen des Parameters kostenlos ist, das Umbenennen eines Constraint-Typs aber nicht, und geht die Reflection-Aufrufe durch (Type.GetGenericArguments().Name, MakeGenericType, GetMethod auf einem offenen Generic), die Namen zurücklesen und brechen, wenn ein Umbenenner das Falsche anfasst.

Benennen Sie eine Assembly um und dekompilieren Sie eine nicht-generische Methode, und die Signatur kollabiert schön: int a.b(string c). Tun Sie dasselbe mit einer generischen Methode, und Sie sehen oft etwas, das halbfertig wirkt — TResult a.b<TResult, TState>(TState c). Der Methodenname ist weg, der Parametername ist weg, aber die Typparameter-Namen sitzen immer noch in den spitzen Klammern, perfekt lesbar.

Das ist kein Versagen der Obfuskierung, und es zu jagen ist ein Fehler. Typparameternamen sind die harmlosesten Namen in der gesamten Assembly, und die interessante Geschichte über das Obfuskieren von Generics ist die umgekehrte: Diese Namen sind so sicher umzubenennen, dass das eigentliche Risiko darin besteht, über sie hinaus in die beiden Dinge hinein umzubenennen, die Generics tragen und die wirklich tragend sind — Constraint-Typen und die Namen, die Reflection zur Laufzeit zurückliest. Um zu sehen, wo die Grenze liegt, muss man sich ansehen, wozu ein Generic tatsächlich kompiliert.

Wozu ein Generic kompiliert

Nehmen wir eine kleine generische Methode mit ein paar Constraints:

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

Die beiden Typparameter, TState und TResult, werden nicht dort gespeichert, wo man es erwarten könnte — sie sind weder Teil des Methodennamens noch in die IL eingebacken. Sie sind Zeilen in einer eigenen Metadaten-Tabelle, GenericParam, eine pro Parameter, und jede Zeile trägt drei Dinge, die zählen: eine Number (die nullbasierte Position — TState ist 0, TResult ist 1), einen Besitzer (die Methode oder den Typ, zu dem sie gehören) und einen Name, einen Zeiger in den #Strings-Heap, der den Literaltext TState oder TResult enthält. In ILDASM liest sich der Methodenkopf so:

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

Lesen Sie den Rumpf dieser Signatur sorgfältig, denn er ist das ganze Argument. Die Parameter werden als !!0 und !!1 referenziert — über den Index, nicht über den Namen. Das Func<TState, TResult>-Argument ist Func2<!!0, !!1>: wieder positionell. Der Rückgabetyp !!1bedeutet „der zweite Methoden-Typparameter“, wie auch immer er gerade heißt. Die NamenTStateundTResulttauchen an genau einer Stelle auf — in denGenericParam.Name`-Strings — und nirgendwo im Code, der sie verwendet.

Namen leben in einer Tabelle; jede Verwendung geht über den IndexGenericParam-TabelleNumber=0 Name="TState"Number=1 Name="TResult"Signatur + Rumpf (Verwendungen)arg: Func`2<!!0, !!1>ret: !!1Umbenennen "TState"→"A", "TResult"→"B"Nur die beiden Name-Strings ändern sich. !!0 und !!1 sind Indizes — unberührt.Die CLR bindet Argumente per Slot, also löst sich jede Instanziierung weiterhin auf. Kosten des Umbenennens: null.

Wenn ein Umbenenner also TState in A und TResult in B ändert, schreibt er zwei Strings in #Strings um und berührt sonst nichts. Jedes !!0 und !!1 in der Signatur und der IL zeigt weiterhin auf den richtigen Slot, denn Slots sind nummeriert, nicht benannt. Die Methode bindet ihre Argumente genau wie zuvor. Deshalb ist das Umbenennen von Typparametern das sicherste Umbenennen im ganzen Werkzeugkasten — sicherer als das Umbenennen einer privaten Methode, denn ein privater Methodenname kann noch über einen Stack-Trace durchsickern, während ein Typparametername unter normalem Code überhaupt keinen Laufzeit-Konsumenten hat.

Auch die Instanziierung ist positionell

Dieselbe positionelle Geschichte gilt an der Aufrufstelle, der anderen Stelle, an der sich ein naiver Umbenenner Sorgen machen könnte. Wenn Sie Map<string, int>(...) schreiben, emittiert der Compiler nicht die Namen string und int gegen Parameter namens TState und TResult. Er emittiert eine MethodSpec — einen Metadaten-Blob, der sagt „instanziiere die Methode Map mit dieser geordneten Liste von Argumenttypen“ —, und die Liste ist positionell: [string, int] bedeutet, Slot 0 ist string, Slot 1 ist int. Ebenso ist ein konstruierter Typ wie List<string> ein TypeSpec-Blob, der die generische Definition und eine geordnete Argumentliste trägt.

Nirgendwo in einer MethodSpec oder TypeSpec gibt es einen Parameter-Namen. Die Bindung lautet „Argument i füllt Parameter i“. Deshalb können Sie jeden Typparameter in Ihrer Assembly in denselben einzelnen Buchstaben umbenennen — oder in etwas Bedeutungsloses —, und jede generische Instanziierung im gesamten Programm löst sich weiterhin korrekt auf. Die Auflösungsmaschinerie hatte die Namen von Anfang an nie in der Hand.

Wo das Umbenennen wirklich zubeißt: Constraints

Nun der Teil, der nicht kostenlos ist. Unsere Methode hatte where TState : notnull und where TResult : class. Diese Constraints sind echte Metadaten, und ein Obfuskator muss sie bewahren — entfernt man sie, kann die Assembly die Verifikation nicht bestehen oder die Überladungsauflösung ändern. Sie leben an zwei Stellen: in den Special-Constraint-Flags auf der GenericParam-Zeile (die Arten class/struct/new() und notnull) und, für Typ-Constraints, in Zeilen der GenericParamConstraint-Tabelle.

Ein notnull- oder class-Constraint nennt keinen Typ, es gibt also nichts umzubenennen. Aber sobald ein Constraint einen Typ nennt —

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

— wird der Constraint zu einer Referenz auf IPlugin und IDisposable, gespeichert als GenericParamConstraint-Zeilen, die auf diese Typen zeigen. Und hier liegt die entscheidende Unterscheidung: Die Constraint-Beziehung ist strukturell und überlebt, aber der Typ, den sie nennt, gehorcht den gewöhnlichen Umbenennungsregeln. IDisposable ist ein Framework-Typ — tabu, wird nie umbenannt. IPlugin gehört Ihnen: Ist er intern und schlägt nichts ihn über den Namen nach, ist das Umbenennen in a.b in Ordnung, und der Constraint zeigt einfach auf den umbenannten Typ. Ist IPlugin aber Teil eines öffentlichen Vertrags, den Ihre Kunden implementieren, oder löst irgendetwas ihn über den Namen auf, dann bricht das Umbenennen diesen Vertrag — und zwar unabhängig davon, dass die Referenz zufällig in einem generischen Constraint auftaucht. Der Constraint ist eine Ablenkung; was zählt, ist die Umbenennbarkeit des genannten Typs, beurteilt nach demselben Test, den Sie überall sonst anwenden.

Wo das Umbenennen härter zubeißt: Reflection über Typargumente

Die zweite Gefahr ist die, die Leute tatsächlich überrascht, denn sie schlägt erst zur Laufzeit fehl. Typparameternamen haben unter normalem Code keinen Konsumenten — aber Reflection kann sie zurücklesen:

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

GetGenericArguments() auf einem offenen Generic liefert die Typparameter, und .Name liest genau den GenericParam.Name-String, den ein Umbenenner umschreibt. Den meisten Code kümmert das nicht — er verwendet die Type-Objekte positionell. Aber Code, der diese Namen loggt, serialisiert oder auf ihnen verzweigt, sieht nun den umbenannten Text. Wenn davon irgendetwas eine Grenze überschreitet — ein Parametername, der in eine Diagnose geschrieben wird, die ein Kunde liest, oder in einen Cache-Schlüssel, oder gegen einen hartkodierten String abgeglichen wird —, hat das Umbenennen stillschweigend das Verhalten geändert.

Die schärfere Variante besteht darin, ein generisches Member über den Namen nachzuschlagen und es dann zu konstruieren:

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

Der verwundbare String hier ist nicht der Typparameter — es ist "LoadAll", der Methoden-Name, der an GetMethod übergeben wird. Benennen Sie LoadAll um, und dieses GetMethod liefert null, und die nächste Zeile wirft. Das ist dasselbe Reflection-bricht-Umbenennen-Problem, das für jedes Member gilt, aber Generics konzentrieren es: Reflection-lastiger generischer Code (Factories, Serialisierer, DI-Container, die offene Generics auflösen, MakeGenericType/MakeGenericMethod-Dispatch) neigt dazu, Member über den Namen nachzuschlagen und sie dann über Laufzeittypen zu schließen. Die Typargumente sind sicher; die Membernamen, die GetMethod, GetType(string) und Konsorten füttern, sind es nicht.

Wie Nebula den Umfang festlegt

Die Regel, die aus den Metadaten folgt, ist sauber, und es ist genau die, die Nebula anwendet. Typparameter werden aggressiv umbenannt, weil ihre Namen keine Laufzeitrolle spielen und das Umbenennen nichts kostet — dass Map<TState, TResult> im Decompiler zu Map<A, B> wird, ist reiner Gewinn. Die Constraint-Typen werden nach demselben Umbenennbarkeitstest beurteilt wie jeder andere Typ: intern und nicht namentlich referenziert, werden sie umbenannt; öffentlicher Vertrag oder namentlich aufgelöst, werden sie ausgeschlossen. Und die Membernamen, die Reflection aus Strings rekonstruiert, bleiben erhalten, sodass eine Factory, die GetMethod("LoadAll") aufruft, ihre Methode weiterhin findet.

In einer Nebula-Konfiguration ist das Einzige, was Sie typischerweise generics-spezifisch tun, die Reflection-Oberfläche zu schützen — die offenen generischen Typen und Methoden, die irgendetwas über den Namen nachschlägt —, während Sie die Typparameter und internen Constraint-Typen normal umbenennen lassen:

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

Das Ergebnis liest sich beim Dekompilieren so, wie Sie es wollen: Die generischen Methoden sind so undurchsichtig wie alles andere, ihre Typparameter auf bedeutungslose Buchstaben reduziert, die Logik darin umbenannt und kontrollfluss-obfuskiert — während die Handvoll Namen, die wirklich eine Laufzeitaufgabe haben, weil Reflection oder ein öffentlicher Vertrag sie liest, stehen bleiben, damit der Code weiterhin funktioniert.

Der Test, in einer Zeile

Typparameternamen können frei umbenannt werden; sie haben keinen Laufzeit-Konsumenten außer Reflection, die Sie kontrollieren. Bevor Sie ein Umbenennen irgendetwas anderes anfassen lassen, das ein Generic referenziert, stellen Sie die Frage, die Sie jedem Namen stellen: Wird dieser Name zurückgelesen — von der CLR über den Namen (nie; sie verwendet Indizes), von Reflection oder über eine Vertragsgrenze hinweg? Für einen Typparameter ist die Antwort fast immer nein, also benennen Sie ihn um. Für den Typ, den ein Constraint nennt, oder das Member, auf das ein GetMethod-String zeigt, kann die Antwort ja lauten — und dort, nicht bei den spitzen Klammern, gehört die Sorgfalt hin.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.