Compartir vía


Mejores prácticas de código no seguro

Este artículo contiene recomendaciones específicas para patrones no seguros específicos, los riesgos que conllevan y cómo mitigar esos riesgos. Estas directrices tienen como destino todos los desarrolladores que escriben o revisan código no seguro en C#. Otros lenguajes .NET, como F# y Visual Basic, están fuera del ámbito de este artículo, aunque algunas recomendaciones también pueden aplicarse a esos lenguajes.

Glosario

  • AVE: excepción de infracción de acceso.
  • Byref: puntero administrado (ref T t) similar al puntero no administrado, pero al que realiza el seguimiento el GC. Normalmente apunta a partes arbitrarias de objetos o pila. La referencia es efectivamente un puntero administrado con un desplazamiento de +0.
  • CVE: vulnerabilidades de ciberseguridad divulgadas públicamente.
  • JIT: compilador Just-In-Time (RyuJIT en CoreCLR y NativeAOT).
  • PGO: optimización guiada por perfiles.
  • Puntero no administrado (o puntero sin formato): puntero (T* p) que apunta a una ubicación de memoria arbitraria y que no es administrado ni supervisado por el GC.

Para ver otros términos, consulte Glosario de .NET Runtime.

Patrones comunes que no son confiables

C# proporciona un entorno seguro en el que los desarrolladores no necesitan preocuparse por el funcionamiento interno del entorno de ejecución y el GC. El código no seguro le permite omitir estas comprobaciones de seguridad, introduciendo patrones potencialmente poco confiables que pueden provocar daños en la memoria. Aunque estos patrones pueden ser útiles en determinados escenarios, debe usarlos con precaución y solo cuando sea absolutamente necesario. No solo C# y .NET no proporcionan herramientas para verificar la solidez del código no seguro (como podrían proporcionar varios saneadores de C/C++), los comportamientos específicos del GC podrían introducir riesgos adicionales en el código no seguro de C# más allá de aquellos con los que los desarrolladores tradicionales de C/C++ podrían estar familiarizados.

El código no seguro en torno a las referencias administradas debe escribirse teniendo en cuenta las siguientes suposiciones conservadoras:

  • El GC puede interrumpir la ejecución de cualquier método en cualquier momento en cualquier instrucción.
  • El GC puede mover objetos en memoria y actualizar todas las referencias rastreadas.
  • El GC sabe exactamente cuándo ya no se necesitan referencias.

Un ejemplo clásico de corrupción del montón se produce cuando el GC pierde la referencia de un objeto o trata punteros no válidos como referencias de montón. Esto suele dar lugar a fallos impredecibles o corrupción de memoria. Los errores de corrupción del montón son especialmente difíciles de diagnosticar y reproducir porque:

  • Estos problemas pueden permanecer ocultos durante mucho tiempo y solo manifiesto después de un cambio de código no relacionado o una actualización en tiempo de ejecución.
  • A menudo requieren una sincronización precisa para reproducirse, como que el GC interrumpa la ejecución en una ubicación específica y comience la compactación del montón, que es un evento poco frecuente y no determinista.

En las secciones siguientes se describen los patrones no seguros comunes con ✔️ recomendaciones de HACER y ❌ NO HACER.

1. Punteros administrados sin seguimiento (Unsafe.AsPointer y similares)

No es posible convertir un puntero administrado (con seguimiento) a un puntero no administrado (sin seguimiento) en C#seguro. Cuando se produzca esa necesidad, puede resultar tentador usar Unsafe.AsPointer<T>(T) para evitar la sobrecarga de una fixed instrucción. Aunque hay casos de uso válidos para ello, presenta un riesgo de crear punteros sin seguimiento a objetos movibles. Ejemplo:

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

Si el GC haya interrumpido la ejecución del método justo después de que el UnreliableCode haya leído el puntero (la dirección referenciada por x) y reubica el objeto al que se hace referencia, el GC actualizará correctamente la ubicación almacenada en x, pero no sabrá nada sobre nativePointer y no actualizará el valor que contiene. En ese momento, escribir en nativePointer significa escribir en memoria arbitraria.

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

Una vez que GC reanuda la ejecución del método, escribirá 42 en la ubicación antigua de x, lo que podría provocar una excepción inesperada, daños generales en el estado global o la finalización del proceso a través de una infracción de acceso.

