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.

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

avertissement 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*;!*");

avertissement 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)

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

}

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

}

astuce 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