Teilen über


Bewährte Methoden für unsicheren Code

Dieser Artikel enthält fein abgestimmte Empfehlungen für bestimmte unsichere Muster, die risiken, die sie mit sich bringen, und wie diese Risiken abgemildert werden. Diese Richtlinien richten sich an alle Entwickler, die unsicheren Code in C# schreiben oder überprüfen. Andere .NET-Sprachen wie F# und Visual Basic liegen außerhalb des Umfangs dieses Artikels, obwohl einige Empfehlungen auch für diese Sprachen gelten können.

Glossar

  • AVE - Zugriffsverletzungs-Ausnahme.
  • Byref – Ein verwalteter Zeiger (ref T t), der dem nicht verwalteten Zeiger ähnelt, aber vom GC nachverfolgt wird. Zeigt in der Regel auf beliebige Teile von Objekten oder Stapeln. Der Verweis ist effektiv ein verwalteter Zeiger mit einem +0 Offset-Wert.
  • CVE – Öffentlich offengelegte Cybersicherheitsrisiken.
  • JIT - Just-in-Time-Compiler (RyuJIT in CoreCLR und NativeAOT).
  • PGO - Profilgeführte Optimierung.
  • Nicht verwalteter Zeiger (oder unformatierter Zeiger) – Ein Zeiger (T* p), der auf einen beliebigen Speicherort verweist und nicht von der GC verwaltet oder nachverfolgt wird.

Weitere Begriffe finden Sie im Glossar zu .NET-Runtime.

Häufige unzuverlässige Muster

C# bietet eine sichere Umgebung, in der Entwickler sich keine Gedanken über die internen Arbeiten der Laufzeit und der GC machen müssen. Unsicherer Code ermöglicht es Ihnen, diese Sicherheitsprüfungen zu umgehen, wodurch möglicherweise unzuverlässige Muster eingeführt werden, die zu Speicherbeschädigungen führen können. Obwohl solche Muster in bestimmten Szenarien nützlich sein könnten, sollten Sie sie mit Vorsicht und nur dann verwenden, wenn sie unbedingt erforderlich sind. Nicht nur stellen C# und .NET keine Tools bereit, um die Soundheit des unsicheren Codes zu überprüfen (wie es verschiedene C/C++-Sanitizer möglicherweise tun), sondern GC-spezifische Verhaltensweisen können zusätzliche Risiken im unsicheren C# einführen, die über diejenigen hinausgehen, mit denen herkömmliche C/C++-Entwickler vertraut sind.

Im Umgang mit unsicherem Code im Zusammenhang mit verwalteten Verweisen sollten folgende konservative Annahmen berücksichtigt werden:

  • Die GC kann die Ausführung jeder Methode jederzeit zu jeder Anweisung unterbrechen.
  • Der GC kann Objekte im Arbeitsspeicher verschieben und alle nachverfolgten Verweise aktualisieren.
  • Der GC weiß genau, wann Verweise nicht mehr benötigt werden.

Ein klassisches Beispiel für Heap-Beschädigung tritt auf, wenn der GC den Überblick über einen Objektverweis verliert oder ungültige Zeiger als Heap-Verweise behandelt. Dies führt häufig zu nicht deterministischen Abstürzen oder Speicherbeschädigungen. Heap-Beschädigungsfehler sind besonders schwierig zu diagnostizieren und zu reproduzieren, da:

  • Diese Probleme können lange verborgen bleiben und sich erst nach einer unabhängigen Codeänderung oder Aktualisierung der Laufzeitumgebung manifestieren.
  • Sie erfordern häufig präzise Zeitangaben für die Reproduktion, z. B. die GC-Unterbrechungsausführung an einer bestimmten Stelle und die Start-Heap-Komprimierung, was ein seltenes und nicht deterministisches Ereignis ist.

In den nächsten Abschnitten werden allgemeine unsichere Muster mit ✔️ DO- und ❌ DON'T-Empfehlungen beschrieben.

1. Nicht nachverfolgte verwaltete Zeiger (Unsafe.AsPointer und andere)

Es ist nicht möglich, einen verwalteten (nachverfolgten) Zeiger in einen nicht verwalteten (nicht nachverfolgten) Zeiger in sicherem C# zu konvertieren. Wenn eine solche Notwendigkeit auftritt, kann es verlockend sein, Unsafe.AsPointer<T>(T) zu verwenden, um den Aufwand einer fixed Anweisung zu vermeiden. Obwohl es gültige Anwendungsfälle gibt, besteht das Risiko, dass Zeiger auf bewegliche Objekte erstellt werden, die nicht nachverfolgt werden können. Beispiel:

unsafe void UnreliableCode(ref int x)
{
    int* nativePointer = (int*)Unsafe.AsPointer(ref x);
    nativePointer[0] = 42;
}

Wenn die GC die Ausführung der UnreliableCode Methode direkt nach dem Lesen des Zeigers auf die von x verwiesene Adresse unterbricht und das referenzierte Objekt verschiebt, aktualisiert die GC den Speicherort, der in x gespeichert ist, weiß jedoch nichts über nativePointer und aktualisiert den darin enthaltenen Wert nicht. Zu diesem Zeitpunkt bedeutet das Schreiben in nativePointer ein Schreiben in einen beliebigen Speicherbereich.

unsafe void UnreliableCode(ref int x)
{
    int* nativePointer = (int*)Unsafe.AsPointer(ref x);
    // <-- GC happens here between the two lines of code and updates `x` to point to a new location.
    // However, `nativePointer` still points to the old location as it's not reported to the GC
    
    nativePointer[0] = 42; // Potentially corrupting write, access violation, or other issue.
}

Sobald der GC die Ausführung der Methode fortgesetzt hat, wird er 42 an den alten Speicherort von x schreiben, was zu einer unerwarteten Ausnahme, einer allgemeinen Beschädigung des globalen Zustands oder zur Beendigung des Prozesses aufgrund einer Zugriffsverletzung führen kann.

Die empfohlene Lösung besteht stattdessen darin, das fixed Schlüsselwort und den & Adressoperator zu verwenden, um sicherzustellen, dass der GC die Zielreferenz für die Dauer des Vorgangs nicht verlagern kann.

unsafe void ReliableCode(ref int x)
{
    fixed (int* nativePointer = &x) // `x` cannot be relocated for the duration of this block.
    {
        nativePointer[0] = 42;
    }
}

Recommendations

  1. ❌ Verwenden Sie ref X keine Argumente mit einem impliziten Vertrag, dass X immer stapelzugewiesen, angeheftet oder anderweitig vom GC nicht verschiebbar ist. Gleiches gilt für einfache Objekte und Spans - führen Sie keine nicht offensichtlichen aufruferbasierten Verträge über ihre Lebensdauer in Methodensignaturen ein. Erwägen Sie stattdessen, ein Verweisstrukturargument zu verwenden oder das Argument in einen unformatierten Zeigertyp (X*) zu ändern.
  2. ❌ Verwenden Sie keinen Zeiger von Unsafe.AsPointer<T>(T), wenn dieser den ursprünglichen Verweisgegenstand überleben könnte, auf den er zeigt. Gemäß der Dokumentation der API liegt es in der Verantwortung des Aufrufers Unsafe.AsPointer<T>(T), sicherzustellen, dass der Garbage Collector den Verweis nicht verschieben kann. Stellen Sie sicher, dass für Code-Reviewer deutlich erkennbar ist, dass der Aufrufer diese Voraussetzung erfüllt hat.
  3. ✔️ Verwenden Sie GCHandle oder fixed Scopes anstelle von Unsafe.AsPointer<T>(T), um explizite Scopes für nicht verwaltete Zeiger zu definieren und sicherzustellen, dass das Objekt immer fixiert ist.
  4. ✔️ Verwenden Sie unmanaged Zeiger (with fixed) anstelle von Byrefs, wenn Sie ein Array an einer spezifischen Grenze ausrichten müssen. Dadurch wird sichergestellt, dass das GC das Objekt nicht verschiebt und keine Annahmen zur Speicherausrichtung ungültig macht, auf die Ihre Logik möglicherweise angewiesen ist.

