Traversée de chemin dans tarfile.extractall de Python (CVE-2007-4559) : comment elle survient et comment la corriger
Avant les filtres d'extraction, tarfile.extractall() écrivait chaque membre au chemin stocké dans l'archive : des noms avec .., des chemins absolus ou des liens pouvaient donc placer des fichiers hors du dossier cible. Comment l'argument filter="data" de la PEP 706 l'empêche, pourquoi la destination ne doit pas venir d'une entrée utilisateur et quelles limites de taille ajouter, avec un exemple Python.
Explication en Langage Simple (ELI5)
Une entreprise de déménagement déballe des cartons dans votre nouvel appartement et pose chaque objet là où l'indique son étiquette. Une étiquette qui dit « cuisine, puis par la porte d'entrée, puis dans la chambre du voisin » est suivie à la lettre, car les déménageurs ne lisent que les étiquettes et ne vérifient jamais que l'adresse reste dans votre appartement. Une archive fonctionne de la même façon : chaque fichier porte son propre chemin de destination. La correction est un chef d'équipe qui lit chaque étiquette d'abord et refuse tout ce qui sortirait de l'appartement, ainsi que tout ce qui n'est pas un carton ordinaire, comme un panneau pointant ailleurs.
Concepts Clés et Termes
- CVE-2007-4559
- Le module
tarfilede Python n'empêchait pas les noms de membres de sortir du répertoire d'extraction. Le problème était connu depuis 2007, et en 2022 Trellix estimait qu'environ 350 000 projets open source appelaientextractall()sur des archives non fiables. - Noms de membres avec .. ou chemins absolus
- Une entrée tar nommée
../outside.txtou/etc/cron.d/jobse résout hors de la destination une fois jointe à celle-ci. Sans filtre,extractall()écrit à ce chemin résolu avec les droits du processus. - Membres liens symboliques ou physiques
- Une archive peut d'abord créer dans la destination un lien pointant ailleurs, puis écrire un fichier ordinaire à travers lui. Vérifier les seuls noms ne le détecte pas, c'est pourquoi les filtres examinent aussi la cible des liens.
- Filtres d'extraction (PEP 706)
filter="data"refuse les noms absolus, les échappements par.., les liens qui sortent de la destination et les fichiers de périphérique, et retire les bits set-user-ID et d'exécution pour les autres. Il est disponible depuis Python 3.12 et dans les versions de sécurité 3.8 à 3.11, et devient le comportement par défaut en Python 3.14.- Limites de ressources
- Les filtres ne limitent pas la taille. Une petite archive compressée peut se déployer en millions de membres ou en gigaoctets, donc comptez les membres et la taille totale avant d'extraire.
Déroulement de l'Attaque Étape par Étape
L'application accepte une archive envoyée
Un import de données, un installateur de plugins ou un gestionnaire d'artefacts de CI reçoit un .tar.gz d'un utilisateur.
Un nom de membre pointe hors du dossier
Le chemin stocké d'une entrée remonte hors de la destination ou est absolu. Une archive de test avec un membre nommé ../outside.txt illustre le cas.
extractall écrit au chemin résolu
Le fichier atterrit là où le nom se résout, et la destination elle-même peut venir d'un job_id fourni par l'utilisateur.
Des fichiers de l'application sont remplacés
La configuration, les templates ou les scripts écrasés s'exécutent la prochaine fois que l'application les charge : c'est ainsi que cette traversée de chemin devient une exécution de code.
Code Source : Vulnérable vs Sécurisé
# imports.py : chaque membre va là où pointe son nom stocké
import tarfile
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
# dossier choisi par l'appelant, sans filtre ni limite de taille
def import_bundle(archive_path: str, job_id: str) -> Path:
dest = UPLOAD_ROOT / job_id
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path) as tar:
tar.extractall(dest)
return dest
# imports.py : filtre data, dossier nommé par le serveur et limites de taille
import tarfile
import uuid
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
MAX_MEMBERS = 10_000
MAX_TOTAL_BYTES = 500 * 1024 * 1024
# refuse les archives avec trop de membres ou trop d'octets
def check_limits(tar: tarfile.TarFile) -> None:
members = tar.getmembers()
if len(members) > MAX_MEMBERS or sum(m.size for m in members) > MAX_TOTAL_BYTES:
raise ValueError("archive too large")
# filter='data' bloque .., noms absolus, liens sortants et fichiers de périphérique
def import_bundle(archive_path: str) -> Path:
dest = UPLOAD_ROOT / uuid.uuid4().hex
dest.mkdir(parents=True)
with tarfile.open(archive_path, mode="r:*") as tar:
check_limits(tar)
tar.extractall(dest, filter="data")
return dest
Liste de Contrôle de Sécurité pour l'Ingénierie
- Passez
filter="data"à chaque appelextractall()etextract(), et faites échouer la CI quand Bandit B202 ou une règle Semgrep en trouve un sansfilter=. - Extrayez dans un nouveau dossier nommé par le serveur, par exemple un UUID, jamais dans un chemin construit à partir de la requête.
- Vérifiez le nombre de membres et la taille totale décompressée avant d'extraire, et refusez les archives qui dépassent les limites.
- Exécutez le processus d'extraction sous un utilisateur sans droit d'écriture sur le code ou la configuration de l'application.
- Ajoutez un test CI avec une archive contenant
../outside.txtet un membre à chemin absolu, et attendeztarfile.FilterError.