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

Umbenennen bis zur Kollision: wie Nebula einen Namen über viele Member hinweg wiederverwendet und warum die CLR sie anhand der Signatur unterscheidet

Ein naiver Umbenenner gibt jedem Member einen eindeutigen kurzen Namen — a, b, c, d — und hört dort auf. Er lässt Potenzial liegen, denn .NET identifiziert ein Member nicht über seinen Namen. Eine Methode wird durch ihren Namen zusammen mit ihrer Signatur identifiziert, ein Feld durch seinen Namen und seinen deklarierenden Typ, und die Laufzeit bindet jeden Aufruf über ein Metadaten-Token, das beim Laden aufgelöst wird — nicht durch erneutes Abgleichen von Zeichenketten. Das bedeutet, Sie können Dutzende nicht zusammenhängender Methoden auf den einen Namen a umbenennen, solange ihre Signaturen sich unterscheiden, und die Assembly lädt, verifiziert und läuft trotzdem. Dieser Beitrag liest die MethodDef- und String-Heap-Metadaten, um zu zeigen, warum Kollisions-Umbenennung zulässig ist, geht die exakten Regeln durch, die sie begrenzen — Kollisionen gleicher Signatur, virtuelle Override-Ketten, explizite Interface-Implementierungen — und erklärt, wie Nebula Namen auf eine winzige wiederverwendete Menge verdichtet, die sowohl den String-Heap schrumpft als auch die Ausgabe eines Decompilers in eine unlesbare Wand identisch benannter Member verwandelt.

Die meisten stellen sich Umbenennen wie ein Wörterbuch vor: Jeder Bezeichner bekommt seinen eigenen frischen, bedeutungslosen Ersatz. CalculateTax wird zu a, ValidateLicense zu b, _cachedResult zu c und so weiter durch die ganze Assembly. Das funktioniert, und das ist es, was ein Umbenenner der ersten Generation tut. Aber es setzt stillschweigend etwas voraus, das für .NET nicht gilt: dass der Name eines Members seine Identität ist. Das ist er nicht. Die CLR identifiziert ein Member über weit mehr als seinen Namen, und sobald man genau sieht, worüber, wird eine aggressivere und verwirrendere Transformation verfügbar — die Wiederverwendung desselben Namens über viele Member hinweg.

Was eine Methode tatsächlich identifiziert

Eine Methode in einer .NET-Assembly ist eine Zeile in der Metadaten-Tabelle MethodDef. Der Name ist eine Spalte — ein Offset in den #Strings-Heap —, aber er ist nicht das, was die Methode unterscheidbar macht. Innerhalb ihres deklarierenden Typs wird eine Methode durch ihren Namen zusammen mit ihrer Signatur identifiziert: Aufrufkonvention, Rückgabetyp und die geordneten Parametertypen, alle als Blob im #Blob-Heap kodiert und über die Spalte Signature der Zeile referenziert.

Das ist kein Obfuskierungstrick; es ist der alltägliche Mechanismus hinter Überladung. Betrachten Sie zwei gewöhnliche Überladungen:

public void Write(int value)    { /* ... */ }
public void Write(string value) { /* ... */ }

Das sind zwei MethodDef-Zeilen, die sich eine Namenszeichenkette teilen — beide Name-Spalten zeigen auf dasselbe "Write" im #Strings-Heap — und sich nur in ihren Signatur-Blobs unterscheiden. Die Laufzeit hat sie schon immer unterschieden. Überladung ist, in Metadaten-Begriffen, exakt Namenswiederverwendung mit unterschiedlichen Signaturen, und sie funktioniert seit v1. Ein Umbenenner, der das versteht, kann Überladungen erzeugen, wo der Quelltext nie welche hatte.

// 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()               { /* ... */ }

Drei Methoden, ein Name, und die Assembly lädt, verifiziert und läuft exakt wie zuvor — weil jedes resultierende (Name, Signatur)-Paar innerhalb des Typs weiterhin eindeutig ist.

MethodDef-TabelleName → "a" Sig → #1 (decimal)Name → "a" Sig → #2 (string)Name → "a" Sig → #3 (void)#Strings: "a"#Blob #1 decimal#Blob #2 string#Blob #3 voideine geteilte Namenszeichenkettedrei verschiedene Signatur-Blobs — das hält sie auseinander

Warum der Aufruf trotzdem die richtige Methode findet

Die naheliegende Sorge ist, dass Aufrufe mehrdeutig werden, wenn Namen kollidieren. Das tun sie nicht, denn nichts löst einen Aufruf zur Laufzeit über den Namen auf. Wenn der C#-Compiler einen Aufruf ausgibt, schreibt er eine call- oder callvirt-Instruktion, deren Operand ein Metadaten-Token ist — ein numerisches Handle, das auf eine bestimmte MethodDef- oder MemberRef-Zeile zeigt, mit der vollständigen Signatur daneben festgehalten.

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

Das Token und die eingebettete Signatur sind das, was der Loader einmal an exakt eine Zeile bindet. Das Umbenennen hat die Name-Spalte dieser Zeile umgeschrieben und den an der Aufrufstelle buchstabierten Namen umgeschrieben — aber es hat nicht geändert, welche Zeile das Token auswählt, und es hat die Signatur nicht geändert. Ein Aufruf, der vorher CalculateTax(decimal) anvisierte, visiert danach dieselbe identische Methode an, obwohl jetzt ein Dutzend anderer Methoden ebenfalls auf a hören. Der Name ist eine Bequemlichkeit für Menschen, die Metadaten lesen; die Laufzeit navigiert über Token und Signatur und bemerkt die Kollision nie.

