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

Explizites Struct-Layout und Unions im C-Stil dekompilieren: [StructLayout] und [FieldOffset] aus den Metadaten rekonstruieren

Explizites Struct-Layout ist eines der wenigen C#-Features, das ein Dekompilierer nicht aus den Formen des Codes wiederherstellen kann, denn es hinterlässt keinen Code – es lebt vollständig in den Metadaten. Der LayoutKind (Sequential/Explicit/Auto) ist ein Flag auf der Typdefinition, Pack und Size stehen in der ClassLayout-Tabelle, und der Byte-Offset jedes Felds steht in der FieldLayout-Tabelle. Teilen sich zwei Felder einen Offset, hast du eine Union im C-Stil: aliasierte Bytes, ein int, der dieselben vier Bytes sieht wie ein float. Ein einziger falscher Offset, und der dekompilierte Struct beschädigt stillschweigend jeden Interop- und Marshalling-Aufruf, der ihn berührt. Dieser Beitrag zeigt, wo die Layout-Daten liegen, wie Glass.NET die Tabellen ClassLayout und FieldLayout liest und [StructLayout(LayoutKind.Explicit, Pack = ..., Size = ...)] auf dem Typ und [FieldOffset(n)] auf jedem Feld wieder ausgibt, wie es eine Union an überlappenden Offsets erkennt und warum das Bewahren der exakten Offsets nicht verhandelbar ist.

Die meisten C#-Sprachfeatures, die ein Dekompilierer wiederherstellt, sind Compiler-Zucker: Der Compiler hat ein foreach zu einem try/finally um einen Enumerator abgesenkt oder ein lock zu Monitor.Enter/Exit, und der Dekompilierer erkennt die Form des erzeugten Codes und faltet sie zurück. Explizites Struct-Layout ist nicht von dieser Art. Es gibt keinen Code zu erkennen, denn Layout ist nicht ausführbar. Ein [StructLayout(LayoutKind.Explicit)]-Struct mit [FieldOffset] auf jedem Feld kompiliert zu Metadaten – Zeilen in zwei Nebentabellen und ein paar Flag-Bits – und nichts in irgendeinem Methodenkörper erwähnt es. Ein Dekompilierer, der nur IL in Anweisungen rekonstruiert, gibt einen vollkommen plausiblen Struct aus, dessen Layout völlig falsch ist.

Das wiegt hier schwerer als fast überall sonst, denn explizites Layout existiert, um einen Speichervertrag einzuhalten, von dem etwas außerhalb deiner Assembly abhängt: ein nativer Struct, der über P/Invoke gemarshallt wird, ein Geräte-Registerblock, ein Paket-Header, ein getaggtes Union-Overlay. Die Offsets sind die Schnittstelle. Dieser Beitrag geht genau durch, wo das Layout in den Metadaten lebt, wie Glass es zurückliest und warum ein einziger verlorener Offset einer der gefährlichsten Fehler ist, die ein Dekompilierer begehen kann.

Ein Union-Struct im Quellcode

Hier ist der kanonische Fall – ein Wert, der als int, als float oder als vier einzelne Bytes gesehen werden kann, die sich alle denselben Speicher teilen:

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
    [FieldOffset(0)] public int AsInt;
    [FieldOffset(0)] public float AsFloat;
    [FieldOffset(0)] public byte Byte0;
    [FieldOffset(1)] public byte Byte1;
    [FieldOffset(2)] public byte Byte2;
    [FieldOffset(3)] public byte Byte3;
}

AsInt und AsFloat beginnen beide am Byte 0. AsInt zu schreiben und dann AsFloat zu lesen, interpretiert dieselben vier Bytes neu – ein Bit-Cast von int zu float ohne Konvertierung, genau der Trick, zu dem C-Programmierer mit einer union greifen. Die vier byte-Felder überlagern dasselbe Wort, sodass du es Byte für Byte zerlegen kannst. Es gibt kein union-Schlüsselwort in C#; die überlappenden [FieldOffset]-Attribute sind die Union.

