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

Virtuelle und Interface-Member obfuskieren: Override-Ketten, explizite Implementierungen und die MethodImpl-Tabelle, die sie zusammenhält

Eine private Methode umzubenennen ist eine lokale Änderung. Eine virtuelle Methode umzubenennen ist es nicht, denn ihr Name ist Teil davon, wie die CLR den Dispatch verdrahtet: Ein Override belegt seinen Basis-Slot erneut, indem beim Laden des Typs Name und Signatur abgeglichen werden, eine implizite Interface-Implementierung wird auf dieselbe Weise gefunden, und nur eine explizite Implementierung wird durch eine MethodImpl-Zeile gebunden, die Tokens statt Text trägt. Macht man die Gruppierung falsch, bekommt man entweder eine TypeLoadException, sobald der Typ geladen wird — oder, schlimmer, gar nichts, während sich der Override stillschweigend löst und stattdessen die Basisimplementierung läuft. Dieser Beitrag liest die IL jeder Bindung, zeigt, welche namensbasiert und welche tabellenbasiert sind, geht die drei Arten durch, auf die ein naiver Renamer sie zerstört, und erklärt, wie Nebula eine virtuelle Methode mit jedem Override und jeder Implementierung gruppiert, mit der sie verbunden ist — über Typen und Interfaces hinweg —, sodass der gesamte Slot auf einen Namen umbenannt oder intakt gelassen wird, wenn ein Mitglied davon in eine Assembly hineinreicht, die Sie nicht kontrollieren.

Benennen Sie einen privaten Helfer um, haben Sie einen String im #Strings-Heap geändert; nichts sonst in der Assembly kümmert das. Benennen Sie eine virtual-Methode um, haben Sie in den Mechanismus eingegriffen, mit dem die Laufzeit entscheidet, auf welcher Methode ein Aufruf tatsächlich landet. Dieser Mechanismus ist teils namensbasiert und teils tabellenbasiert, und der Unterschied zwischen beiden entscheidet, ob ein Umbenennen kostenlos, fatal oder — das schlimmste Ergebnis — stillschweigend falsch ist.

Dieser Beitrag liest, was die CLR tatsächlich mit einer virtuellen Methode, einem Override, einer impliziten Interface-Implementierung und einer expliziten tut; zeigt, welche dieser Bindungen ein Renamer anfassen darf; und erklärt, wie Nebula sie gruppiert, sodass sich der gesamte Slot gemeinsam bewegt oder gar nicht.

Drei Wege, auf denen eine Methode in einem Slot landet

Beginnen wir mit einer kleinen Hierarchie, die alle drei Bindungen abdeckt:

public interface IJob
{
    void Run();
    string Describe();
}

public abstract class JobBase : IJob
{
    public virtual void Run() { }                    // implicit interface implementation, and a base slot
    string IJob.Describe() => GetType().Name;        // explicit interface implementation
}

public sealed class ExportJob : JobBase
{
    public override void Run() { /* ... */ }         // implicit override of JobBase.Run
}

Kompilieren Sie sie und schauen Sie sich die Methodenköpfe an, die ILDASM ausgibt, denn die Flags und Direktiven sind die ganze Geschichte.

// JobBase
.method public hidebysig newslot virtual instance void Run() cil managed
.method private hidebysig newslot virtual final instance string
        'IJob.Describe'() cil managed
{
  .override Acme.Jobs.IJob::Describe
  ...
}

// ExportJob
.method public hidebysig virtual instance void Run() cil managed     // note: no newslot

Drei Dinge sind hier sichtbar.

JobBase.Run ist newslot virtual: Sie führt einen vtable-Slot ein. Sie implementiert auch IJob.Run, aber nichts in ihren Metadaten sagt das — keine Direktive, kein Attribut, kein Verweis auf IJob an der Methode. Die CLR findet das beim Laden des Typs heraus, indem sie die Methoden des Interfaces durchgeht und auf der Klasse eine öffentliche virtuelle Methode mit demselben Namen und derselben Signatur findet. Das ist eine implizite Interface-Implementierung, und sie ist namensbasiert gebunden.

