Obfusquer les readonly structs, les paramètres in et les ref returns : les modreqs et attributs du compilateur que Nebula ne doit pas supprimer
Un readonly struct, un paramètre in et un retour ref readonly ne sont pas de simples commodités de code source — ils sont encodés dans les métadonnées sous forme de modificateurs personnalisés et d'attributs émis par le compilateur, que le compilateur C# relit pour décider si un site d'appel fait une copie défensive cachée, quelle surcharge l'emporte, et si une valeur peut même être passée par référence. Un modreq(InAttribute) sur un paramètre fait partie de sa signature, donc le supprimer change l'identité de la méthode et casse la liaison ; retirer IsReadOnlyAttribute d'un struct fait que chaque appel in vers lui se met à copier. Un obfuscateur qui traite tout cela comme des métadonnées décoratives à renommer ou à jeter produit des assemblies qui se chargent encore mais se comportent différemment, ou qui échouent à se lier contre des appelants qu'il ne contrôle pas. Cet article lit l'IL de chaque fonctionnalité, montre exactement quelles pièces relèvent de la signature et lesquelles sont des attributs, parcourt les trois façons dont un renommeur négligent les corrompt, et explique comment Nebula préserve les modreqs et les attributs bien connus du compilateur à l'identique tout en renommant tout ce qui vous appartient réellement.
Il existe une catégorie de métadonnées .NET qui ressemble à de la décoration et qui est en fait porteuse. Un readonly struct, un paramètre in, un retour ref readonly — en code source C# ils se lisent comme des modificateurs que l’on saupoudre pour la performance. Dans les métadonnées, ce sont un mélange d’attributs personnalisés et de modificateurs personnalisés requis (modreq) que le compilateur suivant relit pour décider si un site d’appel fait une copie cachée, quelle surcharge se lie, et si une valeur peut même être passée par référence.
Cela en fait un piège pour un obfuscateur. Un renommeur qui traite System.Runtime.CompilerServices.IsReadOnlyAttribute comme n’importe quel autre type à renommer, ou un nettoyeur de métadonnées qui jette les attributs « inutilisés », produit un assembly qui se charge parfaitement puis se comporte différemment — des copies supplémentaires, des surcharges mal liées — ou qui échoue à se lier contre des appelants qu’il ne contrôle pas. Cet article lit l’IL de chaque fonctionnalité, sépare les pièces d’attribut des pièces de signature, et montre comment Nebula préserve le contrat tout en obfusquant votre logique.
readonly struct : un attribut que le compilateur suivant relit
Commençons par le marqueur le plus simple :
public readonly struct Money
{
public readonly decimal Amount;
public Money(decimal amount) => Amount = amount;
public Money Add(in Money other) => new(Amount + other.Amount);
}
Le readonly sur le struct se compile en un attribut personnalisé sur le type :
.class public sequential ansi sealed beforefieldinit Money
extends [System.Runtime]System.ValueType
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
...
}
Rien dans le jeton du type ne change — c’est toujours le même Money. L’attribut est un indicateur que le compilateur consommateur relit. Quand un autre assembly écrit void Charge(in Money m) et appelle m.Add(...), le compilateur vérifie la présence de IsReadOnlyAttribute sur Money. S’il est là, le compilateur sait qu’aucun membre de Money ne peut muter le receveur, donc il peut invoquer les membres directement sur la référence in. S’il n’y est pas, le compilateur doit supposer qu’un membre pourrait muter la valeur, et comme in signifie « ne le laisse pas faire », il émet une copie défensive du struct entier avant chaque accès de membre.
C’est là le mode d’échec silencieux. Retirez IsReadOnlyAttribute de Money et rien ne plante ; chaque appelant dans chaque autre assembly se met simplement à copier silencieusement Money à l’accès, ce qui est exactement la surcharge que readonly struct existait pour supprimer. La sémantique demandée par l’auteur a disparu, et le seul symptôme est du code plus lent. Un obfuscateur qui jette les attributs « inutilisés » pour réduire les métadonnées fera précisément cela.
in et ref readonly : le modreq dans la signature
Le marqueur de paramètre est une autre bête, et plus dangereuse, car il vit à l’intérieur du blob de signature, et non à côté. Regardez Add(in Money other) :
.method public hidebysig instance valuetype Money
Add(valuetype Money& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) other) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
...
}
Deux choses distinctes encodent in ici. Le type du paramètre est Money& — une référence managée — portant modreq(InAttribute), et il y a aussi un IsReadOnlyAttribute sur le paramètre. Le modreq est la partie porteuse : un modificateur personnalisé requis fait partie du type dans la signature, de sorte que Add(in Money) et un hypothétique Add(ref Money) ont des signatures véritablement différentes. Les retours ref readonly utilisent le même modreq(InAttribute) sur le type de retour, et out utilise modreq(OutAttribute).
Voilà pourquoi un modreq ne peut pas être renommé ou supprimé comme un attribut :
- Retirez le
modreqet la signature passe deMoney& modreq(InAttribute)àMoney&nu — elle se lit maintenant commeref, pas commein. Un appelant compilé contre l’original attendaitin; l’identité de la méthode a glissé sous ses pieds, et selon ce qui est présent par ailleurs, vous obtenez uneMissingMethodExceptionà l’appel ou une liaison à la mauvaise surcharge. - Renommez le type
InAttributeréférencé ena.b.Cet lemodreqpointe maintenant vers un modificateur que le compilateur consommateur ne reconnaît pas commeref readonly/in. C’est toujours unmodreqvalide du point de vue du runtime, donc l’assembly se charge, mais C# ne voit plus le paramètre commein.
ref struct et [IsByRefLike] : une propriété de sûreté, pas un indice
Un ref struct porte IsByRefLikeAttribute, et celui-ci est un contrat de sûreté que le runtime et le compilateur imposent conjointement :
public ref struct BufferCursor
{
private ref readonly byte _current;
public ref readonly byte Current => ref _current;
}
.class public sequential ansi sealed beforefieldinit BufferCursor
extends [System.Runtime]System.ValueType
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsByRefLikeAttribute::.ctor() = ( 01 00 00 00 )
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
= ( 01 00 09 'RefStructs' 00 00 )
...
}
IsByRefLikeAttribute est ce qui indique à un compilateur consommateur que BufferCursor ne peut pas être boxé, stocké sur le tas, capturé par une lambda, ni utilisé comme argument de type générique là où il pourrait s’échapper — l’ensemble des règles qui empêche les références internes d’un ref struct de survivre au cadre de pile sur lequel elles pointent. Retirez-le et le compilateur qui consomme votre assembly laissera volontiers quelqu’un boxer le curseur, annulant la garantie et ouvrant un use-after-free dans du code d’apparence sûre. L’attribut jumeau CompilerFeatureRequiredAttribute est la propre barrière du compilateur : un consommateur qui ne comprend pas la fonctionnalité RefStructs se voit dire de refuser le type plutôt que de le mal gérer. Les deux sont des contrats ; ni l’un ni l’autre ne vous appartient pour être renommé.
La propriété Current montre le modreq côté retour à l’œuvre : ref readonly byte Current se compile en un accesseur qui renvoie uint8& modreq(InAttribute), de sorte que l’histoire du ref-return et celle du paramètre in partagent exactement le même type de modificateur. Une seule entrée de préservation couvre les deux.
Le cas auto-intégré, là où le renommage fait les vrais dégâts
Voici le détail qui piège les obfuscateurs qui protègent bien les attributs du framework mais par jeton ou par assembly. Quand votre assembly est le premier de sa fermeture à avoir besoin de l’un de ces marqueurs — vous ciblez un framework trop ancien pour définir IsReadOnlyAttribute, disons — le compilateur synthétise le type d’attribut dans votre propre assembly :
.class private auto ansi sealed beforefieldinit
System.Runtime.CompilerServices.IsReadOnlyAttribute
extends [System.Runtime]System.Attribute
{
.custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
...
}
C’est un vrai type dans vos métadonnées, dans un espace de noms que vous n’avez pas écrit, et son identité est reconnue par nom complet. Le compilateur suivant — y compris le vôtre, la prochaine fois que vous compilerez contre ceci comme référence — trouve les readonly structs en cherchant un type littéralement nommé System.Runtime.CompilerServices.IsReadOnlyAttribute et en lisant ses applications. Renommez ce type intégré en A.B et chaque readonly et chaque in de l’assembly cesse silencieusement d’être reconnu, car le marqueur que le compilateur traque a disparu. L’attribut compagnon Microsoft.CodeAnalysis.EmbeddedAttribute marque ces types comme internes au compilateur et doit survivre par son nom lui aussi. Un renommeur naïf voit une classe d’attribut private au nom étrange et la renomme comme n’importe quel autre type interne — et corrompt l’ABI sans toucher à une seule ligne de votre code.
Comment Nebula trace la ligne
La règle de Nebula est de séparer le contrat du compilateur de votre logique, et il tient une liste de préservation de la famille bien connue des modificateurs et marqueurs, reconnue par nom complet pour couvrir à la fois les copies du framework et les copies auto-intégrées :
Concrètement, le pipeline fait trois choses :
- Ne jamais renommer la famille des modificateurs/marqueurs par son nom.
IsReadOnlyAttribute,IsByRefLikeAttribute,IsUnmanagedAttribute,RequiresLocationAttribute,InAttribute,OutAttribute,EmbeddedAttribute,CompilerFeatureRequiredAttribute— définis par le framework ou auto-intégrés — restent à leurs noms complets. Les copies intégrées sont détectées précisément parce qu’elles portentEmbeddedAttribute, qui est lui-même sur la liste. - Traiter les
modreqd’une signature comme des octets immuables. Quand Nebula réécrit une signature — parce qu’un type utilisateur qu’elle contenait a été renommé — il copie les jetonsmodreq/modopttels quels et ne recible jamais leInAttribute/OutAttributequ’ils référencent. Le modificateur reste exactement là où le compilateur l’a mis. - Garder les attributs marqueurs sur leurs déclarations. Le
IsReadOnlyAttributesur votre structMoneyet sur son paramètrein, leIsByRefLikeAttributesur votreBufferCursor— ceux-ci voyagent avec l’entité (possiblement renommée) plutôt que d’être balayés comme des attributs personnalisés inutilisés.
Ce qui reste est tout l’enjeu : votre Money devient un struct renommé aux membres obfusqués et aux constantes chiffrées, votre BufferCursor devient un ref struct renommé au corps brouillé — et un consommateur voit toujours un readonly struct avec une méthode prenant un in et un ref struct avec une propriété ref readonly, car chaque octet que le compilateur relit pour le savoir est toujours là. La protection s’applique à votre logique. L’ABI dont le compilateur suivant dépend reste intacte. Cette ligne — obfusquer l’implémentation, préserver le contrat — est celle qu’un obfuscateur doit tenir, et readonly/in/ref readonly est l’endroit où la tenir exige une véritable connaissance des métadonnées plutôt qu’une table de renommage.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.