W3docs

Désérialisation Java

Désérialisez des objets Java depuis des octets avec ObjectInputStream et comprenez les failles de sécurité liées à la désérialisation.

La désérialisation est le miroir du chapitre précédent : à partir d'un flux d'octets produit par ObjectOutputStream, reconstruire le graphe d'objets. L'API est ObjectInputStream.readObject(), et le mécanisme est — pour des « octets fiables » — presque aussi simple que côté écriture. La complication vient du fait que la désérialisation est la partie de la conception de la sérialisation qui présente le problème de sécurité bien documenté ; la seconde moitié de ce chapitre y est consacrée.

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

Voilà la recette minimale. Le lecteur voit les octets, recherche chaque classe par nom dans son propre chargeur de classes, alloue des instances sans appeler leurs constructeurs, remplit les champs par réflexion, et retourne la racine du graphe castée en Object. Vous la castez vers le type attendu.

Ce que retourne readObject

Il retourne l'objet racine du graphe que l'écrivain a écrit. Le type de retour statique est Object — le lecteur ne peut pas connaître le type à la compilation — donc un cast fait partie de l'idiome :

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

Ce contrôle instanceof (ou un contrôle explicite getClass()) est le seul endroit dans le code normal où vous pouvez vérifier que le flux contenait ce que vous pensiez. Ignorez-le et un flux forgé peut vous transmettre un type différent, votre code lancera une ClassCastException, et vous n'aurez aucune idée de la raison.

Deux exceptions contrôlées

readObject en déclare deux :

  • ClassNotFoundException — le flux a nommé une classe (com.example.User) que le chargeur de classes du lecteur ne peut pas trouver. Vous avez écrit User sur le disque ; le classpath du lecteur n'inclut pas User ; le désérialiseur ne peut pas la reconstruire.
  • IOException — tout le reste : flux tronqué, mauvais en-tête magique, incompatibilité de schéma (InvalidClassException), corruption du flux (StreamCorruptedException).

