Skip to content
← Alle Beiträge
· Delta1 Labs Nebula.NETObfuskierung.NETTiefenanalyse

readonly structs, in-Parameter und ref-Returns obfuskieren: die modreqs und Compiler-Attribute, die Nebula nicht entfernen darf

Ein readonly struct, ein in-Parameter und ein ref readonly-Return sind nicht bloß Bequemlichkeiten im Quellcode — sie sind in den Metadaten als Custom Modifier und compilergenerierte Attribute kodiert, die der C#-Compiler zurückliest, um zu entscheiden, ob eine Aufrufstelle eine versteckte defensive Kopie anlegt, welche Überladung gewinnt und ob ein Wert überhaupt per Referenz übergeben werden darf. Ein modreq(InAttribute) auf einem Parameter ist Teil seiner Signatur, sodass sein Wegfall die Identität der Methode ändert und das Binden bricht; wird IsReadOnlyAttribute von einem Struct entfernt, beginnt jeder in-Aufruf darauf zu kopieren; wird IsByRefLike entfernt, lässt sich ein ref struct boxen. Ein Obfuskator, der all das als dekorative Metadaten behandelt, die man umbenennen oder verwerfen kann, erzeugt Assemblies, die zwar noch laden, sich aber anders verhalten — oder die sich gegen Aufrufer, die er nicht kontrolliert, nicht mehr binden lassen. Dieser Beitrag liest das IL jedes dieser Features, zeigt genau, welche Teile Signatur und welche Attribut sind, geht die drei Wege durch, auf denen ein unachtsamer Renamer sie beschädigt, und erklärt, wie Nebula die modreqs und die bekannten Compiler-Attribute unverändert bewahrt und dennoch alles umbenennt, was wirklich Ihnen gehört.

Es gibt eine Kategorie von .NET-Metadaten, die wie Dekoration aussieht und in Wahrheit tragend ist. Ein readonly struct, ein in-Parameter, ein ref readonly-Return — im C#-Quellcode lesen sie sich wie Modifizierer, die man der Performance zuliebe hinzufügt. In den Metadaten sind sie eine Mischung aus Custom Attributes und Required Custom Modifiers (modreqs), die der nächste Compiler zurückliest, um zu entscheiden, ob eine Aufrufstelle eine versteckte Kopie anlegt, welche Überladung bindet und ob ein Wert überhaupt per Referenz übergeben werden darf.

Das macht sie zu einer Falle für einen Obfuskator. Ein Renamer, der System.Runtime.CompilerServices.IsReadOnlyAttribute wie irgendeinen anderen umzubenennenden Typ behandelt, oder ein Metadaten-Bereiniger, der „ungenutzte“ Attribute verwirft, erzeugt eine Assembly, die problemlos lädt und sich dann anders verhält — zusätzliche Kopien, falsch gebundene Überladungen — oder sich gegen Aufrufer, die er nicht kontrolliert, nicht mehr binden lässt. Dieser Beitrag liest das IL jedes Features, trennt die Attribut-Anteile von den Signatur-Anteilen und zeigt, wie Nebula den Vertrag wahrt und dennoch Ihre Logik obfuskiert.

readonly struct: ein Attribut, das der nächste Compiler liest

Beginnen wir mit dem einfachsten Marker:

public readonly struct Money
{
    public readonly decimal Amount;
    public Money(decimal amount) => Amount = amount;
    public Money Add(in Money other) => new(Amount + other.Amount);
}

Das readonly am Struct kompiliert zu einem Custom Attribute auf dem Typ:

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

Am Typ-Token ändert sich nichts — es ist noch dasselbe Money. Das Attribut ist ein Flag, das der konsumierende Compiler liest. Wenn eine andere Assembly void Charge(in Money m) schreibt und m.Add(...) aufruft, prüft der Compiler Money auf IsReadOnlyAttribute. Ist es vorhanden, weiß der Compiler, dass kein Member von Money den Empfänger verändern kann, und darf Member direkt auf der in-Referenz aufrufen. Ist es nicht vorhanden, muss der Compiler annehmen, dass ein Member den Wert verändern könnte, und weil in „lass das nicht zu“ bedeutet, erzeugt er vor jedem Memberzugriff eine defensive Kopie des gesamten Structs.

Das ist der leise Fehlerfall. Entfernt man IsReadOnlyAttribute von Money, stürzt nichts ab; jeder Aufrufer in jeder anderen Assembly beginnt nur stillschweigend, Money bei jedem Zugriff zu kopieren — genau der Overhead, den readonly struct beseitigen sollte. Die Semantik, die der Autor wollte, ist weg, und das einzige Symptom ist langsamerer Code. Ein Obfuskator, der „ungenutzte“ Attribute verwirft, um die Metadaten zu verkleinern, tut exakt das.

