W3docs

Java Deserialisierung

Java-Objekte mit ObjectInputStream aus Bytes wiederherstellen und die Sicherheitsrisiken der Deserialisierung verstehen.

Die Deserialisierung ist das Spiegelbild des vorherigen Kapitels: Gegeben ein Byte-Stream, der von ObjectOutputStream erzeugt wurde, wird der Objektgraph wiederhergestellt. Die API ist ObjectInputStream.readObject(), und der Mechanismus ist — für "vertrauenswürdige Bytes" — fast so einfach wie die Schreibseite. Die Komplikation besteht darin, dass die Deserialisierung der Teil des Serialisierungsdesigns ist, der das viel diskutierte Sicherheitsproblem aufweist; die zweite Hälfte dieses Kapitels handelt davon.

try (ObjectInputStream in = new ObjectInputStream(
         new BufferedInputStream(Files.newInputStream(path)))) {
  User u = (User) in.readObject();                   // throws ClassNotFoundException, IOException
}

Das ist das minimale Rezept. Der Leser sieht die Bytes, sucht jede Klasse anhand ihres Namens im eigenen Klassenlader, weist Instanzen zu ohne deren Konstruktoren aufzurufen, füllt die Felder per Reflection aus und gibt die Wurzel des Graphen als Object zurück. Sie casten auf den erwarteten Typ.

Was readObject zurückgibt

Es gibt das Wurzelobjekt des Graphen zurück, den der Schreiber geschrieben hat. Der statische Rückgabetyp ist Object — der Leser kann den Typ zur Kompilierzeit nicht kennen — daher ist ein Cast Teil des Idioms:

Object raw = in.readObject();
if (raw instanceof User u) {                         // pattern match, recommended
  process(u);
} else {
  throw new IOException("expected User, got " + raw.getClass());
}

Diese instanceof-Prüfung (oder eine explizite getClass()-Prüfung) ist der einzige Ort in normalem Code, an dem Sie prüfen können, ob der Stream das enthielt, was Sie erwartet haben. Lassen Sie sie weg, und ein manipulierter Stream kann Ihnen einen anderen Typ übergeben, Ihr Code wirft eine ClassCastException, und Sie haben keine Ahnung warum.

Zwei geprüfte Ausnahmen

readObject deklariert zwei:

  • ClassNotFoundException — der Stream nannte eine Klasse (com.example.User), die der Klassenlader des Lesers nicht finden kann. Sie haben User auf die Festplatte geschrieben; der Classpath des Lesers enthält User nicht; der Deserialisierer kann sie nicht wiederherstellen.
  • IOException — alles andere: abgeschnittener Stream, falscher Magic-Header, Schema-Mismatch (InvalidClassException), Stream-Beschädigung (StreamCorruptedException).

Der Schema-Mismatch-Fall ist der häufigste. InvalidClassException wird geworfen, wenn die Version der Klasse des Lesers eine andere serialVersionUID hat als die im Stream — meist weil die Klasse sich zwischen Schreiben und Lesen weiterentwickelt hat und die UID nicht erhöht (oder versehentlich erhöht) wurde. Die Meldung nennt die Klasse und beide UIDs; so debuggen Sie das.

Konstruktoren werden nicht aufgerufen

Das ist der Teil, der jeden überrascht: Deserialisierung ruft die Konstruktoren Ihrer Klasse nicht auf. Das JDK weist eine rohe Instanz der Klasse zu und füllt dann die Felder direkt per Reflection aus den Bytes. Jede Invariante, die Sie im Konstruktor eingerichtet haben — required-non-null-Felder, Integer-Bereichsprüfungen, idempotente Initialisierung — wird stillschweigend umgangen.

class User implements Serializable {
  private static final long serialVersionUID = 1L;
  String name;
  int age;
  User(String name, int age) {
    if (age < 0) throw new IllegalArgumentException("age >= 0");   // never runs on read
    this.name = name;
    this.age = age;
  }
}

Erstellen Sie einen Byte-Stream, in dem age = -1 ist, rufen Sie readObject auf, und Sie erhalten einen User mit age == -1. Der Konstruktor wurde übersprungen. Wenn Sie brauchen, dass eine Klassen-Invariante die Deserialisierung überlebt, müssen Sie einen readObject-Hook hinzufügen:

private void readObject(ObjectInputStream in)
    throws IOException, ClassNotFoundException {
  in.defaultReadObject();                            // do the normal field-by-field read
  if (age < 0) throw new InvalidObjectException("age must be >= 0");
}

Die Signatur ist exakt: Name, Parametertyp, Ausnahmeliste. Es ist eine private Methode, die das JDK per Reflection sucht — es gibt kein Interface, das deklariert werden muss. Wenn Sie sie korrekt schreiben, wird sie am Ende der Deserialisierung ausgeführt und Sie erhalten einen sauberen Fehler bei schlechten Daten.

transient-Felder nach dem Lesen

transient- (und static-)Felder sind nicht im Stream, daher belässt der Leser sie bei ihren Standardwerten: null für Referenzen, 0 für numerische Typen, false für boolesche Typen. Das wiederhergestellte Objekt hat diese Standardwerte — das ist die Regel aus dem Serialisierungskapitel, hier von der Leseseite aus formuliert.

Für Caches ist das in Ordnung. Bei erforderlichen Feldern, die Sie als transient markiert haben, um ihre Persistierung zu vermeiden (eine Connection, ein Worker-Thread, eine abgeleitete Map), befindet sich die deserialisierte Instanz in einem "unvollständigen" Zustand, bis Sie sie fertig initialisieren. Der readObject-Hook ist der richtige Ort dafür:

private void readObject(ObjectInputStream in)
    throws IOException, ClassNotFoundException {
  in.defaultReadObject();
  this.cache = new ConcurrentHashMap<>();            // rebuild the transient
}

Gleicher Hook, anderer Grund — der vorherige Abschnitt nutzte ihn zur Validierung; dieser nutzt ihn zur Initialisierung.

Das Sicherheitsproblem

Hier ist die Warnung, die die Haltung des modernen Java zu dieser gesamten API prägt: Deserialisierung kann beliebigen Code ausführen.

Der Grund: Deserialisierung bedeutet "jede Klasse instanziieren, die die Bytes nennen, und dann ihren readObject-Hook ausführen." Viele Klassen im JDK und auf einem typischen Classpath haben readObject-Hooks, die folgenreiche Dinge tun — einen Thread initialisieren, eine Datei öffnen, einen Objektgraph erstellen, der Nebeneffekte über hashCode/equals auslöst. Ein sorgfältig erstellter Stream kann (eine "Gadget-Chain") readObject-Aufrufe so verketten, dass sie auf dem richtigen Classpath mit Runtime.getRuntime().exec(...) enden.

Das ist nicht theoretisch. Der Apache Commons Collections RCE von 2015, die WebSphere/JBoss/Jenkins/Weblogic-Schwachstellen von 2016–2018 und die meisten "Java-Deserialisierungs"-CVEs seitdem folgen genau diesem Muster: Der Angreifer gibt Ihnen Bytes; Sie rufen readObject darauf auf; ihre Gadget-Chain läuft in Ihrem Prozess.

Die Regel, die daraus entstanden ist:

Rufen Sie readObject niemals auf Bytes auf, die Sie nicht vollständig kontrollieren.

"Vollständig kontrollieren" bedeutet: Sie haben sie geschrieben, auf derselben Maschine, in eine Datei oder Pipe, die niemand anderes berühren kann. In dem Moment, in dem die Bytes eine Trust-Grenze überschreiten — einen Netzwerk-Socket, einen Benutzer-Upload, eine Queue-Nachricht — ist ObjectInputStream das falsche Werkzeug. Verwenden Sie JSON oder Protocol Buffers; diese Formate instanziieren keine Klassen anhand von Namen.

ObjectInputFilter: die teilweise Abschwächung

Java 9 fügte ObjectInputFilter hinzu, einen Hook, mit dem Sie Klassen während der Deserialisierung ablehnen können. Legen Sie beim Start einen prozessweiten Filter fest, und jede Klasse außerhalb der Allowlist löst InvalidClassException aus, bevor ihr readObject-Hook ausgeführt wird:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.example.*;java.util.*;!*"                   // allow these packages; reject everything else
);
ObjectInputFilter.Config.setSerialFilter(filter);

Dies reduziert die Angriffsfläche — ein Gadget, das eine Klasse außerhalb der Allowlist benötigt, kann nicht ausgelöst werden. Es macht die Deserialisierung nicht sicher; Gadgets existieren innerhalb von java.util.*, und die Allowlist muss Klassen einschließen, die Sie nicht geschrieben haben. Verwenden Sie es als Defence-in-Depth, nicht als primäre Kontrolle. Die primäre Kontrolle ist immer noch "deserialisieren Sie keine nicht vertrauenswürdigen Bytes."