ExportJob.Run ist virtual ohne newslot. Dieses Flag — oder vielmehr sein Fehlen, „reuse slot“ — sagt dem Loader: „Finde eine virtuelle Methode auf einem Basistyp mit meinem Namen und meiner Signatur und übernimm ihren Slot.“ Das ist ein impliziter Override, und auch er ist namensbasiert gebunden. Findet der Loader keine Übereinstimmung, schlägt er nicht fehl; er vergibt stillschweigend einen neuen Slot, genau so, als hätte die Methode newslot gesagt.

IJob.Describe auf JobBase ist von anderer Art. Sie ist privat, newslot virtual final, benannt mit dem gepunkteten String 'IJob.Describe' — und sie trägt eine .override-Direktive. Diese Direktive ist eine Zeile in der MethodImpl-Metadatentabelle mit zwei Spalten: MethodBody (das Token dieser Methode) und MethodDeclaration (das Token von IJob::Describe). Der Loader liest die Zeile und verdrahtet den Slot aus den Tokens. Der Name der Methode wird nie konsultiert; sie könnte x heißen, und der Override würde sich identisch auflösen. Das ist eine explizite Implementierung, tabellenbasiert gebunden.

Wie eine Methode in einem Slot landet — und was der Loader liest, um das zu entscheidenImpliziter Override.method virtual (kein newslot)Loader sucht in Basistypen nach Name + SignaturNAMENSGEBUNDEN · Basis + Override gemeinsam umbenennenImplizite Interface-Implementierungpublic virtual void Run() — keine DirektiveLoader sucht in der Klasse nach Name + SignaturNAMENSGEBUNDEN · Interface + Klasse gemeinsam umbenennenExplizite Implementierung / expliziter Override.override Acme.Jobs.IJob::Describe → MethodImpl-Zeile { MethodBody = 0x06000012, MethodDeclaration = 0x06000003 }Loader verdrahtet den Slot aus den beiden Tokens; der eigene Name der Methode ('IJob.Describe') wird nie gelesenTOKENGEBUNDEN · der Name der implementierenden Methode ist frei; die Deklaration, auf die sie zeigt, folgt ihrer eigenen Gruppe

Was das für einen Renamer bedeutet

Stellt man die drei Bindungen nebeneinander, ergibt sich die Regel von selbst.

