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

UTF-8-Zeichenfolgenliterale dekompilieren: der RVA-Blob hinter "..."u8

Ein "abc"u8-Literal ist weder eine Zeichenfolge noch ein Byte-Array – es ist ein ReadOnlySpan<byte> über ein statisches RVA-Datenfeld. Hier sind das IL, der versteckte Null-Terminator und was Glass.NET rekonstruiert.

Ein "abc"u8-Literal sieht aus wie das kleinstmögliche Feature: Nimm eine Zeichenfolge, häng ein u8 ans Ende, erhalte ihre UTF-8-Bytes. Doch was man zurückbekommt, ist weder eine string noch ein byte[]. Es ist ein ReadOnlySpan<byte>, und die Bytes, die er umspannt, liegen überhaupt nicht auf dem Heap: Sie sind als statischer Daten-Blob in die Assembly eingebacken, und der Span zeigt direkt auf sie. Dieses Design ist es, was u8-Literale allokationsfrei macht, und es ist auch das, was sie beim Dekompilieren interessant macht: Das Muster auf der Platte sieht dem Quellcode kein bisschen ähnlich, und ein Dekompiler, der es wörtlich liest, erzeugt etwas Unkenntliches. Dieser Beitrag geht die gesamte Absenkung (Lowering) durch und zeigt, was Glass.NET rekonstruiert.

Wie der Quellcode aussieht

UTF-8-Zeichenfolgenliterale kamen mit C# 11. Ihr Zweck ist es, UTF-8-Bytes an die vielen APIs zu übergeben, die einen ReadOnlySpan<byte> wollen – Utf8Formatter, Utf8Parser, Sockets, Pipelines, JSON-Writer –, ohne bei jeder Verwendung eine string und einen Laufzeitaufruf von Encoding.UTF8.GetBytes zu bezahlen.

using System;

static class Protocol
{
    public static ReadOnlySpan<byte> Preamble => "abc"u8;
}

Die Eigenschaft gibt einen Span zurück. Es gibt kein Feld vom Typ string, kein byte[] und keine Kodierungsarbeit zur Laufzeit. Alles wurde zur Übersetzungszeit geklärt.

Was der Compiler wirklich ausgibt

Zwei Dinge werden in die Assembly geschrieben. Erstens ein statisches Feld, das die kodierten Bytes enthält. Zweitens das IL, das einen Span über dieses Feld konstruiert. Hier ist der Methodenrumpf, so wie Glass ihn in der IL-Ansicht zeigt:

