Freigeben über


Lesen von BinaryFormatter-Nutzlasten (NRBF)

BinaryFormatter hat .NET Remoting: Binärformat für die serialization verwendet. Dieses Format ist unter der Abkürzung MS-NRBF oder einfach NRBF bekannt. Eine häufige Herausforderung bei der Migration von BinaryFormatter ist der Umgang mit Nutzlasten, die im Speicher gespeichert werden, da für das Lesen dieser Nutzlasten bisher BinaryFormatter erforderlich war. Einige Systeme müssen die Möglichkeit behalten, diese Nutzlasten für die schrittweise Migration zu neuen Serialisierungsmodulen zu lesen und gleichzeitig einen Verweis auf BinaryFormatter selbst zu vermeiden.

Als Teil von .NET 9 wurde eine neue NrbfDecoder-Klasse eingeführt, um NRBF-Nutzlasten zu decodieren, ohne eine Deserialisierung der Nutzlast durchzuführen. Diese API kann sicher verwendet werden, um vertrauenswürdige oder nicht vertrauenswürdige Nutzlasten ohne die Risiken zu decodieren, die die BinaryFormatter-Deserialisierung mit sich bringt. Die NrbfDecoder-Klasse dekodiert die Daten jedoch lediglich in Strukturen, die eine Anwendung weiterverarbeiten kann. Bei der Verwendung von NrbfDecoder ist Vorsicht geboten, um die Daten sicher in die entsprechenden Instanzen zu laden.

Sie können sich NrbfDecoder als Entsprechung der Verwendung eines JSON/XML-Readers ohne Deserialisierer vorstellen.

NrbfDecoder

NrbfDecoder ist Teil des neuen NuGet-Pakets System.Formats.Nrbf. Es zielt nicht nur auf .NET 9, sondern auch ältere Moniker wie .NET Standard 2.0 und .NET Framework ab. Diese Festlegung von Zielversionen ermöglicht es allen Benutzern, die eine unterstützte Version von .NET verwenden, von BinaryFormatter zu migrieren. NrbfDecoder kann Nutzlasten lesen, die mit BinaryFormatter deserialisiert wurden, und zwar unter Verwendung von FormatterTypeStyle.TypesAlways (der Standard).

NrbfDecoder ist so konzipiert, dass alle Eingaben als nicht vertrauenswürdig behandelt werden. Daher gelten folgende Grundsätze:

  • Kein Laden von Typen jeglicher Art (um Risiken wie die Ausführung von Remotecode zu vermeiden)
  • Keine Rekursion jeglicher Art (um ungebundene Rekursion, Stapelüberlauf und Denial of Service zu vermeiden)
  • Keine Puffervorabbelegung basierend auf der in der Nutzlast angegebenen Größe, wenn die Nutzlast zu klein ist, um die zugesagten Daten zu enthalten (um Speicherplatzmangel und Denial of Service zu vermeiden)
  • Einmaliges Decodieren aller Eingabeteile (um den gleichen Arbeitsaufwand wie der potenzielle Angreifer zu haben, der die Nutzlast erstellt hat)
  • Verwenden von konfliktbeständigem Hashing nach dem Zufallsprinzip zum Speichern von Datensätzen, auf die von anderen Datensätzen verwiesen wird (um zu wenig Arbeitsspeicher für Wörterbücher zu vermeiden, die von einem Array unterstützt werden, dessen Größe von der Anzahl der Hashcodekonflikte abhängt)
  • Nur primitive Typen können implizit instanziiert werden. Arrays können bedarfsgesteuert instanziiert werden. Andere Typen werden nie instanziiert.

Bei der Verwendung von NrbfDecoder ist es wichtig, diese Fähigkeiten nicht wieder in universellen Code einzuführen, da dies diese Schutzmaßnahmen zunichte machen würde.

Deserialisieren eines geschlossenen Satzes von Typen