En su lugar, la solución recomendada es usar la palabra clave fixed y el operador de dirección & para asegurarse de que el GC no puede reubicar la referencia de destino durante la operación.

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. ❌ NO utilices ref X argumentos con un contrato implícito de que X siempre esté asignado en el stack, fijado o de otra forma no relocatable por el GC. Lo mismo se aplica a objetos sin formato y Spans: no introduzca contratos basados en el llamador no obvios sobre su duración en firmas de métodos. Considere la posibilidad de tomar un argumento ref struct o cambiar el argumento para que sea un tipo de puntero sin formato (X*).
  2. ❌ NO use un puntero desde Unsafe.AsPointer<T>(T) si puede sobrevivir al objeto original al que apunta. Según la documentación de la API, corresponde al responsable de invocar Unsafe.AsPointer<T>(T) garantizar que el GC no reubique la referencia. Asegúrese de que es claramente visible para los revisores de código que el autor de la llamada ha cumplido este requisito previo.
  3. ✔️ Haga uso de GCHandle o fixed ámbitos en lugar de Unsafe.AsPointer<T>(T) para definir ámbitos explícitos para punteros no administrados y para asegurarse de que el objeto siempre está anclado.
  4. ✔️ Use punteros no administrados (con fixed) en lugar de byrefs cuando necesite alinear una matriz con un límite específico. Esto garantiza que el GC no reubicará el objeto y invalidará las suposiciones de alineación en las que podría basarse la lógica.

2. Exponer punteros fuera del fixed ámbito

Aunque la palabra clave fija define un ámbito para el puntero obtenido del objeto anclado, todavía es posible que ese puntero escape el fixed ámbito e introduzca errores, ya que C# no proporciona ninguna protección de propiedad o ciclo de vida para él. Un ejemplo típico es el siguiente fragmento de código:

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

En este ejemplo, la matriz se ancla correctamente mediante la fixed palabra clave (lo que garantiza que el GC no puede reubicarlo dentro del fixed bloque), pero el puntero se expone fuera del fixed bloque. Esto crea un puntero pendiente cuya desreferencia dará lugar a un comportamiento indefinido.

Recommendations

  1. ✔️ Asegúrese de que los punteros en bloques fixed no dejen el ámbito definido.
  2. ✔️ Prefiere primitivos de bajo nivel seguros con análisis de escape integrado, como la estructura ref de C#. Para obtener más información, consulte Mejoras de estructura de bajo nivel.

3. Detalles de implementación interna del entorno de ejecución y las bibliotecas

Aunque acceder o depender de detalles de implementación internos es una mala práctica en general (y no está respaldado por .NET), vale la pena destacar casos específicos que se observan comúnmente. Esto no está pensado para ser una lista exhaustiva de todas las cosas posibles que podrían ir mal cuando el código se basa inapropiadamente en un detalle de implementación interno.

Recommendations

  1. ❌ NO modifique ni lea ninguna parte del encabezado de un objeto.

    • Los encabezados de objeto pueden diferir entre los entornos de ejecución.
    • En CoreCLR, no se puede acceder al encabezado de objeto de forma segura sin anclar primero el objeto.
    • No cambie nunca el tipo del objeto modificando el puntero MethodTable.
  2. ❌ NO almacene ningún dato en el relleno de un objeto. No suponga que el contenido de relleno se conservará o que el relleno siempre esté inicializado a cero por defecto.

  3. ❌ NO realice suposiciones sobre los tamaños y desplazamientos de cualquier cosa que no sea primitivos y estructuras con diseño secuencial o explícito. Incluso después, existen excepciones, como cuando los identificadores de GC están implicados.

  4. ❌ NO invoque métodos privados, acceda a campos privados ni modifique campos de solo lectura en tipos BCL con código de reflection o código inseguro.

  5. ❌ NO suponga que un miembro no público específico en la BCL siempre estará presente o tendrá una estructura específica. En ocasiones, el equipo de .NET modifica o quita las API no publicadas en las versiones de mantenimiento.

  6. ❌ NO cambie static readonly los campos mediante la reflexión o el código no seguro, ya que se supone que son constantes. Por ejemplo, RyuJIT normalmente los inlinea como constantes explícitas.

  7. ❌ NO supongamos simplemente que una referencia no es relocatable. Esta guía se aplica a literales de cadena y UTF-8 ("..."u8), campos estáticos, campos RVA, objetos LOH, etc.

    • Estos son detalles de implementación en tiempo de ejecución que podrían ser válidos para algunos entornos de ejecución, pero no para otros.
    • Es posible que los punteros no administrados a estos objetos no impidan que los ensamblados se descarguen, lo que hace que los punteros se vuelvan colgantes. Use fixed ámbitos para garantizar la corrección.
    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. ❌ NO escriba código que se base en los detalles de implementación de un entorno de ejecución específico.

4. Punteros administrados no válidos (incluso si nunca se desreferencian)

Algunas categorías de código terminan basándose en la manipulación de punteros y la aritmética, y este código suele tener una opción entre usar punteros no administrados (T* p) y punteros administrados (ref T p). Estos punteros se pueden manipular arbitrariamente, por ejemplo, a través de operadores en punteros no administrados (p++) y a través Unsafe de métodos en punteros administrados (p = ref Unsafe.Add(ref p, 1)). Ambos se consideran "código no seguro" y es posible crear patrones no confiables con ambos. Sin embargo, para determinados algoritmos, puede ser más fácil crear accidentalmente patrones no seguros de GC al manipular punteros administrados. Dado que el GC no realiza un seguimiento de los punteros no administrados, el valor que contienen solo es relevante cuando el código del desarrollador desreferencia. Por el contrario, el valor de un puntero administrado es relevante no solo cuando el código del desarrollador lo desreferencia, sino también cuando el GC lo examina. Por lo tanto, un desarrollador puede crear punteros no administrados no válidos sin consecuencias siempre que no se desreferencien, pero la creación de cualquier puntero administrado no válido es un error. Ejemplo:

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

