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

Wie .NET-Metadaten-Tabellen funktionieren — und wie ein Decompiler sie liest

Jede .NET-Assembly trägt eine kleine relationale Datenbank ihrer eigenen Struktur in sich: die Metadaten-Tabellen. Hier steht, was TypeDef, MethodDef und die codierten Indizes tatsächlich enthalten, wie ein Metadaten-Token eine Zeile adressiert und wie ein Decompiler die Tabellen durchläuft, um eine DLL wieder in Typen und Methoden zu verwandeln.

Öffnen Sie eine .NET-DLL in einem Hex-Editor, und das meiste davon ist kein Code. Vor dem IL, vor den Ressourcen, sitzt eine kompakte relationale Datenbank, die die eigene Struktur der Assembly beschreibt: ihre Typen, Methoden, Felder, die externen Member, die sie aufruft, die Zeichenketten und Signaturen, die sie verwendet. Das sind die Metadaten, und sie sind das Erste, was die Laufzeit liest, das Erste, was ein Decompiler liest, und der Grund, warum eine .NET-Assembly so sauber auseinandergenommen werden kann. Die Tabellen zu verstehen erklärt, was ein Werkzeug wie Glass.NET tatsächlich tut, wenn es ein Binary wieder in Typen und Methoden verwandelt.

Eine Datenbank in der Assembly

Die Metadaten leben in einer Reihe von Tabellen, jede mit einem festen, durch ECMA-335 definierten Schema. Jede Tabelle ist eine Liste von Zeilen, und jede Zeile ist ein Tupel von Spalten. Eine Spalte enthält entweder eine kleine Ganzzahl, einen Index in einen Heap (wo Daten variabler Länge wie Namen und Signaturen leben) oder einen Index in eine andere Tabelle. Dieser letzte Typ ist das, was die Metadaten relational macht: Zeilen zeigen auf Zeilen.

Die Heaps sind vier Streams, die neben den Tabellen liegen:

  • #Strings — der gesamte Bezeichner-Text (Typnamen, Methodennamen, Namespaces), jeder eine UTF-8-Folge.
  • #Blob — Binärdaten, vor allem Signaturen (die Parameter- und Rückgabetypen einer Methode sind ein Blob).
  • #GUID — Modul-Bezeichner.
  • #US — Benutzer-Zeichenketten, die Literale, die Ihr Code mit ldstr lädt.

Eine Spalte speichert nie einen Namen direkt; sie speichert einen Index in #Strings. Deshalb ist Umbenennen während der Obfuskierung billig — nur der Heap-Text ändert sich, nicht die Tabellenstruktur.

Die für die Rekonstruktion relevantesten Tabellen:

TabelleIdEine Zeile pro
Module0x00das Modul selbst
TypeRef0x01einen referenzierten (externen) Typ
TypeDef0x02einen hier definierten Typ
Field0x04ein Feld
MethodDef0x06eine hier definierte Methode
MemberRef0x0Aein referenziertes externes Member
TypeSpec0x1Beinen instanziierten generischen Typ

Was eine TypeDef-Zeile enthält

Eine TypeDef-Zeile ist der Anker für einen Typ. Ihre Spalten sind:

TypeDef:
  Flags      : UInt32          // visibility, abstract/sealed, layout
  Name       : #Strings index  // "InvoiceService"
  Namespace  : #Strings index  // "Billing"
  Extends    : TypeDefOrRef     // base type (a coded index)
  FieldList  : Field  RID       // first field; runs to next row's FieldList
  MethodList : MethodDef RID    // first method; same run trick

Zwei Ideen leisten hier viel Arbeit. Erstens sind Name und Namespace Heap-Indizes, die lesbare Identität ist also eine Dereferenzierung entfernt. Zweitens sind FieldList und MethodList bereichscodiert: Die Methoden eines Typs sind die MethodDef-Zeilen von der MethodList dieser Zeile bis (ausschließlich) zur MethodList des nächsten TypeDef. Es gibt keine explizite Anzahl — die Grenze ist der Beginn des nächsten Typs. Ein Leser, der das ignoriert und MethodList als einzelne Methode behandelt, liegt nach dem ersten Typ bei allem falsch, was ein klassischer Fehler eines erstmaligen Parsers ist.

Codierte Indizes: auf „eine von mehreren Tabellen” zeigen

