Leggere BinaryFormatter payload (NRBF)

BinaryFormatter ha usato .NET Remoting: Binary Format per serialization. Questo formato è noto con l'abbreviazione di MS-NRBF o semplicemente NRBF. Una sfida comune che riguarda la migrazione da BinaryFormatter consiste nel gestire i payload salvati in modo permanente nell'archiviazione, perché la loro lettura in precedenza richiedeva BinaryFormatter. Alcuni sistemi devono mantenere la capacità di leggere questi payload per migrazioni graduali a nuovi serializzatori evitando al tempo stesso un riferimento a BinaryFormatter stesso.

Nell'ambito di .NET 9, è stata introdotta una nuova classe NrbfDecoder per decodificare i payload NRBF senza eseguire la deserializzazione del payload. Questa API può essere usata in modo sicuro per decodificare payload attendibili o non attendibili senza alcun rischio relativo alla deserializzazione di BinaryFormatter. Tuttavia, NrbfDecoder decodifica semplicemente i dati in strutture che un'applicazione può elaborare ulteriormente. È necessario prestare attenzione quando si usa NrbfDecoder per caricare i dati in modo sicuro nelle istanze appropriate.

Si può pensare all'uso di NrbfDecoder come l'equivalente di un lettore JSON/XML senza il deserializzatore.

NrbfDecoder

NrbfDecoder fa parte del nuovo pacchetto NuGet System.Formats.Nrbf. È destinato non solo a .NET 9, ma anche a moniker meno recenti come .NET Standard 2.0 e .NET Framework. Questo multitargeting consente a tutti gli utenti che usano una versione supportata di .NET di eseguire la migrazione da BinaryFormatter. NrbfDecoder può leggere i payload serializzati con BinaryFormatter using FormatterTypeStyle.TypesAlways (opzione predefinita).

NrbfDecoder è progettato per considerare tutti gli input come non attendibili. Per questo motivo, presenta i seguenti principi:

  • Nessun caricamento di tipi di ogni sorta (per evitare rischi quali l'esecuzione remota di codici).
  • Nessuna ricorsione di qualsiasi tipo (per evitare ricorsioni illimitate, overflow dello stack e Denial of Service).
  • Nessuna preallocazione del buffer in base alle dimensioni fornite nel payload se questo è troppo piccolo per contenere i dati promessi (per evitare l'esaurimento della memoria e il denial of service).
  • Decodifica di ogni parte dell'input una sola volta (per eseguire la stessa quantità di lavoro del potenziale utente malintenzionato che ha creato il payload).
  • Uso dell'hash casuale resistente ai conflitti per archiviare i record a cui fanno riferimento altri record (per evitare l'esaurimento della memoria del dizionario supportato da una matrice le cui dimensioni dipendono dal numero di conflitti di codice hash).
  • È possibile creare implicitamente un'istanza solo di tipi primitivi. È possibile creare un'istanza di matrici su richiesta. Non vengono mai create istanze di altri tipi.

Quando si usa NrbfDecoder, è importante non reintrodurre queste funzionalità nel codice per utilizzo generico, perché bloccherebbe l'effetto di queste misure di sicurezza.

Deserializzare un set chiuso di tipi

NrbfDecoder è utile solo quando l'elenco dei tipi serializzati è un set chiuso e noto. In altre parole, è necessario sapere in anticipo ciò che si vuole leggere, perché occorre anche creare istanze di tali tipi e popolarle con i dati letti dal payload. Prendiamo in considerazione due esempi opposti:

  • Tutti i tipi di [Serializable] di Quartz.NET che possono essere salvati in modo permanente dalla libreria stessa sono sealed. Perciò non esistono tipi personalizzati che gli utenti possono creare e di conseguenza il payload può contenere solo tipi noti. I tipi forniscono anche costruttori pubblici, quindi è possibile ricreare questi tipi in base alle informazioni lette dal payload.
  • Il tipo SettingsPropertyValue espone la proprietà PropertyValue del tipo object che può usare internamente BinaryFormatter per serializzare e deserializzare ogni oggetto archiviato nel file di configurazione. Può essere usato per archiviare un numero intero, un tipo personalizzato, un dizionario o letteralmente qualsiasi cosa. Per questo motivo, è impossibile eseguire la migrazione di questa libreria senza introdurre modifiche di rilievo all'API.

Identificare i payload NRBF

NrbfDecoder fornisce due metodi StartsWithPayloadHeader per verificare se un determinato flusso o buffer inizia con l'intestazione NRBF. È consigliabile usare questi metodi quando si esegue la migrazione dei payload salvati in modo permanente con BinaryFormatter in un serializzatore diverso:

  • Controllare se il payload letto dall'archiviazione è un payload NRBF con NrbfDecoder.StartsWithPayloadHeader.
  • In tal caso, leggerlo con NrbfDecoder.Decode, serializzarlo di nuovo con un nuovo serializzatore e sovrascrivere i dati nell'archiviazione.
  • Altrimenti, usare il nuovo serializzatore per deserializzare i dati.
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;
}