Sin embargo, el código similar que usa byrefs (punteros administrados) no es válido.

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
}

Aunque la implementación administrada aquí evita la menor sobrecarga de anclaje, no es rotundo porque invalidPtrpodría convertirse en un puntero exterior mientras la dirección real de array[0] está siendo actualizada por GC. Estos errores son sutiles e incluso .NET ha tropezado con ellos durante el desarrollo.

Recommendations

  1. ❌ NO cree punteros administrados no válidos, aunque no se desreferencien o se encuentren dentro de rutas de código que nunca se ejecutan.
  2. ✔️ Use punteros no administrados anclados si el algoritmo requiere tales manipulaciones.

5. Conversiones de tipos similares a Reinterpret

Aunque todos los tipos de struct-to-class o class-to-struct casts son un comportamiento indefinido por definición, también es posible encontrar patrones no confiables con struct-to-struct o conversiones de clase a clase. Un ejemplo típico de un patrón no confiable es el código siguiente:

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.

E incluso si el diseño es similar, debe tener cuidado cuando intervienen las referencias de GC (campos).

Recommendations

  1. ❌ No convierta estructuras en clases o viceversa.
  2. ❌ NO use Unsafe.As para conversiones de struct a struct o de clase a clase a menos que esté absolutamente seguro de que la conversión es legal. Para más información, consulte la sección Comentarios de la documentación de apiUnsafe.As.
  3. ✔️ PREFIERE la copia más segura por campo, bibliotecas externas como AutoMapper o Generadores de código fuente para dichas conversiones.
  4. ✔️ Prefiere Unsafe.BitCast sobre Unsafe.As, ya que BitCast proporciona algunas comprobaciones de uso rudimentarias. Tenga en cuenta que estas comprobaciones no proporcionan garantías de corrección completas, lo que significa BitCast que todavía se considera una API no segura.

6. Omitiendo las operaciones de barrera de escritura y no atómicas en las referencias de GC

Normalmente, todo tipo de escrituras o lecturas de referencias de GC siempre son atómicas. Además, todos los intentos de asignar una referencia de GC (o una referencia byref a una estructura con campos GC) a una posible ubicación del montón pasan por la Barrera de Escritura que garantiza que el GC tenga en cuenta nuevas conexiones entre objetos. Sin embargo, el código no seguro nos permite omitir estas garantías e introducir patrones no confiables. Ejemplo:

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

Del mismo modo, el código siguiente con punteros administrados también es poco confiable:

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. ❌ NO use operaciones no atómicas en las referencias de GC (por ejemplo, las operaciones SIMD a menudo no las proporcionan).
  2. ❌ NO UTILICE punteros no administrados para almacenar referencias de GC en ubicaciones en el montón (omitiendo la barrera de escritura).

7. Suposiciones sobre el ciclo de vida de los objetos (finalizadores, GC.KeepAlive)

Evite realizar suposiciones sobre la duración de los objetos desde la perspectiva del GC. En concreto, no supongamos que un objeto sigue activo cuando podría no estar. Las duraciones de objetos pueden variar en diferentes entornos de ejecución o incluso entre diferentes niveles del mismo método (Tier0 y Tier1 en RyuJIT). Los finalizadores son un escenario común en el que tales suposiciones pueden ser incorrectas.

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();

En este ejemplo, DestroyHandle podría ser llamado antes de que DoWork se complete o incluso antes de que comience. Por lo tanto, es fundamental no asumir que los objetos, como this, permanecerán activos hasta el final del método.

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

Por lo tanto, se recomienda extender explícitamente la duración de los objetos mediante GC.KeepAlive(Object) o SafeHandle.

Otra instancia clásica de este problema es Marshal.GetFunctionPointerForDelegate<TDelegate>(TDelegate) 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. ❌ NO realice suposiciones sobre las duraciones de objetos. Por ejemplo, nunca se supone this que siempre está activo al final del método .
  2. ✔️ Usa SafeHandle para administrar recursos nativos.
  3. ✔️ UTILIZA GC.KeepAlive(Object) para extender la duración de los objetos cuando sea necesario.

8. Acceso entre subprocesos a variables locales

El acceso a variables locales desde un subproceso diferente suele considerarse un procedimiento incorrecto. Sin embargo, se convierte explícitamente en un comportamiento indefinido cuando las referencias administradas están implicadas, como se describe en el modelo de memoria de .NET.

Ejemplo: una estructura que contiene referencias de GC puede ser puesta a cero o sobrescribirse de manera no segura para hilos dentro de una región sin GC mientras otro hilo lo lee, lo que conduce a un comportamiento indefinido.

