BinaryFormatter (NRBF) ペイロードを読み取る
BinaryFormatter はserializationのために .NET リモート処理: バイナリ形式を使用しました。 この形式は、MS-NRBF または単に NRBF という略称で知られています。 BinaryFormatter から移行する際に生じる一般的な課題は、ストレージに永続化されたペイロードの処理です。これまで、これらのペイロードの読み取りには BinaryFormatter が必要でした。 システムによっては、BinaryFormatter 自体への参照を避けつつ、新しいシリアライザーに段階的に移行するために、これらのペイロードを読み取る機能を保持する必要があります。
ペイロードの逆シリアル化を実行せずに NRBF ペイロードをデコードするために、新しい NrbfDecoder クラスが .NET 9 の一部として導入されました。 この API は、BinaryFormatter の逆シリアル化のようなリスクを負うことなく、信頼できるペイロードや信頼されていないペイロードを安全にデコードするために使用できます。 ただし、NrbfDecoder はデータをデコードして、アプリケーションがさらに処理できる構造に変えるだけです。 NrbfDecoder を使用してデータを適切なインスタンスに安全にロードする際は、注意が必要です。
NrbfDecoder は、逆シリアライザーなしで JSON/XML リーダーを使用するのと同じであると考えることができます。
NrbfDecoder
NrbfDecoder は、新しい System.Formats.Nrbf NuGet パッケージの一部です。 その対象は .NET 9 だけでなく、.NET Standard 2.0 や .NET Framework のような以前のモニカーも含まれます。 このマルチ ターゲットによって、サポートされているバージョンの .NET を使用しているすべてのユーザーが BinaryFormatter から移行できるようになります。 NrbfDecoder は、FormatterTypeStyle.TypesAlways (デフォルト) を使用する BinaryFormatter でシリアル化されたペイロードを読み取ることができます。
NrbfDecoder は、すべての入力を信頼できないものとして扱うように設計されています。 そのため、次のような原則があります。
- いかなる型の読み込みも行わない (リモート コード実行などのリスクを避けるため)。
- いかなる再帰も行わない (バインドされていない再帰、スタック オーバーフロー、サービス拒否を避けるため)。
- ペイロードが小さすぎて保証されたデータを含めることができない場合、ペイロードで指定されるサイズに基づくバッファーの事前割り当てを行わない (メモリ不足とサービス拒否を避けるため)。
- 入力のすべての部分を一度だけデコードする (ペイロードを作成した潜在的な攻撃者と同じ作業量を実行するため)。
- 他のレコードで参照されるレコードを格納するために、競合に強いランダム化ハッシュを使用する (ハッシュコードの競合回数にサイズが依存する配列に基づくディクショナリのメモリ不足を回避するため)。
- 暗黙的な方法でインスタンス化できるのは、プリミティブ型だけです。 配列はオンデマンドでインスタンス化できます。 それ以外の型はインスタンス化されません。
NrbfDecoder の使用時には、これらの機能を汎用コードに再導入しないことが重要です。再導入すると、これらのセーフガードが無効になるためです。
閉じた型のセットを逆シリアル化する
NrbfDecoder が役立つのは、シリアル化された型のリストが既知の限定されたセットである場合のみです。 別の言い方をすると、ペイロードから読み込まれたデータをインスタンスとして作成し、そのインスタンスに入力する必要があるため、読み取る内容を事前に把握しておく必要があります。 次のような 2 つの正反対の例について考えてみましょう。
- ライブラリ自体で永続化できる Quartz.NET の
[Serializable]
型はすべてsealed
です。 そのため、ユーザーが作成できるカスタム型は存在せず、ペイロードには既知の型のみ含めることができます。 この型はパブリック コンストラクターも提供しているので、ペイロードから読み取った情報に基づいてこれらの型を再作成することもできます。 - SettingsPropertyValue 型は、構成ファイルに格納されたオブジェクトをシリアル化および逆シリアル化するために内部的に BinaryFormatter を使用する可能性がある型
object
のプロパティ PropertyValue を公開します。 整数、カスタム型、ディクショナリなど、文字どおりすべてを格納できます。 そのため、API に破壊的な変更を加えずにこのライブラリを移行することは不可能です。
NRBF ペイロードを特定する
NrbfDecoder は、指定のストリームまたはバッファーが NRBF ヘッダーで始まるかどうかを確認できる 2 つの StartsWithPayloadHeader メソッドを提供します。 BinaryFormatter で永続化されたペイロードを異なるシリアライザーに移行する場合は、次のメソッドを使用することをお勧めします。
- ストレージから読み取られたペイロードが NRBF ペイロードであるかどうかを、NrbfDecoder.StartsWithPayloadHeader を使用して確認します。
- NrbfDecoder.Decode でそれを読み取る場合は、新しいシリアライザーでシリアル化して戻し、ストレージのデータを上書きします。
- そうでない場合は、新しいシリアライザーを使用してデータを逆シリアル化します。
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;
}
NRBF ペイロードを安全に読み取る
NRBF ペイロードは、シリアル化されたオブジェクトとそのメタデータを表すserializationレコードで構成されます。 ペイロード全体を読み取ってルート オブジェクトを取得するには、Decode メソッドを呼び出す必要があります。
Decode メソッドは SerializationRecord インスタンスを返します。 SerializationRecord はserializationレコードを表す抽象クラスで、3 つの自己言及的なプロパティ (Id、RecordType、TypeName) が提供されます。 これは、ペイロードから読み込まれた (および TypeName プロパティを介して公開された) 型名を、指定された型と比較する 1 つのメソッド (TypeNameMatches) を公開します。 この方法はアセンブリ名を無視するため、ユーザーは型の転送やアセンブリのバージョン管理を気にする必要はありません。 また、メンバー名やその型も考慮しません (この情報を得るには型読み込みが必要になるため)。
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}`."
}
}
異なるserialization レコードの種類は 10 種類以上あります。 このライブラリでは抽象化された機能が提供されるため、そのうちのいくつかを学ぶだけです。
- PrimitiveTypeRecord<T>: NRBF でネイティブにサポートされるすべてのプリミティブ型 (
string
、bool
、byte
、sbyte
、char
、short
、ushort
、int
、uint
、long
、ulong
、float
、double
、decimal
、TimeSpan
、DateTime
) を記述します。Value
プロパティを介して値を公開します。- PrimitiveTypeRecord<T> は、Value プロパティも公開される非ジェネリックの PrimitiveTypeRecord から派生します。 しかし、基底クラスでは、値は (値型のボックス化を導入する)
object
として返されます。
- ClassRecord: 前述のプリミティブ型以外のすべての
class
とstruct
を記述します。 - ArrayRecord: ジャグ配列や多次元配列などのすべての配列レコードを記述します。
- SZArrayRecord<T>:
T
はプリミティブ型か ClassRecord のいずれかである 1 次元、インデックスなしの配列レコード。
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.");
}
Decode のほかに、NrbfDecoder は ClassRecord を返す (またはスローする) DecodeClassRecord メソッドを公開します。
ClassRecord
SerializationRecord から派生する最も重要な型は ClassRecord で、配列とネイティブにサポートされるプリミティブ型に加えてすべての class
および struct
インスタンスを表します。 すべてのメンバー名と値を読み取ることができます。 メンバーとは何かを理解するには、BinaryFormatter機能リファレンスを参照してください。
提供される API は次のとおりです。
- シリアル化されたメンバーの名前を取得する MemberNames プロパティ。
- 指定された名前のメンバーがペイロードに存在するかどうかを確認する HasMember メソッド。 これは、指定されたメンバーの名前が変更される可能性のあるバージョン管理シナリオを処理するために設計されました。
- 指定されたメンバー名のプリミティブ値を取得するための専用メソッドのセット: GetString、GetBoolean、GetByte、GetSByte、GetChar、GetInt16、GetUInt16、GetInt32、GetUInt32、GetInt64、GetUInt64、GetSingle、GetDouble、GetDecimal、GetTimeSpan、および GetDateTime。
- 指定されたレコードの種類のインスタンスを取得するための GetClassRecord および GetArrayRecord メソッド。
- 任意のGetSerializationRecordレコードを取得するための serialization と、任意のserializationレコードまたは生プリミティブ値を取得するための GetRawValue。
次のコード スニペットは動作中の ClassRecord を示しています。
[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 は NRBF 配列レコードのコア動作を定義し、派生クラスの基底クラスを提供します。 次の 2 つのプロパティが提供されます。
次のメソッドも提供されます: GetArray。 初めて使用する場合、配列が割り当てられ、シリアル化されたレコードで提供されるデータ (string
や int
のようなネイティブにサポートされているプリミティブ型の場合) またはシリアル化されたレコード自体 (複雑な型の配列の場合) でそれを塗りつぶします。
GetArray では、期待される配列の型を指定する必須の引数が必要です。 たとえば、レコードが整数の 2 次元配列である必要がある場合は、expectedArrayType
は typeof(int[,])
として提供される必要があり、返される配列も int[,]
です。
ArrayRecord arrayRecord = (ArrayRecord)NrbfDecoder.Decode(stream);
int[,] array2d = (int[,])arrayRecord.GetArray(typeof(int[,]));
型が一致しない場合 (攻撃者が 20 億個の文字列の配列を持つペイロードを提供した場合など) は、メソッドは InvalidOperationException をスローします。
NrbfDecoder はカスタム型を読み込んだりインスタンス化したりしないため、複合型の配列の場合は 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 は、NRBF ペイロード内のゼロ以外にインデックス設定された配列をサポートしましたが、このサポートは .NET (Core) には移植されませんでした。 したがって、NrbfDecoder ではゼロ以外にインデックス設定された配列のデコードがサポートされません。
SZArrayRecord
SZArrayRecord<T>
は NRBF の 1 次元、ゼロにインデックス設定された配列レコードのコア動作を定義し、派生クラスの基底クラスを提供します。 T
には、ネイティブにサポートされているプリミティブ型か SerializationRecord のいずれかを指定できます。
これは Length プロパティ、および GetArray を返す 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()
};
.NET