Skip to content
← Tous les articles
· Delta1 Labs Décompilation.NETAnalyse approfondie

Comment fonctionnent les tables de métadonnées .NET — et comment un décompilateur les lit

Chaque assemblage .NET embarque une petite base de données relationnelle de sa propre structure : les tables de métadonnées. Voici ce que contiennent réellement TypeDef, MethodDef et les index codés, comment un token de métadonnées adresse une ligne, et comment un décompilateur parcourt les tables pour reconvertir une DLL en types et méthodes.

Ouvrez une DLL .NET dans un éditeur hexadécimal et la plus grande partie n”est pas du code. Avant l”IL, avant les ressources, se trouve une base de données relationnelle compacte qui décrit la structure même de l”assemblage : ses types, méthodes, champs, les membres externes qu”il appelle, les chaînes et signatures qu”il utilise. Ce sont les métadonnées, et elles sont la première chose que lit le runtime, la première chose que lit un décompilateur, et la raison pour laquelle un assemblage .NET peut être démonté si proprement. Comprendre les tables explique ce qu”un outil comme Glass.NET fait réellement quand il reconvertit un binaire en types et méthodes.

Une base de données à l’intérieur de l’assemblage

Les métadonnées vivent dans un ensemble de tables, chacune avec un schéma fixe défini par ECMA-335. Chaque table est une liste de lignes, et chaque ligne est un tuple de colonnes. Une colonne contient soit un petit entier, soit un index vers un heap (où vivent les données de longueur variable comme les noms et les signatures), soit un index vers une autre table. Ce dernier type est ce qui rend les métadonnées relationnelles : les lignes pointent vers des lignes.

Les heaps sont quatre streams qui se tiennent à côté des tables :

  • #Strings — tout le texte des identifiants (noms de type, noms de méthode, espaces de noms), chacun une séquence UTF-8.
  • #Blob — données binaires, surtout les signatures (les types de paramètres et de retour d”une méthode sont un blob).
  • #GUID — identifiants de module.
  • #US — chaînes utilisateur, les littéraux que votre code charge avec ldstr.

Une colonne ne stocke jamais un nom directement ; elle stocke un index vers #Strings. C”est pourquoi renommer pendant l”obscurcissement est bon marché — seul le texte du heap change, pas la structure de la table.

Les tables les plus pertinentes pour la reconstruction :

TableIdUne ligne par
Module0x00le module lui-même
TypeRef0x01un type référencé (externe)
TypeDef0x02un type défini ici
Field0x04un champ
MethodDef0x06une méthode définie ici
MemberRef0x0Aun membre externe référencé
TypeSpec0x1Bun type générique instancié

Ce que contient une ligne TypeDef

Une ligne TypeDef est l”ancre d”un type. Ses colonnes sont :

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

Deux idées font beaucoup de travail ici. D”abord, Name et Namespace sont des index de heap, donc l”identité lisible est à un déréférencement de distance. Ensuite, FieldList et MethodList sont encodés par plage : les méthodes d”un type sont les lignes MethodDef depuis le MethodList de cette ligne jusqu”au (sans l”inclure) MethodList du TypeDef suivant. Il n”y a pas de compte explicite — la limite est le début du type suivant. Un lecteur qui ignore cela et traite MethodList comme une seule méthode se trompe sur tout après le premier type, ce qui est un bug classique d”analyseur débutant.

Index codés : pointer vers « l’une de plusieurs tables »

Extends peut être un type défini dans cet assemblage (TypeDef), un type référencé depuis un autre (TypeRef), ou un TypeSpec. La colonne doit donc pointer vers l”une de plusieurs tables. Les métadonnées résolvent cela avec un index codé : quelques bits de poids faible sélectionnent quelle table, et le reste est le RID. Pour TypeDefOrRef, 2 bits choisissent parmi TypeDef/TypeRef/TypeSpec et les bits restants sont la ligne. Donc décoder Extends signifie masquer les bits d”étiquette pour connaître la table, puis décaler pour obtenir la ligne. Les index codés sont partout dans les métadonnées et sont la raison principale pour laquelle une approche naïve « le lire comme une struct plate » échoue ; vous devez décoder chaque colonne selon son type défini.

Tokens : comment l’IL nomme une ligne

Quand l”IL a besoin de se référer à une méthode, un type ou un champ, il utilise un token de métadonnées — une valeur de 4 octets où l”octet de poids fort est l”id de table et les trois octets de poids faible sont le RID (base 1) :

0x060x00 00 04id de tableid de ligne (RID)MethodDefligne 4token = 0x06000004

Ainsi 0x02000003 est « TypeDef, ligne 3 » ; 0x0A000011 est « MemberRef, ligne 17 ». L”opérande d”une instruction call est exactement un tel token. Pour savoir ce qu”invoque call 0x0A000011, le lecteur saute à la ligne 17 de MemberRef, lit ses colonnes Class (sur quoi porte le membre) et Name/Signature, et les résout à travers les heaps et les autres tables. Ce mécanisme unique — token en entrée, ligne en sortie — est la façon dont toutes les références croisées de l”IL sont suivies.

Parcourir les tables pour reconstruire une méthode

Mettez tout cela ensemble et la passe structurelle du décompilateur est une séquence de parcours de tables :

// 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) est le pont des métadonnées vers le code : la colonne RVA de la ligne MethodDef est une adresse virtuelle relative qui pointe vers le corps IL de la méthode dans le fichier PE. Le corps a un en-tête minuscule (qui donne la taille du code et, pour les en-têtes gras, un token pour la signature des variables locales), puis l”IL brut. Le décompilateur décode cet IL, et chaque token opérande le renvoie dans les tables. Les noms viennent de #Strings, les formes de type des signatures #Blob. Ce n”est qu”après cette récupération structurelle que commence le travail plus difficile et heuristique — transformer l”IL et les métadonnées vérifiés en C# lisible, reconstruire les boucles, les blocs using et le reste.

Pourquoi cela compte quand vous lisez un binaire

Parce que la structure est une base de données standardisée, un décompilateur peut vous montrer la forme exacte d”un type — membres, type de base, signatures, attributs — avec certitude, même quand le corps est difficile à reconstruire ou délibérément protégé. Cela explique aussi ce que l”obscurcissement peut et ne peut pas toucher : renommer réécrit les entrées de #Strings mais laisse intact le graphe des tables, donc la structure reste entièrement lisible ; ce sont les corps IL et le flux de contrôle qu”une protection plus forte remodèle. Quand vous ouvrez un assemblage dans Glass.NET et descendez d”un type à ses méthodes à l”IL brut, vous parcourez exactement ces tables — TypeDef vers MethodDef vers RVA vers IL, en résolvant les tokens tout le long. Les métadonnées sont la carte ; le décompilateur ne fait que les lire à voix haute.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.