Recommendations

  1. ❌ NO acceda a las variables locales entre subprocesos (especialmente si contienen referencias de GC).
  2. ✔️ Utiliza memoria heap o memoria no administrada (por ejemplo, NativeMemory.Alloc) en su lugar.

9. Eliminación de comprobaciones de límites no seguros

En C#, todos los accesos a memoria de forma idiomática incluyen comprobaciones de límites de forma predeterminada. El compilador JIT puede quitar estas comprobaciones si puede demostrar que no son necesarias, como en el ejemplo siguiente.

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];
    }
}

Aunque el JIT mejora continuamente al reconocer estos patrones, todavía hay escenarios en los que deja las comprobaciones presentes, lo que podría afectar al rendimiento en el código crítico. En tales casos, es posible que tenga la tentación de usar código no seguro para quitar manualmente estas comprobaciones sin comprender completamente los riesgos o evaluar con precisión las ventajas de rendimiento.

Considere, por ejemplo, el método siguiente.

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

Si el JIT no puede demostrar que index siempre está dentro de los límites de array, reescribirá el método para tener un aspecto similar al siguiente.

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

Para reducir la sobrecarga de esa comprobación en el código crítico, es posible que tenga la tentación de usar APIs con un nivel de seguridad equivalente y menor (Unsafe y MemoryMarshal):

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

O bien, use anclajes y punteros sin formato:

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];
    }
}

Esto puede provocar bloqueos aleatorios o daños en el estado si index está fuera de los límites de array. Estas transformaciones no seguras pueden tener ventajas de rendimiento en rutas de acceso muy activas, pero estas ventajas suelen ser transitorias, ya que cada versión de .NET mejora la capacidad de JIT para eliminar las comprobaciones de límites innecesarias cuando es seguro hacerlo.

Recommendations

  1. ✔️ Compruebe si la versión más reciente de .NET todavía no puede eliminar la comprobación de límites. Si es posible, vuelva a escribir con código seguro. De lo contrario, abra un problema contra el RyuJIT. Use este problema de seguimiento como un buen punto de partida.
  2. ✔️ Mida el impacto en el rendimiento real. Si la ganancia de rendimiento es insignificante o no se ha demostrado que el código es crítico fuera de un microbenchmark trivial, reescribe utilizando código seguro.
  3. ✔️ Proporcione sugerencias adicionales al JIT, como comprobaciones de límites manuales antes de bucles y guardar campos en variables locales, ya que el modelo de memoria de .NET podría impedir de forma conservadora que el JIT quite las comprobaciones de límites en algunos escenarios.
  4. ✔️ Proteja el código con comprobaciones de límites Debug.Assert si el código inseguro sigue siendo necesario. Tenga en cuenta el ejemplo siguiente.
Debug.Assert(array is not null);
Debug.Assert((index >= 0) && (index < array.Length));
// Unsafe code here

Incluso puede refactorizar estas comprobaciones en métodos auxiliares reutilizables.

[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);
}

La inclusión de Debug.Assert no proporciona ninguna verificación de solidez para las compilaciones Release, pero puede ayudar a detectar posibles errores en las compilaciones Debug.

10. Fusión del acceso a la memoria

Es posible que tenga la tentación de usar código no seguro para fusionar los accesos a memoria para mejorar el rendimiento. Un ejemplo clásico es el código siguiente para escribir "False" en una matriz char:

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

En versiones anteriores de .NET, la versión no segura que usa MemoryMarshal era muy rápida que la versión segura sencilla. Sin embargo, las versiones modernas de .NET contienen un JIT mucho mejorado que genera códigogen equivalente para ambos casos. A partir de .NET 10, el codegen x64 es:

; 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

Hay una versión aún más sencilla y legible del código:

"False".CopyTo(dst);

A partir de .NET 10, esta llamada produce códigogen idéntico como se indicó anteriormente. Incluso tiene una ventaja adicional: sugiere al JIT que las escrituras estrictas por elemento no son necesarias para ser atómicas. El JIT podría combinar esta sugerencia con otros conocimientos contextuales para proporcionar aún más optimizaciones más allá de lo que se explicó aquí.

Recommendations

  1. ✔️ Prefiere código seguro y idiomático en lugar de código inseguro para la fusión de accesos a memoria.
    • Prefiere Span<T>.CopyTo y Span<T>.TryCopyTo para copiar datos.
    • Prefiere String.Equals y Span<T>.SequenceEqual para comparar datos (incluso cuando se usa StringComparer.OrdinalIgnoreCase).
    • Prefiera Span<T>.Fill para rellenar los datos y Span<T>.Clear para borrar los datos.
    • Tenga en cuenta que JIT puede fusionar automáticamente escrituras y lecturas por elementos o por campos.
  2. ✔️ Presenta un problema contra dotnet/runtime si escribes código idiomático y observas que no está optimizado como se esperaba.
  3. ❌ No fusionar manualmente los accesos a memoria si no está seguro de los riesgos de accesos de memoria desalineados, las garantías de atomicidad o las ventajas de rendimiento asociadas.

