读取 BinaryFormatter (NRBF) 有效负载
BinaryFormatter 使用 .NET 远程处理:二进制格式进行 serialization。 此格式的缩写是 MS-NRBF 或直接简称 NRBF。 从 BinaryFormatter 迁移所涉及的一个常见挑战是处理持久化到存储中的有效负载,因为以前读取这些有效载荷需要 BinaryFormatter。 有些系统需要保留读取这些有效负载的能力,以便逐步迁移到新的序列化程序,同时避免引用 BinaryFormatter 本身。
作为 .NET 9 的一部分,引入了一个新的 NrbfDecoder 类,用于解码 NRBF 有效负载,而无需对有效负载执行反序列化。 可以安全地使用此 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 只有在序列化类型列表是一个已知的封闭集合时才有用。 换而言之,需要预先知道要读取什么,因为还需要创建这些类型的实例,并用从有效负载中读取的数据来填充它们。 请看两个相反的例子:
- 所有来自 Quartz.NET 的
[Serializable]
类型都是sealed
库本身可以持久化的类型。 因此,用户无法创建任何自定义类型,有效负载只能包含已知类型。 这些类型还提供了公共构造函数,因此可以根据从有效负载中读取的信息来重新创建这些类型。 - SettingsPropertyValue 类型公开了
object
类型的 PropertyValue 属性,该属性可能在内部使用 BinaryFormatter 来序列化和反序列化存储在配置文件中的任何对象。 它可以用来存储整数、自定义类型、字典或任何东西。 正因如此,要迁移该库而不对 API 进行破坏性修改是不可能的。
识别 NRBF 有效负载
NrbfDecoder 提供了两种 StartsWithPayloadHeader 方法,可以检查给定的流或缓冲区是否以 NRBF 标头开始。 建议在将使用 BinaryFormatter 持久化的有效负载迁移到 不同的序列化程序时使用这些方法:
- 检查从存储区读取的有效负载是否为带有 NrbfDecoder.StartsWithPayloadHeader 的 NRBF 有效负载。
- 如果是这样,则用 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 记录的抽象类,提供三个自描述属性:Id、RecordType 和 TypeName。 它公开了一种方法 TypeNameMatches,该方法将从有效负载中读取的类型名称(并通过 TypeName 属性公开)与指定类型进行比较。 此方法忽略程序集名称,因此用户无需担心类型转发和程序集版本控制问题。 它也不考虑成员名称或其类型(因为获取这些信息需要类型加载)。
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记录类型。 此库提供了一系列抽象概念,因此只需了解其中的几个:
- 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
可以是基元类型,也可以是 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.");
}
除了 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 记录,而 GetRawValue 用于检索任何 serialization 记录或原始基元值。
以下代码片段展示了 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 数组记录的核心行为,并为派生类提供了基础。 它提供两个属性:
它还提供了一种方法:GetArray 首次使用时,它会分配一个数组,并用序列化记录中提供的数据(如果是本地支持的原始类型,如 string
或 int
)或序列化记录本身(如果是复杂类型的数组)填充数组。
GetArray 需要一个强制参数,用于指定预期数组的类型。 例如,如果记录应是一个 2D 整数数组,则 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 单维零索引数组记录的核心行为,并为派生类提供了基础。 T
可以是本地支持的基元类型之一,也可以是 SerializationRecord。
它提供一个 Length 属性和一个返回 T[]
的 GetArray 重载。
[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()
};