2. Offenlegen von Zeigern außerhalb des fixed Geltungsbereichs

Während das feste Schlüsselwort einen Bereich für den Zeiger definiert, der aus dem angehefteten Objekt abgerufen wird, ist es dennoch möglich, dass dieser Zeiger den fixed Bereich escapet und Fehler einführt, da C# keinen Besitz-/Lebenszyklusschutz für ihn bereitstellt. Ein typisches Beispiel ist der folgende Codeausschnitt:

unsafe int* GetPointerToArray(int[] array)
{
    fixed (int* pArray = array)
    {
        _ptrField = pArray; // Bug!

        Method(pArray);     // Bug if `Method` allows `pArray` to escape,
                            // perhaps by assigning it to a field.

        return pArray;      // Bug!

        // And other ways to escape the scope.
    }
}

In diesem Beispiel wird das Array ordnungsgemäß mithilfe des fixed Keywords angeheftet, um sicherzustellen, dass der GC das Array innerhalb des fixed Blocks nicht verlagern kann. Der Zeiger wird jedoch außerhalb des fixed Blocks verfügbar gemacht. Dadurch wird ein schwebender Zeiger erstellt, dessen Dereferenzierung zu einem undefinierten Verhalten führt.

Recommendations

  1. ✔️ Stellen Sie sicher, dass Zeiger in fixed Blöcken den definierten Bereich nicht verlassen.
  2. ✔️ DO bevorzugt sichere Grundtypen auf niedriger Ebene mit integrierter Escapeanalyse, z. B. der Referenzstruktur von C#. Weitere Informationen finden Sie unter Verbesserungen der Struktur auf niedriger Ebene.

3. Interne Implementierungsdetails der Laufzeitumgebung und Bibliotheken

Während der Zugriff auf interne Implementierungsdetails oder das Vertrauen auf diese im Allgemeinen schlechte Praxis ist (und von .NET nicht unterstützt wird), ist es sinnvoll, bestimmte häufig beobachtete Fälle hervorzuheben. Dies ist nicht beabsichtigt, eine vollständige Liste aller möglichen Dinge zu sein, die schief gehen können, wenn Code unangemessen auf ein internes Implementierungsdetail angewiesen ist.

Recommendations

  1. ❌ Ändern oder lesen Sie keine Teile der Kopfzeile eines Objekts.

    • Objekt-Header können sich zwischen verschiedenen Laufzeitumgebungen unterscheiden.
    • In CoreCLR kann auf den Objektheader nicht sicher zugegriffen werden, ohne zuerst das Objekt anzupinnen.
    • Ändern Sie niemals den Typ des Objekts, indem Sie den MethodTable-Zeiger ändern.
  2. ❌ Speichern Sie keine Daten in der Auffüllung eines Objekts. Gehen Sie nicht davon aus, dass die Auffüllinhalte beibehalten werden oder dass die Auffüllung standardmäßig auf null gesetzt ist.

  3. ❌ MACHEN SIE keine Annahmen über die Größen und Offsets anderer Elemente als Grundtypen und Strukturen mit sequenziellem oder expliziten Layout. Selbst dann gibt es Ausnahmen, z. B. wenn GC-Handles beteiligt sind.

  4. ❌ NICHT nicht-öffentliche Methoden aufrufen, nicht-öffentliche Felder zugreifen oder unveränderbare Felder in BCL-Typen mit Reflection oder unsicherem Code ändern.

  5. ❌ Nehmen Sie nicht an, dass ein nicht öffentlicher Mitglied in der BCL immer vorhanden sein oder eine bestimmte Form haben wird. Das .NET-Team ändert oder entfernt gelegentlich nicht öffentliche APIs in Wartungsversionen.

  6. ❌ Ändern Sie static readonly Felder nicht mithilfe von Spiegelung oder unsicherem Code, da sie als konstant angenommen werden. Beispielsweise ordnet RyuJIT sie in der Regel als explizite Konstanten an.

  7. ❌ Nehmen Sie NICHT einfach an, dass eine Referenz nicht verschiebbar ist. Dieser Leitfaden gilt für Zeichenfolgen und UTF-8 ("..."u8) Literale, statische Felder, RVA-Felder, LOH-Objekte usw.

    • Hierbei handelt es sich um Details zur Laufzeitimplementierung, die bei einigen Laufzeiten zutreffen, jedoch nicht bei anderen.
    • Nicht verwaltete Zeiger auf solche Objekte verhindern möglicherweise nicht das Entladen von Assemblys, wodurch die Zeiger funktionslos werden. Verwenden Sie fixed Scopes, um die Genauigkeit zu gewährleisten.
    ReadOnlySpan<int> rva = [1, 2, 4, 4];
    int* p = (int*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(rva));
    // Bug! The assembly containing the RVA field might be unloaded at this point
    // and `p` becomes a dangling pointer.
    int value = p[0]; // Access violation or other issue.
    
  8. ❌ SCHREIBEN SIE KEINEN Code, der auf den Implementierungsdetails einer bestimmten Laufzeit basiert.

4. Ungültige verwaltete Zeiger (auch wenn sie nie dereferenziert werden)

Bestimmte Codekategorien neigen zu Zeigermanipulation und Arithmetik, und dieser Code hat häufig die Wahl zwischen der Verwendung von nicht verwalteten Zeigern (T* p) und verwalteten Zeigern (ref T p). Diese Zeiger können willkürlich bearbeitet werden, z. B. über Operatoren auf nicht verwalteten Zeigern (p++) und über Unsafe Methoden für verwaltete Zeiger (p = ref Unsafe.Add(ref p, 1)). Beide werden als "unsicherer Code" betrachtet, und es ist möglich, unzuverlässige Muster mit beiden zu erstellen. Bei bestimmten Algorithmen kann es jedoch einfacher sein, versehentlich GC-unsichere Muster beim Bearbeiten verwalteter Zeiger zu erstellen. Da nicht verwaltete Zeiger nicht von der GC nachverfolgt werden, ist der darin enthaltene Wert nur relevant, wenn er vom Code des Entwicklers abgeleitet wird. Im Gegensatz dazu ist der Wert eines verwalteten Zeigers nicht nur dann relevant, wenn er vom Code des Entwicklers abgeleitet wird, sondern auch, wenn er von der GC untersucht wird. Daher kann ein Entwickler ungültige nicht verwaltete Zeiger ohne weitere Folgen erstellen, solange sie nicht dereferenziert werden, aber das Erstellen eines ungültigen verwalteten Zeigers ist ein Fehler. Beispiel:

unsafe void UnmanagedPointers(int[] array)
{
    fixed (int* p = array)
    {
        int* invalidPtr = p - 1000;
        // invalidPtr is pointing to an undefined location in memory
        // it's ok as long as it's not dereferenced.

        int* validPtr = invalidPtr + 1000; // Returning back to the original location
        *validPtr = 42; // OK
    }
}

Ein ähnlicher Code mit Byrefs (verwalteten Zeigern) ist jedoch ungültig.

void ManagedPointers_Incorrect(int[] array)
{
    ref int invalidPtr = ref Unsafe.Add(ref array[0], -1000); // Already a bug!
    ref int validPtr = ref Unsafe.Add(ref invalidPtr, 1000);
    validPtr = 42; // possibly corrupting write
}

Während die verwaltete Implementierung hier den kleineren Anheftungsaufwand vermeidet, ist sie unzuverlässig, da invalidPtr möglicherweise zu einem äußeren Zeiger wird, während die tatsächliche Adresse vom GC aktualisiert wird. Solche Fehler sind subtil, und sogar .NET ist während der Entwicklung auf sie gestoßen.