11. Acceso a memoria desalineada

La coalición del acceso a memoria descrita en Coalición de acceso a memoria suele dar lugar a lecturas o escrituras explícitas o implícitas no alineadas. Aunque esto normalmente no causa problemas graves (aparte de las posibles penalizaciones de rendimiento debido a cruzar los límites de caché y página), sigue planteando algunos riesgos reales.

Por ejemplo, considere el escenario en el que va a borrar dos elementos de una matriz a la vez:

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

Supongamos que los valores anteriores en estas ubicaciones eran ambos uint.MaxValue (0xFFFFFFFF). El modelo de memoria de .NET garantiza que ambas escrituras son atómicas, por lo que todos los demás subprocesos del proceso solo observarán el nuevo valor 0 o el valor anterior 0xFFFFFFFF, nunca valores "rotos" como 0xFFFF0000.

Sin embargo, suponga que se utiliza el siguiente código no seguro para omitir la comprobación de límites y poner a cero ambos elementos con una operación de almacenamiento de 64 bits.

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

Este código tiene el efecto secundario de quitar la garantía de atomicidad. Otros hilos pueden observar valores fragmentados, lo que conduce a un comportamiento indefinido. Para que una escritura fusionada sea atómica, la memoria debe alinearse con el tamaño de la escritura (8 bytes en este caso). Si intenta alinear manualmente la memoria antes de la operación, debe considerar que la GC puede reubicar (y, de forma eficaz, cambiar la alineación de) la matriz en cualquier momento si no está anclada. Consulte la documentación del modelo de memoria de .NET para obtener más información.

Otro riesgo del acceso a memoria no alineada es la posibilidad de que una aplicación se bloquee en ciertos escenarios. Aunque algunos entornos de ejecución de .NET dependen del sistema operativo para corregir los accesos desalineados, todavía hay algunos escenarios en algunas plataformas en las que el acceso desalineado puede provocar DataMisalignedException (o SEHException). Algunos de los ejemplos son:

  • Interlocked operaciones en memoria desalineada en algunas plataformas (ejemplo).
  • Operaciones de punto flotante mal alineadas en ARM.
  • Acceso a la memoria de dispositivo especial con determinados requisitos de alineación (no compatibles realmente con .NET).

Recommendations

  1. ❌ No use accesos a memoria no alineados en algoritmos sin bloqueo y otros escenarios en el que es importante la atomicidad.
  2. ✔️ Alinee los datos manualmente si es necesario, pero tenga en cuenta que el GC puede reubicar objetos en cualquier momento, cambiando de forma eficaz la alineación dinámicamente. Esto es especialmente importante para varias StoreAligned/LoadAligned API en SIMD.
  3. ✔️ UTILICE APIs de lectura/escritura explícitas no alineadas, como Unsafe.ReadUnaligned/Unsafe.WriteUnaligned, en lugar de las alineadas como Unsafe.Read<T>(Void*)/Unsafe.Write<T>(Void*, T) o Unsafe.As<TFrom,TTo>(TFrom), si los datos podrían estar desalineados.
  4. ✔️ Tenga en cuenta que varias API de manipulación de memoria, como Span<T>.CopyTo(Span<T>) tampoco proporcionan garantías de atomicidad.
  5. ✔️ Consulte la documentación del modelo de memoria de .NET (consulte referencias) para obtener más información sobre las garantías de atomicidad.
  6. ✔️ Mida el rendimiento en todas las plataformas de destino, ya que algunas plataformas imponen una penalización de rendimiento significativa para los accesos a memoria no asignados. En estas plataformas, el código naïve funciona mejor que el código inteligente.
  7. ✔️ Tenga en cuenta que hay escenarios y plataformas en los que el acceso a memoria no alineada puede causar una excepción.

12. Serialización y deserialización binaria de estructuras con rellenos o miembros "blittables" no compatibles.

Tenga cuidado al usar varias API similares a la serialización para copiar o leer estructuras a o desde matrices de bytes. Si una estructura contiene rellenos o miembros no blittables (por ejemplo, bool o campos GC), las operaciones de memoria no seguras clásicas, como Fill, CopyTo y SequenceEqual podrían copiar accidentalmente datos confidenciales de la pila a los rellenos o tratar los datos basura como significativos durante las comparaciones, creando errores que rara vez se reproducen. Un antipatrón común podría tener este aspecto:

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

El único enfoque correcto es usar cargas y almacenes especializados por campo para cada TObject entrada (o generalizado con Reflexión, generadores de código fuente o bibliotecas de (de)serialización).

