Sécurité : la désérialisation par la pratique
Contexte
Dans les billets précédents (JEP 290, Filter Incoming Serialization Data et JEP 415, Context Deserialization Filters), je vous parlais de sécurité, notamment sur le sujet de la désérialisation. Ce point n’est pas anodin car la désérialisation non sécurisée fait partie du Top 10 OWASP des failles de sécurité des applications web.
L’objectif de ce billet est de passer de la théorie à la pratique.
Besoin de rappel ?, je vous invite à lire le chapitre Rappel sur la sérialisation
Pour faire court, l’objectif est pouvoir transformer une instance d’une classe en flux d’octets. Ce dernier sera stocké dans un fichier, passera par le réseau ou autres. Puis, le flux d’octects sera retransformer en instance.
Le cas d’étude
Nous avons deux classes sérialisables Point
et Cercle
.
public class Point implements Serializable {
protected int x, y;
public Point() {
}
...
public class Cercle implements Serializable {
protected Point centre;
protected int rayon;
...
Sérialisation
La sérialisation est très facile, il suffit de manipuler la classe ObjectOutputStream
et d’y écrire nos instances de Point
et Cercle
.
Point c = new Point(4, 5);
Cercle cercle = new Cercle(c, 20);
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("target/serial.data"));) {
oos.writeObject(cercle);
} catch (IOException e) {
e.printStackTrace();
}
Désérialisation
La désérialisation est l’opération inverse. Pour cela, nous manipulons ObjectInputStream
.
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("target/serial.data"));) {
Object f = ois.readObject();
System.out.println(f.toString());
} catch (IOException | ClassNotFoundException e) {
//...
}
Activation / Autorisation
La classe doit implémenter tout simplement l’interface java.lang.Serializable
.
class MaClasse implements Serializable {
}
Et c’est tout !
Le problème
C’est que nous pouvons désérialiser une classe que nous ne connaissons pas, donc la charger en mémoire. Ce qui est potentiellement dangereux si nous sommes amené à exécuter du code malveillant.
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FICHIER_SERIALISATION));) {
oos.writeObject(cercle);
oos.writeObject(new Intrus("INTRUSION"));
} catch (IOException e) {
e.printStackTrace();
}
Dans ce billet, la classe Intrus
est connue car elle est présente dans le classpath local. Mais plusieurs API Java permettent de récupérer le code et de le charger via la désérialisation.
Pour rappel, il suffit que la classe Intrus
réalise l’interface Serializable
.
package fr.lbenoit.billets.codes_sources.securite.intrus;
import java.io.Serializable;
public class Intrus implements Serializable {
...
}
La classe n’a rien à voir avec la classe Cercle
. Et pourtant, nous allons pouvoir la désérialiser.
Object f = ois.readObject();
System.out.println(f.toString());
assertTrue(f.equals(cercle), "La première forme devrait être le cercle");
f = ois.readObject();
System.out.println(f.toString()); // Boom, la classe `Intrus` est chargée.
Solution
Utilisation des filtres par programmation
Comme recommandé par OWASP, la meilleure solution est définit une liste blanche.
Pour cela, depuis le JDK 9, il nous suffit simplement de passer par l’interface ObjectInputFilter
.
ObjectInputFilter filtre = ObjectInputFilter.Config
.createFilter("fr.lbenoit.billets.codes_sources.securite.deserialisation.filtre.modele*;!*");
Notez l’usage de la dernière valeur !*
qui permet de dire que nous souhaitons exclure toutes les valeurs non citées.
Lors du test unitaire associé, j’ai précisé que je souhaitais récupérer une exception sinon le cas échoue. Utilisation de fail()
qui provoque l’échec du test si la désérialisation fonctionne.
...
try {
f = ois.readObject();
fail("Une exception aurait dû se produire.");
} catch (InvalidClassException ice) {
System.out.println(ice.getMessage());
assertTrue(ice.getMessage().contains("REJECTED"));
}
J’obtiens bien le résultat prévu :
Filter status: REJECTED
Utilisation des filtres par ligne de commande
Nous pouvons obtenir le même résultat en définissant la propriété suivante jdk.serialFilter
.
Dans le code, aucune référence à ce filtre.
java -jar ...jar -Djdk.serialFilter=fr.lbenoit.billets.codes_sources.securite.deserialisation.filtre.modele*;!*
Lors de l’exécution, nous obtenons :
oct. 10, 2021 12:17:13 AM java.io.ObjectInputFilter$Config lambda$static$0
INFO: Creating serialization filter from fr.lbenoit.billets.codes_sources.securite.deserialisation.filtre.modele*;!*
fr.lbenoit.billets.codes_sources.securite.deserialisation.filtre.modele.Cercle@87d3
java.io.InvalidClassException: filter status: REJECTED
at java.base/java.io.ObjectInputStream.filterCheck(ObjectInputStream.java:1354)
at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2005)
Vous avez le lanceur Eclipse au niveau du projet : /2021-10-Filtre-Securite/launcher/Programme (jdk.serial).launch
Utilisation de son propre filtre
Nous pouvons avoir des conditions de rejet plus spécifique que ce qui est offert de base dans le JDK.
Cela n’est pas un problème, nous pouvons écrire notre propre classe qui implémente l’interface ObjectInputFilter
et mettre en place nos propres règles :
public class MonFiltre implements ObjectInputFilter {
@Override
public Status checkInput(FilterInfo arg0) {
if (arg0.serialClass() == null) {
return Status.ALLOWED;
}
System.out.println("class : " + arg0.serialClass());
if (arg0.serialClass().getName().startsWith("fr.lbenoit.billets.codes_sources.securite.deserialisation.filtre.modele")) {
return Status.ALLOWED;
} else {
return Status.REJECTED;
}
}
}
Pour rappel, la méthode checkInput
prend une instance de FilterInfo
en paramètre. Cela nous donne plein d’information sur le contexte courant.
Au delà du filtrage, il est possible de réaliser d’autres opérations que le filtre avec acceptation et refus. Il est possible tout simplement de journaliser de ce type d’usage et les classes concernées :
public class LoggerFiltre implements ObjectInputFilter {
@Override
public Status checkInput(FilterInfo arg0) {
if (arg0.serialClass() == null) {
return Status.ALLOWED;
}
System.err.println(arg0.serialClass().getName());
return Status.ALLOWED;
}
}
Je vous l’accorde l’utilisation d’un LOGGER serait plus approprié.
Le code source
Le code source est disponible sous Github à l’adresse suivante : https://github.com/lilian-benoit/billets-codes-sources/tree/master/2021/10/2021-10-Filtre-Securite
Moteur de recherche
"Eduquer, ce n'est pas remplir des vases mais c'est d'allumer des feux." - Michel Montaigne
Billets récents
- Eclipse plante systématiquement sous Debian (et autres distribution Linux)
- JEP 463, Implicitly Declared Classes and Instance Main Methods (Second Preview)
- Debian - Montée de version de Debian 11 (Bullseye) à Debian 12 (Bookworm)
- JEP 451, Prepare to Disallow the Dynamic Loading of Agents
- JEP 444, Virtual Threads