Explizite Implementierungen sind frei. Die MethodImpl-Zeile trägt Tokens. Benennen Sie 'IJob.Describe' in a um, und die Zeile sagt immer noch „Rumpf 0x06000012 implementiert Deklaration 0x06000003“; der Loader schaut nie auf den String. Ein Decompiler zeigt string IJob.a() oder, wenn die Interface-Methode ebenfalls umbenannt wurde, string b.a() — aber am Dispatch hat sich nichts geändert. (Dasselbe gilt für ein C#-override, das der Compiler mit einem expliziten .override emittiert hat, was er in manchen Fällen mit kovarianten Rückgabetypen und generischer Varianz tut.)

Implizite Bindungen sind tragend, in beide Richtungen. ExportJob.Run findet JobBase.Run über den Namen. JobBase.Run wird von IJob.Run über den Namen gefunden. Benennen Sie eine der drei um, und die Kette bricht an diesem Glied. Sie können also nicht einzeln umbenannt werden — nur gemeinsam, auf denselben neuen Namen. Und „gemeinsam“ ist transitiv: Sollte JobBase.Run zufällig auch IRunnable.Run aus einem zweiten Interface implementieren, tritt die Methode dieses Interfaces der Menge bei, und ebenso jede andere Klasse, die IRunnable.Run implizit implementiert, und jeder Override davon. Die Einheit des Umbenennens ist keine Methode; es ist die Zusammenhangskomponente von Methoden, die durch Overrides und Implementierungen verbunden sind.

Ist ein Mitglied der Komponente extern, ist die gesamte Komponente eingefroren. Angenommen, JobBase überschreibt auch object.ToString(). Dieser Override ist namensbasiert an eine Methode in System.Private.CoreLib gebunden, die Sie nicht umbenennen. Benennen Sie den Override um, löst er sich; also muss er seinen Namen behalten — und per Transitivität von oben auch alles, was mit ihm verbunden ist. Dasselbe gilt für IDisposable.Dispose, Stream.Read, ein Interface aus einem NuGet-Paket, das keine Eingabe des Laufs ist, oder ein öffentliches Interface in einer Bibliothek, deren API Sie bewahren. Die Grenze der Assembly (oder des Multi-Assembly-Laufs) ist die Grenze dessen, was sich bewegen kann.

Die drei Fehlermodi, konkret

Der Grund, warum das Sorgfalt braucht, ist, dass die drei Arten, es falsch zu machen, sehr unterschiedlich scheitern, und die leiseste ist die gefährlichste.

Interface umbenannt, Implementierung nicht → TypeLoadException. Benennen Sie IJob.Run in a um, aber lassen Sie JobBase.Run in Ruhe. Sobald irgendetwas JobBase zum ersten Mal berührt, baut der Loader dessen Interface-Map, sucht nach einer Methode, die zu a() mit der richtigen Signatur passt, findet keine und weigert sich, den Typ zu laden:

System.TypeLoadException: Method 'a' in type 'Acme.Jobs.JobBase' from assembly
'Acme.Jobs, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' does not have an implementation.

Laut, sofort und leicht zuzuordnen — das ist das gute Scheitern. Beachten Sie, dass es vom Loader geworfen wird, nicht von einem Aufruf, es feuert also selbst dann, wenn Run nie aufgerufen wird, und es reißt den ganzen Typ mit, nicht nur eine Methode.

Override umbenannt, Basis nicht → stilles Loslösen. Benennen Sie ExportJob.Run in a um, aber lassen Sie JobBase.Run stehen. ExportJob.a ist immer noch virtual ohne newslot, also sucht der Loader nach einer virtuellen Basismethode namens a mit dieser Signatur. Es gibt keine. Der Loader wirft nicht; die Spezifikation sagt, dass eine Reuse-Slot-Virtual ohne passende Basis einfach einen neuen Slot bekommt. ExportJob lädt einwandfrei. Und jeder Aufruf von job.Run() über eine IJob- oder JobBase-Referenz dispatcht jetzt an JobBase.Run — den leeren Basisrumpf —, denn das ist, was im geerbten Slot sitzt. Der Override ist zu einer unverbundenen Methode geworden, die niemand aufruft. Es gibt keine Ausnahme, keine Warnung, keine Logzeile; der Export findet einfach stillschweigend nicht mehr statt. Das ist der Fehlermodus, der ausgeliefert wird.

Implementierung eines externen Interfaces umbenannt → TypeLoadException, möglicherweise weit entfernt vom Umbenennen. Benennen Sie Dispose auf einer Klasse um, die IDisposable implizit implementiert, und Sie bekommen das Loader-Scheitern von oben — aber die „fehlende“ Methode ist System.IDisposable.Dispose, definiert in einer Assembly, die Sie nie angefasst haben, und der Typ, der scheitert, kann ein winziger internal-Helfer sein, der nichts mit dem Code zu tun hat, an den Sie gedacht haben. Die Variante, die in der Praxis zuschlägt, ist das Interface aus einem Paket: Ihr Typ implementiert IHostedService oder IValueConverter oder IEqualityComparer<T>, die Paket-Assembly ist keine Eingabe des Obfuskierungslaufs, und die implizite Implementierung wird umbenannt, weil nichts im Modul sie festgehalten hat.

Wie Nebula die Slots gruppiert

Nebulas Renamer benennt keine Methoden um; er benennt Gruppen um. Bevor irgendein Name vergeben wird, baut eine Methodengruppen-Analyse ein Union-Find über jede Methode im Modul auf, und zwar mit genau den drei Beziehungen von oben:

  1. Explizite Overrides — jeder .override-Eintrag verbindet den Rumpf mit der Deklaration, die er implementiert, sofern diese Deklaration in diesem Modul lebt.
  2. Implizite Basis-Overrides — jede Reuse-Slot-Virtual wird mit der virtuellen Methode des Basistyps mit demselben Namen und derselben Signatur verbunden, die Basiskette aufwärts.
  3. Implizite Interface-Implementierungen — für jedes Interface, das der Typ implementiert (Interface-Vererbung und Basiskette eingeschlossen), wird jede Interface-Methode mit der passenden virtuellen Methode der Klasse verbunden.

Das Ergebnis ist ein Repräsentant pro Zusammenhangskomponente. Dann wird die Grenzregel angewendet: Eine Gruppe wird als erhalten markiert, wenn irgendein Mitglied davon etwas außerhalb des Moduls überschreibt oder implementiert — die Basiskette, die System.Object erreicht, ein .override, dessen Deklaration sich zu einer anderen Assembly auflöst, ein Interface, das der Resolver sehen kann, aber nicht besitzt. Die Erhaltung wird auch in die andere Richtung propagiert: Muss ein einzelnes Mitglied einer Gruppe aus irgendeinem Grund seinen Namen behalten — es ist öffentlich unter preservePublicApi, es steht in exclude, die automatische Erkennung hat Reflection darauf gefunden —, behält die gesamte Gruppe ihren Namen, denn ein halb umbenannter Slot ist das stille Loslösen von oben.

Jede Gruppe, die übrig bleibt, wird auf einen Namen umbenannt. IJob.Run, JobBase.Run und ExportJob.Run werden allesamt zu, sagen wir, a; der namensbasierte Abgleich des Loaders findet a auf jedem Typ genau so, wie er Run gefunden hat, und das explizite 'IJob.Describe' wird in derselben Gruppe wie IJob.Describe umbenannt, da seine .override-Zeile beide verbindet.

Es gibt noch einen Fall, den man kennen sollte, weil er eine Logzeile erzeugt. Wenn ein Typ ein Interface implementiert, das zur Obfuskierungszeit nicht aufgelöst werden kann — die referenzierte Assembly liegt nicht neben der Eingabe, oder es ist ein Paket, das Nebula nicht finden kann —, kann Nebula die Member dieses Interfaces nicht sehen und daher nicht sagen, welche virtuellen Methoden des Typs es implizit implementieren. Die sichere Antwort ist, jede virtuelle Methode auf diesem Typ zu erhalten und das zu sagen:

warn: preserved 4 virtual member(s) of 'Acme.Jobs.ExportJob' (implements an unresolved interface; names may be part of that contract).

Wenn Sie diese Warnung sehen, besteht die Lösung meist darin, die referenzierte Assembly auflösbar zu machen (den Build-Ausgabeordner intakt lassen oder Nebula darauf zeigen), wonach sich das Interface als extern auflöst, nur die Methoden, die es tatsächlich implementieren, festgehalten werden und der Rest des Typs normal umbenannt wird.

Standard-Interface-Methoden, Generics und Varianz

Einige neuere Sprachfeatures sitzen auf derselben Maschinerie und erben ihre Regeln.

Eine Standard-Interface-Methode (C# 8) ist einfach eine virtuelle Methode mit Rumpf, die auf dem Interface deklariert ist. Eine Klasse, die sie nicht überschreibt, bekommt die Implementierung des Interfaces über die Interface-Map; eine Klasse, die sie überschreibt, tut das mit derselben namensbasierten impliziten Bindung oder einem expliziten .override. So oder so gehört die Methode zur Gruppe der Interface-Methode, und das Umbenennen folgt der Gruppe.

Ein generisches Interface wie IRepository<T> fügt nichts Neues hinzu: Das Implementieren von IRepository<Customer> verbindet das Load der Klasse über Name und Signatur mit substituiertem T mit IRepository<T>.Load, und die Methode des offenen Generics ist das Gruppenmitglied. Generics obfuskieren behandelt, warum die Typparameter selbst frei sind.

Kovariante Rückgabetypen (C# 9) und manche Varianzfälle lassen den Compiler selbst für das, was Sie als gewöhnliches override geschrieben haben, ein .override emittieren, plus ein [PreserveBaseOverrides]-Attribut — was schlicht wieder die explizite, tokenbasierte Bindung ist. Nebula liest die MethodImpl-Zeilen, also werden diese identisch behandelt.

Die Reflection-Ausnahme

Alles oben handelt davon, was der Loader liest. Es gibt einen zweiten Leser von Interface-Member-Namen, und das ist Ihr eigener Code. Eine Bibliothek für dynamische Proxys, ein DI-Decorator, ein Interception-Framework — alles, was zur Laufzeit Typen baut oder Member mit GetMethod("Run") oder GetInterfaceMap auflöst und dann Namen vergleicht — hängt davon ab, dass die Namen überleben:

// A decorator that forwards by name: breaks if IJob.Run is renamed.
MethodInfo target = typeof(IJob).GetMethod("Run")!;

Nebulas automatische Erkennung fängt die üblichen Muster ab (String-Literale, die Member-Namen auf reflektierten Typen entsprechen, Serialisierungs- und DI-Konventionen), und weil sich die Erhaltung über die Gruppe propagiert, hält das Festhalten von IJob.Run automatisch jede Implementierung davon fest. Für die Fälle, die sie nicht sehen kann — ein zur Laufzeit zusammengesetzter Name, eine Konvention in einer Drittbibliothek —, schließen Sie das Interface explizit aus, und die Gruppe folgt.

Die Konfiguration

Für eine Bibliothek, deren Interfaces ihr öffentlicher Vertrag sind, behält preservePublicApi öffentliche Interfaces und öffentliche Implementierungen von selbst, und die Gruppierung stellt sicher, dass kein interner Override einer öffentlichen virtuellen Methode unter ihr weg umbenannt wird. Für eine Anwendung benennen Sie alles um und schließen nur die Vertragstypen aus, an die Reflection oder externe Plugins über den Namen binden:

{
  "inputs": ["out/Acme.Jobs.dll", "out/Acme.Host.dll"],
  "outputDirectory": "out/obf",
  "preservePublicApi": false,
  "crossAssemblyRename": true,
  "exclude": [
    "Acme.Jobs.IPlugin"
  ],
  "controlFlowObfuscation": true,
  "encryptStrings": true
}

Zwei Anmerkungen dazu. crossAssemblyRename mit beiden Assemblys als Eingaben ist das, was einem virtuellen Slot, der Acme.Jobs und Acme.Host überspannt — eine Basisklasse in der einen, Overrides in der anderen —, erlaubt, als eine einzige Gruppe umbenannt zu werden, statt an der Assembly-Grenze eingefroren zu sein; mit nur einer der beiden als Eingabe ist der Slot extern für den Lauf und bleibt. Und das Ausschließen von IPlugin über den Typnamen erhält dessen Member, was per Propagation jede Klasse erhält, die sie implementiert, ohne dass die Implementierer aufgelistet werden müssten.

Dekompilieren Sie das Ergebnis, und die Hierarchie liest sich so, wie Sie es wollen: interface a { void b(); string c(); }, abstract class d : a { public virtual void b() {} string a.c() ... }, sealed class e : d { public override void b() ... } — jeder Slot intakt, jeder Name verschwunden, und ToString() und Dispose() stehen immer noch genau dort, wo die Laufzeit sie erwartet.

Die Regel, in einer Zeile

Der Name einer virtuellen Methode wird mit jedem Override und jeder Implementierung geteilt, mit der sie verbunden ist, also wird sie als Gruppe umbenannt oder gar nicht — und die Gruppe ist in dem Moment eingefroren, in dem irgendein Mitglied davon an etwas außerhalb der Assemblys bindet, die Sie umbenennen. Explizite Implementierungen sind über die MethodImpl-Tabelle gebunden und kosten beim Umbenennen nichts; implizite sind über den Namen gebunden und kosten alles, wenn Sie sie allein umbenennen. Die Aufgabe des Obfuskators ist es, zu wissen, was was ist, und nie einen Slot halb umbenannt zurückzulassen.

Nebula.NET testen

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