Aller au contenu

Attaque par désérialisation YAML non sécurisée

Attaque par désérialisation YAML non sécurisée

Section intitulée « Attaque par désérialisation YAML non sécurisée »

La désérialisation YAML non sécurisée transforme une donnée de configuration en capacité d’exécution. Le défaut n’est pas dans YAML (YAML Ain’t Markup Language) comme format, mais dans le chargeur qui accepte des tags capables d’instancier des objets natifs. Dès qu’une entrée contrôlable atteint ce chargeur, le fichier de configuration devient un programme avec une extension très polie.

→ Lire l’explication complète de la marmotte

YAML représente des scalaires, listes et mappings, c’est-à-dire des associations clé-valeur. Un parseur peut se limiter à ces structures primitives, puis fournir un dictionnaire, une liste, une chaîne ou un nombre à l’application. C’est le comportement attendu pour un fichier de paramètres.

Certains parseurs proposent aussi des tags de construction d’objets. En Python, des tags tels que !!python/object, !!python/object/new et !!python/object/apply demandent à la bibliothèque PyYAML de résoudre une classe ou une fonction Python, puis de l’instancier ou de l’appeler. En Ruby, des variantes de Psych ont exposé des objets Ruby via des tags similaires. En Java, SnakeYAML a longtemps permis des constructions de types arbitraires selon sa configuration et sa version.

Le compromis central est brutal : la construction automatique d’objets économise du code de conversion pour des données de confiance, mais elle confond données et instructions. Cette commodité doit être refusée pour toute donnée pouvant provenir d’un dépôt, d’une API (Application Programming Interface), d’un ticket, d’une variable d’environnement, d’un stockage partagé ou d’un utilisateur. La recommandation est nette : charger YAML en types primitifs uniquement, puis valider un schéma applicatif explicite.

Une authentification en amont ne change pas ce verdict. Un compte compromis, un jeton de pipeline volé ou un dépôt dont les droits d’écriture sont trop larges suffit à franchir cette frontière. Les contrôles d’accès réduisent les sources possibles du fichier ; ils ne rendent pas les constructeurs d’objets moins dangereux.

Le problème apparaît souvent dans les zones qui portent le mot « configuration », donc celles que beaucoup de revues traitent avec la vigilance d’un ficus. Manifestes de déploiement, règles d’automatisation, définitions de tâches, imports d’inventaire et paramètres de CI/CD (Continuous Integration/Continuous Delivery) sont des vecteurs classiques. Un fichier YAML versionné reste du code lorsqu’un chargeur dangereux le lit.

Les défauts voisins relèvent de la même famille : les failles applicatives naissent fréquemment d’une frontière de confiance mal placée. YAML mérite toutefois une page distincte car ses tags ont une apparence déclarative et parce que les bibliothèques diffèrent fortement sur leurs chargeurs par défaut.

La démonstration suivante utilise Python 3.11 ou ultérieur et PyYAML 6.0.2. Elle crée un fichier marqueur dans /tmp lorsque le chargeur dangereux traite un tag !!python/object/apply. L’exemple ne nécessite ni réseau ni privilège élevé. Exécuter ce code dans un conteneur ou une machine de test reste la bonne hygiène : un chargeur dangereux ne devient pas fréquentable parce que le premier essai paraît inoffensif.

Fenêtre de terminal
# Créer un environnement isolé et installer une version connue.
python3 -m venv .venv
. .venv/bin/activate
pip install 'PyYAML==6.0.2'
cat > demo.py <<'PY'
import os
import yaml
payload = """
action: !!python/object/apply:os.system
- "touch /tmp/yaml-deserialization-proof"
"""
# SafeLoader n'autorise que les structures YAML primitives.
try:
yaml.load(payload, Loader=yaml.SafeLoader)
except yaml.constructor.ConstructorError as error:
print(f"Chargeur sûr : tag refusé ({error.problem})")
# UnsafeLoader résout os.system et exécute la commande embarquée.
yaml.load(payload, Loader=yaml.UnsafeLoader)
print("Preuve créée :", os.path.exists("/tmp/yaml-deserialization-proof"))
PY
python demo.py
test -f /tmp/yaml-deserialization-proof && echo "Exécution confirmée"
rm -f /tmp/yaml-deserialization-proof

L’appel à os.system est exécuté pendant le chargement, avant toute validation métier de action. Une liste blanche appliquée après yaml.load() ne protège donc rien : le dommage est déjà fait. C’est le piège le plus coûteux de cette classe de défauts, car une validation de schéma semble présente dans le code tout en arrivant après l’exécution.