Recommendations

  1. ❌ Erstellen Sie keine ungültigen verwalteten Pointer, auch wenn sie nicht dereferenziert werden oder sich in nie ausgeführten Codepfaden befinden.
  2. ✔️ DO verwenden angeheftete nicht verwaltete Zeiger, wenn der Algorithmus solche Manipulationen erfordert.

5. Reinterpretierungs-artige Typumwandlungen

Während alle Arten von Struktur-zu-Klasse- oder Klassen-zu-Struktur-Umwandlungen ein nicht definiertes Verhalten nach Definition sind, ist es auch möglich, unzuverlässige Muster mit Struktur-zu-Struktur- oder Klassen-zu-Klassen-Konvertierungen zu finden. Ein typisches Beispiel für ein unzuverlässiges Muster ist der folgende Code:

struct S1
{
    string a;
    nint b;
}

struct S2
{
    string a;
    string b;
}

S1 s1 = ...
S2 s2 = Unsafe.As<S1, S2>(ref s1); // Bug! A random nint value becomes a reference reported to the GC.

Und selbst wenn das Layout ähnlich ist, sollten Sie immer noch vorsichtig sein, wenn GC-Verweise (Felder) beteiligt sind.

Recommendations

  1. ❌ Casten Sie keine Strukturen in Klassen um und umgekehrt.
  2. ❌ Nutzen Sie Unsafe.As nicht für Struct-zu-Struct- oder Klasse-zu-Klasse-Konvertierungen, sofern Sie nicht absolut sicher sind, dass der Cast zulässig ist. Weitere Informationen finden Sie im Abschnitt "Hinweise " der Unsafe.As API-Dokumente.
  3. ✔️ DO bevorzugen sicherere Feld-nach-Feld-Kopieren, externe Bibliotheken wie AutoMapper oder Quellgeneratoren für solche Konvertierungen.
  4. ✔️ Bevorzugen Sie Unsafe.BitCast gegenüber Unsafe.As, da BitCast einige grundlegende Anwendungsprüfungen bietet. Beachten Sie, dass diese Prüfungen keine vollständigen Korrektheitsgarantien bieten, was bedeutet, dass BitCast immer noch als unsichere API gilt.

6. Umgehen der Schreibbarriere und nicht-atomare Operationen an GC-Referenzen

Normalerweise sind alle Schreib- und Lesevorgänge von GC-Verweisen atomar. Außerdem durchlaufen alle Versuche, einen GC-Verweis (oder einen Byref auf eine Struktur mit GC-Feldern) an einen potenziellen Heap-Speicherort zuzuweisen, die „Write Barrier“, die sicherstellt, dass die GC über neue Verbindungen zwischen Objekten informiert ist. Unsicherer Code ermöglicht es uns jedoch, diese Garantien zu umgehen und unzuverlässige Muster einzuführen. Beispiel:

unsafe void InvalidCode1(object[] arr1, object[] arr2)
{
    fixed (object* p1 = arr1)
    fixed (object* p2 = arr2)
    {
        nint* ptr1 = (nint*)p1;
        nint* ptr2 = (nint*)p2;

        // Bug! We're assigning a GC pointer to a heap location
        // without going through the Write Barrier.
        // Moreover, we also bypass array covariance checks.
        *ptr1 = *ptr2;
    }
}

Ebenso ist der folgende Code mit verwalteten Zeigern ebenfalls unzuverlässig:

struct StructWithGcFields
{
    object a;
    int b;
}

void InvalidCode2(ref StructWithGcFields dst, ref StructWithGcFields src)
{
    // It's already a bad idea to cast a struct with GC fields to `ref byte`, etc.
    ref byte dstBytes = ref Unsafe.As<StructWithGcFields, byte>(ref dst);
    ref byte srcBytes = ref Unsafe.As<StructWithGcFields, byte>(ref src);

    // Bug! Bypasses the Write Barrier. Also, non-atomic writes/reads for GC references.
    Unsafe.CopyBlockUnaligned(
        ref dstBytes, ref srcBytes, (uint)Unsafe.SizeOf<StructWithGcFields>());

    // Bug! Same as above.
    Vector128.LoadUnsafe(ref srcBytes).StoreUnsafe(ref dstBytes);
}

Recommendations

  1. ❌ Verwenden Sie keine nicht-atomischen Operationen auf GC-Verweisen (z. B. SIMD-Vorgänge bieten sie häufig nicht an).
  2. ❌ Verwenden Sie keine nicht verwalteten Zeiger, um GC-Verweise an Heap-Speicherorten zu speichern (ohne Schreibbarriere).

7. Annahmen über Objektlebensdauern (Finalizer, GC.KeepAlive)

Vermeiden Sie Annahmen über die Lebensdauer von Objekten aus der Sicht der GC. Nehmen Sie insbesondere nicht an, dass ein Objekt noch aktiv ist, wenn es nicht sein kann. Objektlebensdauern können über verschiedene Laufzeiten oder sogar zwischen verschiedenen Ebenen derselben Methode (Tier0 und Tier1 in RyuJIT) variieren. Finalizer sind ein häufiges Szenario, in dem solche Annahmen falsch sein können.

public class MyClassWithBadCode
{
    public IntPtr _handle;

    public void DoWork() => DoSomeWork(_handle); // A use-after-free bug!

    ~MyClassWithBadCode() => DestroyHandle(_handle);
}

// Example usage:
var obj = new MyClassWithBadCode()
obj.DoWork();

In diesem Beispiel kann DestroyHandle aufgerufen werden, bevor DoWork fertiggestellt ist oder sogar bevor es beginnt. Daher ist es wichtig, nicht davon auszugehen, dass Objekte, z. B. this, bis zum Ende der Methode weiterhin vorhanden sind.

void DoWork()
{
    // A pseudo-code of what might happen under the hood:

    IntPtr reg = this._handle;
    // 'this' object is no longer alive at this point.

    // <-- GC interrupts here, collects the 'this' object, and triggers its finalizer.
    // DestroyHandle(_handle) is called.

    // Bug! 'reg' is now a dangling pointer.
    DoSomeWork(reg);

    // You can resolve the issue and force 'this' to be kept alive (thus ensuring the
    // finalizer will not run) by uncommenting the line below:
    // GC.KeepAlive(this);
}

Daher empfiehlt es sich, die Lebensdauer von Objekten explizit zu erweitern, indem man GC.KeepAlive(Object) oder SafeHandle anwendet.

Eine weitere klassische Instanz dieses Problems ist Marshal.GetFunctionPointerForDelegate<TDelegate>(TDelegate) DIE API:

var callback = new NativeCallback(OnCallback);

// Convert delegate to function pointer
IntPtr fnPtr = Marshal.GetFunctionPointerForDelegate(callback);

// Bug! The delegate might be collected by the GC here.
// It should be kept alive until the native code is done with it.

RegisterCallback(fnPtr);

Recommendations

  1. ❌ MACHEN SIE keine Annahmen zu Objektlebensdauern. Gehen Sie beispielsweise nie davon aus, dass this bis zum Ende der Methode aktiv bleibt.
  2. ✔️ Verwenden Sie SafeHandle zur Verwaltung von nativen Ressourcen.
  3. ✔️ Verwenden Sie GC.KeepAlive(Object), um die Lebensdauer von Objekten bei Bedarf zu verlängern.

8. Threadübergreifender Zugriff auf lokale Variablen

Der Zugriff auf lokale Variablen aus einem anderen Thread wird in der Regel als schlechte Methode angesehen. Es wird jedoch explizit zu einem undefinierten Verhalten, wenn verwaltete Verweise beteiligt sind, wie im .NET-Speichermodell beschrieben.

Beispiel: Eine Struktur, die GC-Verweise enthält, kann innerhalb eines no-GC Bereichs null oder überschrieben werden, während ein anderer Thread sie liest, was zu einem nicht definierten Verhalten führt.