Das Layout ist der ganze Sinn. Lägen die Felder hintereinander, wäre Register32 sieben Bytes breit und die Neuinterpretation wäre kaputt. Die Überlappung ist beabsichtigt, und sie ist in keiner Methode festgehalten – nur in den Metadaten.

Wo das Layout tatsächlich lebt

Drei unabhängige Metadaten-Stücke tragen das, und ein Dekompilierer muss alle drei lesen.

Der Layout-Kind ist ein Flag auf dem Typ. In der TypeDef-Tabelle enthält die Spalte Flags (TypeAttributes) eine Zwei-Bit-LayoutMask:

  • 0x00000000 – AutoLayout (die Runtime darf Felder umordnen)
  • 0x00000008 – SequentialLayout (der Loader packt in Deklarationsreihenfolge)
  • 0x00000010 – ExplicitLayout (jeder Offset wird von Hand angegeben)

Kein IL-Opcode setzt dies. Es ist eine Loader-Direktive, gelesen beim Laden des Typs, unsichtbar für alles, was nur die Methodenkörper betrachtet.

Pack und Size leben in der ClassLayout-Tabelle. Jede Zeile bindet einen Typ an eine PackingSize (1, 2, 4, 8, 16 oder 0 für „Standard“) und eine ClassSize (die explizite Gesamt-Size, oder 0, um die Felder entscheiden zu lassen). Ein Typ erhält nur dann eine ClassLayout-Zeile, wenn er ein nicht standardmäßiges Pack oder eine explizite Größe braucht.

Der Offset jedes Felds lebt in der FieldLayout-Tabelle. Für einen Typ mit explizitem Layout hat jedes Feld eine FieldLayout-Zeile, die das Feld mit seinem Byte-Offset paart. Das sind die Überlappungsdaten: Zwei Zeilen mit demselben Offset bedeuten zwei aliasierte Felder.

So, wie ILDasm es darstellt, sieht Register32 so aus – beachte explicit im Klassenkopf, die .size/.pack-Direktiven und den Offset in Klammern auf jedem Feld:

.class public explicit ansi sealed beforefieldinit Register32
    extends [System.Runtime]System.ValueType
{
    .pack 0
    .size 4
    .field [0] public int32 AsInt
    .field [0] public float32 AsFloat
    .field [0] public uint8 Byte0
    .field [1] public uint8 Byte1
    .field [2] public uint8 Byte2
    .field [3] public uint8 Byte3
}

Die [0], [0], [0], [1], [2], [3] vor jedem Feld sind die FieldLayout-Offsets. Drei Felder an [0] ist die Union. .size 4 ist die ClassLayout.ClassSize; .pack 0 ist das Standard-Packing. Nichts davon erscheint in irgendeiner .method – es ist rein deklarative Metadaten.

Was ein naiver Dekompilierer produziert

Ein Dekompilierer, der um die Methodenkörper herum gebaut ist, hat hier nichts, woran er sich verankern kann, denn am Layout sind keine Methodenkörper beteiligt. Die Fehlermodi sind still und gefährlich:

// FALSCH — Layout-Kind entfernt, Offsets verloren
public struct Register32
{
    public int AsInt;
    public float AsFloat;
    public byte Byte0;
    public byte Byte1;
    public byte Byte2;
    public byte Byte3;
}

Das kompiliert. Es hat sogar die richtigen Feldnamen und -typen. Aber mit entferntem Attribut fällt es auf den Compiler-Standard zurück – Sequential – und die Felder liegen nun hintereinander: AsInt bei 0, AsFloat bei 4, die Bytes bei 8, 9, 10, 11. Der Struct ist jetzt 12 Bytes breit statt 4, AsInt und AsFloat aliasieren nicht mehr, und der Neuinterpretations-Cast ist tot. Jeder P/Invoke, der diesen Struct übergab, marshallt nun einen anderen, größeren Blob. Nichts warnt dich: Die Typen passen, der Code baut, und er liest und schreibt für immer die falschen Bytes.

