Compartir a través de


Lectura de cargas de BinaryFormatter (NRBF)

BinaryFormatter usó el .NET Remoting: Binary Format para la serialization. Este formato es conocido por su abreviatura de MS-NRBF o simplemente NRBF. Un desafío común involucrado en la migración de BinaryFormatter es lidiar con las cargas útiles persistentes en el almacenamiento, ya para la lectura de estas cargas antes se necesitaba BinaryFormatter. Algunos sistemas necesitan conservar la capacidad de leer estas cargas para migraciones graduales a nuevos serializadores, a la vez que evitan una referencia de BinaryFormatter a sí mismo.

Como parte de .NET 9, se introdujo una nueva clase NrbfDecoder para descodificar cargas NRBF sin realizar la deserialización de la carga. Esta API se puede usar de forma segura para descodificar cargas de confianza o que no son de confianza sin ninguno de los riesgos que la deserialización de BinaryFormatter conlleva. Sin embargo, NrbfDecoder simplemente decodifica los datos en estructuras que una aplicación puede procesar posteriormente. Se debe tener cuidado al usar NrbfDecoder para cargar de forma segura los datos en las instancias adecuadas.

Puede considerar NrbfDecoder como equivalente al uso de un lector JSON/XML sin el deserializador.

NrbfDecoder

NrbfDecoder forma parte del nuevo paquete NuGet System.Formats.Nrbf. Tiene como destino no solo .NET 9, sino también monikers anteriores, como .NET Standard 2.0 y .NET Framework. Ese multi-destino hace posible que todos los usuarios que usen una versión compatible de .NET se migren de BinaryFormatter. NrbfDecoder puede leer cargas útiles que se serializaron con BinaryFormatter using FormatterTypeStyle.TypesAlways (el valor predeterminado).

NrbfDecoder está diseñado para tratar todas las entradas como no de confianza. Como tal, tiene estos principios:

  • Sin carga de tipos de ninguna variante (para evitar riesgos como la ejecución remota de código).
  • No hay recursividad de ninguna variante (para evitar la recursividad sin enlazar, el desbordamiento de pila y la denegación de servicio).
  • No hay asignación previa del búfer en función del tamaño proporcionado en la carga, si la carga es demasiado pequeña para contener los datos prometidos (para evitar quedarse sin memoria y denegación de servicio).
  • Descodifique cada parte de la entrada una sola vez (para realizar la misma cantidad de trabajo que el posible atacante que creó la carga).
  • Use hash aleatorio resistente a colisiones para almacenar registros a los que hacen referencia otros registros (para evitar quedarse sin memoria para un diccionario respaldado por una matriz cuyo tamaño depende del número de colisiones de código hash).
  • Solo se pueden crear instancias de tipos primitivos de forma implícita. Las matrices pueden instanciarse a petición. Otros tipos nunca pueden instanciarse.

Al usar NrbfDecoder, es importante no volver a introducir esas capacidades en el código de uso general, ya que hacerlo anularía estas salvaguardas.

Deserializar un conjunto cerrado de tipos

NrbfDecoder solo es útil cuando la lista de tipos serializados es un conjunto cerrado conocido. Para expresarlo de otra manera, debe saber por adelantado lo que desea leer, ya que también debe crear instancias de esos tipos y rellenarlas con datos leídos de la carga. Considere dos ejemplos opuestos:

  • Todos los tipos [Serializable] de Quartz.NET que la propia biblioteca puede conservar son sealed. Por lo tanto, no hay tipos personalizados que los usuarios puedan crear y la carga solo puede contener tipos conocidos. Los tipos también proporcionan constructores públicos, por lo que es posible volver a crear estos tipos en función de la información leída de la carga.
  • El tipo SettingsPropertyValue expone la propiedad PropertyValue de tipo object que puede usar internamente BinaryFormatter para serializar y deserializar cualquier objeto almacenado en el archivo de configuración. Se puede usar para almacenar un entero, un tipo personalizado, un diccionario o literalmente cualquier cosa. Por eso, es imposible migrar esta biblioteca sin introducir cambios importantes en la API.

Identificar cargas de NRBF