Recommendations

  1. ❌ Greifen Sie nicht über Threads hinweg auf Lokale zu (insbesondere, wenn sie GC-Verweise enthalten).
  2. ✔️ Verwenden Sie stattdessen Heap-Speicher oder nicht verwalteten Speicher (z. B. NativeMemory.Alloc).

9. Entfernung unsicherer Grenzen

In C# enthalten alle idiomatischen Speicherzugriffe standardmäßig Begrenzungsprüfungen. Der JIT-Compiler kann diese Überprüfungen entfernen, wenn er nachweisen kann, dass sie unnötig sind, wie im folgenden Beispiel gezeigt.

int SumAllElements(int[] array)
{
    int sum = 0;
    for (int i = 0; i < array.Length; i++)
    {
        // The JIT knows that within this loop body, i >= 0 and i < array.Length.
        // The JIT can reason that its own bounds check would be duplicative and
        // unnecessary, so it opts not to emit the bounds check into the final
        // generated code.
        sum += array[i];
    }
}

Während sich das JIT ständig darin verbessert, solche Muster zu erkennen, gibt es immer noch Szenarien, in denen die Prüfungen bestehen bleiben, was sich möglicherweise auf die Leistung in kritischem Code auswirkt. In solchen Fällen sind Sie möglicherweise versucht, unsicheren Code zu verwenden, um diese Prüfungen manuell zu entfernen, ohne die Risiken vollständig zu verstehen oder die Leistungsvorteile genau zu bewerten.

Betrachten Sie z. B. die folgende Methode.

int FetchAnElement(int[] array, int index)
{
    return array[index];
}

Wenn der JIT nicht nachweisen kann, dass index immer innerhalb der Grenzen von array liegt, wird er die Methode umschreiben, so dass sie etwa wie die folgende aussieht.

int FetchAnElement_AsJitted(int[] array, int index)
{
    if (index < 0 || index >= array.Length)
        throw new IndexOutOfBoundsException();
    return array.GetElementAt(index);
}

Um den Overhead durch diese Überprüfung in kritischem Code zu verringern, könnten Sie versucht sein, unsichere äquivalente APIs (Unsafe und MemoryMarshal) zu verwenden.

int FetchAnElement_Unsafe1(int[] array, int index)
{
    // DANGER: The access below is not bounds-checked and could cause an access violation.
    return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}

Oder verwenden Sie Pinning und Rohzeiger:

unsafe int FetchAnElement_Unsafe2(int[] array, int index)
{
    fixed (int* pArray = array)
    {
        // DANGER: The access below is not bounds-checked and could cause an access violation.
        return pArray[index];
    }
}

Dies kann zu zufälligen Abstürzen oder Beschädigungen des Zustands führen, wenn index außerhalb der Grenzen von array liegt. Solche unsicheren Transformationen können Leistungsvorteile auf sehr heißen Pfaden haben, aber diese Vorteile sind häufig vorübergehend, da jede .NET-Version die Fähigkeit des JIT verbessert, unnötige Begrenzungsprüfungen zu beseitigen, wenn dies sicher ist.

Recommendations

  1. ✔️ Überprüfen Sie, ob die neueste Version von .NET die Grenzprüfung immer noch nicht beseitigen kann. Wenn dies möglich ist, schreiben Sie es mit sicherem Code um. Andernfalls müssen Sie ein Problem mit dem RyuJIT einreichen. Verwenden Sie dieses Tracking-Problem als guten Ausgangspunkt.
  2. ✔️ Messen Sie den Einfluss der Leistung in der Praxis. Wenn die Leistungssteigerung vernachlässigbar ist oder der Code sich außerhalb eines trivialen Mikrobenchmarks nicht als leistungsintensiv erwiesen hat, schreiben Sie ihn mit sicherem Code neu.
  3. ✔️ DO stellt zusätzliche Hinweise für JIT bereit, z. B. manuelle Grenzprüfungen vor Schleifen und Speichern von Feldern in lokale Variablen, da das .NET-Speichermodell möglicherweise in konservativer Weise verhindert, dass JIT Grenzprüfungen in einigen Szenarien entfernt.
  4. ✔️ Sichern Sie Code mit Debug.Assert Grenzprüfungen, wenn unsicherer Code weiterhin erforderlich ist. Sehen Sie sich das folgende Beispiel an.
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe code here

Sie können diese Überprüfungen sogar in wiederverwendbare Hilfsmethoden umgestalten.

[MethodImpl(MethodImplOptions.AggressiveInlining)]
static T UnsafeGetElementAt<T>(this T[] array, int index)
{
    Debug.Assert(array is not null);
    Debug.Assert((index >= 0) && (index < array.Length));
    return Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);
}

Die Einbeziehung von Debug.Assert bietet keine Korrektheitsprüfungen für Release-Builds, aber sie kann hilfreich sein, um potenzielle Fehler in Debug-Builds zu erkennen.

10. Koaleszierung des Arbeitsspeicherzugriffs

Möglicherweise sind Sie versucht, unsicheren Code zu verwenden, um Speicherzugriffe zu zusammenführen, um die Leistung zu verbessern. Ein klassisches Beispiel ist der folgende Code, um "False" in ein Zeichenarray zu schreiben:

// Naive implementation
static void WriteToDestination_Safe(char[] dst)
{
    if (dst.Length < 5) { throw new ArgumentException(); }
    dst[0] = 'F';
    dst[1] = 'a';
    dst[2] = 'l';
    dst[3] = 's';
    dst[4] = 'e';
}

// Unsafe coalesced implementation
static void WriteToDestination_Unsafe(char[] destination)
{
    Span<char> dstSpan = destination;
    if (dstSpan.Length < 5) { throw new ArgumentException(); }
    ulong fals_val = BitConverter.IsLittleEndian ? 0x0073006C00610046ul : 0x00460061006C0073ul;
    MemoryMarshal.Write(MemoryMarshal.AsBytes(dstSpan.Slice(0, 4)), in fals_val); // Write "Fals" (4 chars)
    dstSpan[4] = 'e';                                                             // Write "e" (1 char)
}

In früheren Versionen von .NET war die unsichere Version, die MemoryMarshal verwendet, messbar schneller als die einfache sichere Version. Moderne Versionen von .NET enthalten jedoch einen viel verbesserten JIT, der für beide Fälle gleichwertigen Code erzeugt. Ab .NET 10 lautet der x64-Codegen:

; WriteToDestination_Safe
cmp      eax, 5
jl       THROW_NEW_ARGUMENTEXCEPTION
mov      rax, 0x73006C00610046
mov      qword ptr [rdi+0x10], rax
mov      word  ptr [rdi+0x18], 101

; WriteToDestination_Unsafe
cmp      edi, 5
jl       THROW_NEW_ARGUMENTEXCEPTION
mov      rdi, 0x73006C00610046
mov      qword ptr [rax], rdi
mov      word  ptr [rax+0x08], 101

Es gibt eine noch einfachere und besser lesbare Version des Codes:

"False".CopyTo(dst);

Ab .NET 10 erzeugt dieser Aufruf identische Codegen wie oben. Es hat sogar einen zusätzlichen Vorteil: Es gibt dem JIT einen Hinweis, dass strikte Schreibvorgänge pro Element nicht atomisch sein müssen. Der JIT kann diesen Hinweis mit anderen kontextbezogenen Kenntnissen kombinieren, um noch mehr Optimierungen bereitzustellen, die über das hier besprochene Thema hinausgehen.

Recommendations

  1. ✔️ DO bevorzugt idiomatischen sicheren Code anstelle unsicherer Speicherzugriffskombinationen:
    • Bevorzugen Span<T>.CopyTo Und Span<T>.TryCopyTo zum Kopieren von Daten.
    • Bevorzugen Sie String.Equals und Span<T>.SequenceEqual zum Vergleichen von Daten, auch bei Verwendung von StringComparer.OrdinalIgnoreCase.
    • Bevorzugen Sie Span<T>.Fill das Ausfüllen von Daten und Span<T>.Clear das Löschen von Daten.
    • Beachten Sie, dass Schreib- und Lesevorgänge pro Element oder pro Feld automatisch von JIT kombiniert werden.
  2. ✔️ Reichen Sie ein Problem gegen dotnet/runtime ein, wenn Sie idiomatischen Code schreiben und feststellen, dass er nicht wie erwartet optimiert wird.
  3. ❌ Führen Sie den Speicherzugriff nicht manuell zusammen, wenn Sie sich nicht über die Risiken eines fehlangepassten Speicherzugriffs, atomare Garantien oder die damit verbundenen Leistungsvorteile im Klaren sind.

