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