NrbfDecoder ist nur dann nützlich, wenn es sich bei der Liste der serialisierten Typen um eine bekannte, geschlossene Menge handelt. Anders ausgedrückt: Sie müssen vorab wissen, was Sie lesen möchten, da Sie auch Instanzen dieser Typen erstellen und mit Daten auffüllen müssen, die aus der Nutzlast gelesen wurden. Betrachten Sie zwei gegensätzliche Beispiele:

  • Alle [Serializable]-Typen von Quartz.NET, die von der Bibliothek selbst gespeichert werden können, sind vom Typ sealed. Es gibt also keine benutzerdefinierten Typen, die Benutzer erstellen können, und die Nutzlast darf nur bekannte Typen enthalten. Die Typen stellen zudem öffentliche Konstruktoren bereit, sodass es möglich ist, diese Typen basierend auf den Informationen neu zu erstellen, die aus der Nutzlast gelesen werden.
  • Der SettingsPropertyValue-Typ macht die Eigenschaft PropertyValue des Typs object verfügbar, die intern möglicherweise BinaryFormatter verwendet, um alle Objekte zu serialisieren und zu deserialisieren, die in der Konfigurationsdatei gespeichert wurden. Es kann dazu verwendet werden, eine ganze Zahl, einen benutzerdefinierten Typ, ein Wörterbuch und vieles mehr zu speichern. Daher ist es unmöglich, diese Bibliothek zu migrieren, ohne dass Breaking Changes an der API eingeführt werden.

Ermitteln von NRBF-Nutzlasten

NrbfDecoder bietet zwei StartsWithPayloadHeader-Methoden, mit denen Sie überprüfen können, ob ein bestimmter Stream oder Puffer mit dem NRBF-Header beginnt. Es wird empfohlen, diese Methoden zu verwenden, wenn Sie mit BinaryFormatter gespeicherte Nutzlasten zu einem anderen Serialisierungsmodul migrieren:

  • Überprüfen Sie, ob es sich bei der aus dem Speicher gelesenen Nutzlast um eine NRBF-Nutzlast mit NrbfDecoder.StartsWithPayloadHeader handelt.
  • Wenn ja, lesen Sie sie mit NrbfDecoder.Decode, serialisieren Sie sie wieder mit einem neuen Serialisierungsmodul, und überschreiben Sie die Daten im Speicher.
  • Wenn nicht, verwenden Sie das neue Serialisierungsmodul, um die Daten zu deserialisieren.
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;
}

Sicheres Lesen von NRBF-Nutzlasten

Die NRBF-Nutzlast besteht aus serialization-Datensätzen, die die serialisierten Objekte und deren Metadaten darstellen. Um die gesamte Nutzlast zu lesen und das Stammobjekt abzurufen, müssen Sie die Decode-Methode aufrufen.

Die Decode-Methode gibt eine SerializationRecord-Instanz zurück. SerializationRecord ist eine abstrakte Klasse, die den serialization-Datensatz darstellt und drei selbstbeschreibende Eigenschaften bereitstellt: Id, RecordType und TypeName. Sie macht eine Methode (TypeNameMatches) verfügbar, die den aus der Nutzlast gelesenen (und über die Eigenschaft TypeName verfügbar gemachten) Typnamen mit dem angegebenen Typ vergleicht. Diese Methode ignoriert Assemblynamen, sodass Benutzer sich keine Gedanken über die Typweiterleitung und Assemblyversionsverwaltung machen müssen. Sie berücksichtigt außerdem keine Membernamen oder ihre Typen (da das Abrufen dieser Informationen das Laden von Typen erfordern würde).

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

