Lire les charges utiles BinaryFormatter (NRBF)
BinaryFormatter utilisait le format binaire .NET Remoting pour la serialization. Ce format est connu par son abréviation MS-NRBF, ou simplement NRBF. L'une des difficultés rencontrées lors de la migration depuis BinaryFormatter est la gestion des charges utiles persistantes dans le stockage, car la lecture de ces charges utiles nécessitait auparavant l'utilisation de BinaryFormatter. Certains systèmes doivent conserver la capacité à lire ces charges utiles pour les migrations progressives vers de nouveaux sérialiseurs tout en évitant toute référence à BinaryFormatter lui-même.
Dans le cadre de .NET 9, une nouvelle classe NrbfDecoder a été introduite pour décoder les charges utiles NRBF sans effectuer de désérialisation de la charge utile. Cette API peut être utilisée de manière sécurisée pour décoder des charges utiles approuvées ou non approuvées sans risque qu’une désérialisation BinaryFormatter ne se produise. Cependant, NrbfDecoder ne fait que décoder les données en structures qu’une application peut ensuite traiter. Lors de l’utilisation de NrbfDecoder , il est important de faire attention à charger en toute sécurité les données dans les instances appropriées.
Vous pouvez considérer NrbfDecoder comme l'équivalent d'un lecteur JSON/XML sans le désérialiseur.
NrbfDecoder
NrbfDecoder fait partie du nouveau package NuGet System.Formats.Nrbf. Il cible non seulement .NET 9, mais également des monikers plus anciens comme .NET Standard 2.0 et le .NET Framework. Ce multi-ciblage permet à tous ceux qui utilisent une version prise en charge de .NET de migrer hors de BinaryFormatter. NrbfDecoder peut lire les charges utiles qui ont été sérialisées avec BinaryFormatter en utilisant FormatterTypeStyle.TypesAlways (la valeur par défaut).
NrbfDecoder est conçu pour traiter toutes les entrées comme non approuvées. Par conséquent, les principes suivants s’appliquent :
- Absolument aucun chargement de type (pour éviter des risques tels que l’exécution de code à distance).
- Absolument aucune récursivité (pour éviter la récursivité sans limite, le dépassement de capacité de pile et le déni de service).
- Aucune pré-allocation de mémoire tampon basée sur la taille fournie dans la charge utile, si celle-ci est trop petite pour contenir les données promises (afin d’éviter de manquer de mémoire et de prévenir les dénis de service).
- Décoder chaque partie de l’entrée une seule fois (pour effectuer la même quantité de travail que l’attaquant potentiel qui a créé la charge utile).
- Utiliser le hachage aléatoire résistant aux collisions pour stocker les enregistrements référencés par d’autres enregistrements (afin d’éviter de manquer de mémoire pour le dictionnaire soutenu par un tableau dont la taille dépend du nombre de collisions de code de hachage).
- Seuls les types primitifs peuvent être instanciés de manière implicite. Les tableaux peuvent être instanciés à la demande. Les autres types ne sont jamais instanciés.
Lors de l’utilisation de NrbfDecoder, il est important de ne pas réintroduire ces fonctionnalités dans du code à usage général, car cela annulerait ces protections.
Désérialiser un ensemble fermé de types
NrbfDecoder n’est utile que lorsque la liste des types sérialisés est un ensemble fermé connu. Autrement dit, vous devez savoir à l’avance ce que vous souhaitez lire, car vous devez également créer des instances de ces types et les remplir avec des données qui ont été lues à partir de la charge utile. Considérez deux exemples opposés :
- Tous les types
[Serializable]
de Quartz.NET qui peuvent être conservés par la bibliothèque elle-même sontsealed
. Par conséquent, il n’existe aucun type personnalisé pouvant être créé par les utilisateurs, et la charge utile ne peut contenir que des types connus. Les types fournissent également des constructeurs publics. Il est donc possible de recréer ces types en fonction des informations lues à partir de la charge utile. - Le type SettingsPropertyValue expose la propriété PropertyValue de type
object
qui peut utiliser BinaryFormatter en interne pour sérialiser et désérialiser un objet stocké dans le fichier de configuration. Il peut être utilisé pour stocker presque n’importe quoi : un entier, un type personnalisé, un dictionnaire etc. Pour cette raison, il est impossible de migrer cette bibliothèque sans introduire de changements cassants dans l’API.
Identifier les charges utiles NRBF
NrbfDecoder fournit deux méthodes StartsWithPayloadHeader qui vous permettent de vérifier si un flux ou un tampon donné commence avec l’en-tête NRBF. Nous vous recommandons d’utiliser ces méthodes lorsque vous migrez des charges utiles persistées avec BinaryFormatter vers un autre sérialiseur :
- Vérifiez si la charge utile lue à partir du stockage est une charge utile NRBF avec NrbfDecoder.StartsWithPayloadHeader.
- Si c’est le cas, lisez-la avec NrbfDecoder.Decode, sérialisez-la avec un nouveau sérialiseur, et remplacez les données dans le stockage.
- Si ce n’est pas le cas, utilisez le nouveau sérialiseur pour désérialiser les données.
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;
}
Lire des charges utiles NRBF de manière sécurisée
La charge utile NRBF se compose d’enregistrements de serialization qui représentent les objets sérialisés et leurs métadonnées. Pour lire toute la charge utile et obtenir l’objet racine, vous devez appeler la méthode Decode.
La méthode Decode retourne une instance SerializationRecord. SerializationRecord est une classe abstraite qui représente l’enregistrement de serialization et fournit trois propriétés autodescriptives : Id, RecordType et TypeName. Elle expose une méthode, TypeNameMatches, qui compare le nom de type lu à partir de la charge utile (et exposé via la propriété TypeName) au type spécifié. Cette méthode ignore les noms d’assembly. Les utilisateurs n’ont donc pas à se soucier du transfert de type ou du contrôle de version d’assembly. Elle ne prend pas non plus en compte les noms de membres ou leurs types (car l’obtention de ces informations nécessiterait le chargement de type).
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}`."
}
}
Il existe plus d’une douzaine de serializationtypes d’enregistrements différents. Cette bibliothèque fournit un ensemble d’abstractions. Vous n’avez donc besoin d’en apprendre que quelques-uns :
- PrimitiveTypeRecord<T> : décrit tous les types primitifs pris en charge en mode natif par le NRBF (
string
,bool
,byte
,sbyte
,char
,short
,ushort
,int
,uint
,long
,ulong
,float
,double
,decimal
,TimeSpan
etDateTime
).- Expose la valeur via la propriété
Value
. - PrimitiveTypeRecord<T> dérive du PrimitiveTypeRecord non générique, qui expose également une propriété Value. Toutefois, sur la classe de base, la valeur est retournée comme
object
(qui introduit le boxing pour les types valeur).
- Expose la valeur via la propriété
- ClassRecord : décrit tous les
class
etstruct
en plus des types primitifs mentionnés ci-dessus. - ArrayRecord : décrit tous les enregistrements de tableau, y compris les tableaux en escalier et multidimensionnels.
- SZArrayRecord<T> : décrit les enregistrements de tableau unidimensionnels et à index zéro, où
T
peut être un type primitif ou 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.");
}
En plus de Decode, le NrbfDecoder expose une méthode DecodeClassRecord qui renvoie (ou lève) ClassRecord.
ClassRecord
Le type le plus important duquel SerializationRecord dérive est ClassRecord, qui représente toutes les instances de class
et struct
en plus des tableaux et des types primitifs pris en charge en mode natif. Il vous permet de lire tous les noms et valeurs des membres. Pour comprendre ce qu’est un membre, consultez les informations de référence sur BinaryFormatter.
L’API fournit ce qui suit :
- Une propriété MemberNames qui obtient les noms des membres sérialisés.
- Une méthode HasMember qui vérifie si le membre d’un nom donné était présent dans la charge utile. Elle a été conçue pour gérer les scénarios de contrôle de version où un membre donné aurait pu être renommé.
- Un ensemble de méthodes qui permettent de récupérer les valeurs primitives du nom de membre fourni : GetString, GetBoolean, GetByte, GetSByte, GetChar, GetInt16, GetUInt16, GetInt32, GetUInt32, GetInt64, GetUInt64, GetSingle, GetDouble, GetDecimal, GetTimeSpan, and GetDateTime.
- Des méthodes GetClassRecord et GetArrayRecord pour récupérer l’instance des types d’enregistrements donnés.
- GetSerializationRecord pour récupérer tout enregistrement de serialization et GetRawValue pour récupérer tout enregistrement de serialization ou une valeur primitive brute.
L’extrait de code suivant montre ClassRecord en action :
[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 définit le comportement de base des enregistrements de tableaux NRBF, et fournit une base pour les classes dérivées. Elle fournit deux propriétés :
- Rank, qui obtient le rang du tableau.
- Lengths, qui obtient une mémoire tampon d’entiers qui représentent le nombre d’éléments dans chaque dimension.
Elle fournit également une méthode : GetArray. Lors de sa première utilisation, elle alloue un tableau et le remplit avec les données fournies dans les enregistrements sérialisés (dans les cas où il s’agit des types primitifs pris en charge en mode natif comme string
ou int
) ou les enregistrements sérialisés eux-mêmes (dans les cas où il s’agit de tableaux de types complexes).
GetArray nécessite un argument obligatoire qui spécifie le type du tableau attendu. Par exemple, si l’enregistrement doit être un tableau 2D d’entiers, le expectedArrayType
fourni doit être typeof(int[,])
et le tableau retourné est également int[,]
:
ArrayRecord arrayRecord = (ArrayRecord)NrbfDecoder.Decode(stream);
int[,] array2d = (int[,])arrayRecord.GetArray(typeof(int[,]));
S’il existe une incompatibilité de type (par exemple, l’attaquant a fourni une charge utile avec un tableau de deux milliards de chaînes), la méthode lève InvalidOperationException.
NrbfDecoder ne charge ni n'instancie aucun type personnalisé, de sorte que dans le cas de tableaux de types complexes, il renvoie un tableau 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();
Le .NET Framework prenait en charge les tableaux à index différent de zéro dans les charges utiles NRBF, mais cette prise en charge n’a jamais été portée vers .NET (Core). NrbfDecoder ne prend donc pas en charge le décodage des tableaux indexés non nuls.
SZArrayRecord
SZArrayRecord<T>
définit le comportement de base des enregistrements de tableaux NRBF unidimensionnels et à index zéro, et fournit une base pour les classes dérivées. T
peut être l’un des types primitifs pris en charge en mode natif ou SerializationRecord.
Il fournit une propriété Length et une surcharge GetArray qui renvoie 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()
};