Skip to content
← Alle Beiträge
· Delta1 Labs DecompilationGlass

Modulinitialisierer dekompilieren: der statische Konstruktor von <Module> und ModuleInitializerAttribute

Eine mit [ModuleInitializer] markierte Methode läuft vor allem anderen in der Assembly, aber das Attribut ist nicht das, was die Runtime aufruft. Der C#-Compiler erzeugt einen statischen Konstruktor auf dem speziellen Pseudotyp <Module>, der jeden Initialisierer der Reihe nach aufruft. Hier ist das exakte IL, das der Compiler erzeugt, die Metadaten-Signale, die den <Module>-cctor markieren und ihn von einem gewöhnlichen Typinitialisierer unterscheiden, und wie Glass.NET das Ganze zu den [ModuleInitializer]-Methoden zurückliest, die Sie geschrieben haben.

Eine mit [ModuleInitializer] markierte Methode läuft vor allem anderen in der Assembly: vor Main, bevor das erste statische Feld berührt wird, bevor irgendein Typ verwendet wird. Es ist der früheste Benutzercode, den die Runtime ausführen wird. Aber das Attribut ist nicht das, was die Runtime aufruft. Öffnen Sie die Assembly in einem Dekompilierer, und der Mechanismus wird sichtbar: ein statischer Konstruktor auf einem Typ namens <Module>, den Sie nicht von Hand schreiben können und der Ihren Initialisierer für Sie aufruft.

Dies ist ein praktischer Tiefgang in diesen Mechanismus: was der C#-Compiler für eine oder mehrere [ModuleInitializer]-Methoden erzeugt, die Metadaten, die den <Module>-Konstruktor markieren und ihn von einem gewöhnlichen Typinitialisierer trennen, und wie Glass.NET das Ganze zu den attributierten Methoden zurückliest, mit denen Sie begonnen haben. Glass ist ein kostenloser .NET-Dekompilierer, und alles hier ist reine statische Analyse: das Lesen von Metadaten und IL aus einer Assembly, die Sie inspizieren dürfen, ohne sie jemals auszuführen.

Der Quellcode

Ein einzelner Initialisierer, der ein Bereitschafts-Flag umlegt — so schlicht wie es geht:

using System.Runtime.CompilerServices;

namespace Acme.Diagnostics;

internal static class Bootstrap
{
    internal static bool Ready;

    [ModuleInitializer]
    internal static void Init()
    {
        Ready = true;
    }
}

Die Methode muss einen festen Satz von Regeln erfüllen, sonst weist der Compiler sie ab: Sie muss static und parameterlos sein, void zurückgeben, nicht generisch sein und nicht in einem generischen Typ liegen, und sie muss für das Modul zugänglich sein (also internal oder public, niemals private oder protected). Diese Regeln sind nicht optional — markieren Sie eine private-Methode mit [ModuleInitializer], erhalten Sie einen Build-Fehler, kein stillschweigend verworfenes Attribut. Sie sind hier wichtig, weil sie zugleich die Signatur sind, auf die ein Dekompilierer anspricht, um den generierten Dispatcher zu erkennen.

Was der Compiler erzeugt

Ihre Methode bleibt fast genau so, wie Sie sie geschrieben haben, Attribut inklusive. Der interessante Teil ist eine zweite Sache, die der Compiler synthetisiert: ein statischer Konstruktor auf dem speziellen Typ <Module>. In eine lesbare Form dekompiliert, ist der erzeugte Konstruktor nur ein Aufruf:

// synthetisiert — keine C#-Syntax kann das deklarieren
static <Module>()
{
    Acme.Diagnostics.Bootstrap.Init();
}

Der Typ <Module> ist keine Klasse, die Sie geschrieben haben oder schreiben könnten. Er ist der Modul-Pseudotyp — Zeile eins der TypeDef-Metadatentabelle — der Container, den die CLI für Member auf Modulebene und für den Modulinitialisierer reserviert. Es gibt keine C#-Syntax, um ihn zu deklarieren oder ihm einen Konstruktor zu geben, was der ganze Grund für die Existenz von [ModuleInitializer] ist: Das Attribut ist der einzige sprachliche Weg, Code in diesen Slot einzuschleusen.

