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

Einen Array-Initialisierer dekompilieren: Die Werte liegen in einem Daten-Blob, und RuntimeHelpers.InitializeArray ist der einzige Hinweis

Schreiben Sie int[] x = { 1, 2, 3, 4, 5 }, und Sie erwarten vielleicht fünf stelem-Instruktionen, eine pro Element. Für ein kleines Array bekommen Sie ungefähr das, aber jenseits eines Schwellenwerts hört der C#-Compiler auf, Elemente einzeln zu speichern, und tut etwas Seltsameres: Er allokiert das Array und ruft dann RuntimeHelpers.InitializeArray mit einem Handle auf ein statisches Feld auf, dessen Bytes das ganze Array dicht gepackt sind, und dieses Feld hat im IL überhaupt keinen Initialisierer – seine Daten liegen in einer Datensektion der PE-Datei, auf die eine FieldRVA-Metadatenzeile zeigt, typisiert als synthetische Struktur in einer versteckten Klasse, deren einzige Eigenschaft ihre Bytelänge ist. Ein Decompiler kann das Literal nicht aus dem IL ablesen, weil das IL nur ein memcpy enthält; er muss das InitializeArray-Muster erkennen, das Feld-Token auflösen, der FieldRVA zu den rohen Bytes folgen, den Elementtyp aus dem vorangehenden newarr nehmen und den Little-Endian-Blob wieder in { 1, 2, 3, 4, 5 } aufteilen. Dieser Beitrag geht IL und Metadaten für ein blob-initialisiertes Array durch, zeigt, was Glass.NET rekonstruiert, erklärt, warum ein String-Array diesen Weg nie nimmt, und behandelt den ReadOnlySpan-of-byte-Fall, in dem es gar kein Array gibt – nur ein Feld-Handle und ein CreateSpan.

Schreiben Sie dies:

int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };

und die natürliche Vermutung ist, dass der Compiler neun Stores ausgibt – Array laden, Index laden, Wert laden, stelem.i4, neunmal hintereinander. Für ein kurzes Array tut er ungefähr das. Aber jenseits einer Handvoll Elemente wechselt der C#-Compiler zu etwas, das dem Quelltext überhaupt nicht ähnelt: Er allokiert das Array und ruft dann RuntimeHelpers.InitializeArray auf, dem er einen Zeiger auf ein statisches Feld übergibt, dessen Bytes das ganze Array sind. Die Werte sind überhaupt nicht mehr im Code. Sie wurden in eine Datensektion der Assembly gepackt, und das Einzige, was im IL übrig bleibt, ist eine Blockkopie.

Das ist ein echtes Problem für einen Decompiler, denn es gibt kein Literal zu lesen. Das IL enthält ein memcpy, nicht { 2, 3, 5, ... }. Den Initialisierer zurückzugewinnen bedeutet, das Muster zu erkennen, ein Feld-Token aus dem Instruktionsstrom heraus in die rohen Bytes der Datei zu verfolgen und diese Bytes mit einem Typ zu reinterpretieren, den das IL nur an anderer Stelle erwähnt. Dieser Beitrag geht diese Verfolgung für Glass.NET durch.

Was der Compiler tatsächlich ausgibt

Hier ist das IL für das obige Array. Die Werte fehlen auffällig:

IL_0000:  ldc.i4.s   9
IL_0002:  newarr     [System.Runtime]System.Int32          // allocate int[9] — element type lives HERE
IL_0007:  dup
IL_0008:  ldtoken    field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=36' '<PrivateImplementationDetails>'::'2BCA...E6'
IL_000d:  call       void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_0012:  stloc.0

