● CWE-22 · CVE-2007-4559

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 tarfile de 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 appelaient extractall() sur des archives non fiables.
Noms de membres avec .. ou chemins absolus
Une entrée tar nommée ../outside.txt ou /etc/cron.d/job se 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

Étape 1

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.

Étape 2

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.

Étape 3

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.

Étape 4

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é

IMPLÉMENTATION VULNÉRABLE
# 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
PATCH SÉCURISÉ ET ROBUSTE
# 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

Sources

← Parcourir tout l'annuaire sécurité Tous les guides de vulnérabilités →