Skip to content
← Tous les articles
· Delta1 Labs DecompilationGlass

Décompiler les littéraux de chaîne UTF-8 : le blob RVA derrière "..."u8

Un littéral "abc"u8 n'est ni une chaîne ni un tableau d'octets : c'est un ReadOnlySpan<byte> sur un champ de données RVA statique. Voici l'IL, le terminateur nul caché et ce que Glass.NET reconstruit.

Un littéral "abc"u8 ressemble à la plus petite fonctionnalité possible : prenez une chaîne, collez un u8 à la fin, obtenez ses octets UTF-8. Mais ce que vous récupérez n’est ni une string ni un byte[]. C’est un ReadOnlySpan<byte>, et les octets qu’il couvre ne vivent pas du tout sur le tas : ils sont cuits dans l’assembly sous forme de blob de données statique et le span pointe directement dessus. Cette conception est ce qui rend les littéraux u8 sans allocation, et c’est aussi ce qui les rend intéressants à décompiler : le motif sur le disque ne ressemble en rien au code source, et un décompilateur qui le lit au pied de la lettre produit quelque chose de méconnaissable. Cet article parcourt tout l’abaissement (lowering) et montre ce que reconstruit Glass.NET.

À quoi ressemble le code source

Les littéraux de chaîne UTF-8 sont arrivés avec C# 11. Leur but est de livrer des octets UTF-8 aux nombreuses API qui veulent un ReadOnlySpan<byte> — Utf8Formatter, Utf8Parser, sockets, pipelines, rédacteurs JSON — sans payer une string et un appel à Encoding.UTF8.GetBytes au moment de l’exécution, à chaque utilisation.

using System;

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

La propriété renvoie un span. Il n’y a aucun champ de type string, aucun byte[], et aucun travail d’encodage à l’exécution. Tout a été réglé à la compilation.

Ce que le compilateur émet vraiment

Deux choses sont écrites dans l’assembly. D’abord, un champ statique contenant les octets encodés. Ensuite, l’IL qui construit un span sur ce champ. Voici le corps de la méthode, tel que Glass l’affiche dans la vue IL :

.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
}

Trois instructions. ldsflda empile l’adresse d’un champ statique — non pas sa valeur, son adresse, sous forme de void*. ldc.i4.3 empile la longueur. newobj appelle le constructeur non sécurisé (unsafe) de ReadOnlySpan<byte> qui prend un pointeur et une longueur. Le span est désormais une fenêtre sur les octets du champ, et ces octets ne bougent jamais.

Le champ et son blob RVA

Le champ dont provient l’adresse vit dans le type généré par le compilateur <PrivateImplementationDetails> — le même enclos caché où vont les initialiseurs de tableaux et autres blobs constants. Son type est un value type imbriqué nommé d’après sa propre taille :

.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)

Le champ n’a pas de valeur initiale au sens habituel ; il possède un RVA — une adresse virtuelle relative — pointant dans la section de données brute de l’assembly, déclarée ici par .data. Le blob est 61 62 63 00 : a, b, c, et un 00 final. Le nom du champ est dérivé d’un hachage du contenu, c’est pourquoi deux littéraux u8 identiques n’importe où dans l’assembly partagent un seul champ.

Le terminateur nul caché

Regardez à nouveau les deux nombres qui ne concordent pas. Le type du champ est __StaticArrayInitTypeSize=4 et le blob fait quatre octets, mais la longueur empilée avant le constructeur est 3. Roslyn ajoute un unique 0x00 à chaque charge utile u8 et dimensionne le champ pour l’inclure, mais construit le span uniquement sur les octets réels. Ainsi "abc"u8 fait physiquement quatre octets sur le disque et logiquement un span de Length 3.

Ce terminateur est délibéré. Il permet aux octets incorporés de servir aussi de chaîne terminée par NUL à la façon du C pour l’interopérabilité native qui en attend une, au prix d’un seul octet. Votre span, lui, s’arrête à l’indice 2 : le 00 est un stockage sous-jacent que les bornes du span n’atteignent jamais. Bien saisir cette relation est exactement le genre de détail qu’un décompilateur doit encoder : le littéral reconstruit doit contenir les octets de la charge utile et non le terminateur.

Le pipeline, de bout en bout

Comment "abc"u8 compile vers un champ de données RVA et un ReadOnlySpan qui le couvre, et comment Glass.NET reconstruit le littéral 1 · Source "abc"u8 encodé en UTF-8 à la compilation 2 · Champ de données RVA 61 62 63 00 dans <PrivateImplementationDetails> 3 · IL au point d'usage ldsflda field ldc.i4.3 newobj ROSpan..ctor Glass.NET reconstruit ReadOnlySpan<byte> x = "abc"u8; longueur 3 = taille du blob 4 moins le terminateur 00 final

Le compilateur encode, stocke et référence ; un décompilateur doit exécuter les mêmes trois étapes à l’envers et reconnaître que toute la forme n’est qu’un seul littéral.

Ce qu’affiche un décompilateur naïf — et ce qu’affiche Glass

Rien dans les trois instructions ne dit « littéral UTF-8 ». Un décompilateur qui reconstruit l’IL au pied de la lettre a un ReadOnlySpan<byte> construit à partir de l’adresse d’un champ opaque, et la chose la plus honnête qu’il puisse afficher est exactement cela :

// reconstruction littérale — techniquement correcte, illisible
public static ReadOnlySpan<byte> Preamble =>
    new ReadOnlySpan<byte>(
        Unsafe.AsPointer(ref <PrivateImplementationDetails>.2E7D2C...A25AEFC6),
        3);

Cela compile dans l’esprit mais ne vous apprend rien, et cela divulgue un type généré qui n’a jamais figuré dans votre code source. Glass, à l’inverse, reconnaît le motif. Quand il voit un ldsflda sur un champ __StaticArrayInitTypeSize doté de données RVA, alimentant un ReadOnlySpan<byte>..ctor(void*, int32) dont la longueur est la taille du blob moins un unique 0x00 final, et que les octets se décodent comme de l’UTF-8 valide, il reconstruit le littéral :

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

Si les octets ne sont pas de l’UTF-8 valide, ou si la longueur ne correspond pas à la convention du terminateur, Glass ne devine pas : il revient à afficher le span sur un blob d’octets, car c’est ce que l’assembly signifie réellement. La reconstruction n’est appliquée que lorsque la forme est sans ambiguïté.

Le lire soi-même

Vous pouvez suivre chaque étape de cela dans Glass. Ouvrez l’assembly, sélectionnez le membre et basculez entre la vue C# décompilé et la vue IL pour voir le littéral u8 d’un côté et la paire ldsflda / newobj de l’autre. Ouvrez ensuite la navigation des métadonnées, sautez vers <PrivateImplementationDetails> et inspectez le champ __StaticArrayInitTypeSize pour voir les octets .data bruts — y compris le 00 final que votre span n’atteint jamais. C’est la façon la plus claire d’observer comment un unique token du code source se répartit sur trois parties différentes d’un fichier PE.

Glass.NET est un décompilateur .NET gratuit pour inspecter du code que vous avez le droit d’inspecter : vos propres assemblies, vos dépendances et des binaires tiers que vous êtes autorisé à analyser. Reconstruire fidèlement des constructions comme les littéraux u8, c’est la différence entre lire votre code et lire la comptabilité du compilateur.

Essayez Nebula.NET

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