Le cas d'incompatibilité de schéma est le plus courant. InvalidClassException est levée lorsque la version de la classe du lecteur a un serialVersionUID différent de celui du flux — généralement parce que la classe a évolué entre l'écriture et la lecture et que l'UID n'a pas été mis à jour (ou l'a été accidentellement). Le message nomme la classe et les deux UIDs ; c'est ainsi que vous déboguez.

Les constructeurs ne s'exécutent pas

C'est ce qui surprend tout le monde : la désérialisation n'appelle pas les constructeurs de votre classe. Le JDK alloue une instance brute de la classe, puis remplit les champs directement via réflexion depuis les octets. Tous les invariants que vous avez établis dans le constructeur — champs obligatoirement non nuls, vérifications d'entiers dans une plage, initialisation idempotente — sont silencieusement contournés.

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

Forgez manuellement un flux d'octets où age = -1, exécutez readObject, et vous obtiendrez un User avec age == -1. Le constructeur a été ignoré. Si vous avez besoin qu'un invariant de classe survive à la désérialisation, vous devez ajouter un hook readObject :

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

La signature est exacte : nom, type de paramètre, liste d'exceptions. C'est une méthode privée que le JDK recherche par réflexion — il n'y a pas d'interface à déclarer. Si vous l'écrivez correctement, elle s'exécute à la fin de la désérialisation et vous obtenez un échec propre sur des données incorrectes.

Champs transient après la lecture

Les champs transient (et static) ne sont pas dans le flux, donc le lecteur les laisse à leurs valeurs par défaut : null pour les références, 0 pour les numériques, false pour les booléens. L'objet reconstruit a ces valeurs par défaut — c'est la règle du chapitre sur la sérialisation, exprimée ici du côté lecture.

Pour les caches, c'est acceptable. Pour les champs obligatoires que vous avez marqués transient afin d'éviter leur persistance (une Connection, un Thread de travail, une Map dérivée), l'instance désérialisée est dans un état « incomplet » jusqu'à ce que vous finissiez de l'initialiser. Le hook readObject est l'endroit approprié pour le faire :

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

Même hook, raison différente — la section précédente l'utilisait pour la validation ; celle-ci l'utilise pour l'initialisation.

Le problème de sécurité

Voici l'avertissement qui motive la position du Java moderne sur toute cette API : la désérialisation peut exécuter du code arbitraire.

La raison : la désérialisation consiste à « instancier toute classe nommée par les octets, puis à exécuter son hook readObject ». De nombreuses classes du JDK et d'un classpath typique ont des hooks readObject qui font des choses importantes — initialiser un thread, ouvrir un fichier, construire un graphe d'objets qui déclenche des effets secondaires via hashCode/equals. Un flux soigneusement forgé peut enchaîner (une « chaîne de gadgets ») des appels readObject qui, sur le bon classpath, aboutissent à Runtime.getRuntime().exec(...).

Ce n'est pas théorique. La RCE Apache Commons Collections de 2015, les vulnérabilités WebSphere/JBoss/Jenkins/Weblogic de 2016–2018, et la plupart des CVE « désérialisation Java » depuis lors suivent exactement ce schéma : l'attaquant vous donne des octets ; vous appelez readObject dessus ; leur chaîne de gadgets s'exécute dans votre processus.

La règle qui en est ressortie :

N'appelez jamais readObject sur des octets que vous ne contrôlez pas totalement.

« Contrôler totalement » signifie : vous les avez écrits, sur la même machine, dans un fichier ou un tube que personne d'autre ne peut toucher. Dès que les octets franchissent une frontière de confiance quelconque — un socket réseau, un téléchargement utilisateur, un message de file d'attente — ObjectInputStream est le mauvais outil. Utilisez JSON ou Protocol Buffers ; ces formats n'instancient pas de classes par nom.

ObjectInputFilter : l'atténuation partielle

Java 9 a ajouté ObjectInputFilter, un hook qui vous permet de rejeter des classes pendant la désérialisation. Définissez un filtre à l'échelle du processus au démarrage, et toute classe hors de la liste d'autorisation déclenche une InvalidClassException avant que son hook readObject ne s'exécute :

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

Cela réduit la surface d'attaque — un gadget qui nécessite une classe hors de la liste d'autorisation ne peut pas se déclencher. Cela ne rend pas la désérialisation sûre ; des gadgets existent dans java.util.*, et la liste d'autorisation doit inclure des classes que vous n'avez pas écrites. Utilisez-le comme défense en profondeur, non comme contrôle principal. Le contrôle principal reste « ne désérialisez pas des octets non fiables ».

Pour le nouveau code, la réponse demeure JSON.

Un exemple concret : aller-retour, évolution et échec

Le programme ci-dessous étend l'exemple du chapitre sur la sérialisation en relisant les octets. Il désérialise le graphe Department/Employee, vérifie que les références arrières sont reconnectées, démontre que le champ transient revient comme null, et termine avec le mode d'échec de version incompatible : un flux écrit avec un serialVersionUID et lu par une classe avec un autre.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • readObject() a reconstruit le graphe complet de Department en un seul appel. La liste des Employees est revenue peuplée, chaque pointeur Employee.department était correctement défini, et la référence arrière (employé → même instance de département) a été préservée comme identité d'objet, non comme copie. Ce dernier point est ce qui rend la sérialisation « en forme de graphe » plutôt que « en forme d'arbre » — le JDK a suivi les références déjà vues et les a reconnectées.
  • Le contrôle instanceof Department d était la porte qui transformait un Object brut en un Department typé. Sans lui, un flux contenant un type différent aurait échoué au cast (Department) raw avec ClassCastException — plus difficile à diagnostiquer. La forme instanceof est l'idiome.
  • Les trois champs passwordHash sont revenus comme null. Marquer le champ transient l'a exclu du flux ; le lecteur n'avait aucune valeur à assigner, donc le champ est resté à sa valeur par défaut. C'est la règle du chapitre sur la sérialisation, confirmée ici côté lecture.
  • Le bloc de version incompatible a produit l'InvalidClassException attendue : le flux indiquait « UID = 1 » et la classe indiquait « UID = 2 », donc le JDK a refusé d'instancier. Le message d'erreur nomme les deux UIDs — c'est ainsi que vous trouvez quelle classe a divergé. Le code de niveau production déclare serialVersionUID explicitement et ne le met à jour que lorsque la modification est incompatible.
  • Rien dans cet exemple n'a appelé un constructeur Employee ou Department. Les objets ont été créés par réflexion, les champs remplis directement. Toute validation au moment de la construction (if (salary < 0) throw ...) a été contournée ; si vous avez besoin qu'elle s'exécute côté lecture, c'est à cela que sert le hook private readObject. La question pratique en bas approfondit ce point.

La suite

La sérialisation et la désérialisation ont clôturé la partie flux de java.io — octets, caractères, et graphes d'objets, tous écrits sous forme de flux. Le chapitre suivant, Présentation de Java NIO, passe à une autre famille d'API : java.nio et java.nio.file. NIO remplace une partie de java.io, complète le reste, et est le foyer des classes modernes Path et Files que les chapitres liés aux fichiers utilisaient déjà discrètement.

Pratique

Pratique
Un invariant de classe — 'le salaire doit être supérieur à 0' — est appliqué dans le constructeur d'une classe `Serializable`. Un attaquant envoie à votre serveur un flux d'octets sérialisé où le champ salary est encodé comme -1. Que se passe-t-il quand votre code appelle `readObject()` ?
Un invariant de classe — 'le salaire doit être supérieur à 0' — est appliqué dans le constructeur d'une classe `Serializable`. Un attaquant envoie à votre serveur un flux d'octets sérialisé où le champ salary est encodé comme -1. Que se passe-t-il quand votre code appelle `readObject()` ?
Was this page helpful?