Recommendations

  1. ❌ NO utilices código inseguro para copiar, cargar o comparar estructuras con rellenos o componentes no blittables. Las cargas de entradas que no son de confianza son problemáticas incluso para tipos básicos como bool o decimal. Al mismo tiempo, los almacenes pueden serializar accidentalmente información confidencial de la pila en espacios o rellenos de una estructura.
  2. ❌ NO se base en T : unmanaged restricciones, RuntimeHelpers.IsReferenceOrContainsReferenceso API similares para garantizar que un tipo genérico sea seguro para realizar operaciones bit a bit. En el momento de escribir estas directrices, no hay ninguna manera de programación confiable para determinar si es legal realizar operaciones arbitrarias bit a bit en un tipo determinado.
    • Si debe realizar esta manipulación bit a bit, solo debe hacerlo con esta lista codificada de tipos y tener en cuenta la endianidad de la máquina actual:
      • Los tipos enteros primitivos Byte, SByte, Int16, UInt16Int32, UInt32, , Int64y UInt64;
      • Respaldado por uno de los tipos integrales primitivos mencionados anteriormente;
      • Char, Int128, UInt128, Half, Single, Double, IntPtr, . UIntPtr
  3. ✔️ ES RECOMENDABLE usar la (de)serialización de cargas/almacenamiento campo por campo en su lugar. Considere usar bibliotecas populares y seguras para la (de)serialización.

13. Punteros administrados null

Por lo general, los byrefs (punteros administrados) rara vez son NULL y la única manera segura de crear un byref null a partir de hoy es inicializar un ref struct con default. A continuación, todos sus ref campos son punteros administrados nulos:

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

Sin embargo, hay varias maneras no seguras de crear byrefs NULL. Algunos ejemplos son:

// 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);

El riesgo de introducir problemas de seguridad de memoria es bajo y cualquier intento de desreferenciar una byref nula provocará una excepción NullReferenceException bien definida. Sin embargo, el compilador de C# supone que la desreferenciación de una byref siempre se realiza correctamente y no produce ningún efecto secundario observable. Por lo tanto, es una optimización legal para elide cualquier desreferencia cuyo valor resultante se desecha inmediatamente. Consulte dotnet/runtime#98681 (y este comentario relacionado) para ver un ejemplo de un error corregido ahora en .NET donde el código de biblioteca dependía incorrectamente de la desreferencia que desencadenaba un efecto secundario, sin tener en cuenta que el compilador de C# ha cortocircuitado eficazmente la lógica prevista.

Recommendations

  1. ❌ NO cree byrefs NULL en C# si no es necesario. Considere la posibilidad de usar referencias administradas normales, el patrón de objetos NULL o intervalos vacíos en su lugar.
  2. ❌ NO descarte el resultado de una desreferencia de byref, ya que podría optimizarse y provocar posibles errores.

14. stackalloc

stackalloc históricamente se ha usado para crear matrices pequeñas y sin escape en la pila, lo que reduce la presión de GC. En el futuro, el análisis de escape de JIT podría empezar a optimizar las asignaciones de GC sin escape de matrices para apilar objetos, lo que podría hacer stackalloc redundante. Hasta entonces, stackalloc sigue siendo útil para asignar búferes pequeños en la pila. En el caso de búferes más grandes o que permiten escape, se suele combinar con ArrayPool<T>.

Recommendations

  1. ✔️ Use siempre stackalloc en ReadOnlySpan<T>/Span<T> el lado izquierdo de la expresión para proporcionar comprobaciones de límites:

    // 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. ❌ NO uses stackalloc dentro de bucles. El espacio de pila no se libera hasta que el método retorna, por lo que incluir un stackalloc dentro de un bucle podría dar lugar a la finalización del proceso debido al desbordamiento de la pila.

  3. ❌ NO use longitudes grandes para stackalloc. Por ejemplo, 1024 bytes podría considerarse un límite superior razonable.

  4. ✔️ Compruebe el rango de variables usadas como stackalloc longitudes.

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

    Versión fija:

    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. ✔️ Use características modernas de C# como literales de colección (Span<int> s = [1, 2, 3];), params Span<T>y matrices insertadas para evitar la administración manual de memoria siempre que sea posible.

15. Búferes de tamaño fijo

Los búferes de tamaño fijo eran útiles para escenarios de interoperabilidad con orígenes de datos de otros lenguajes o plataformas. A continuación, se reemplazaron por matrices insertadas más seguras y cómodas. Un ejemplo de un búfer de tamaño fijo (requiere unsafe contexto) es el siguiente fragmento de código:

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.

Una alternativa moderna y más segura es matrices insertadas:

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

Otra razón para evitar los búferes de tamaño fijo en favor de arrays en línea, que siempre se inicializan en cero por defecto, es que los búferes de tamaño fijo podrían tener contenido no cero en determinados escenarios.

Recommendations

  1. ✔️ Prefiere reemplazar los búferes de tamaño fijo por matrices insertadas o atributos de marshaling IL siempre que sea posible.

16. Pasar datos continuos como punteros y longitudes (o confiar en la terminación en cero)

Evite definir las API que aceptan punteros no administrados o administrados a datos contiguos. En su lugar, use Span<T> o 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);

La terminación cero es especialmente arriesgada porque no todos los búferes están terminados en cero y leer más allá de cualquier terminador cero puede provocar la divulgación de información, los daños en los datos o la terminación del proceso a través de una infracción de acceso.

