读取 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.StartsWithPayloadHeaderNRBF 有效负载。
  • 如果是这样,则用 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 记录的抽象类,提供三个自描述属性:IdRecordTypeTypeName。 它公开了一种方法 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 本地支持的所有基元类型(stringboolbytesbytecharshortushortintuintlongulongfloatdoubledecimalTimeSpanDateTime)。
  • ClassRecord:描述上述基元类型之外的所有 classstruct
  • 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,它代表了除数组和本地支持的基元类型之外的所有 classstruct 实例。 通过它可以读取所有成员的名称和值。 要了解什么是成员,请参阅BinaryFormatter功能参考

它提供的 API:

以下代码片段展示了 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 数组记录的核心行为,并为派生类提供了基础。 它提供两个属性:

  • Rank,它可获取数组的秩。
  • Lengths,它可获取一个整数缓冲区,表示每个维度的元素个数。

它还提供了一种方法:GetArray 首次使用时,它会分配一个数组,并用序列化记录中提供的数据(如果是本地支持的原始类型,如 stringint)或序列化记录本身(如果是复杂类型的数组)填充数组。

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()
};