in und ref readonly: das modreq in der Signatur

Der Parameter-Marker ist ein anderes Kaliber, und ein gefährlicheres, weil er im Signatur-Blob lebt, nicht daneben. Sehen Sie sich Add(in Money other) an:

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

Zwei getrennte Dinge kodieren hier in. Der Parametertyp ist Money& — eine verwaltete Referenz — mit modreq(InAttribute), und zusätzlich sitzt ein IsReadOnlyAttribute auf dem Parameter. Das modreq ist der tragende Teil: Ein Required Custom Modifier ist Teil des Typs in der Signatur, sodass Add(in Money) und ein hypothetisches Add(ref Money) tatsächlich verschiedene Signaturen haben. ref readonly-Returns verwenden dasselbe modreq(InAttribute) auf dem Rückgabetyp, und out verwendet modreq(OutAttribute).

Deshalb lässt sich ein modreq nicht wie ein Attribut umbenennen oder entfernen:

  • Entfernt man das modreq, ändert sich die Signatur von Money& modreq(InAttribute) zu einem bloßen Money& — sie liest sich jetzt als ref, nicht als in. Ein gegen das Original kompilierter Aufrufer erwartete in; die Identität der Methode hat sich unter ihm verschoben, und je nachdem, was sonst noch vorhanden ist, bekommt man beim Aufruf eine MissingMethodException oder eine Bindung an die falsche Überladung.
  • Benennt man den referenzierten InAttribute-Typ in a.b.C um, zeigt das modreq nun auf einen Modifier, den der konsumierende Compiler nicht als ref readonly/in erkennt. Für die Laufzeit ist es weiterhin ein gültiges modreq, die Assembly lädt also, aber C# sieht den Parameter nicht mehr als in.
Zwei Kodierungen, zwei ErhaltungsregelnCustom Attributes — neben der Entität, Token unverändertIsReadOnlyAttribute → auf readonly structIsReadOnlyAttribute → auf in-/ref readonly-ParamIsByRefLikeAttribute → auf ref structRequiresLocationAttribute → ref readonly-ParamIsUnmanagedAttribute → unmanaged-ConstraintRequired Modifiers — im Signatur-BlobT& modreq(InAttribute) → in-ParamT& modreq(InAttribute) → ref readonly-ReturnT& modreq(OutAttribute) → out-ParamTeil des TYPS in der Signatur →ändert die binäre Identität der MethodeAttribute per VOLLEM NAMEN erhalten (nicht umbenennen, nicht entfernen).modreq-Bytes unangetastet lassen UND das InAttribute / OutAttribute dahinter nicht umbenennen.

ref struct und [IsByRefLike]: eine Sicherheitseigenschaft, kein Hinweis

Ein ref struct trägt IsByRefLikeAttribute, und das ist ein Sicherheitsvertrag, den Laufzeit und Compiler gemeinsam durchsetzen:

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 sagt einem konsumierenden Compiler, dass BufferCursor nicht geboxt, auf dem Heap gespeichert, von einem Lambda eingefangen oder als generisches Typargument verwendet werden darf, wo es entkommen könnte — der gesamte Regelsatz, der die inneren Referenzen eines ref struct daran hindert, den Stackframe zu überleben, in den sie zeigen. Entfernt man es, lässt der Compiler, der Ihre Assembly konsumiert, bereitwillig zu, dass jemand den Cursor boxt, was die Garantie aushebelt und in harmlos aussehendem Code einen Use-after-free eröffnet. Das begleitende CompilerFeatureRequiredAttribute ist das Tor des Compilers selbst: Ein Konsument, der das Feature RefStructs nicht versteht, wird angewiesen, den Typ abzulehnen, statt ihn falsch zu behandeln. Beides sind Verträge; keiner davon gehört Ihnen zum Umbenennen.

Die Eigenschaft Current zeigt das modreq auf der Rückgabeseite in Aktion: ref readonly byte Current kompiliert zu einem Getter, der uint8& modreq(InAttribute) zurückgibt, sodass die ref-Return-Geschichte und die in-Parameter-Geschichte exakt denselben Modifier-Typ teilen. Ein Erhaltungseintrag deckt beide ab.

Der selbst eingebettete Fall, in dem das Umbenennen den eigentlichen Schaden anrichtet