Es gibt mehr als ein Dutzend verschiedener serializationDatensatztypen. Diese Bibliothek bietet eine Reihe von Abstraktionen, sodass Sie sich nur mit einigen davon vertraut machen müssen:

  • PrimitiveTypeRecord<T>: beschreibt alle primitiven Typen, die nativ von NRBF unterstützt werden (string, bool, byte, sbyte, char, short, ushort, int, uint, long, ulong, float, double, decimal, TimeSpan und DateTime).
    • Macht den Wert über die Eigenschaft Value verfügbar.
    • PrimitiveTypeRecord<T> wird vom nicht generischen PrimitiveTypeRecord-Element abgeleitet, das auch eine Value-Eigenschaft verfügbar macht. Für die Basisklasse wird der Wert jedoch als object zurückgegeben (wodurch Boxing für Werttypen eingeführt wird).
  • ClassRecord: beschreibt alle class- und struct-Elemente neben den oben genannten primitiven Typen.
  • ArrayRecord: beschreibt alle Arraydatensätze, einschließlich Jagged Arrays und mehrdimensionaler Arrays angeben.
  • SZArrayRecord<T>: beschreibt eindimensionale Arraydatensätze mit Nullindex, wobei T entweder ein primitiver Typ oder ClassRecord sein kann.
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.");
}

Außerdem Decode macht NrbfDecoder eine DecodeClassRecord-Methode verfügbar, die ClassRecord zurückgibt (oder auslöst).

ClassRecord

Der wichtigste Typ, der von SerializationRecord abgeleitet wird, ist das ClassRecord-Element, das alle class-und struct-Instanzen neben Arrays und nativen unterstützten primitiven Typen darstellt. Damit können Sie alle Membernamen und -werte lesen. Informationen dazu, was ein Member ist, finden Sie in der BinaryFormatter-Funktionsreferenz.

Die API stellt Folgendes bereit:

Der folgende Codeschnipsel zeigt ClassRecord in Aktion:

[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 definiert das Kernverhalten für NRBF-Arraydatensätze und bietet eine Basis für abgeleitete Klassen. Das Element stellt zwei Eigenschaften bereit:

  • Rank zum Abrufen des Rangs des Arrays
  • Lengths zum Abrufen eines Puffers mit ganzen Zahlen, die die Anzahl der Elemente in jeder Dimension darstellen

Es bietet auch eine Methode: GetArray. Wenn sie zum ersten Mal verwendet wird, wird ein Array zugewiesen und mit den Daten aus den serialisierten Datensätzen (im Fall von nativ unterstützten primitiven Typen wie string oder int) oder mit den serialisierten Datensätzen selbst (im Fall von Arrays komplexer Typen) gefüllt.

GetArray erfordert ein obligatorisches Argument, das den Typ des erwarteten Arrays angibt. Wenn der Datensatz beispielsweise ein 2D-Array mit ganzen Zahlen sein soll, muss expectedArrayType als typeof(int[,]) angegeben werden, und das zurückgegebene Array ist ebenfalls int[,]:

ArrayRecord arrayRecord = (ArrayRecord)NrbfDecoder.Decode(stream);
int[,] array2d = (int[,])arrayRecord.GetArray(typeof(int[,]));

Wenn ein Typenkonflikt vorliegt (Beispiel: Der Angreifer hat eine Nutzlast mit einem Array von zwei Milliarden Zeichenfolgen bereitgestellt.), löst die Methode InvalidOperationException aus.

NrbfDecoder lädt oder instanziiert keine benutzerdefinierten Typen; im Falle von Arrays komplexer Typen wird ein Array von SerializationRecord zurückgegeben.

[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 hat Arrays ohne Nullindex innerhalb von NRBF-Nutzlasten unterstützt, diese Unterstützung wurde jedoch nie zu .NET (Core) portiert. NrbfDecoder unterstützt daher nicht die Dekodierung von indizierten Arrays ungleich Null.

SZArrayRecord

SZArrayRecord<T> definiert das Kernverhalten für eindimensionale NRBF-Arraydatensätze mit Nullindex und bietet eine Basis für abgeleitete Klassen. Das T-Element kann einer der nativ unterstützten primitiven Typ oder SerializationRecord sein.

Es stellt eine Length-Eigenschaft und eine GetArray-Überladung bereit, die T[] zurückgibt.

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