Vier Dinge tragen den ganzen Initialisierer, und keines davon reicht für sich allein:

  • newarr [System.Runtime]System.Int32 – die einzige Stelle, an der der Elementtyp auftaucht. Der Blob, der folgt, ist untypisiert; diese Instruktion ist es, die sagt, dass die Bytes Int32-Werte sind.
  • ldc.i4.s 9 – die Elementanzahl, die der Decompiler mit der Blob-Länge gegenprüfen kann.
  • ldtoken ... '__StaticArrayInitTypeSize=36' – ein Handle auf ein synthetisches statisches Feld. Der Name kodiert die Bytelänge: 9 ints x 4 Bytes = 36. Der zweite Name (2BCA...E6) ist ein Inhalts-Hash, sodass sich zwei identische Initialisierer irgendwo in der Assembly ein Feld teilen.
  • call RuntimeHelpers::InitializeArray – das Signal. Genau dieser Aufruf, der ein Array und ein RuntimeFieldHandle nimmt, ist das Muster, auf das ein Decompiler achtet.

Die drei Sprünge zu den Bytes

Das Feld, das dieses ldtoken benennt, hat keinen verwalteten Initialisierer. Es ist ein statisches Feld eines synthetischen Werttyps – __StaticArrayInitTypeSize=36, eine Struktur, deren einziges bedeutsames Attribut [StructLayout(Size = 36)] ist – und lebt im versteckten Typ <PrivateImplementationDetails>, den der Compiler genau für diesen Zweck erzeugt. Was dem Feld seinen Wert gibt, ist eine Zeile in der FieldRVA-Metadatentabelle, die das Token des Felds auf eine relative virtuelle Adresse innerhalb der PE-Datei abbildet. An dieser Adresse liegen die 36 Bytes:

02 00 00 00  03 00 00 00  05 00 00 00  07 00 00 00  0B 00 00 00
0D 00 00 00  11 00 00 00  13 00 00 00  17 00 00 00

Jede Vierergruppe ist ein Int32, Little-Endian: 02 00 00 00 ist 2, 0B 00 00 00 ist 11, 17 00 00 00 ist 23. Um das Literal zurückzugewinnen, macht ein Decompiler also drei Sprünge – Token zu Feld, Feld zu FieldRVA-Zeile, FieldRVA zu rohen Bytes – und zerlegt die Bytes dann mit dem Typ, den er aus dem newarr gelesen hat.

IL → FieldRVA → Datensektion → LiteralILnewarr Int32 ↑ Elementtyp + Größeldtoken field ...call InitializeArrayFieldRVA-Zeiletoken → RVA 0x2104.data-Sektion02 00 00 00 03 00 00 0005 00 00 00 07 00 00 000B 00 00 00 ...rekonstruiertes Literalnew int[]{ 2, 3, 5, 7, 11, 13, 17, 19, 23 }den Blob zerlegenDer Blob sind untypisierte Bytes. newarr sagt Int32, also liest der Decompilerihn in 4-Byte-Little-Endian-Stücken: 02 00 00 00 → 2, 0B 00 00 00 → 11,17 00 00 00 → 23. Ein char[] liest 2 Bytes pro Element, ein long[] oderdouble[] liest 8 – Elementgröße und Endianness sind tragend.

Was Glass rekonstruiert

Glass.NET erkennt den InitializeArray-Aufruf, löst das RuntimeFieldHandle zum <PrivateImplementationDetails>-Feld auf, liest dessen FieldRVA-Bytes, nimmt Int32 und seine 4-Byte-Breite aus dem newarr und geht die 36 Bytes in Little-Endian-Vierergruppen durch. Es rendert genau das, was geschrieben wurde:

int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };

Die Elementgröße ist kein Detail – sie ist das, was die Zahlen richtig herauskommen lässt. Derselbe 16-Byte-Blob 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 ist { 1, 2, 3, 4 }, wenn das newarr Int32 sagt, aber { 1, 2 }, wenn es Int64 sagt, denn dann ist jedes Element acht Bytes, Little-Endian gelesen. Glass nimmt die Breite aus der Allokation, nicht aus einer Vermutung, sodass ein long[], ein double[] (bei dem die acht Bytes ein IEEE-754-Bitmuster sind) und ein char[] (zwei Bytes UTF-16 pro Element) jeweils korrekt zerlegt werden. Geteilte Blobs werden ebenfalls behandelt: Weil das Feld über einen Inhalts-Hash adressiert wird, zeigen zwei identische Initialisierer in verschiedenen Methoden auf dasselbe Feld, und Glass rendert jede Aufrufstelle als eigenes vollständiges Literal, statt die Teilung in die Ausgabe durchsickern zu lassen.