11. Nicht ausgerichteter Speicherzugriff

Der in Speicherzugriffs-Koaleszierung beschriebene Vorgang führt häufig zu expliziten oder impliziten falsch ausgerichteten Lese-/Schreibvorgängen. Obwohl dies in der Regel keine schwerwiegenden Probleme verursacht (abgesehen von potenziellen Leistungsstrafen aufgrund des Überschreitens von Cache- und Seitengrenzen), stellt sie dennoch einige echte Risiken dar.

Betrachten Sie beispielsweise das Szenario, in dem Sie zwei Elemente eines Arrays gleichzeitig löschen:

uint[] arr = _arr;
arr[i + 0] = 0;
arr[i + 1] = 0;

Angenommen, die vorherigen Werte an diesen Speicherorten waren beide uint.MaxValue (0xFFFFFFFF). Das .NET-Speichermodell garantiert, dass beide Schreibvorgänge atomisch sind, sodass alle anderen Threads im Prozess nur den neuen Wert oder den alten Wert 00xFFFFFFFFbeobachten, niemals "torn"-Werte wie 0xFFFF0000.

Gehen Sie jedoch davon aus, dass der folgende unsichere Code verwendet wird, um die Begrenzungsprüfung zu umgehen und beide Elemente mit einer einzigen 64-Bit Speicheroperation auf Null zu setzen.

ref uint p = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(arr), i);
Unsafe.WriteUnaligned<ulong>(ref Unsafe.As<uint, byte>(ref p), 0UL);

Dieser Code hat die Nebenwirkung, die Atomitätsgarantie zu entfernen. Tornwerte können von anderen Threads beobachtet werden, was zu einem nicht definierten Verhalten führt. Damit ein solcher zusammengeklammerter Schreibvorgang atomisch ist, muss der Speicher an der Größe des Schreibvorgangs ausgerichtet werden (in diesem Fall 8 Bytes). Wenn Sie versuchen, den Speicher vor dem Vorgang manuell auszurichten, müssen Sie berücksichtigen, dass die GC das Array jederzeit verschieben kann (und effektiv die Ausrichtung des Arrays ändern kann, wenn es nicht angeheftet ist). Weitere Informationen finden Sie in der .NET-Speichermodelldokumentation .

Ein weiteres Risiko für den nicht ausgerichteten Speicherzugriff ist das Potenzial für einen Anwendungsabsturz in bestimmten Szenarien. Während einige .NET-Runtimes darauf angewiesen sind, dass das Betriebssystem falsch ausgerichtete Zugriffe behebt, gibt es weiterhin bestimmte Szenarien auf einigen Plattformen, in denen ein falsch ausgerichteter Zugriff zu einem DataMisalignedException (oder SEHException) führen kann. Zu den Beispielen gehören:

  • Interlocked Operationen mit falsch ausgerichtetem Speicher auf einigen Plattformen (Beispiel).
  • Nicht ausgerichtete Gleitkommaoperationen auf ARM.
  • Zugreifen auf speziellen Gerätespeicher mit bestimmten Ausrichtungsanforderungen (nicht wirklich unterstützt von .NET).

Recommendations

  1. ❌ Verwenden Sie keinen nicht ausgerichteten Speicherzugriff in sperrfreien Algorithmen und anderen Szenarien, in denen Atomität wichtig ist.
  2. ✔️ Richten Sie Daten bei Bedarf manuell aus, denken Sie jedoch daran, dass die GC Objekte jederzeit verschieben kann und die Ausrichtung dynamisch ändert. Dies ist besonders wichtig für verschiedene StoreAligned/LoadAligned APIs in SIMD.
  3. ✔️ DO verwenden Sie explizite nicht ausgerichtete Lese-/Schreib-APIs wie Unsafe.ReadUnaligned/Unsafe.WriteUnaligned anstelle von ausgerichteten APIs wie Unsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T) oder Unsafe.As<TFrom,TTo>(TFrom), wenn die Daten möglicherweise falsch ausgerichtet sind.
  4. ✔️ Denken Sie daran, dass verschiedene Speichermanipulations-APIs wie Span<T>.CopyTo(Span<T>) auch keine Atomitätsgarantien bieten.
  5. ✔️ Konsultieren Sie die Dokumentation zum .NET Memory Model (sehen Sie die Referenzen), um mehr Details zu den Atomaritätsgarantien zu erfahren.
  6. ✔️ Messen Sie unbedingt die Leistung auf allen Zielplattformen, da einige Plattformen erhebliche Leistungseinbußen für unalignierte Speicherzugriffe verursachen. Möglicherweise stellen Sie fest, dass auf diesen Plattformen naive Code besser als cleveren Code funktioniert.
  7. ✔️ Beachten Sie, dass es Szenarien und Plattformen gibt, in denen ein nicht ausgerichteter Speicherzugriff zu einer Ausnahme führen kann.

12. Binäre (De)Serialisierung von Strukturen mit Padding oder nicht-blittbaren Elementen

Seien Sie vorsichtig, wenn Sie verschiedene serialisierungsähnliche APIs zum Kopieren oder Lesen von Strukturen in oder aus Bytearrays verwenden. Wenn eine Struktur Auffüllungen oder nicht-blittable Elemente (z. B. GC-Felder) enthält, können klassische unsichere Speichervorgänge wie bool, Fill und CopyTo möglicherweise versehentlich vertrauliche Daten aus dem Stack in die Auffüllungen kopieren oder unbrauchbare Daten beim Vergleich als bedeutsam behandeln, wodurch selten reproduzierbare Fehler entstehen. Ein gängiges Antimuster sieht möglicherweise wie folgt aus:

T UnreliableDeserialization<TObject>(ReadOnlySpan<byte> data) where TObject : unmanaged
{
    return MemoryMarshal.Read<TObject>(data); // or Unsafe.ReadUnaligned
    // BUG! TObject : unmanaged doesn't guarantee that TObject is blittable and contains no paddings.
}

Der einzige richtige Ansatz besteht darin, Feld-nach-Feld-Ladevorgänge/Speicher für jede TObject Eingabe (oder generalisiert mit Spiegelungs-, Quellgeneratoren oder (de)Serialisierungsbibliotheken) zu verwenden.

Recommendations

  1. ❌ Verwenden Sie keinen unsicheren Code, um Strukturen mit Paddings oder nicht-blittable Mitgliedern zu kopieren, zu laden oder zu vergleichen. Operationen von unzuverlässigen Eingaben sind problematisch, selbst für grundlegende Typen wie bool oder decimal. Gleichzeitig serialisieren Speicher möglicherweise versehentlich vertrauliche Informationen aus dem Stapel in den Lücken/Abständen einer Struktur.
  2. ❌ Verlassen Sie sich nicht auf T : unmanaged Einschränkungs- oder ähnliche APIs, RuntimeHelpers.IsReferenceOrContainsReferencesum sicherzustellen, dass ein generischer Typ bitweise Vorgänge ausführen kann. Zum Zeitpunkt der Erstellung dieser Richtlinien gibt es keine zuverlässige programmgesteuerte Möglichkeit, zu bestimmen, ob es zulässig ist, beliebige bitweise Vorgänge für einen bestimmten Typ auszuführen.
    • Wenn Sie eine solche bitweise Manipulation durchführen müssen, führen Sie sie nur gegen diese hartcodierte Liste von Typen aus, und beachten Sie die Endianität des aktuellen Computers:
      • Die primitiven integralen Typen Byte, SByte, Int16, UInt16, Int32, UInt32und Int64UInt64;
      • Ein Enum unterstützt durch einen der oben genannten primitiven Ganzzahltypen;
      • Char, Int128, UInt128, Half, Single, Double, IntPtr, UIntPtr.
  3. ✔️ Verwenden Sie stattdessen die Feld-nach-Feld-Lade-/Speicher-Serialisierung (de). Erwägen Sie die Verwendung beliebter und sicherer Bibliotheken für die (De)Serialisierung.