yaml.safe_load() est l’alternative courte de PyYAML lorsque les options par défaut conviennent. yaml.load(..., Loader=yaml.SafeLoader) rend le choix visible dans une revue. Dans les deux cas, le résultat doit encore être validé : sûr pour le parseur ne signifie pas valide pour l’application.

L’exécution de code à distance, ou RCE (Remote Code Execution), est le risque principal. Le processus qui charge le document exécute alors une fonction, importe un module, écrit un fichier ou lance une commande avec ses propres droits. Une tâche d’automatisation ayant accès au cloud, à un registre de conteneurs ou à des secrets de déploiement devient un point de pivot. Le YAML n’a pas besoin d’être exposé sur Internet ; un contributeur ayant le droit de modifier une branche consommée automatiquement suffit.

Les effets possibles ne se limitent pas à la RCE. La création récursive d’alias peut provoquer un déni de service par consommation de mémoire ou de processeur. Des ancres massivement réutilisées, des arbres très profonds et des chaînes gigantesques attaquent le parseur même avec un chargeur sûr. Certains parseurs limitent la profondeur ou les alias, d’autres délèguent le problème à l’application. Il faut vérifier cette propriété dans la version réellement embarquée, pas dans une slide de fournisseur qui promet de sécuriser les données par vibrations positives.

Les chaînes d’attaque réalistes suivent généralement cette séquence :

  1. un acteur modifie une source YAML acceptée par le service ;
  2. un pipeline, un agent ou une application charge le document ;
  3. le chargeur résout un tag actif ou épuise les ressources ;
  4. le processus atteint les secrets, le réseau ou le système de fichiers auxquels il est autorisé.

La contre-mesure ne consiste donc pas à chercher des motifs !!python dans les fichiers. Le filtrage textuel est contournable par les syntaxes du format, les encodages et les gadgets disponibles dans l’environnement d’exécution. Il faut interdire la capacité de construire des objets, puis réduire les privilèges du processus au cas où une autre faille passerait la revue.

Cette réduction de privilèges inclut un compte de service dédié, un système de fichiers en lecture seule lorsque possible, l’absence de shell dans l’image d’exécution, des secrets montés seulement dans les tâches qui en ont besoin et une sortie réseau restreinte. Les contrôles réseau restent une couche, pas une permission de conserver UnsafeLoader. Fermer une porte de sortie ne rend pas acceptable l’explosif posé dans le couloir.

Contre-mesures de chargement et validation de schéma

Section intitulée « Contre-mesures de chargement et validation de schéma »

La première mesure est de choisir le chargeur le moins expressif qui couvre le besoin. En Python, utiliser yaml.safe_load ou SafeLoader. En Java, configurer SnakeYAML avec une limite de code points, une limite d’alias et un constructeur qui n’autorise que les types attendus, puis vérifier les changements de comportement entre versions. En Ruby, employer YAML.safe_load avec une liste minimale de classes autorisées, idéalement vide.

Une liste de classes autorisées n’est justifiée que si le format doit réellement transporter des objets métier typés. Cette situation est rare pour de la configuration. Le gain est la désérialisation automatique ; le coût est une surface d’attaque liée aux constructeurs, aux accesseurs et aux dépendances transitives. Pour des paramètres, préférer des mappings primitifs et une conversion manuelle. Quelques lignes explicites coûtent moins qu’un inventaire de gadgets exploitables à chaque mise à jour.

La seconde mesure est la validation après parsing, mais avant utilisation métier. Définir les clés attendues, les types, les bornes numériques, les expressions régulières et l’interdiction des champs supplémentaires. Une configuration de déploiement doit échouer sur une clé inconnue, pas l’ignorer gentiment. Les fautes de frappe ne sont pas un protocole d’extension.

Un schéma peut être exprimé avec JSON Schema (JavaScript Object Notation Schema), Pydantic côté Python, ou un validateur métier dédié. Le choix dépend de l’écosystème. La règle reste identique : parser sans effets de bord, valider, convertir, puis agir. Ne jamais appeler un mécanisme de chargement générique avant cette étape sous prétexte que le schéma « rattrapera ».

