Compartilhar via


Leitura de cargas de BinaryFormatter (NRBF)

BinaryFormatter usava o .NET Remoting: Formato Binário para serialização. Esse formato é conhecido por sua abreviação de MS-NRBF ou apenas NRBF. Um desafio comum envolvido na migração é BinaryFormatter lidar com cargas persistentes para o armazenamento, pois a leitura dessas cargas antes exigiam BinaryFormatter. Alguns sistemas precisam manter a capacidade de ler essas cargas para migrações graduais para novos serializadores, evitando uma referência ao próprio BinaryFormatter.

Como parte do .NET 9, uma nova classe NrbfDecoder foi introduzida para decodificar cargas NRBF sem executar a desserialização da carga. Essa API pode ser usada com segurança para decodificar cargas confiáveis ou não confiáveis sem nenhum dos riscos que a desserialização de BinaryFormatter acarreta. No entanto, o NrbfDecoder apenas decodifica os dados em estruturas que um aplicativo pode processar posteriormente. Deve-se ter cuidado ao usar o NrbfDecoder para carregar com segurança os dados nas instâncias apropriadas.

Cuidado

NrbfDecoder é uma implementação de um leitor NRBF, mas seus comportamentos não seguem estritamente a implementação de BinaryFormatter. Portanto, você não deve usar a saída de NrbfDecoder para determinar se uma chamada para BinaryFormatter seria segura.

Você pode pensar em NrbfDecoder como sendo o equivalente a usar um leitor JSON/XML sem o desserializador.

NrbfDecoder

NrbfDecoder faz parte do novo pacote NuGet System.Formats.Nrbf. Ele tem como destino não apenas o .NET 9, mas também os nomes mais antigos, como .NET Standard 2.0 e .NET Framework. Esse direcionamento múltiplo possibilita que todos que usam uma versão com suporte do .NET migrem para longe de BinaryFormatter. NrbfDecoder pode ler cargas que foram serializadas com BinaryFormatter using FormatterTypeStyle.TypesAlways (o padrão).

O NrbfDecoder foi projetado para tratar todas as entradas como não confiáveis. Como tal, ele tem estes princípios:

  • Nenhum carregamento de tipo (para evitar riscos, como execução remota de código).
  • Nenhuma recursão de qualquer tipo (para evitar recursão desassociada, excedente de pilha e negação de serviço).
  • Nenhuma pré-alocação de buffer com base no tamanho fornecido na carga, se a carga for muito pequena para conter os dados prometidos (para evitar ficar sem memória e negação de serviço).
  • Decodificar cada parte da entrada apenas uma vez (para executar a mesma quantidade de trabalho que o invasor potencial que criou a carga).
  • Use hash randomizado resistente a colisões para armazenar registros referenciados por outros registros (para evitar ficar sem memória para um dicionário apoiado por uma matriz cujo tamanho depende do número de colisões de código hash).
  • Somente tipos primitivos podem ser instanciados de forma implícita. As matrizes podem ser instanciadas sob demanda. Outros tipos nunca são instanciados.

Cuidado

Ao usar NrbfDecoder, é importante não reintroduzir esses recursos no código de uso geral, pois isso negaria essas proteções.

Desserializar um conjunto fechado de tipos

O NrbfDecoder é útil somente quando a lista de tipos serializados é um conjunto conhecido e fechado. Em outras palavras, você precisa saber antecipadamente o que deseja ler, pois também precisa criar instâncias desses tipos e preenchê-las com dados lidos da carga. Considere dois exemplos opostos:

  • Todos os tipos de [Serializable] de Quartz.NET que podem ser persistidos pela própria biblioteca são sealed. Portanto, não há tipos personalizados que os usuários possam criar, e a carga pode conter apenas tipos conhecidos. Os tipos também fornecem construtores públicos, portanto, é possível recriar esses tipos com base nas informações lidas da carga.
  • O SettingsPropertyValue tipo expõe a propriedade PropertyValue do tipo object que pode ser usada BinaryFormatter internamente para serializar e desserializar qualquer objeto armazenado no arquivo de configuração. Ele pode ser usado para armazenar um inteiro, um tipo personalizado, um dicionário ou literalmente qualquer coisa. Por isso, é impossível migrar essa biblioteca sem introduzir alterações interruptivas na API.

Identificar cargas NRBF

O NrbfDecoder fornece dois StartsWithPayloadHeader métodos que permitem verificar se um determinado fluxo ou buffer começa com o cabeçalho NRBF. É recomendável usar esses métodos ao migrar cargas persistidas com BinaryFormatter para um serializador diferente:

  • Verifique se a carga lida do armazenamento é uma carga NRBF com NrbfDecoder.StartsWithPayloadHeader.
  • Nesse caso, leia-o com NrbfDecoder.Decode, serialize-o novamente com um novo serializador e substitua os dados no armazenamento.
  • Caso contrário, use o novo serializador para desserializar os dados.
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;
}