Leggere in modo sicuro i payload NRBF

Il payload NRBF è costituito da record di serialization che rappresentano gli oggetti serializzati e i relativi metadati. Per leggere l'intero payload e ottenere l'oggetto radice, è necessario chiamare il metodo Decode.

Il metodo Decode restituisce un'istanza SerializationRecord. SerializationRecord è una classe astratta che rappresenta il serialization record di serializzazione e fornisce tre proprietà auto-descrittive: Id, RecordType e TypeName. Espone un metodo, TypeNameMatches, che confronta il nome del tipo letto dal payload (ed esposto tramite la proprietà TypeName) con il tipo specificato. Questo metodo ignora i nomi di assembly, quindi gli utenti non devono preoccuparsi dell'inoltro dei tipi e del controllo delle versioni degli assembly. Ignora anche i nomi dei membri o i relativi tipi (perché per ottenere queste informazioni sarebbe necessario il caricamento del tipo).

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

Esistono più di una decina di diversi serializationtipi di record. Questa libreria fornisce un set di astrazioni, pertanto è sufficiente impararne solo alcune:

  • PrimitiveTypeRecord<T>: descrive tutti i tipi primitivi supportati in modo nativo da NRBF (string, bool, byte, sbyte, char, short, ushort, int, uint, long, ulong, float, double, decimal, TimeSpan e DateTime).
    • Espone il valore tramite la proprietà Value.
    • PrimitiveTypeRecord<T> deriva dall'oggetto non generico PrimitiveTypeRecord, che espone anche una proprietà Value. Nella classe base, tuttavia, il valore viene restituito come object (che introduce la conversione boxing per i tipi valore).
  • ClassRecord: descrive tutti i tipi class e struct oltre i tipi primitivi menzionati in precedenza.
  • ArrayRecord: descrive tutti i record delle matrici, incluse le matrici di matrici e quelle multidimensionali.
  • SZArrayRecord<T>: descrive i record di matrici unidimensionali e a indice zero, dove T può essere un tipo primitivo o un oggetto 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.");
}

Accanto a Decode, il NrbfDecoder espone un metodo DecodeClassRecord che restituisce ClassRecord (o lo genera).

ClassRecord

Il tipo più importante che deriva da SerializationRecord è ClassRecord, che rappresenta tutte le istanze class e struct oltre alle matrici e ai tipi primitivi supportati in modo nativo. Consente di leggere tutti i nomi e i valori dei membri. Per capire di che membro si tratti, consultare il BinaryFormatter riferimento sulle funzionalità.

L'API che fornisce:

Il seguente frammento di codice mostra ClassRecord in azione:

[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 definisce il comportamento principale dei record di matrice NRBF e fornisce una base per le classi derivate. Fornisce due proprietà:

  • Rank che ottiene l'ordine di priorità della matrice.
  • Lengths che ottiene un buffer di numeri interi che rappresentano il numero di elementi in ogni dimensione.

Fornisce anche un metodo: GetArray. Se usato per la prima volta, alloca una matrice e la riempie con i dati forniti nei record serializzati (nel caso dei tipi primitivi supportati in modo nativo come string o int) o con i record serializzati stessi (in caso di matrici di tipi complessi).

GetArray richiede un argomento obbligatorio che specifichi il tipo della matrice prevista. Ad esempio, se il record deve essere una matrice 2D di numeri interi, il expectedArrayType deve essere fornito come typeof(int[,]) e anche la matrice restituita è int[,]:

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

Se si verifica una mancata corrispondenza del tipo (ad esempio, l'autore dell'attacco ha fornito un payload con una matrice di due miliardi di stringhe), il metodo genera InvalidOperationException.

NrbfDecoder non carica o crea un'istanza di alcun tipo personalizzato, quindi in caso di matrici di tipi complessi restituisce una matrice di 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 supportava matrici a indice non zero all'interno dei payload NRBF, ma questo supporto non è stato mai applicato in .NET (Core). NrbfDecoder non supporta pertanto la decodifica di matrici a indice non zero.

SZArrayRecord

SZArrayRecord<T> definisce il comportamento di base dei record di matrici NRBF unidimensionali e a indice zero e fornisce una base per le classi derivate. T può essere uno dei tipi primitivi supportati in modo nativo o SerializationRecord.

Fornisce una proprietà Length e un overload GetArray che restituisce 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()
};