Hier ist das Detail, das Obfuskatoren erwischt, die die Framework-Attribute zwar schützen — aber per Token oder per Assembly. Wenn Ihre Assembly die erste in ihrem Abschluss ist, die einen dieser Marker braucht — etwa weil Sie ein Framework anvisieren, das zu alt ist, um IsReadOnlyAttribute zu definieren —, synthetisiert der Compiler den Attributtyp in Ihre eigene 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 )
    ...
}

Das ist ein echter Typ in Ihren Metadaten, in einem Namespace, den Sie nicht geschrieben haben, und seine Identität wird über den vollen Namen abgeglichen. Der nächste Compiler — einschließlich Ihres eigenen, wenn Sie das nächste Mal gegen diese Assembly als Referenz bauen — findet readonly structs, indem er nach einem Typ sucht, der buchstäblich System.Runtime.CompilerServices.IsReadOnlyAttribute heißt, und dessen Anwendungen liest. Benennt man diesen eingebetteten Typ in A.B um, hört jedes readonly und in in der Assembly stillschweigend auf, erkannt zu werden, weil der Marker, nach dem der Compiler sucht, verschwunden ist. Das begleitende Microsoft.CodeAnalysis.EmbeddedAttribute kennzeichnet diese Typen als compiler-intern und muss ebenfalls namentlich überleben. Ein naiver Renamer sieht eine private Attributklasse mit seltsamem Namen und benennt sie um wie jeden anderen internen Typ — und beschädigt die ABI, ohne eine einzige Zeile Ihres Codes anzufassen.

Wo Nebula die Grenze zieht

Nebulas Regel ist, Compiler-Vertrag von Ihrer Logik zu trennen, und es führt eine Erhaltungsliste der bekannten Modifier- und Marker-Familie, abgeglichen per vollem Namen, sodass sie sowohl die Framework-Kopien als auch die selbst eingebetteten abdeckt:

Per vollem Namen erhaltenWarum — was bei Umbenennung oder Entfernung brichtIsReadOnlyAttributedefensive Kopien an jeder in-AufrufstelleIsByRefLikeAttributeref struct kann geboxt werden / entkommen — Use-after-freeIn/OutAttribute (modreq-Ziel)Signaturidentität ändert sich; in/out/ref readonly verlorenRequiresLocationAttributeSemantik von ref readonly-Parametern (C# 12) verlorenEmbedded / CompilerFeatureRequiredkonsumierender Compiler erkennt die Marker nicht mehrAlles andere — Typname, Member, Rümpfe, Strings, Konstanten, Kontrollfluss — bleibt voll obfuskierbar.

Konkret tut die Pipeline drei Dinge:

  1. Die Modifier-/Marker-Familie nie namentlich umbenennen. IsReadOnlyAttribute, IsByRefLikeAttribute, IsUnmanagedAttribute, RequiresLocationAttribute, InAttribute, OutAttribute, EmbeddedAttribute, CompilerFeatureRequiredAttribute — ob vom Framework definiert oder selbst eingebettet — behalten ihre vollen Namen. Die eingebetteten Kopien werden gerade deshalb erkannt, weil sie EmbeddedAttribute tragen, das selbst auf der Liste steht.
  2. modreqs in einer Signatur als unveränderliche Bytes behandeln. Wenn Nebula eine Signatur umschreibt — weil ein Benutzertyp darin umbenannt wurde —, kopiert es die modreq/modopt-Tokens unverändert durch und zielt die referenzierten InAttribute/OutAttribute nie um. Der Modifier bleibt genau dort, wo der Compiler ihn hingesetzt hat.
  3. Die Marker-Attribute auf ihren Deklarationen belassen. Das IsReadOnlyAttribute auf Ihrem Money-Struct und auf seinem in-Parameter, das IsByRefLikeAttribute auf Ihrem BufferCursor — sie reisen mit der (möglicherweise umbenannten) Entität mit, statt als ungenutzte Custom Attributes weggefegt zu werden.

Was übrig bleibt, ist der eigentliche Zweck: Ihr Money wird zu einem umbenannten Struct mit obfuskierten Membern und verschlüsselten Konstanten, Ihr BufferCursor zu einem umbenannten ref struct mit verwürfeltem Rumpf — und ein Konsument sieht weiterhin ein readonly struct mit einer in-Methode und ein ref struct mit einer ref readonly-Eigenschaft, weil jedes Byte, das der Compiler liest, um das zu wissen, noch da ist. Der Schutz trifft Ihre Logik. Die ABI, auf die der nächste Compiler angewiesen ist, bleibt unberührt. Diese Linie — die Implementierung obfuskieren, den Vertrag bewahren — ist die, die ein Obfuskator halten muss, und bei readonly/in/ref readonly erfordert das Halten echtes Wissen über die Metadaten statt einer Umbenennungstabelle.

Nebula.NET testen

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