Wann die Blob-Form nicht auftaucht

Zwei Fälle bleiben elementweise, und ein Decompiler liest sie direkt aus dem IL, ohne FieldRVA-Sprung:

string[] names = { "ada", "alan", "grace" };   // references — cannot be memcpy'd
int[] sized    = new int[Count];                // not constant — no blob to copy

Ein string[] hält Objektreferenzen, keine Zeichen; die Zeiger existieren erst zur Laufzeit und sind keine Konstanten, also gibt es nichts zu packen. Der Compiler gibt pro Element dup / ldc.i4 / ldstr / stelem.ref aus, was Glass direkt als die String-Werte zurückliest. Dasselbe gilt für jedes Referenztyp-Array und für jedes Array, dessen Elemente keine Compile-Zeit-Konstanten sind – es gibt keinen konstanten Blob, also bleiben die Stores explizit. Deshalb ist die Blob-Form spezifisch eine Optimierung für blittbare primitive Konstanten: Es ist der einzige Fall, in dem der Wert des Arrays seine Bytes ist.

Der Span-Fall: gar kein Array

C# hat noch einen Trick, den man kennen sollte, weil sein IL fast wie die Array-Form aussieht, aber nichts allokiert:

ReadOnlySpan<byte> magic = [ 0x4D, 0x5A, 0x90, 0x00 ];   // PE header bytes, as a span

Ein ReadOnlySpan<byte>, der aus einer konstanten Bytefolge gebaut wird, erzeugt kein Array auf dem Heap. Der Compiler richtet den Span direkt auf die FieldRVA-Daten:

IL_0000:  ldsflda    field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4' ...
IL_0005:  ldc.i4.4
IL_0006:  call       valuetype [System.Runtime]System.ReadOnlySpan`1<uint8> System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<uint8>(valuetype [System.Runtime]System.RuntimeFieldHandle)

Es gibt kein newarr und kein InitializeArray – stattdessen legt RuntimeHelpers.CreateSpan<byte> einen Span ohne jedes Kopieren über die Bytes des Felds, weshalb dieses Muster nur für byte funktioniert (und andere Einzelbyte- oder endianness-freie Elementtypen, deren Speicherlayout portabel ist). Glass erkennt CreateSpan genauso, wie es InitializeArray erkennt: Feld auflösen, FieldRVA-Bytes lesen und den Collection-Ausdruck [ 0x4D, 0x5A, 0x90, 0x00 ] rendern. Das Merkmal, das es von der Array-Form unterscheidet, ist ldsflda + CreateSpan statt newarr + InitializeArray – dieselbe Datensektion, eine andere Eingangstür, und keine Allokation dahinter.

Das Fazit

Ein Array-Initialisierer aus blittbaren Konstanten überlebt nicht als Werteliste ins IL. Der Compiler allokiert das Array, packt die Elemente als rohe Little-Endian-Bytes in ein statisches Feld in <PrivateImplementationDetails>, gibt diesem Feld seine Daten über eine FieldRVA-Metadatenzeile statt über eine Instruktion und hinterlässt einen einzigen RuntimeHelpers.InitializeArray-Aufruf, der die Bytes per memcpy hineinkopiert. Ein Decompiler kann das Literal also nicht aus dem Code lesen – er muss den Aufruf erkennen, das Feld-Token auflösen, der FieldRVA in die Datensektion folgen, den Elementtyp aus dem newarr nehmen, das das Array allokiert hat, und den Blob in elementgroße Little-Endian-Stücke zerlegen. Glass.NET tut genau das und bekommt die Breite richtig, sodass ein long[] nicht als doppelt so viele ints fehlgelesen wird; es liest die String- und Nicht-Konstanten-Fälle direkt aus ihren expliziten Stores; und es erkennt die CreateSpan-Variante, bei der ein ReadOnlySpan<byte> ohne jedes Array auf dieselben Daten zeigt. Die Werte waren immer da – nur drei Sprünge außerhalb des Instruktionsstroms, in einer Sektion, auf die das IL immer nur andeutungsweise verweist.

Nebula.NET testen

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