Leggere BinaryFormatter payload (NRBF)
BinaryFormatter ha usato .NET Remoting: Binary Format per la serializzazione. 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.
Attenzione
nrbfDecoder è un'implementazione di un lettore NRBF, ma i relativi comportamenti non seguono rigorosamente l'implementazione di BinaryFormatter. Pertanto, non è consigliabile usare l'output di NrbfDecoder per determinare se una chiamata a BinaryFormatter sarebbe sicura.
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.
Attenzione
Quando si usa NrbfDecoder, è importante non reintrodurre tali funzionalità nel codice per utilizzo generico, in quanto ciò nega tali 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 serializzazione 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 record di serializzazione e fornisce tre proprietà autodescrittive: Id, RecordTypee TypeName.
Nota
Un utente malintenzionato potrebbe creare un payload con cicli (ad esempio: classe o matrice di oggetti con un riferimento a se stesso). Il Id restituisce un'istanza di SerializationRecordId che implementa IEquatable<T> e, tra le altre cose, può essere usata per rilevare cicli in record decodificati.
SerializationRecord espone un metodo, TypeNameMatches, che confronta il nome del tipo letto dal payload (ed esposto tramite TypeName proprietà) 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 Animal Pseudocode(Stream payload)
{
SerializationRecord record = NrbfDecoder.Read(payload);
if (record.TypeNameMatches(typeof(Cat)) && record is ClassRecord catRecord)
{
return new Cat()
{
Name = catRecord.GetString("Name"),
WorshippersCount = catRecord.GetInt32("WorshippersCount")
};
}
else if (record.TypeNameMatches(typeof(Dog)) && record is ClassRecord dogRecord)
{
return new Dog()
{
Name = dogRecord.GetString("Name"),
FriendsCount = dogRecord.GetInt32("FriendsCount")
};
}
else
{
throw new Exception($"Unexpected record: `{record.TypeName.AssemblyQualifiedName}`.");
}
}
Ci sono più di una dozzina di diversi tipi 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 SerializationRecord.
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.
- GetClassRecord recupera un'istanza di [ClassRecord]. Nel caso di un ciclo, si tratta della stessa istanza dell'oggetto [ClassRecord] corrente con lo stesso Id.
- GetArrayRecord recupera un'istanza di [ArrayRecord].
- GetSerializationRecord per recuperare qualsiasi record di serializzazione e GetRawValue per recuperare qualsiasi record di serializzazione 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
ClassRecord? referenced = rootRecord.GetClassRecord(nameof(Sample.ClassInstance));
if (referenced is not null)
{
if (referenced.Id.Equals(rootRecord.Id))
{
throw new Exception("Unexpected cycle detected!");
}
output.ClassInstance = new()
{
Text = referenced.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 il rango della matrice.
- Lengths, che ottiene un buffer di interi che rappresentano il numero di elementi in ogni dimensione. È consigliabile controllare la lunghezza totale del record di matrice specificato prima di chiamare GetArray.
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);
if (arrayRecord.Rank != 2 || arrayRecord.Lengths[0] * arrayRecord.Lengths[1] > 10_000)
{
throw new Exception("The array had unexpected rank or length!");
}
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.
Attenzione
Sfortunatamente, il formato NRBF semplifica la compressione di un numero elevato di elementi di matrice Null da parte di un utente malintenzionato. Ecco perché è consigliabile controllare sempre la lunghezza totale della matrice prima di chiamare GetArray. Inoltre, GetArray accetta un argomento booleano facoltativo allowNulls
, che, se impostato su false
, genererà un'eccezione per i valori nulli.
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);
if (arrayRecord.Rank != 1 || arrayRecord.Lengths[0] > 10_000)
{
throw new Exception("The array had unexpected rank or length!");
}
SerializationRecord[] records = (SerializationRecord[])arrayRecord.GetArray(expectedArrayType: typeof(ComplexType3D[]), allowNulls: false);
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()
};