Über verschiedene Typen hinweg ist die Einschränkung noch lockerer. Der deklarierende Typ ist Teil der Identität einer Methode, also darf jeder Typ ungehindert seine eigene Methode namens a mit beliebiger Signatur haben — einschließlich einer Signatur, die mit dem a eines anderen Typs identisch ist. Felder verhalten sich genauso: Ein Feld wird durch Namen und deklarierenden Typ identifiziert, also bekommt jeder Typ sein eigenes Feld a, und ein Typ kann sogar gleichzeitig ein Feld namens a und eine Methode namens a halten, weil Felder und Methoden in verschiedenen Tabellen leben.

Die Regeln, die es begrenzen

Kollisions-Umbenennung ist aggressiv, nicht unbegrenzt. Ein korrekter Umbenenner muss dieselben Regeln respektieren, die Metadaten und Typsystem auferlegen, sonst erzeugt er eine Assembly, die nicht lädt.

  • Gleiche Signatur, gleicher Typ. Zwei Methoden in einem Typ können sich nicht sowohl Name als auch Signatur teilen — das ist eine doppelte Zeile, die die Laufzeit mit einer TypeLoadException ablehnt. Kollisionen dürfen also nur unter Methoden zugewiesen werden, deren Signaturen sich bereits unterscheiden. Der Umbenenner berechnet pro Typ, welche Member sich gefahrlos einen Namen teilen können.
  • Virtuelle Override-Ketten. Ein Override muss denselben Namen und dieselbe Signatur tragen wie die Basismethode, die er überschreibt, die gesamte Hierarchie hinunter, sonst passt der vtable-Slot nicht mehr und der Override hört stillschweigend auf zu dispatchen. Virtuelle Methoden und jeder ihrer Overrides werden deshalb gemeinsam, im Gleichschritt, auf einen geteilten Namen umbenannt.
  • Explizite Interface-Implementierungen. Ein Member wie void IDisposable.Dispose() hält die implementierte Interface-Methode per Token fest; sein Name in den Metadaten ist dekoriert (IDisposable.Dispose), und die Zuordnung muss konsistent bleiben. Diese werden nur in Abstimmung mit dem Interface umbenannt oder erhalten.
  • Alles, was über eine Zeichenkette aufgelöst wird. Member, die ein Serialisierer über den Namen abgleicht, die ein Framework per Konvention entdeckt oder die Ihr eigener Code über Reflection oder nameof nachschlägt, müssen ausgeschlossen werden — dieselben Erhaltungsregeln, die für jede Umbenennung gelten, gelten hier unverändert.
umzubenennendes Membermit wem darf es den Namen teilen?eigene Signatur im Typ → auf geteilten Namen kollidierenverdichtet den String-Heap, verwirrt den Leservirtuelle Kette / explizite Impl. → als Einheit umbenennenNamen und Signaturen müssen zusammenpassenper Zeichenkette aufgelöst → erhalten, nicht anfassen

Was der Angreifer sieht

Eindeutige Umbenennung hinterlässt immer noch eine brauchbare Karte. Wenn a immer eine Sache bedeutet, kann ein Analyst a durch die Assembly verfolgen und langsam Verständnis aufbauen — die Namen sind opak, aber sie sind konsistent. Kollisions-Umbenennung nimmt das weg. Verwenden Sie einen Namen über viele nicht zusammenhängende Methoden und Felder wieder, und die dekompilierte Ausgabe wird zu einer Wand aus a.a(a), in der jedes a je nach umgebendem Typ und Argumentsignatur etwas anderes bezeichnet. Es bleibt keine Wegmarke; das eine Werkzeug, das ein Analyst umsonst hat — „dieser Name ist jenes Ding“ —, hört auf zu funktionieren.

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

Das ist kein konstruiertes Beispiel; es ist die ehrliche Ausgabe eines Decompilers, der eine Namenswiederverwendung reproduziert, die nur die Signaturen legal gemacht haben. In den schärferen Fällen ist das ausgegebene C# nicht einmal direkt neu kompilierbar, ohne explizite Casts einzufügen, um die Überladungsauflösung zu steuern — der Decompiler hat gültiges IL in Quelltext zurückgewonnen, den die Regeln der C#-Sprache als mehrdeutig bezeichnen würden. Der Angreifer muss anhand der Signaturen die Unterscheidungen rekonstruieren, die die Namen früher kostenlos getragen haben.

Es gibt auch eine mechanische Dividende: Der #Strings-Heap schrumpft. Ein naiver Umbenenner braucht immer noch Tausende verschiedener kurzer Zeichenketten; Kollisions-Umbenennung referenziert ein "a" aus Hunderten von Zeilen, sodass der Heap eine Handvoll Einträge enthält statt vieler. Kleinere Metadaten sind ein kleiner, willkommener Nebeneffekt des Hauptziels, nämlich die Orientierung — das Erste, was jeder Reverse-Engineer tut — echte Zeit kosten zu lassen. Nebula faltet Namen nicht blind zusammen; es faltet exakt so weit, wie die Signaturregeln, die Override-Ketten und Ihre Erhaltungsliste es erlauben, und keinen Schritt weiter.

Nebula.NET testen

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