Für neuen Code bleibt JSON die Antwort.

Ein ausgearbeitetes Beispiel: Round-Trip, Evolution und ein Fehler

Das folgende Programm erweitert das Beispiel aus dem Serialisierungskapitel, indem es die Bytes zurückliest. Es deserialisiert den Department/Employee-Graphen, überprüft, ob die Rückverweise wiederhergestellt wurden, demonstriert das transient-Feld, das als null zurückkommt, und schließt mit dem Versions-Mismatch-Fehlermodus: ein Stream, der mit einer serialVersionUID geschrieben und von einer Klasse mit einer anderen gelesen wurde.

java— editable, runs on the server

Was aus dem Lauf zu entnehmen ist:

  • readObject() hat den vollständigen Department-Graphen in einem einzigen Aufruf wiederhergestellt. Die Liste der Employees kam bevölkert zurück, jeder Employee.department-Zeiger wurde korrekt gesetzt, und der Rückverweis (Employee → dieselbe Department-Instanz) wurde als Objektidentität bewahrt, nicht als Kopie. Dieser letzte Punkt macht die Serialisierung "graph-förmig" statt "baum-förmig" — das JDK verfolgte, welche Referenzen es gesehen hatte, und verdrahtete sie neu.
  • Die instanceof Department d-Prüfung war das Tor, das ein rohes Object in ein typisiertes Department verwandelte. Ohne sie hätte ein Stream mit einem anderen Typ beim (Department) raw-Cast eine ClassCastException geworfen — unschöner und schwerer zu diagnostizieren. Die instanceof-Form ist das Idiom.
  • Alle drei passwordHash-Felder kamen als null zurück. Das Markieren des Feldes als transient schloss es aus dem Stream aus; der Leser hatte keinen Wert zuzuweisen, daher blieb das Feld bei seinem Standardwert. Das ist die Regel aus dem Serialisierungskapitel, hier in der Leserichtung bestätigt.
  • Der Versions-Mismatch-Block produzierte die InvalidClassException, die zu erwarten war: der Stream sagte "UID = 1" und die Klasse sagte "UID = 2," daher weigerte sich das JDK zu instanziieren. Die Fehlermeldung nennt beide UIDs — so finden Sie heraus, welche Klasse abgedriftet ist. Produktionsreifer Code deklariert serialVersionUID explizit und erhöht sie nur, wenn die Änderung inkompatibel ist.
  • In diesem Beispiel wurde kein Employee- oder Department-Konstruktor aufgerufen. Die Objekte entstanden per Reflection, Felder direkt befüllt. Jede Validierung zur Konstruktorzeit (if (salary < 0) throw ...) wurde umgangen; wenn Sie brauchen, dass sie auf der Leseseite ausgeführt wird, ist dafür der private readObject-Hook zuständig. Die Practice-Frage am Ende vertieft diesen Punkt.

Was als nächstes kommt

Serialisierung und Deserialisierung haben die Streaming-Seite von java.io abgeschlossen — Bytes, Zeichen und Objektgraphen, alle als Streams geschrieben. Das nächste Kapitel, Java NIO Überblick, wechselt zu einer anderen API-Familie: java.nio und java.nio.file. NIO ersetzt einen Teil von java.io, ergänzt den Rest, und ist die Heimat der modernen Path- und Files-Klassen, die die dateibezogenen Kapitel bereits stillschweigend verwendet haben.

Übungen

Übung
Eine Klassen-Invariante — 'salary muss größer als 0 sein' — wird im Konstruktor einer `Serializable`-Klasse erzwungen. Ein Angreifer übergibt Ihrem Server einen serialisierten Byte-Stream, in dem das salary-Feld als -1 kodiert ist. Was passiert, wenn Ihr Code `readObject()` aufruft?
Eine Klassen-Invariante — 'salary muss größer als 0 sein' — wird im Konstruktor einer `Serializable`-Klasse erzwungen. Ein Angreifer übergibt Ihrem Server einen serialisierten Byte-Stream, in dem das salary-Feld als -1 kodiert ist. Was passiert, wenn Ihr Code `readObject()` aufruft?
Was this page helpful?