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 écritUsersur le disque ; le classpath du lecteur n'inclut pasUser; 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
readObjectsur 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.
Ce qu'il faut retenir de l'exécution :
readObject()a reconstruit le graphe complet deDepartmenten un seul appel. La liste desEmployees est revenue peuplée, chaque pointeurEmployee.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 unObjectbrut en unDepartmenttypé. Sans lui, un flux contenant un type différent aurait échoué au cast(Department) rawavecClassCastException— plus difficile à diagnostiquer. La formeinstanceofest l'idiome. - Les trois champs
passwordHashsont revenus commenull. Marquer le champtransientl'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'
InvalidClassExceptionattendue : 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éclareserialVersionUIDexplicitement et ne le met à jour que lorsque la modification est incompatible. - Rien dans cet exemple n'a appelé un constructeur
EmployeeouDepartment. 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 hookprivate 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.