
Laboratoire Docker autonome démontrant CVE-2023-24329, une différentielle du parseur urllib Python qui contourne les filtres de schéma d'URL et d'hôte, avec des environnements vulnérables et corrigés pour un apprentissage pratique de la sécurité.
Usage éducatif uniquement. Ce laboratoire existe pour démontrer une vulnérabilité réelle dans un environnement sûr et isolé. Ne l'exécutez jamais contre des systèmes qui ne vous appartiennent pas. Ne réutilisez jamais le code de filtrage volontairement cassé dans un système de production.
Un laboratoire Docker autonome démontrant CVE-2023-24329 — une différence de parsing dans urllib.parse.urlparse() de Python qui permet de contourner les filtres de schéma d'URL et d'hôte sur Python < 3.11.4.
Le laboratoire montre une API qui bloque explicitement les URLs file:// et les noms d'hôte internes, qui est trompée pour lire /etc/passwd depuis son propre conteneur et atteindre un service interne privé — puis prouve que la même exploitation échoue sur Python corrigé.
urlparse() de Python et les récupérateurs HTTP/fichiers sous-jacents ne sont pas d'accord sur la façon de gérer les URLs avec des espaces en début de chaîne. Sur les versions affectées :
from urllib.parse import urlparse
urlparse(" file:///etc/passwd").scheme # → "" (vide — le filtre passe)
urlparse(" file:///etc/passwd").hostname # → None (vide — le filtre passe)
Mais urllib.request.urlopen(" file:///etc/passwd") supprime l'espace et récupère quand même file:///etc/passwd.
Cet écart entre ce que le parseur voit et ce que le récupérateur fait — voilà la vulnérabilité.
Python 3.11.4 a corrigé cela en supprimant les espaces/caractères de contrôle en début de chaîne avant le parsing, fermant ainsi l'écart.
Quatre services sur un réseau pont Docker isolé (cve-lab-net) :
internal-service n'a pas de mapping de port hôte — il est accessible uniquement depuis l'intérieur du réseau Docker, simulant une véritable frontière de confiance.
git clone <repo-url>
cd CVE-2023-24329-lab
docker compose -f docker-compose.vulnerable.yml up --build -d
docker compose -f docker-compose.vulnerable.yml exec attacker python exploit.py baseline

docker compose -f docker-compose.vulnerable.yml exec attacker python exploit.py exploit

docker compose -f docker-compose.vulnerable.yml down
docker compose -f docker-compose.fixed.yml up --build -d
docker compose -f docker-compose.fixed.yml exec attacker python exploit.py verify

docker compose -f docker-compose.fixed.yml down
CVE-2023-24329-lab/
├── docker-compose.vulnerable.yml # Python 3.11.3 (affecté)
├── docker-compose.fixed.yml # Python 3.11.4 (corrigé)
├── vulnerable-api/
│ ├── app.py # API Flask avec le filtre naïf
│ ├── requirements.txt
│ └── Dockerfile
├── internal-service/
│ ├── app.py # Faux point de terminaison de métadonnées internes
│ ├── requirements.txt
│ └── Dockerfile
└── attacker/
├── exploit.py # Pilote de démo (base / exploitation / vérification)
├── requirements.txt
└── Dockerfile
Les services API vulnérable et corrigé partagent le même code source — seule la version Python de l'image de base diffère. C'est la propriété clé de contrôle scientifique du laboratoire.
Le filtre de l'API vulnérable (simplifié) :
parsed = urllib.parse.urlparse(url)
if parsed.scheme.lower() in {"file", "gopher", "ftp", "data"}:
return 403 # bloqué
if parsed.hostname in {"localhost", "127.0.0.1", "internal-service"}:
return 403 # bloqué
urllib.request.urlopen(url) # récupère la chaîne originale non modifiée
La charge utile de contournement est un seul espace en tête :
file:///etc/passwd
^
espace (0x20)
Sur Python ≤ 3.11.3, urlparse voit un schéma vide et aucun nom d'hôte → le filtre passe. urlopen supprime l'espace → récupère file:///etc/passwd.
Sur Python ≥ 3.11.4, urlparse supprime d'abord l'espace → voit correctement scheme=file → le filtre bloque avec 403.
CPython issue #102153 — le correctif supprime les caractères de contrôle C0 et les espaces du début de l'URL avant le parsing. Après le correctif, le parseur et le récupérateur s'accordent sur ce qu'est l'URL, donc le filtre ne peut pas être contourné de cette façon.
Le modèle défensif correct indépendamment de la version de Python :
# Analyser → reconstruire à partir des parties → passer l'URL reconstruite en aval.
# Le filtre et le récupérateur opèrent alors sur la même chaîne.
parsed = urllib.parse.urlparse(url)
safe_url = parsed.geturl() # reconstruit à partir des composants
urllib.request.urlopen(safe_url)
${jndi:...}.internal-service à l'hôte.vulnerable-api/app.py est volontairement cassé à des fins pédagogiques — ne le copiez pas dans un système réel.MIT — libre d'utilisation, de partage et d'adaptation à des fins éducatives avec attribution.
| Étape | Ce que vous voyez | Ce que cela enseigne |
|---|
| 1 — Référence | file:///etc/passwd → 403 blocked scheme | Le filtre semble raisonnable |
| 2 — Exploitation | Même URL avec un préfixe d'espace → 200 + contenu de /etc/passwd et secret interne | Un seul espace bat tout le filtre |
| 3 — Correctif | Même charge utile contre Python 3.11.4 → 403 bloqué | urlparse corrigé supprime d'abord les espaces ; le filtre le rattrape correctement |
| Service | Version Python | Rôle | Port hôte |
|---|
vulnerable-api | 3.11.3 | API cible avec filtre d'URL naïf | 8000 |
fixed-api | 3.11.4 | Même code, interpréteur corrigé | 8000 |
internal-service | 3.12 | Faux point de terminaison de métadonnées internes | aucun |
attacker | 3.12 | Pilote d'exploitation | aucun |