.method public hidebysig specialname static
        valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>
        get_Preamble () cil managed
{
    .maxstack 8
    IL_0000: ldsflda   valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4'
                        '<PrivateImplementationDetails>'::'2E7D2C...A25AEFC6'
    IL_0005: ldc.i4.3
    IL_0006: newobj    instance void [System.Runtime]System.ReadOnlySpan`1<uint8>::.ctor(void*, int32)
    IL_000b: ret
}

Drei Anweisungen. ldsflda legt die Adresse eines statischen Feldes ab – nicht seinen Wert, seine Adresse, als void*. ldc.i4.3 legt die Länge ab. newobj ruft den unsicheren (unsafe) Konstruktor von ReadOnlySpan<byte> auf, der einen Zeiger und eine Länge entgegennimmt. Der Span ist nun ein Fenster auf die Bytes des Feldes, und diese Bytes bewegen sich nie.

Das Feld und sein RVA-Blob

Das Feld, aus dem die Adresse stammt, lebt im compilergenerierten Typ <PrivateImplementationDetails> – demselben verborgenen Gehege, in das Array-Initialisierer und andere konstante Blobs wandern. Sein Typ ist ein verschachtelter Werttyp, benannt nach seiner eigenen Größe:

.class nested private explicit ansi sealed '__StaticArrayInitTypeSize=4'
       extends [System.Runtime]System.ValueType
{
    .pack 1
    .size 4
}

.field static assembly valuetype
       '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4'
       '2E7D2C...A25AEFC6' at I_00002A50

.data cil I_00002A50 = bytearray (61 62 63 00)

Das Feld hat keinen Anfangswert im üblichen Sinne; es hat eine RVA – eine relative virtuelle Adresse –, die in den rohen Datenabschnitt der Assembly zeigt, hier durch .data deklariert. Der Blob ist 61 62 63 00: a, b, c und ein abschließendes 00. Der Feldname wird aus einem Hash des Inhalts abgeleitet, weshalb sich zwei identische u8-Literale an beliebiger Stelle in der Assembly ein einziges Feld teilen.

Der versteckte Null-Terminator

Sehen Sie sich noch einmal die beiden Zahlen an, die nicht übereinstimmen. Der Feldtyp ist __StaticArrayInitTypeSize=4 und der Blob hat vier Bytes, aber die vor dem Konstruktor abgelegte Länge ist 3. Roslyn hängt jeder u8-Nutzlast ein einzelnes 0x00 an und dimensioniert das Feld so, dass es dieses einschließt, konstruiert den Span aber nur über die echten Bytes. So ist "abc"u8 physisch vier Bytes auf der Platte und logisch ein Span mit Length 3.

Dieser Terminator ist beabsichtigt. Er erlaubt es den eingebetteten Bytes, zum Preis eines einzigen Bytes zugleich als NUL-terminierte Zeichenfolge im C-Stil für native Interoperabilität zu dienen, die eine solche erwartet. Ihr Span hingegen endet bei Index 2: Das 00 ist dahinterliegender Speicher, den die Grenzen des Spans nie erreichen. Diese Beziehung richtig zu treffen, ist genau die Art von Detail, die ein Dekompiler kodieren muss: Das rekonstruierte Literal muss die Nutzlast-Bytes enthalten und nicht den Terminator.

Die Pipeline, von Anfang bis Ende

Wie "abc"u8 zu einem RVA-Datenfeld und einem ReadOnlySpan darüber kompiliert und wie Glass.NET das Literal rekonstruiert 1 · Quelle "abc"u8 bei Übersetzung UTF-8-kodiert 2 · RVA-Datenfeld 61 62 63 00 in <PrivateImplementationDetails> 3 · IL an der Verwendung ldsflda field ldc.i4.3 newobj ROSpan..ctor Glass.NET rekonstruiert ReadOnlySpan<byte> x = "abc"u8; Länge 3 = Blob-Größe 4 minus abschließender 00-Terminator

Der Compiler kodiert, speichert und referenziert; ein Dekompiler muss dieselben drei Stufen rückwärts durchlaufen und erkennen, dass die gesamte Form ein einziges Literal ist.

Was ein naiver Dekompiler zeigt – und was Glass zeigt

Nichts in den drei Anweisungen sagt „UTF-8-Literal”. Ein Dekompiler, der das IL wörtlich rekonstruiert, hat einen ReadOnlySpan<byte>, der aus der Adresse eines undurchsichtigen Feldes gebaut wird, und das Ehrlichste, was er ausgeben kann, ist genau das:

// wörtliche Rekonstruktion – technisch korrekt, unlesbar
public static ReadOnlySpan<byte> Preamble =>
    new ReadOnlySpan<byte>(
        Unsafe.AsPointer(ref <PrivateImplementationDetails>.2E7D2C...A25AEFC6),
        3);

Das kompiliert dem Sinne nach, sagt Ihnen aber nichts, und es gibt einen generierten Typ preis, der nie in Ihrem Quellcode stand. Glass gleicht stattdessen das Muster ab. Wenn es ein ldsflda auf ein __StaticArrayInitTypeSize-Feld mit RVA-Daten sieht, das einen ReadOnlySpan<byte>..ctor(void*, int32) speist, dessen Länge die Blob-Größe minus einem einzelnen abschließenden 0x00 ist, und die Bytes sich als gültiges UTF-8 dekodieren, rekonstruiert es das Literal:

static class Protocol
{
    public static ReadOnlySpan<byte> Preamble => "abc"u8;
}

Wenn die Bytes kein gültiges UTF-8 sind oder die Länge nicht zur Terminator-Konvention passt, rät Glass nicht: Es fällt darauf zurück, den Span über einen Byte-Blob zu zeigen, denn das ist es, was die Assembly tatsächlich bedeutet. Die Rekonstruktion wird nur angewandt, wenn die Form eindeutig ist.

Es selbst lesen

Sie können jeden Schritt davon in Glass nachvollziehen. Öffnen Sie die Assembly, wählen Sie das Element und wechseln Sie zwischen der dekompilierten C#-Ansicht und der IL-Ansicht, um das u8-Literal auf der einen Seite und das Paar ldsflda / newobj auf der anderen zu sehen. Öffnen Sie dann die Metadaten-Navigation, springen Sie zu <PrivateImplementationDetails> und untersuchen Sie das __StaticArrayInitTypeSize-Feld, um die rohen .data-Bytes zu sehen – einschließlich des abschließenden 00, das Ihr Span nie erreicht. Es ist der klarste Weg, zu beobachten, wie sich ein einzelnes Quellcode-Token über drei verschiedene Teile einer PE-Datei verteilt.

Glass.NET ist ein kostenloser .NET-Dekompiler zum Untersuchen von Code, den Sie untersuchen dürfen: Ihre eigenen Assemblys, Ihre Abhängigkeiten und Binärdateien Dritter, die Sie analysieren dürfen. Konstrukte wie u8-Literale originalgetreu zu rekonstruieren, ist der Unterschied zwischen dem Lesen Ihres Codes und dem Lesen der Buchführung des Compilers.

Nebula.NET testen

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