Hier ist das IL, in dem beide Hälften des Musters auf einmal sichtbar sind:

.class private auto ansi '<Module>'
{
  .method private hidebysig static specialname rtspecialname
          void .cctor() cil managed
  {
    .maxstack  8
    call       void Acme.Diagnostics.Bootstrap::Init()
    ret
  }
}

.class private auto ansi sealed beforefieldinit
       Acme.Diagnostics.Bootstrap extends [System.Runtime]System.Object
{
  .method assembly hidebysig static void Init() cil managed
  {
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ModuleInitializerAttribute::.ctor() = ( 01 00 00 00 )
    .maxstack  8
    ldc.i4.1
    stsfld     bool Acme.Diagnostics.Bootstrap::Ready
    ret
  }
}

Lesen Sie es von oben. Der Typ <Module> trägt einen .cctor, markiert mit specialname rtspecialname — den Metadaten-Flags, die einen laufzeitspeziellen Konstruktor kennzeichnen. Auf einem benannten Typ ist dieselbe Form ein gewöhnlicher statischer Konstruktor; auf <Module> ist es der Modulinitialisierer. Sein Rumpf tut nichts außer call Bootstrap::Init und zurückkehren. Die Initialisierer-Logik selbst lebt in Bootstrap::Init, das weiterhin die .custom-Zeile für ModuleInitializerAttribute::.ctor() trägt — das Attribut wurde nie verbraucht oder entfernt, es bleibt als dauerhafter Nachweis der Absicht auf der Methode.

Diese Aufteilung ist die Schlüsseltatsache für jeden, der das Binärprogramm liest: Das Attribut markiert die Methode des Autors, aber die Runtime ruft den <Module>-Konstruktor auf. Der Konstruktor ist die Grundwahrheit darüber, wann der Code läuft; das Attribut ist die Grundwahrheit darüber, warum er erzeugt wurde.

Quellmethode[ModuleInitializer]static void Init()internal · void · ()synthetisierter Containerclass <Module> .cctor specialname call Bootstrap::InitMethode behält AttributInit() { Ready = true; }.custom ModuleInit..ctorAttribut nie entferntGlass-Rekonstruktion[ModuleInitializer]static void Init()cctor weggefaltet

Mehr als ein Initialisierer

Nichts hindert Sie daran, mehrere Methoden in mehreren Typen mit [ModuleInitializer] zu markieren. Der Compiler sammelt sie alle und erzeugt einen einzigen <Module>-Konstruktor, dessen Rumpf eine geradlinige Folge von Aufrufen ist. Bei zwei Initialisierern:

internal static class Logging
{
    [ModuleInitializer]
    internal static void RegisterProvider() { /* ... */ }
}

internal static class Formats
{
    [ModuleInitializer]
    internal static void RegisterResolver() { /* ... */ }
}

wird der erzeugte Konstruktor zu:

.method private hidebysig static specialname rtspecialname
        void .cctor() cil managed
{
  .maxstack  8
  call  void Acme.Diagnostics.Logging::RegisterProvider()
  call  void Acme.Diagnostics.Formats::RegisterResolver()
  ret
}

Die Reihenfolge der call-Instruktionen ist ein Implementierungsdetail des Compilers — deterministisch für eine gegebene Kompilierung, aber nicht etwas, das die Sprache über Dateien hinweg festlegt —, verlassen Sie sich also nicht darauf, dass ein Initialisierer vor einem anderen läuft. Was für das Lesen des Binärprogramms zählt, ist die Form: ein <Module>-cctor, der nur eine Liste von calls zu statischen, void-, parameterlosen Methoden ist, von denen jede ModuleInitializerAttribute trägt. Das ist ein unverkennbarer Fingerabdruck, und er ist es, der einem Dekompilierer erlaubt, jeden Aufruf seiner Quellmethode zuzuordnen, statt einen rätselhaften Konstruktor auf Modulebene zu präsentieren.

Wann er tatsächlich läuft