13. Null verwaltete Zeiger

In der Regel sind Byrefs (verwaltete Zeiger) selten NULL und die einzige sichere Methode zum Erstellen einer Null-Byref ab heute ist das Initialisieren eines ref struct mit default. Dann sind alle Felder ref nullverwaltete Zeiger:

RefStructWithRefField s = default;
ref byte nullRef = ref s.refFld;

Es gibt jedoch mehrere unsichere Methoden zum Erstellen von Null-Byrefs. Einige Beispiele sind:

// Null byref by calling Unsafe.NullRef directly:
ref object obj = ref Unsafe.NullRef<object>();

// Null byref by turning a null unmanaged pointer into a null managed pointer:
ref object obj = ref Unsafe.AsRef<object>((void*)0);

Das Risiko der Einführung von Speichersicherheitsproblemen ist gering, und jeder Versuch, ein Null-Byref abzuleiten, führt zu einer gut definierten NullReferenceException. Der C#-Compiler geht jedoch davon aus , dass das Ableiten eines Byref immer erfolgreich ist und keine feststellbare Nebenwirkung erzeugt. Daher ist es eine legale Optimierung, jede Dereferenzierung zu überspringen, deren resultierender Wert sofort verworfen wird. Ein Beispiel für einen jetzt behobenen Fehler in .NET finden Sie unter dotnet/runtime#98681 (und diesem zugehörigen Kommentar), bei dem Bibliothekscode unsachgemäß auf das Dereferenzieren als Seiteneffekt setzte, ohne sich dessen bewusst zu sein, dass der C#-Compiler die beabsichtigte Logik effektiv kurzschaltet.

Recommendations

  1. ❌ Erstellen Sie keine Null-Byrefs in C#, wenn dies nicht erforderlich ist. Erwägen Sie stattdessen die Verwendung normaler verwalteter Verweise, des Null-Objektmusters oder leerer Bereiche.
  2. ❌ Werfen Sie das Ergebnis einer byref-Dereferenzierung NICHT weg, da es möglicherweise optimiert wird und zu potenziellen Fehlern führen kann.

14. stackalloc

stackalloc wurde in der Vergangenheit verwendet, um kleine, nicht entweichende Arrays auf dem Stapel zu erstellen und den GC-Druck zu reduzieren. In Zukunft könnte JIT's Escapeanalyse mit der Optimierung nicht entweichender GC-Reservierungen von Arrays zu Stapelobjekten beginnen, wodurch stackalloc potenziell redundant werden könnte. Bis dahin bleibt es nützlich, stackalloc kleine Puffer auf dem Stapel zuzuordnen. Bei größeren oder entweichenden Puffern wird sie häufig mit ArrayPool<T>.

Recommendations

  1. ✔️ Verwenden Sie immer stackalloc auf der linken Seite des Ausdrucks in ReadOnlySpan<T>/Span<T>, um Bereichsüberprüfungen bereitzustellen.

    // Good:
    Span<int> s = stackalloc int[10];
    s[2] = 0;  // Bounds check is eliminated by JIT for this write.
    s[42] = 0; // IndexOutOfRangeException is thrown
    
    // Bad:
    int* s = stackalloc int[10];
    s[2] = 0;
    s[42] = 0; // Out of bounds write, undefined behavior.
    
  2. ❌ Verwenden Sie stackalloc nicht innerhalb von Schleifen. Der Stapelspeicher wird erst freigegeben, wenn die Methode zurückkehrt. Das Einfügen eines stackalloc innerhalb einer Schleife könnte daher zu einem Prozessabbruch aufgrund eines Stapelüberlaufs führen.

  3. ❌ Verwenden Sie keine großen Längen für stackalloc. Beispielsweise könnten 1024 Byte als vernünftige Obergrenze betrachtet werden.

  4. ✔️ Überprüfen Sie den Bereich der Variablen, die als stackalloc Längen verwendet werden.

    void ProblematicCode(int length)
    {
        Span<int> s = stackalloc int[length]; // Bad practice: check the range of `length`!
        Consume(s);
    }
    

    Feste Version:

    void BetterCode(int length)
    {
        // The "throw if length < 0" check below is important, as attempting to stackalloc a negative
        // length will result in process termination.
        ArgumentOutOfRangeException.ThrowIfLessThan(length, 0, nameof(length));
        Span<int> s = length <= 256 ? stackalloc int[length] : new int[length];
        // Or:
        // Span<int> s = length <= 256 ? stackalloc int[256] : new int[length];
        // Which performs a faster zeroing of the stackalloc, but potentially consumes more stack space.
        Consume(s);
    }
    
  5. ✔️ Verwenden Sie moderne C#-Features wie Sammlungsliterale (Span<int> s = [1, 2, 3];), params Span<T>und Inlinearrays, um die manuelle Speicherverwaltung nach Möglichkeit zu vermeiden.

15. Puffer mit fester Größe

Puffer mit fester Größe waren für Interoperabilitätsszenarien mit Datenquellen aus anderen Sprachen oder Plattformen hilfreich. Sie wurden dann durch sicherere und komfortablere Inlinearrays ersetzt. Ein Beispiel für einen Puffer mit fester Größe (erfordert unsafe Kontext) ist der folgende Codeausschnitt:

public struct MyStruct
{
    public unsafe fixed byte data[8];
    // Some other fields
}

MyStruct m = new();
ms.data[10] = 0; // Out-of-bounds write, undefined behavior.

Eine moderne und sicherere Alternative ist Inlinearrays:

[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer
{
    private int _element0; // can be generic
}

public struct MyStruct
{
    public Buffer buffer;
    // Some other fields
}

MyStruct ms = new();
ms.buffer[i] = 0; // Runtime performs a bounds check on index 'i'; could throw IndexOutOfRangeException.
ms.buffer[7] = 0; // Bounds check elided; index is known to be in range.
ms.buffer[10] = 0; // Compiler knows this is out of range and produces compiler error CS9166.

Ein weiterer Grund, Puffer mit fester Größe zugunsten von Inline-Arrays zu vermeiden, die standardmäßig immer nullinitialisiert sind, besteht darin, dass Puffer mit fester Größe in bestimmten Szenarien nicht nullinitialisiert sein könnten.

Recommendations

  1. ✔️ Do bevorzugt, puffer mit fester Größe durch Inlinearrays oder IL-Marshallingattribute zu ersetzen, sofern möglich.

16. Übergeben zusammenhängender Daten als Zeiger + Längen (oder auf Nullterminierung verlassen)

Vermeiden Sie das Definieren von APIs, die nicht verwaltete oder verwaltete Zeiger auf zusammenhängende Daten akzeptieren. Verwenden Sie stattdessen Span<T> oder ReadOnlySpan<T>.

// Poor API designs:
void Consume(ref byte data, int length);
void Consume(byte* data, int length);
void Consume(byte* data); // zero-terminated
void Consume(ref byte data); // zero-terminated

// Better API designs:
void Consume(Span<byte> data);
void Consume(Memory<byte> data);
void Consume(byte[] data);
void Consume(byte[] data, int offset, int length);

Nullterminierung ist besonders riskant, da nicht alle Puffer nullterminiert sind und das Lesen über einen Nullterminator hinaus zu einer Offenlegung von Informationen, Datenbeschädigung oder Prozessbeendigung durch eine Zugriffsverletzung führen kann.

Recommendations

  1. Expose keine Methoden, deren Argumente Zeigertypen sind (nicht verwaltete Zeiger oder verwaltete Zeiger), wenn diese Argumente Puffer darstellen sollen. Verwenden Sie sichere Puffertypen wie Span<T> oder ReadOnlySpan<T> stattdessen.

  2. ❌ Verwenden Sie keine impliziten Verträge für Byref-Argumente, z. B. dass alle Aufrufer die Eingabe auf dem Stapel zuordnen müssen. Wenn ein solcher Vertrag erforderlich ist, erwägen Sie stattdessen die Verwendung der Referenzstruktur .

  3. ❌ Gehen Sie nicht davon aus, dass Puffer nullterminiert sind, es sei denn, das Szenario dokumentiert ausdrücklich, dass dies eine gültige Annahme ist. Obwohl .NET beispielsweise garantiert, dass string-Instanzen und "..."u8-Literale null-terminiert sind, gilt dies nicht für andere Puffertypen wie ReadOnlySpan<char> oder char[].

    unsafe void NullTerminationExamples(string str, ReadOnlySpan<char> span, char[] array)
    {
        Debug.Assert(str is not null);
        Debug.Assert(array is not null);
    
        fixed (char* pStr = str)
        {
            // OK: Strings are always guaranteed to have a null terminator.
            // This will assign the value '\0' to the variable 'ch'.
            char ch = pStr[str.Length];
        }
    
        fixed (char* pSpan = span)
        {
            // INCORRECT: Spans aren't guaranteed to be null-terminated.
            // This could throw, assign garbage data to 'ch', or cause an AV and crash.
            char ch = pSpan[span.Length];
        }
    
        fixed (char* pArray = array)
        {
            // INCORRECT: Arrays aren't guaranteed to be null-terminated.
            // This could throw, assign garbage data to 'ch', or cause an AV and crash.
            char ch = pArray[array.Length];
        }
    }
    
  4. ❌ Übergeben Sie keine angeheftete Span<char> oder ReadOnlySpan<char> über eine P/Invoke-Grenze, es sei denn, Sie haben auch ein explizites Längenargument übergeben. Andernfalls kann der Code auf der anderen Seite der P/Invoke-Grenze falsch glauben, dass der Puffer null-beendet ist.

unsafe static extern void SomePInvokeMethod(char* pwszData);

unsafe void IncorrectPInvokeExample(ReadOnlySpan<char> data)
{
    fixed (char* pData = data)
    {
        // INCORRECT: Since 'data' is a span and is not guaranteed to be null-terminated,
        // the receiver might attempt to keep reading beyond the end of the buffer,
        // resulting in undefined behavior.
        SomePInvokeMethod(pData);
    }
}

Um dies zu beheben, verwenden Sie eine alternative P/Invoke-Signatur, die sowohl den Datenzeiger alsauch die Länge akzeptiert, falls möglich. Andernfalls, wenn der Empfänger die Übergabe eines separaten Längenarguments nicht unterstützt, stellen Sie sicher, dass die ursprünglichen Daten in ein string konvertiert werden, bevor sie festgelegt und über die P/Invoke-Grenze hinweg übergeben werden.

unsafe static extern void SomePInvokeMethod(char* pwszData);
unsafe static extern void SomePInvokeMethodWhichTakesLength(char* pwszData, uint cchData);

unsafe void CorrectPInvokeExample(ReadOnlySpan<char> data)
{
    fixed (char* pData = data)
    {
        // OK: Since the receiver accepts an explicit length argument, they're signaling
        // to us that they don't expect the pointer to point to a null-terminated buffer.
        SomePInvokeMethodWhichTakesLength(pData, (uint)data.Length);
    }
    
    // Alternatively, if the receiver doesn't accept an explicit length argument, use
    // ReadOnlySpan<T>.ToString to convert the data to a null-terminated string before
    // pinning it and sending it across the p/invoke boundary.
    
    fixed (char* pStr = data.ToString())
    {
        // OK: Strings are guaranteed to be null-terminated.
        SomePInvokeMethod(pStr);
    }
}

17. Zeichenfolgenmutationen

Zeichenfolgen in C# sind von Natur aus unveränderlich, und jeder Versuch, sie mit unsicherem Code zu verändern, kann zu nicht definiertem Verhalten führen. Beispiel:

string s = "Hello";
fixed (char* p = s)
{
    p[0] = '_';
}
Console.WriteLine("Hello"); // prints "_ello" instead of "Hello"

Das Ändern einer internen Zeichenfolge (die meisten Zeichenfolgenliterale sind) ändert den Wert für alle anderen Verwendungen. Auch ohne Zeichenfolgen-Internierung sollte das Schreiben in eine neu erstellte Zeichenfolge durch die sicherere String.Create API ersetzt werden:

// Bad:
string s = new string('\n', 4); // non-interned string
fixed (char* p = s)
{
    // Copy data into the newly created string
}

// Good:
string s = string.Create(4, state, (chr, state) =>
{
    // Copy data into the newly created string
});

Recommendations

  1. ❌ NICHT Zeichenfolgen verändern. Verwenden Sie die String.Create API, um eine neue Zeichenfolge zu erstellen, wenn komplexe Kopierlogik erforderlich ist. Verwenden Sie andernfalls .ToString(), StringBuilder, new string(...) oder die Syntax für die Interpolation von Zeichenfolgen.

18. Unformatierter IL-Code (z. B. System.Reflection.Emit und Mono.Cecil)

Durch das Ausgeben von Roh-IL (entweder über System.Reflection.EmitDrittanbieterbibliotheken wie Mono.Cecil oder durch direktes Schreiben von IL-Code) werden per Definition alle Speichersicherheitsgarantien von C# umgangen. Vermeiden Sie die Verwendung solcher Techniken, es sei denn, es ist unbedingt erforderlich.

Recommendations

  1. ❌ Geben Sie keinen rohen IL-Code aus, da er ohne Leitplanken kommt und es einfach macht, Probleme mit der Typsicherheit und anderen Aspekten einzuführen. Wie bei anderen Techniken zur dynamischen Codegenerierung ist das Erzeugen von roher IL auch nicht AOT-freundlich, wenn es nicht zur Build-Zeit erfolgt.
  2. ✔️ Verwenden Sie stattdessen soweit möglich Quellgeneratoren.
  3. ✔️ Bevorzugen Sie [UnsafeAccessor] anstelle von raw IL, um bei Bedarf Code zur effizienten Serialisierung von privaten Mitgliedern zu schreiben.
  4. ✔️ Reichen Sie einen API-Vorschlag für dotnet/runtime ein, wenn eine API fehlt und Sie gezwungen sind, stattdessen rohen IL-Code zu verwenden.
  5. ✔️ Verwenden Sie ilverify oder ähnliche Tools, um den ausgegebenen IL-Code zu validieren, falls Sie rohes IL nutzen müssen.

19. Nicht initialisierte lokale Variablen [SkipLocalsInit] und Unsafe.SkipInit

[SkipLocalsInit] wurde in .NET 5.0 eingeführt, um dem JIT zu ermöglichen, das Nullsetzen von lokalen Variablen in Methoden zu überspringen, entweder auf Methodenbasis oder moduIweit. Dieses Feature wurde häufig verwendet, um dem JIT zu helfen, redundante Nullinitialisierungen zu beseitigen, z. B. solche für stackalloc. Dies kann jedoch zu nicht definierten Verhaltensweisen führen, wenn Lokale nicht explizit vor der Verwendung initialisiert werden. Mit den jüngsten Verbesserungen bei der Fähigkeit des JIT, Nullinitialisierungen zu beseitigen und Vektorisierung durchzuführen, hat sich der Bedarf an [SkipLocalsInit] und Unsafe.SkipInit deutlich verringert.

Recommendations

  1. ❌ Verwenden Sie [SkipLocalsInit] und Unsafe.SkipInit nicht, wenn keine Leistungsvorteile im leistungskritischen Code beobachtet werden oder Sie sich nicht sicher sind, welche Risiken sie darstellen.
  2. ✔️ Programmieren Sie defensiv, wenn Sie APIs wie GC.AllocateUninitializedArray und ArrayPool<T>.Shared.Rent verwenden, die auf ähnliche Weise nicht initialisierte Puffer zurückgeben können.

20. ArrayPool<T>.Shared und ähnliche Pool-APIs

ArrayPool<T>.Shared ist ein gemeinsam genutzter Pool von Arrays, die zum Verringern des GC-Drucks im Hot Code verwendet werden. Es wird häufig zum Zuordnen temporärer Puffer für E/A-Vorgänge oder andere kurzlebige Szenarien verwendet. Die API ist zwar einfach und enthält inhärent keine unsicheren Features, kann aber in C# zu Use-After-Free-Fehlern führen. Beispiel:

var buffer = ArrayPool<byte>.Shared.Rent(1024);
_buffer = buffer; // buffer object escapes the scope
Use(buffer);
ArrayPool<byte>.Shared.Return(buffer);

Jede Verwendung von _buffer nach dem Return-Anruf ist ein "Use-after-free"-Fehler. Dieses minimale Beispiel ist leicht zu erkennen, aber der Fehler wird schwieriger zu erkennen, wann Rent und Return in verschiedenen Bereichen oder Methoden vorhanden ist.

Recommendations

  1. ✔️ Halten Sie nach Möglichkeit passende Aufrufe von Rent und Return innerhalb derselben Methode, um den möglichen Fehlerbereich einzugrenzen.
  2. ❌ Vermeiden Sie, ein try-finally-Muster zu verwenden, um Return im finally-Block aufzurufen, es sei denn, Sie sind sicher, dass die fehlgeschlagene Logik die Verwendung des Puffers abgeschlossen hat. Es ist besser, den Puffer zu verwerfen, als ein Use-After-Free-Fehler aufgrund eines unerwarteten frühen Return zu riskieren.
  3. ✔️ Beachten Sie, dass ähnliche Probleme mit anderen Pool-APIs oder Mustern auftreten können, z. B. ObjectPool<T>.

21. bool<->int Konvertierungen

Während der ECMA-335-Standard einen booleschen Wert im Bereich von 0 bis 255 definiert, wobei jede Zahl ungleich Null als wahr gilt, ist es besser, explizite Konvertierungen zwischen Ganzzahlen und Booleschen Werten zu vermeiden, um "denormalisierte" Werte zu verhindern. Dies könnte sonst zu einem unzuverlässigen Verhalten führen, da nur 0 und 1 als gültige boolesche Werte betrachtet werden sollten.

// Bad:
bool b = Unsafe.As<int, bool>(ref someInteger);
int i = Unsafe.As<bool, int>(ref someBool);

// Good:
bool b = (byte)someInteger != 0;
int i = someBool ? 1 : 0;

Das JIT, das in früheren .NET-Runtimes vorhanden ist, hat die sichere Version dieser Logik nicht vollständig optimiert, was dazu führt, dass Entwickler unsichere Konstrukte verwenden, um zwischen bool und int in leistungssensitiven Codepfaden zu konvertieren. Dies ist nicht mehr der Fall, und moderne .NET JITs können die sichere Version effektiv optimieren.

Recommendations

  1. ❌ Schreiben Sie keine "verzweigungslosen" Konvertierungen zwischen ganzen Zahlen und Booleans mithilfe von unsicherem Code.
  2. ✔️ Verwenden Sie stattdessen ternäre Operatoren (oder andere Verzweigungslogik). Moderne .NET JITs optimieren sie effektiv.
  3. ❌ Lesen Sie bool NICHT mithilfe unsicherer APIs wie Unsafe.ReadUnaligned oder MemoryMarshal.Cast, wenn Sie der Eingabe nicht vertrauen. Verwenden Sie stattdessen ternäre Operatoren oder Gleichheitsvergleiche:
// Bad:
bool b = Unsafe.ReadUnaligned<bool>(ref byteData);

// Good:
bool b = byteData[0] != 0;

// Bad:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = MemoryMarshal.Cast<byte, bool>(byteSpan).ToArray();

// Good:
ReadOnlySpan<byte> byteSpan = ReadDataFromNetwork();
bool[] boolArray = new bool[byteSpan];
for (int i = 0; i < byteSpan.Length; i++) { boolArray[i] = byteSpan[i] != 0; }

Weitere Informationen finden Sie unter Binäre (De-)Serialisierung von Strukturen mit Padding oder nicht-blittable Mitgliedern.

22. Interoperabilität

Während die meisten Vorschläge in diesem Dokument auch für Interoperabilitätsszenarien gelten, empfiehlt es sich, dem Leitfaden für die native Interoperabilität bewährte Methoden zu folgen. Darüber hinaus sollten Sie autogenerierte Interopwrapper wie CsWin32 und CsWinRT verwenden. Dadurch wird die Notwendigkeit minimiert, dass Sie manuellen Interoperabilitätscode schreiben und das Risiko reduzieren, dass Speichersicherheitsprobleme auftreten.

23. Threadsicherheit

Speichersicherheit und Threadsicherheit sind orthogonale Konzepte. Code kann speichersicher sein, enthält aber weiterhin Datenrennen, zerrissene Lesevorgänge oder Sichtbarkeitsfehler; Hingegen kann Code threadsicher sein, während weiterhin undefiniertes Verhalten durch unsichere Speicheroperationen aufgerufen wird. Eine umfassendere Anleitung finden Sie unter bewährte Methoden für verwaltete Threading und .NET-Speichermodell.

24. Unsicherer Code im Zusammenhang mit SIMD/Vektorisierung

Weitere Details finden Sie in den Vektorisierungsrichtlinien . Im Kontext des unsicheren Codes ist es wichtig, Folgendes zu beachten:

  • SIMD-Vorgänge haben komplexe Anforderungen, um Atomitätsgarantien bereitzustellen (manchmal bieten sie sie überhaupt nicht).
  • Die meisten SIMD Load/Store-APIs stellen keine Begrenzungsprüfungen bereit.

25. Fuzz-Tests

Fuzz-Tests (oder "Fuzzing") ist eine automatisierte Softwaretesttechnik, die ungültige, unerwartete oder zufällige Daten als Eingaben für ein Computerprogramm bereitstellt. Es bietet eine Möglichkeit, Speichersicherheitsprobleme im Code zu erkennen, die möglicherweise Lücken bei der Testabdeckung aufweisen. Sie können Tools wie SharpFuzz verwenden, um Fuzztests für .NET-Code einzurichten.

26. Compilerwarnungen

Im Allgemeinen bietet der C#-Compiler keine umfassende Unterstützung, z. B. Warnungen und Analysen zur falschen unsicheren Codeverwendung. Es gibt jedoch einige Warnungen, die dabei helfen können, potenzielle Probleme zu erkennen und sollten nicht ignoriert oder unterdrückt werden, ohne sorgfältige Überlegungen zu berücksichtigen. Einige Beispiele sind:

nint ptr = 0;
unsafe
{
    int local = 0;
    ptr = (nint)(&local);
}
await Task.Delay(100);

// ptr is used here

Dieser Code erzeugt warnung CS9123 ("Der Operator '&' sollte nicht für Parameter oder lokale Variablen in asynchronen Methoden verwendet werden"), was bedeutet, dass der Code wahrscheinlich falsch ist.

Recommendations

  1. ✔️ ACHTEN Sie auf Compilerwarnungen und beheben Sie die zugrunde liegenden Probleme, anstatt sie zu unterdrücken.
  2. ❌ Gehen Sie nicht davon aus, dass das Fehlen von Compilerwarnungen bedeutet, dass der Code korrekt ist. Der C#-Compiler ist auf keine Unterstützung für die Erkennung falscher unsicherer Codeverwendung beschränkt.

References