Recommendations

  1. ❌ No exponga métodos cuyos argumentos sean tipos de puntero (punteros T* no administrados o punteros ref T administrados) cuando se espera que esos argumentos representen búferes. Use tipos de búfer seguros como Span<T> o ReadOnlySpan<T> en su lugar.

  2. ❌ NO uses contratos implícitos para argumentos por referencia, como requerir que todos los llamadores asignen la entrada en la pila. Si es necesario este contrato, considere la posibilidad de usar la estructura ref en su lugar.

  3. ❌ NO asuma que los búferes están terminados con un cero a menos que el escenario documente explícitamente que esta es una suposición válida. Por ejemplo, aunque .NET garantiza que las instancias de string y los literales de "..."u8 están terminadas en nulo, lo mismo no sucede con otros tipos de buffers como ReadOnlySpan<char> o 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. ❌ NO pase un objeto anclado Span<char> o ReadOnlySpan<char> a través de un p/invoke a menos que también haya pasado un argumento de longitud explícita. De lo contrario, el código al otro lado del límite de p/invoke podría creer incorrectamente que el búfer está terminado en NULL.

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

Para resolverlo, use una firma p/invoke alternativa que acepte tanto el puntero de datos como la longitud, si es posible. De lo contrario, si el receptor no tiene forma de aceptar un argumento de longitud independiente, asegúrese de que los datos originales se conviertan en un string antes de fijarlos y pasarlos a través del límite p/invoke.

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. Mutaciones de cadena

Las cadenas de C# son inmutables por diseño y cualquier intento de mutarlos mediante código no seguro puede provocar un comportamiento indefinido. Ejemplo:

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

La modificación de una cadena intercalada (la mayoría de los literales de cadena) cambiará el valor para todos los demás usos. Incluso sin la internación de cadenas, la escritura en una cadena recién creada debe reemplazarse por la API más segura String.Create :

// 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. ❌ NO modifiques cadenas. Use la String.Create API para crear una cadena si se necesita lógica de copia compleja. De lo contrario, use .ToString(), StringBuilder, new string(...) o la sintaxis de interpolación de cadenas.

Código IL en bruto (por ejemplo, System.Reflection.Emit y Mono.Cecil)

Emitir IL sin formato (ya sea a través de System.Reflection.Emit, bibliotecas de Mono.Cecil de terceros, o escribiendo código IL directamente) por definición omite todas las garantías de seguridad de memoria que proporciona C#. Evite usar estas técnicas a menos que sea absolutamente necesario.

Recommendations

  1. ❌ NO emita código IL sin procesar, ya que no incluye límites de guía y facilita la introducción de la seguridad de tipos y otros problemas. Al igual que otras técnicas de generación de código dinámico, la emisión de código IL sin procesar tampoco es compatible con AOT si no se realiza en el tiempo de construcción.
  2. ✔️ Usa Generadores de código fuente en su lugar, si es posible.
  3. ✔️ Es preferible utilizar [UnsafeAccessor] en lugar de generar IL sin procesar para escribir código de serialización con baja sobrecarga para los miembros privados si surge la necesidad.
  4. ✔️ Presente una propuesta de API en dotnet/runtime si falta alguna API y se ve obligado a usar IL en bruto en su lugar.
  5. ✔️ Utilice ilverify o herramientas similares para validar el código IL emitido si debe usar IL sin formato.

19. Variables locales [SkipLocalsInit] y Unsafe.SkipInit no inicializadas

[SkipLocalsInit] se introdujo en .NET 5.0 para permitir que JIT omita la inicialización a cero de las variables locales en los métodos, ya sea por método o a nivel de módulo. Esta característica se usó a menudo para ayudar al JIT a eliminar inicializaciones redundantes de cero, como las de stackalloc. Sin embargo, puede provocar un comportamiento indefinido si las variables locales no se inicializan explícitamente antes de su uso. Con mejoras recientes en la capacidad de JIT para eliminar inicializaciones cero y realizar la vectorización, la necesidad de [SkipLocalsInit] y Unsafe.SkipInit ha disminuido significativamente.

Recommendations

  1. ❌ NO utilice [SkipLocalsInit] y Unsafe.SkipInit si no se observan ventajas de rendimiento en el código crítico o no está seguro de los riesgos que presentan.
  2. ✔️ Escriba código de forma defensiva al usar APIs como GC.AllocateUninitializedArray y ArrayPool<T>.Shared.Rent, que pueden devolver de forma similar búferes no inicializados.

20. ArrayPool<T>.Shared y API de agrupación similares

ArrayPool<T>.Shared es un grupo compartido de matrices que se utilizan para reducir la presión del GC en código de ejecución frecuente. A menudo se usa para asignar búferes temporales para operaciones de E/S u otros escenarios de corta duración. Aunque la API es sencilla y no contiene intrínsecamente características no seguras, puede provocar errores de uso después de liberar memoria en C#. Ejemplo:

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