Die Runtime behandelt den Modulinitialisierer weitgehend wie einen Typinitialisierer, aber auf Modulebene. Er läuft genau einmal, und er läuft spätestens beim ersten Zugriff auf das Modul — dem ersten statischen Methodenaufruf, Zugriff auf ein statisches Feld, einer Typinstanziierung oder dem Aufruf des Einstiegspunkts. Sein Zeitpunkt ist präzise (nicht die träge beforefieldinit-Lockerung), sodass Sie sich darauf verlassen können, dass er abgeschlossen ist, bevor irgendein anderer Code der Assembly den Zustand beobachtet. Das macht ihn zu einem legitimen einmaligen Haken: einen Logging-Provider registrieren, einen Format-Resolver installieren, einen Cache vorwärmen. Wenn Sie bei einem Audit einen sehen, lohnt es sich, ihn genau zu lesen, gerade weil er zuerst läuft — es ist der Code, der die Umgebung formt, in der alles andere läuft.

Wie Glass ihn zurückliest

Ein naiver Dekompilierer bleibt bei den buchstäblichen Metadaten stehen: ein Typ namens <Module> mit einem statischen Konstruktor. Das ist korrekt und nutzlos — Sie können in C# keinen <Module>-Typ deklarieren, die Ausgabe lässt sich also nicht zurückkompilieren, und die eigentliche Logik ist einen call entfernt in einer Methode, deren Verbindung zum Konstruktor unsichtbar ist, solange Sie ihr nicht folgen.

Glass.NET erkennt das Muster. Es sieht den <Module>-cctor, der ausschließlich aus Aufrufen zu Methoden besteht, die jeweils ModuleInitializerAttribute tragen, faltet den synthetischen Konstruktor weg und präsentiert jedes Ziel so, wie Sie es geschrieben haben — eine normale static void-Methode mit wiederhergestelltem [ModuleInitializer]-Attribut. Sie lesen die attributierten Methoden, in dem Modul, zu dem sie gehören, ohne jeden <Module>-Geist im Baum. Sie können jederzeit auf das rohe IL hinabsteigen und den <Module>.cctor und seine Aufrufliste selbst sehen, wann immer Sie bestätigen wollen, was die Runtime genau aufruft und in welcher Reihenfolge.

Über die CLI ist dieselbe Rekonstruktion ein Befehl. Um ein baubares Projekt zu rekonstruieren:

$ glass export ./Acme.Diagnostics.dll -o ./out/Acme.Diagnostics

oder um nur den Initialisierer herauszuziehen und zu bestätigen, dass das Attribut den Hin- und Rückweg überlebt hat:

$ glass export ./Acme.Diagnostics.dll --member "M:Acme.Diagnostics.Bootstrap.Init"

Beide lesen die Metadaten und das IL der Assembly und rekonstruieren C#; keiner führt eine einzige Zeile davon aus.

Das Fazit für die Praxis

Wenn Sie eine Assembly öffnen und einen Typ namens <Module> mit einem statischen Konstruktor finden — besonders einen, der nichts weiter als eine Liste von call-Instruktionen ist —, sehen Sie nichts Exotisches oder Handgestricktes. Sie sehen Modulinitialisierer: gewöhnliche mit [ModuleInitializer] markierte Methoden, die der Compiler in den einen Metadaten-Slot verdrahtet hat, der vor allem anderen läuft. Finden Sie den <Module>-cctor, um zu sehen, was zuerst läuft und in welcher Reihenfolge; lesen Sie die aufgerufenen Methoden (oder lassen Sie Glass sie in ihre attributierte Form zurückfalten), um zu sehen, was sie tun. Da dies der früheste Code der Assembly ist, ist er bei Interop-Arbeit, Debugging oder einem Sicherheitsaudit oft das Interessanteste, das man zuerst liest. Glass.NET ist kostenlos und erledigt all das durch das Lesen des Binärprogramms — Sie können also genau sehen, was eine Assembly, die Sie analysieren dürfen, beim Laden tut, ohne sie jemals zu laden.

Nebula.NET testen

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