Ein subtilerer Fehler behält [StructLayout(LayoutKind.Explicit)], berechnet aber die Offsets aus den Feldgrößen neu, statt FieldLayout zu lesen. Das ergibt nicht überlappende Offsets (0, 4, 8, 9, 10, 11), die die Union ebenso gründlich zerstören, dabei aber überzeugender aussehen. Die einzige korrekte Quelle eines Offsets ist der Metadaten-Offset. Er muss kopiert, nie abgeleitet werden.

Die Abbildung unten stellt die beiden Layouts gegenüber – die echte Union, in der drei Felder dieselben vier Bytes aliasieren, gegen das sequenzielle Layout, das ein entferntes Attribut still erzeugt:

Explizite Union — AsInt und AsFloat aliasieren Bytes 0 bis 3 Byte 0 Byte 1 Byte 2 Byte 3 AsInt @ Offset 0 (4 Bytes) AsFloat @ Offset 0 (dieselben 4 Bytes) Byte0..Byte3 an Offsets 0,1,2,3 Size = 4. Ein Wort, auf drei Arten gesehen. Sequenziell (Attribut entfernt) — Felder hintereinander AsInt 0-3 AsFloat 4-7 B0 8 B1 9 B2 10 B3 11 12 Bytes breit. Kein Aliasing. Interop-Vertrag gebrochen. Gleiche Feldnamen und -typen in beiden. Nur die Metadaten-Offsets unterscheiden sie — also müssen sie gelesen, nicht geraten werden.

Wie Glass es rekonstruiert

Glass behandelt Layout als erstklassiges Metadaten-Anliegen, getrennt von der Methoden-Dekompilierung. Für jeden Typ durchläuft es eine kleine, explizite Pipeline:

  1. Die LayoutMask lesen aus den TypeDef-Flags. Ist sie ExplicitLayout oder AutoLayout, wird ein [StructLayout]-Attribut benötigt. Ist sie SequentialLayout und es gibt keine ClassLayout-Zeile, ist das Layout der Compiler-Standard für einen Struct und das Attribut wird weggelassen: es auszugeben wäre redundant.
  2. Die ClassLayout-Zeile lesen, falls vorhanden, für PackingSize und ClassSize. Ein Pack ungleich null wird zu Pack = n; eine Size ungleich null wird zu Size = n. Null-Werte werden weggelassen, sodass das Attribut nur trägt, was die Metadaten tatsächlich angeben.
  3. Jede FieldLayout-Zeile lesen und den exakten Byte-Offset an jedes Feld als [FieldOffset(n)] anhängen. Diese Offsets werden wörtlich aus den Metadaten kopiert: Glass berechnet sie nie aus den Feldgrößen neu.
  4. Unions erkennen, indem die gesammelten Offsets durchlaufen werden: Wann immer sich die Byte-Bereiche zweier Felder (offset bis offset + size) überlappen, wird diese Überlappung genau so bewahrt, wie sie geschrieben wurde, was die Ausgabe zu einer getreuen Union macht.

Das Ergebnis ist der Quellcode, mit dem du begonnen hast, im Layout Byte für Byte äquivalent:

[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
    [FieldOffset(0)] public int AsInt;
    [FieldOffset(0)] public float AsFloat;
    [FieldOffset(0)] public byte Byte0;
    [FieldOffset(1)] public byte Byte1;
    [FieldOffset(2)] public byte Byte2;
    [FieldOffset(3)] public byte Byte3;
}

Weil die Offsets direkt aus FieldLayout und die Size direkt aus ClassLayout kamen, marshallt der neu kompilierte Struct zum identischen Vier-Byte-Abdruck. Die int/float-Neuinterpretation funktioniert, jeder Byte-Accessor landet dort, wo er landete, und jeder P/Invoke, der Register32 übergab, übergibt weiterhin dieselben Bytes.