NrbfDecoder proporciona dos métodos StartsWithPayloadHeader que le permiten comprobar si una secuencia o búfer determinado comienza con el encabezado NRBF. Se recomienda usar estos métodos al migrar cargas persistentes con BinaryFormatter a un serializador diferente:

  • Compruebe si la carga útil leída del almacenamiento es una carga útil NRBF con NrbfDecoder.StartsWithPayloadHeader.
  • Si es así, léalo con NrbfDecoder.Decode, serialícelo con un nuevo serializador y sobrescriba los datos en el almacenamiento.
  • Si no es así, use el nuevo serializador para deserializar los datos.
internal static T LoadFromFile<T>(string path)
{
    bool update = false;
    T value;

    using (FileStream stream = File.OpenRead(path))
    {
        if (NrbfDecoder.StartsWithPayloadHeader(stream))
        {
            value = LoadLegacyValue<T>(stream);
            update = true;
        }
        else
        {
            value = LoadNewValue<T>(stream);
        }
    }

    if (update)
    {
        File.WriteAllBytes(path, NewSerializer(value));
    }

    return value;
}

Lectura segura de cargas de NRBF

La carga NRBF consta de registros de serialization que representan los objetos serializados y sus metadatos. Para leer toda la carga y obtener el objeto raíz, debe llamar al método Decode.

El método Decode devuelve una instancia de SerializationRecord. SerializationRecord es una clase abstracta que representa el registro de serialization y proporciona tres propiedades autodescriptivas: Id, RecordType y TypeName. Expone un método, TypeNameMatches, que compara el nombre de tipo leído de la carga (y expuesto a través de TypeName propiedad) con el tipo especificado. Este método omite los nombres de ensamblado, por lo que los usuarios no necesitan preocuparse por el reenvío de tipos y el control de versiones de ensamblados. Tampoco tiene en cuenta los nombres de miembro ni sus tipos (ya que la obtención de esta información requeriría la carga de tipos).

using System.Formats.Nrbf;

