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 sonosealed
. 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
eDateTime
).- 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).
- Espone il valore tramite la proprietà
- ClassRecord: descrive tutti i tipi
class
estruct
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:
- Proprietà MemberNames che ottiene i nomi dei membri serializzati.
- Metodo HasMember che verifica se il membro del nome specificato era presente nel payload. Progettato per la gestione degli scenari di controllo delle versioni in cui il membro specificato poteva essere stato rinominato.
- Set di metodi dedicati al recupero dei valori primitivi del nome del membro fornito: GetString, GetBoolean, GetByte, GetSByte, GetChar, GetInt16, GetUInt16, GetInt32, GetUInt32, GetInt64, GetUInt64, GetSingle, GetDouble, GetDecimal, GetTimeSpan e GetDateTime.
- Metodi GetClassRecord e GetArrayRecord per recuperare l'istanza di tipi di record specifici.
- GetSerializationRecord per recuperare qualsiasi serialization record e GetRawValue per recuperare qualsiasi serialization record o un valore primitivo non elaborato.
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()
};