Ler cargas NRBF com segurança

A carga NRBF consiste em registros de serialização que representam os objetos serializados e seus metadados. Para ler toda a carga e obter o objeto raiz, você precisa chamar o método Decode.

O método Decode retorna uma instância de SerializationRecord. SerializationRecord é uma classe abstrata que representa o registro de serialização e fornece três propriedades autodescritivas: Id, RecordTypee TypeName.

Observação

Um invasor pode criar uma carga com ciclos (exemplo: classe ou uma matriz de objetos com uma referência a si mesmo). O Id retorna uma instância de SerializationRecordId que implementa IEquatable<T> e, entre outras coisas, pode ser usada para detectar ciclos em registros decodificados.

SerializationRecord expõe um método, TypeNameMatches, que compara o nome do tipo lido da carga (e exposto por meio da propriedade TypeName) com o tipo especificado. Esse método ignora nomes de assembly, para que os usuários não precisem se preocupar com o encaminhamento de tipos e o controle de versão do assembly. Ele também não considera nomes de membros ou seus tipos (porque obter essas informações exigiria o carregamento de tipo).

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}`.");
    }
}

Há mais de uma dúzia de tipos de registro de serialização diferentes. Esta biblioteca fornece um conjunto de abstrações, portanto, você só precisa aprender algumas delas:

  • PrimitiveTypeRecord<T>: descreve todos os tipos primitivos com suporte nativo pelo NRBF (string, bool, byte, sbyte, char, short, ushort, int, uint, long, ulong, float, double, decimal, TimeSpan e DateTime).
    • Expõe o valor por meio da propriedade Value.
    • PrimitiveTypeRecord<T> deriva do PrimitiveTypeRecordnão genérico, que também expõe uma propriedade Value. No entanto, na classe base, o valor é retornado como object (que introduz o boxing para tipos de valor).
  • ClassRecord: descreve todos os class e struct além dos tipos primitivos mencionados anteriormente.
  • ArrayRecord: descreve todos os registros de matriz, incluindo matrizes irregulares e multidimensionais.
  • SZArrayRecord<T>: descreve registros de matriz unidimensional, com indexação zero, em que T pode ser um tipo primitivo ou um SerializationRecord.
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.");
}

Além disso Decode, o NrbfDecoder expõe um DecodeClassRecord método que retorna ClassRecord (ou lança).

ClassRecord

O tipo mais importante que deriva de SerializationRecord é ClassRecord, que representa todas as instâncias class e struct além de matrizes e tipos primitivos com suporte nativo. Ele permite que você leia todos os nomes e valores de membro. Para entender o que é membro, consulte a BinaryFormatter referência de funcionalidade.

A API que ela fornece:

O snippet de código a seguir mostra ClassRecord em ação:

[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 define o comportamento principal para registros de matriz NRBF e fornece uma base para classes derivadas. Ele fornece duas propriedades:

  • Rank, que obtém a classificação da matriz.
  • Lengths, que obtém um buffer de inteiros que representam o número de elementos em cada dimensão. É recomendável verificar o comprimento total do registro de matriz fornecido antes de chamar GetArray.

Ele também fornece um método: GetArray. Quando usado pela primeira vez, ele aloca uma matriz e a preenche com os dados fornecidos nos registros serializados (no caso dos tipos primitivos com suporte nativo, como string ou int) ou os próprios registros serializados (no caso de matrizes de tipos complexos).

GetArray requer um argumento obrigatório que especifica o tipo da matriz esperada. Por exemplo, se o registro deve ser uma matriz 2D de inteiros, o expectedArrayType deve ser fornecido como typeof(int[,]) e a matriz retornada também é 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[,]));

Se houver tipos incompatíveis (exemplo: o invasor forneceu uma carga com uma matriz de dois bilhões de cadeias de caracteres), o método gerará InvalidOperationException.

Cuidado

Infelizmente, o formato NRBF torna mais fácil para um invasor compactar um grande número de itens de matriz nula. É por isso que é recomendável sempre verificar o comprimento total da matriz antes de chamar GetArray. Além disso, GetArray aceita um argumento booliano allowNulls opcional, que, quando definido como false, será gerado para nulos.

O NrbfDecoder não carrega ou instancia nenhum tipo personalizado, portanto, no caso de matrizes de tipos complexos, ele retorna uma matriz de 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();

O .NET Framework dá suporte a matrizes com indexação não zero em cargas NRBF, mas esse suporte nunca foi portado para o .NET (Core). O NrbfDecoder, portanto, não dá suporte à decodificação de matrizes indexadas diferentes de zero.

SZArrayRecord

SZArrayRecord<T> define o comportamento principal para registros de matriz NRBF unidimensional, com indexação zero, e fornece uma base para classes derivadas. O T pode ser um dos tipos primitivos com suporte nativo ou SerializationRecord.

Ele fornece uma propriedade Length e uma sobrecarga GetArray que retorna 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()
};