static T Pseudocode<T>(Stream payload)
{
    SerializationRecord record = NrbfDecoder.Read(payload);
    if (!record.TypeNameMatches(typeof(T))
    {
        throw new Exception($"Expected the record to match type name `{typeof(T).AssemblyQualifiedName}`, but got `{record.TypeName.AssemblyQualifiedName}`."
    }
}

Hay más de una docena de tipos de registro de serialization diferentes. Esta biblioteca proporciona un conjunto de abstracciones, por lo que solo tiene que aprender algunos de ellos:

  • PrimitiveTypeRecord<T>: describe todos los tipos primitivos admitidos de forma nativa por el NRBF (string, bool, byte, sbyte, char, short, ushort, int, uint, long, ulong, float, double, decimal, TimeSpan y DateTime).
    • Expone el valor a través de la propiedad Value.
    • PrimitiveTypeRecord<T> deriva del PrimitiveTypeRecordno genérico, que también expone una propiedad Value. Pero en la clase base, el valor se devuelve como object (que introduce la conversión boxing para los tipos de valor).
  • ClassRecord: describe todos los class y struct además de los tipos primitivos mencionados anteriormente.
  • ArrayRecord: describe todos los registros de matriz, incluidas las matrices escalonadas y multidimensionales.
  • SZArrayRecord<T>: describe registros de matriz unidimensionales e indexados en cero, donde T pueden ser un tipo primitivo o un ClassRecord.
SerializationRecord rootObject = NrbfDecoder.Decode(payload); // payload is a Stream

if (rootObject is PrimitiveTypeRecord primitiveRecord)
{
    Console.WriteLine($"It was a primitive value: '{primitiveRecord.Value}'");
}
else if (rootObject is ClassRecord classRecord)
{
    Console.WriteLine($"It was a class record of '{classRecord.TypeName.AssemblyQualifiedName}' type name.");
}
else if (rootObject is SZArrayRecord<byte> arrayOfBytes)
{
    Console.WriteLine($"It was an array of `{arrayOfBytes.Length}`-many bytes.");
}

Además de Decode, el NrbfDecoder expone un método DecodeClassRecord que devuelve (o genera) ClassRecord.

ClassRecord

El tipo más importante que se deriva de SerializationRecord es ClassRecord, que representa todas las instancias de class y struct además de matrices y tipos primitivos admitidos de forma nativa. Permite leer todos los nombres y valores de miembro. Para comprender qué es un miembro, consulte la referencia de funcionalidad deBinaryFormatter.

La API proporciona:

El siguiente fragmento de código muestra ClassRecord en acción:

[Serializable]
public class Sample
{
    public int Integer;
    public string? Text;
    public byte[]? ArrayOfBytes;
    public Sample? ClassInstance;
}

ClassRecord rootRecord = NrbfDecoder.DecodeClassRecord(payload);
Sample output = new()
{
    // using the dedicated methods to read primitive values
    Integer = rootRecord.GetInt32(nameof(Sample.Integer)),
    Text = rootRecord.GetString(nameof(Sample.Text)),
    // using dedicated method to read an array of bytes
    ArrayOfBytes = ((SZArrayRecord<byte>)rootRecord.GetArrayRecord(nameof(Sample.ArrayOfBytes))).GetArray(),
    // using GetClassRecord to read a class record
    ClassInstance = new()
    {
        Text = rootRecord
            .GetClassRecord(nameof(Sample.ClassInstance))!
            .GetString(nameof(Sample.Text))
    }
};

ArrayRecord

ArrayRecord define el comportamiento principal de los registros de matriz NRBF y proporciona una base para las clases derivadas. Proporciona dos propiedades:

  • Rank que obtiene el rango de la matriz.
  • Lengths que obtienen un búfer de enteros que representan el número de elementos de cada dimensión.

También proporciona un método: GetArray. Cuando se usa por primera vez, asigna una matriz y la rellena con los datos proporcionados en los registros serializados (en el caso de los tipos primitivos admitidos de forma nativa, como string o int) o los propios registros serializados (en caso de matrices de tipos complejos).

GetArray requiere un argumento obligatorio que especifica el tipo de la matriz esperada. Por ejemplo, si el registro debe ser una matriz 2D de enteros, el expectedArrayType debe proporcionarse como typeof(int[,]) y la matriz devuelta también es int[,]:

ArrayRecord arrayRecord = (ArrayRecord)NrbfDecoder.Decode(stream);
int[,] array2d = (int[,])arrayRecord.GetArray(typeof(int[,]));

Si hay un error de coincidencia de tipos (por ejemplo: el atacante ha proporcionado una carga con una matriz de dos mil millones de cadenas), el método produce InvalidOperationException.

NrbfDecoder no carga ni crea instancias de ningún tipo personalizado, por lo que en el caso de matrices de tipos complejos, devuelve una matriz de SerializationRecord.

[Serializable]
public class ComplexType3D
{
    public int I, J, K;
}

ArrayRecord arrayRecord = (ArrayRecord)NrbfDecoder.Decode(payload);
SerializationRecord[] records = (SerializationRecord[])arrayRecord.GetArray(expectedArrayType: typeof(ComplexType3D[]));
ComplexType3D[] output = records.OfType<ClassRecord>().Select(classRecord => new ComplexType3D()
{
    I = classRecord.GetInt32(nameof(ComplexType3D.I)),
    J = classRecord.GetInt32(nameof(ComplexType3D.J)),
    K = classRecord.GetInt32(nameof(ComplexType3D.K)),
}).ToArray();

.NET Framework admitía matrices indexadas distintas de cero dentro de las cargas de NRBF, pero esta compatibilidad nunca se migró a .NET (Core). Por lo tanto, NrbfDecoder no admite la descodificación de matrices indexadas distintas de cero.

SZArrayRecord

SZArrayRecord<T> define el comportamiento principal de los registros de matriz unidimensional con índice cero en NRBF y proporciona una base para las clases derivadas. El T puede ser uno de los tipos primitivos admitidos de forma nativa o SerializationRecord.

Proporciona una propiedad Length y una sobrecarga GetArray que devuelve T[].

[Serializable]
public class PrimitiveArrayFields
{
    public byte[]? Bytes;
    public uint[]? UnsignedIntegers;
}

ClassRecord rootRecord = NrbfDecoder.DecodeClassRecord(payload);
SZArrayRecord<byte> bytes = (SZArrayRecord<byte>)rootRecord.GetArrayRecord(nameof(PrimitiveArrayFields.Bytes));
SZArrayRecord<uint> uints = (SZArrayRecord<uint>)rootRecord.GetArrayRecord(nameof(PrimitiveArrayFields.UnsignedIntegers));
if (bytes.Length > 100_000 || uints.Length > 100_000)
{
    throw new Exception("The array exceeded our limit");
}

PrimitiveArrayFields output = new()
{
    Bytes = bytes.GetArray(),
    UnsignedIntegers = uints.GetArray()
};