Getaggte Union-Overlays

Dieselbe Maschinerie bewältigt das reichere Muster der „getaggten Union“, bei dem ein Diskriminator-Feld an seinem eigenen Offset sitzt und mehrere Nutzlast-Felder weiter innen überlappen:

[StructLayout(LayoutKind.Explicit)]
public struct Variant
{
    [FieldOffset(0)] public VariantKind Kind;   // Diskriminator
    [FieldOffset(8)] public long IntValue;      // Nutzlast, aliasiert...
    [FieldOffset(8)] public double RealValue;   // ...mit diesem
    [FieldOffset(8)] public IntPtr Handle;      // ...und diesem
}

Hier wird Kind am Offset 0 gelesen, um zu entscheiden, welches der drei überlappenden Nutzlast-Felder am Offset 8 aktiv ist. Glass liest vier FieldLayout-Zeilen, sieht, dass drei davon den Offset 8 teilen, und bewahrt alle vier Offsets. Diesmal gibt es keine Size-Zeile, also wird kein Size = ausgegeben; die Runtime berechnet die Gesamtgröße aus den Feldern. Die Rekonstruktion bleibt minimal – nur die Attribute, die die Metadaten tatsächlich rechtfertigen – und hält dabei jeden Offset intakt.

Es ausführen

Richte Glass auf die Assembly und frage den einen Typ ab:

glass decompile MyApp.dll --type MyApp.Interop.Register32

Die Ausgabe ist der attributierte Struct von oben, Offsets und Size eingeschlossen. Um einen ganzen Interop-Namespace zu durchsuchen und zu bestätigen, dass jeder Typ mit explizitem Layout mit intakten Offsets den Rundlauf macht:

glass decompile MyApp.dll --namespace MyApp.Interop --layout-check

--layout-check liest die Tabellen ClassLayout und FieldLayout nach der Rekonstruktion erneut und prüft, dass die ausgegebenen Werte von [FieldOffset]/Size/Pack exakt mit den Metadaten übereinstimmen, sodass du eine Garantie – keine Hoffnung – hast, dass das dekompilierte Layout das echte ist. Glass ist ein freier, offener Dekompilierer; das ist Standardausgabe, nichts Eingeschränktes.

Warum es sich lohnt, das genau richtig zu machen

Explizites Layout ist ein Versprechen an etwas außerhalb der verwalteten Welt. Der ganze Grund, warum ein Struct [StructLayout(LayoutKind.Explicit)] trägt, ist, dass seine Bytes mit einer Definition fluchten müssen, die er nicht besitzt: einem C-Header, einem Wire-Format, einer Registerkarte. Ein Dekompilierer, der die Felder korrekt darstellt, aber die Offsets verliert, hat Code produziert, der richtig aussieht, richtig kompiliert und genau auf die am schwersten zu entdeckende Weise falsch ist: Die Datenbeschädigung geschieht an der Marshalling-Grenze, weit entfernt von jeder Zeile, die du zu prüfen gedächtest, und weder der Compiler noch die Runtime sagen ein Wort.

Die Regel, der Glass folgt, ist deshalb einfach und streng: Layout ist Metadaten, Metadaten sind Grundwahrheit, und Offsets werden kopiert, nie gefolgert. Lies die LayoutMask, lies ClassLayout, lies jede FieldLayout-Zeile und gib präzise aus, was dort steht – bis hin zu den überlappenden Offsets, die eine Union zu einer Union machen. Wenn du Interop-Code inspizierst oder debuggst, den du einsehen darfst, ist diese Treue der Unterschied zwischen Quellcode, dem du vertrauen kannst, und Quellcode, der still darüber lügt, wo seine Bytes leben.

Nebula.NET testen

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