Les entrées YAML doivent être traitées comme non fiables par défaut, y compris lorsqu’elles sont signées. Une signature atteste l’origine et l’intégrité selon la clé de signature ; elle n’atteste ni l’innocuité du contenu ni l’absence de compromission du signataire. La gestion de cette confiance rejoint la gouvernance des identités et la séparation des rôles de modification, de validation et d’exécution.

La supervision doit journaliser l’origine du document, son empreinte cryptographique, la version du parseur, le chargeur sélectionné et le résultat de validation. Ne pas journaliser le contenu complet s’il peut contenir des secrets. Une empreinte SHA-256 (Secure Hash Algorithm 256 bits) permet de corréler les exécutions sans transformer les journaux en coffre-fort sans serrure.

Détecter les tags interdits dans les dépôts et artefacts reste utile comme contrôle de prévention, à condition de le présenter pour ce qu’il est : un détecteur de fumée, pas un extincteur. Ajouter une règle de recherche sur !!python/, !!ruby/ et les tags spécifiques au parseur, puis bloquer les dépendances qui appellent des chargeurs non sûrs. Une analyse statique doit aussi chercher les appels directs à yaml.load, pas seulement les fichiers YAML.

En cas de suspicion d’exploitation, considérer l’identité du processus comme compromise. Révoquer et remplacer les jetons, mots de passe, clés d’API et certificats accessibles à ce processus. Faire tourner une clé dans un HSM (Hardware Security Module) limite l’export de la clé privée, mais ne protège pas les opérations autorisées qu’un processus compromis peut demander au HSM. Le module ne lit pas les intentions ; heureusement, sinon les comités d’architecture y brancheraient déjà un chatbot.

Préserver le document incriminé, les journaux et l’image de l’environnement avant nettoyage. Réinstaller depuis une image de référence, corriger le chargeur, ajouter un test de non-régression avec le tag malveillant, puis vérifier les comptes et connexions sortantes. Une restauration de sauvegarde sans rotation des secrets rejoue parfois l’attaque avec une décoration différente.

Ce mécanisme ne garantit pas la sécurité de la logique métier, du contrôle d’accès ou des dépendances. Un chargeur sûr n’empêche ni une injection de commande construite plus tard à partir d’un champ YAML, ni un secret exposé dans un journal, ni une autorisation abusive dans le pipeline. Il enlève une classe précise d’exécution au moment du parsing. C’est déjà beaucoup, mais ce n’est pas un exorcisme généraliste.

Sans chargeur limité aux types primitifs, chaque outil qui lit le même fichier hérite de la faille : générateur de documentation, hook de pré-commit, script de migration ou poste de développement qui ouvre le dépôt localement. Le document piégé reste versionné ; chaque relance de tâche, chaque réplica qui démarre et chaque retour arrière vers un commit contenant le tag déclenche de nouveau l’exécution, sans nouvelle action de l’attaquant. La trace applicative aide peu : la fonction appelée peut renvoyer une valeur conforme au schéma, si bien que la validation réussit et que les journaux métier n’enregistrent aucune anomalie. Délimiter la période d’exposition impose alors de retrouver, dans l’historique du dépôt, chaque exécution ayant lu une version contenant le tag.

Un fichier YAML sert à écrire des réglages sous forme de texte. Une application le lit, le transforme en listes, nombres, textes et associations entre un nom et une valeur, puis utilise ces réglages. Ce traitement doit s’arrêter là.

Certaines bibliothèques vont plus loin. Elles acceptent des marques spéciales dans le fichier. Ces marques peuvent demander au programme de créer un objet interne ou d’appeler une fonction déjà présente sur la machine. Si une personne peut modifier le fichier, elle peut alors choisir une fonction dangereuse, par exemple lancer une commande ou écrire un fichier. L’action se produit pendant la lecture du YAML, avant que l’application vérifie si les réglages ont une forme correcte.

La défense consiste d’abord à employer un mode de lecture qui refuse ces marques et ne produit que des données ordinaires. Ensuite, le programme vérifie que chaque nom attendu existe, que les valeurs ont le bon type et que les valeurs inconnues sont rejetées. Il faut aussi limiter la taille et la profondeur des fichiers pour éviter de bloquer la machine avec un document trop lourd.

Si un ancien mode de lecture dangereux a traité un fichier suspect, les mots de passe, clés et jetons accessibles au programme peuvent avoir été copiés. Il faut les remplacer, examiner les journaux, reconstruire l’environnement depuis une source connue et ajouter un test qui prouve que les marques dangereuses sont refusées.

À lire aussi

Pages voisines

: les deux pages se citent l’une l’autre.