Extends kann ein in dieser Assembly definierter Typ (TypeDef), ein aus einer anderen referenzierter Typ (TypeRef) oder ein TypeSpec sein. Die Spalte muss daher in eine von mehreren Tabellen zeigen. Die Metadaten lösen das mit einem codierten Index: ein paar niederwertige Bits wählen, welche Tabelle, und der Rest ist die RID. Für TypeDefOrRef wählen 2 Bits zwischen TypeDef/TypeRef/TypeSpec und die übrigen Bits sind die Zeile. Extends zu decodieren heißt also, die Tag-Bits auszumaskieren, um die Tabelle zu erfahren, und dann zu verschieben, um die Zeile zu erhalten. Codierte Indizes sind überall in den Metadaten und der Hauptgrund, warum ein naiver „als flache Struct lesen”-Ansatz scheitert; Sie müssen jede Spalte gemäß ihrem definierten Typ decodieren.

Tokens: wie das IL eine Zeile benennt

Wenn das IL sich auf eine Methode, einen Typ oder ein Feld beziehen muss, verwendet es ein Metadaten-Token — einen 4-Byte-Wert, bei dem das höchstwertige Byte die Tabellen-ID und die drei niederwertigen Bytes die RID (1-basiert) sind:

0x060x00 00 04Tabellen-IDZeilen-ID (RID)MethodDefZeile 4token = 0x06000004

So ist 0x02000003 „TypeDef, Zeile 3”; 0x0A000011 ist „MemberRef, Zeile 17”. Der Operand einer call-Instruktion ist genau ein solches Token. Um zu wissen, was call 0x0A000011 aufruft, springt der Leser zu MemberRef-Zeile 17, liest ihre Spalten Class (worauf das Member liegt) und Name/Signature und löst sie über die Heaps und andere Tabellen auf. Dieser eine Mechanismus — Token hinein, Zeile heraus — ist die Art, wie allen Querverweisen im IL gefolgt wird.

Die Tabellen durchlaufen, um eine Methode zu rekonstruieren

Setzt man alles zusammen, ist der strukturelle Durchgang des Decompilers eine Folge von Tabellen-Durchläufen:

// Pseudocode over the raw tables (names/sigs via the heaps):
foreach (var typeRow in tables.TypeDef)              // 0x02
{
    var typeName = strings[typeRow.Name];            // #Strings
    var ns       = strings[typeRow.Namespace];
    var baseType = Resolve(typeRow.Extends);         // decode coded index

    foreach (var m in MethodsOf(typeRow))            // run-encoded MethodList
    {
        var name = strings[m.Name];
        var sig  = blob[m.Signature];                // #Blob → params/return
        var il   = ReadBodyAt(m.Rva);                // RVA → IL bytes
        // tokens inside `il` resolve back through TypeRef/MemberRef/TypeDef
    }
}

ReadBodyAt(m.Rva) ist die Brücke von den Metadaten zum Code: Die Spalte RVA der MethodDef-Zeile ist eine relative virtuelle Adresse, die auf den IL-Körper der Methode in der PE-Datei zeigt. Der Körper hat einen winzigen Header (der die Codegröße und, für fette Header, ein Token für die Signatur der lokalen Variablen angibt), dann das rohe IL. Der Decompiler decodiert dieses IL, und jedes Operanden-Token schickt ihn zurück in die Tabellen. Namen kommen aus #Strings, Typformen aus den #Blob-Signaturen. Erst nach dieser strukturellen Wiedergewinnung beginnt die schwierigere, heuristische Arbeit — verifiziertes IL und Metadaten in lesbares C# zu verwandeln, Schleifen, using-Blöcke und den Rest wiederaufzubauen.

Warum das wichtig ist, wenn Sie ein Binary lesen

Weil die Struktur eine standardisierte Datenbank ist, kann ein Decompiler Ihnen die exakte Form eines Typs — Member, Basistyp, Signaturen, Attribute — mit Gewissheit zeigen, selbst wenn der Körper schwer zu rekonstruieren oder absichtlich geschützt ist. Es erklärt auch, was die Obfuskierung berühren kann und was nicht: Umbenennen schreibt die #Strings-Einträge um, lässt aber den Tabellengraphen intakt, sodass die Struktur vollständig lesbar bleibt; es sind die IL-Körper und der Kontrollfluss, die ein stärkerer Schutz umformt. Wenn Sie eine Assembly in Glass.NET öffnen und von einem Typ zu seinen Methoden zum rohen IL hinabsteigen, durchlaufen Sie genau diese Tabellen — TypeDef zu MethodDef zu RVA zu IL, und lösen dabei den ganzen Weg hinunter Tokens auf. Die Metadaten sind die Karte; der Decompiler liest sie nur laut vor.

Nebula.NET testen

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