DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

Decompiling module initializers: the <Module> static constructor and ModuleInitializerAttribute

A method marked [ModuleInitializer] runs before anything else in the assembly — before Main, before the first static field is touched, before any type is used. It is the earliest user code the runtime will run. But the attribute is not what the runtime calls. Open the assembly in a decompiler and the mechanism becomes visible: a static constructor on a type named <Module> that you cannot write by hand, calling your initializer for you.

This is a working deep-dive on that mechanism — what the C# compiler emits for one or several [ModuleInitializer] methods, the metadata that marks the <Module> constructor and separates it from an ordinary type initializer, and how Glass.NET reads it all back into the attributed methods you started with. Glass is a free .NET decompiler, and everything here is pure static analysis: reading metadata and IL from an assembly you have the right to inspect, never running it.

The source

A single initializer that flips a readiness flag — about as plain as it gets:

using System.Runtime.CompilerServices;

namespace Acme.Diagnostics;

internal static class Bootstrap
{
    internal static bool Ready;

    [ModuleInitializer]
    internal static void Init()
    {
        Ready = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

The method has to satisfy a fixed set of rules or the compiler rejects it: it must be static, parameterless, return void, be non-generic and not sit inside a generic type, and be accessible to the module (so internal or public, never private or protected). Those rules are not advisory — mark a private method with [ModuleInitializer] and you get a build error, not a silently dropped attribute. They matter here because they are also the signature a decompiler keys on to recognize the generated dispatcher.

What the compiler emits

Your method is left almost exactly as written, attribute included. The interesting part is a second thing the compiler synthesizes: a static constructor on the special <Module> type. Decompiled back to a readable shape, the generated constructor is just a call:

// synthesized — no C# syntax can declare this
static <Module>()
{
    Acme.Diagnostics.Bootstrap.Init();
}
Enter fullscreen mode Exit fullscreen mode

The <Module> type is not a class you wrote or could write. It is the module pseudo-type — row one of the TypeDef metadata table — the container the CLI reserves for module-level members and the module initializer. There is no C# syntax for declaring it or giving it a constructor, which is the whole reason [ModuleInitializer] exists: the attribute is the only language-level way to inject code into that slot.

Here is the IL, where the two halves of the pattern are visible at once:

.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
  }
}
Enter fullscreen mode Exit fullscreen mode

Read it from the top. The <Module> type carries a .cctor marked specialname rtspecialname — the metadata flags that identify a runtime-special constructor. On a named type that same shape is an ordinary static constructor; on <Module> it is the module initializer. Its body does nothing but call Bootstrap::Init and return. The initializer logic itself lives in Bootstrap::Init, which still carries the .custom row for ModuleInitializerAttribute::.ctor() — the attribute was never consumed or stripped, it is left on the method as the durable record of intent.

That split is the key fact for anyone reading the binary: the attribute marks the author's method, but the runtime calls the <Module> constructor. The constructor is the ground truth for when the code runs; the attribute is the ground truth for why it was generated.

More than one initializer

Nothing stops you from marking several methods, across several types, with [ModuleInitializer]. The compiler collects them all and emits one <Module> constructor whose body is a straight sequence of calls. Given two initializers:

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

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

the generated constructor becomes:

.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
}
Enter fullscreen mode Exit fullscreen mode

The order of the call instructions is a compiler implementation detail — deterministic for a given compilation, but not something the language specifies across files, so you should not depend on one initializer running before another. What matters for reading the binary is the shape: a <Module> cctor that is only a list of calls to static, void, parameterless methods, each of which carries ModuleInitializerAttribute. That is an unmistakable fingerprint, and it is what lets a decompiler attribute each call back to its source method instead of presenting a mysterious module-level constructor.

When it actually runs

The runtime treats the module initializer much like a type initializer, but at module scope. It runs exactly once, and it runs no later than the first access to the module — the first static method call, static field access, type instantiation, or entry-point invocation. Its timing is precise (not the lazy beforefieldinit relaxation), so you can rely on it having completed before any other code in the assembly observes state. That is what makes it a legitimate one-time hook: registering a logging provider, installing a format resolver, warming a cache. When you see one during an audit, it is worth reading closely precisely because it runs first — it is the code that shapes the environment everything else runs in.

How Glass reads it back

A naive decompiler stops at the literal metadata: a type called <Module> with a static constructor. That is accurate and useless — you cannot declare a <Module> type in C#, so the output does not round-trip, and the real logic is one call away in a method whose connection to the constructor is invisible unless you follow it.

Glass.NET recognizes the pattern. It sees the <Module> cctor consisting solely of calls to methods that each carry ModuleInitializerAttribute, folds the synthetic constructor away, and presents each target the way you wrote it — a normal static void method with its [ModuleInitializer] attribute restored. You read the attributed methods, in the module they belong to, with no <Module> ghost in the tree. You can still drop to the raw IL and see the <Module>.cctor and its call list for yourself whenever you want to confirm exactly what the runtime invokes and in what order.

From the CLI the same reconstruction is one command. To reconstruct a buildable project:

$ glass export ./Acme.Diagnostics.dll -o ./out/Acme.Diagnostics
Enter fullscreen mode Exit fullscreen mode

or to pull back just the initializer and confirm the attribute survived the round trip:

$ glass export ./Acme.Diagnostics.dll --member "M:Acme.Diagnostics.Bootstrap.Init"
Enter fullscreen mode Exit fullscreen mode

Both read the assembly's metadata and IL and reconstruct C#; neither runs a line of it.

The practical takeaway

When you open an assembly and find a type named <Module> with a static constructor — especially one that is nothing but a list of call instructions — you are not looking at anything exotic or hand-rolled. You are looking at module initializers: ordinary [ModuleInitializer]-marked methods the compiler wired into the one metadata slot that runs before everything else. Find the <Module> cctor to see what runs first and in what order; read the called methods (or let Glass fold them back to their attributed form) to see what they do. Because this is the earliest code in the assembly, it is often the most interesting thing to read first during interop work, debugging, or a security review. Glass.NET is free and does all of it by reading the binary — so you can see exactly what an assembly you have the right to analyze does at load time, without ever loading it.

Top comments (0)