Cualquier uso de _buffer después de la llamada Return es un error de uso después de liberación. Este ejemplo mínimo es fácil de detectar, pero el error se vuelve más difícil de detectar cuándo Rent y Return están en distintos ámbitos o métodos.

Recommendations

  1. ✔️ Mantenga las llamadas coincidentes a Rent y Return dentro del mismo método si es posible para restringir el ámbito de posibles errores.
  2. ❌ No uses un try-finally patrón para llamar Return en el finally bloque, a menos que estés seguro de que la lógica fallida ha terminado de usar el búfer. Es mejor abandonar el buffer en lugar de arriesgarse a un error de uso después de una liberación debido a una inesperada temprana Return.
  3. ✔️ Tenga en cuenta que pueden surgir problemas similares con otras API o patrones de agrupación, como ObjectPool<T>.

21. bool<->int Conversiones

Aunque ECMA-335 estándar define un valor booleano como 0-255, donde true es cualquier valor distinto de cero, es mejor evitar conversiones explícitas entre enteros y booleanos para evitar introducir valores "desnormalizados" como cualquier otro que no sea 0 o 1 probablemente conduce a un comportamiento poco confiable.

// 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;

El JIT presente en los entornos de ejecución de .NET anteriores no optimizaba completamente la versión segura de esta lógica, lo que provocaba que los desarrolladores usen construcciones no seguras para convertir entre bool y int en rutas de acceso de código sensibles al rendimiento. Esto ya no es así y los JIT de .NET modernos pueden optimizar la versión segura de forma eficaz.

Recommendations

  1. ❌ NO escriba conversiones "sin rama" entre enteros y booleanos mediante código no seguro.
  2. ✔️ Use operadores ternarios (u otra lógica de bifurcación) en su lugar. Los JIT de .NET modernos los optimizarán de forma eficaz.
  3. ❌ NO lea bool usando APIs no seguras, como Unsafe.ReadUnaligned o MemoryMarshal.Cast, si no confía en la entrada. Considere la posibilidad de usar operadores ternarios o comparaciones de igualdad en su lugar:
// 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; }

Para obtener más información, vea Serialización binaria (de)serialización de estructuras con rellenos o miembros no blittables.

22. Interoperabilidad

Aunque la mayoría de las sugerencias de este documento también se aplican a escenarios de interoperabilidad, se recomienda seguir la guía de procedimientos recomendados de interoperabilidad nativa . Además, considere la posibilidad de usar contenedores de interoperabilidad generados automáticamente como CsWin32 y CsWinRT. Esto minimiza la necesidad de escribir código de interoperabilidad manual y reduce el riesgo de introducir problemas de seguridad de memoria.

23. Seguridad de subprocesos

La seguridad de la memoria y la seguridad de hilos son conceptos ortogonales. El código puede ser seguro de memoria pero todavía contener carreras de datos, lecturas erróneas o errores de visibilidad; por el contrario, el código puede ser seguro de subprocesos aunque invoque un comportamiento indefinido a través de la manipulación de memoria no segura. Para obtener instrucciones más amplias, consulte Procedimientos recomendados de subprocesos administrados y Modelo de memoria de .NET.

24. Código no seguro alrededor de SIMD/Vectorización

Consulte Directrices de vectorización para obtener más detalles. En el contexto del código no seguro, es importante tener en cuenta lo siguiente:

  • Las operaciones SIMD tienen requisitos complejos para proporcionar garantías de atomicidad (a veces, no las proporcionan en absoluto).
  • La mayoría de las API de carga y almacenamiento de SIMD no proporcionan comprobaciones de límites.

25. Pruebas aproximadas

Las pruebas de fuzzing (o "fuzzing") son una técnica de prueba de software automatizada que implica proporcionar datos no válidos, inesperados o aleatorios como entradas a un programa informático. Proporciona una manera de detectar problemas de seguridad de memoria en el código que pueden tener lagunas en la cobertura de pruebas. Puede usar herramientas como SharpFuzz para configurar pruebas de fuzzing para código .NET.

26. Advertencias del compilador

Por lo general, el compilador de C# no proporciona compatibilidad extensa, como advertencias y analizadores en torno al uso incorrecto de código no seguro. Sin embargo, hay algunas advertencias existentes que pueden ayudar a detectar posibles problemas y no deben omitirse ni suprimirse sin tener en cuenta cuidadosamente. Algunos ejemplos son:

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

// ptr is used here

Este código genera la advertencia CS9123 ("El operador '&' no debe usarse en parámetros o variables locales en métodos asincrónicos"), lo que implica que el código es probable que sea incorrecto.

Recommendations

  1. ✔️ Preste atención a las advertencias del compilador y corrija los problemas subyacentes en lugar de suprimirlos.
  2. ❌ NO suponga que la ausencia de advertencias del compilador implica que el código es correcto. El compilador de C# no tiene ninguna compatibilidad con la detección de un uso incorrecto de código no seguro.

Referencias