
Laboratorio Docker autocontenido que demuestra CVE-2023-24329, un diferencial del analizador urllib de Python que evade los filtros de esquema y host de URL, con entornos vulnerables y parcheados para educación práctica en seguridad.
Solo con fines educativos. Este laboratorio existe para demostrar una vulnerabilidad real en un entorno seguro y aislado. No lo ejecutes nunca contra sistemas que no te pertenezcan. No reutilices el código de filtro intencionalmente roto en ningún sistema de producción.
Un laboratorio Docker autocontenido que demuestra CVE-2023-24329 — un diferencial de analizador en urllib.parse.urlparse() de Python que permite eludir filtros de esquema URL y host en Python < 3.11.4.
El laboratorio muestra una API que bloquea explícitamente las URL file:// y los nombres de host internos, siendo engañada para leer /etc/passwd desde su propio contenedor y acceder a un servicio privado interno — luego demuestra que el mismo exploit falla en Python parcheado.
urlparse() de Python y los recuperadores HTTP/archivo subyacentes no se ponen de acuerdo sobre cómo manejar las URL con espacios en blanco al inicio. En versiones afectadas:
from urllib.parse import urlparse
urlparse(" file:///etc/passwd").scheme # → "" (vacío — el filtro pasa)
urlparse(" file:///etc/passwd").hostname # → None (vacío — el filtro pasa)
Pero urllib.request.urlopen(" file:///etc/passwd") elimina el espacio y obtiene file:///etc/passwd de todas formas.
Esa brecha entre lo que el analizador ve y lo que el recuperador hace — esa es la vulnerabilidad.
Python 3.11.4 corrigió esto eliminando los espacios en blanco y caracteres de control al inicio antes de analizar, cerrando la brecha.
| Paso | Lo que ves | Lo que enseña |
|---|---|---|
| 1 — Línea base | file:///etc/passwd → 403 esquema bloqueado | El filtro parece razonable |
| 2 — Exploit | Misma URL con un prefijo de espacio → 200 + contenido de /etc/passwd y secreto interno | Un solo espacio derrota todo el filtro |
| 3 — Parche | Misma carga útil contra Python 3.11.4 → 403 bloqueado | urlparse parcheado elimina espacios primero; el filtro lo captura correctamente |
Cuatro servicios en una red Docker puente aislada (cve-lab-net):
| Servicio | Versión de Python | Rol | Puerto del host |
|---|---|---|---|
vulnerable-api | 3.11.3 | API objetivo con filtro URL ingenuo | 8000 |
fixed-api | 3.11.4 | Mismo código, intérprete parcheado | 8000 |
internal-service | 3.12 | Punto final falso de metadatos internos | ninguno |
attacker | 3.12 | Controlador de exploit | ninguno |
internal-service no tiene mapeo de puertos host — solo es accesible desde dentro de la red Docker, simulando un límite de confianza real.
git clone <url-del-repositorio>
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 (afectado)
├── docker-compose.fixed.yml # Python 3.11.4 (parcheado)
├── vulnerable-api/
│ ├── app.py # API Flask con el filtro ingenuo
│ ├── requirements.txt
│ └── Dockerfile
├── internal-service/
│ ├── app.py # Punto final falso de metadatos internos
│ ├── requirements.txt
│ └── Dockerfile
└── attacker/
├── exploit.py # Controlador de demo (línea base / exploit / verificar)
├── requirements.txt
└── Dockerfile
Los servicios de API vulnerable y fija comparten el mismo código fuente — solo difiere la versión de Python de la imagen base. Esta es la propiedad clave de control científico del laboratorio.
El filtro de la API vulnerable (simplificado):
parsed = urllib.parse.urlparse(url)
if parsed.scheme.lower() in {"file", "gopher", "ftp", "data"}:
return 403 # bloqueado
if parsed.hostname in {"localhost", "127.0.0.1", "internal-service"}:
return 403 # bloqueado
urllib.request.urlopen(url) # obtiene la cadena original sin modificar
La carga útil de evasión es un único espacio al inicio:
file:///etc/passwd
^
espacio (0x20)
En Python ≤ 3.11.3, urlparse ve un esquema vacío y ningún hostname → el filtro pasa. urlopen elimina el espacio → obtiene file:///etc/passwd.
En Python ≥ 3.11.4, urlparse primero elimina el espacio → ve correctamente scheme=file → el filtro bloquea con 403.
CPython issue #102153 — la corrección elimina los caracteres de control C0 y espacios del inicio de la URL antes de analizar. Después del parche, tanto el analizador como el recuperador coinciden en cuál es la URL, por lo que el filtro no puede ser evadido de esta manera.
El patrón defensivo correcto independientemente de la versión de Python:
# Analizar → reconstruir a partir de partes → pasar la URL reconstruida downstream.
# Tanto el filtro como el recuperador operan sobre la misma cadena.
parsed = urllib.parse.urlparse(url)
safe_url = parsed.geturl() # reconstruida a partir de componentes
urllib.request.urlopen(safe_url)
${jndi:...} de log4j.internal-service al host.vulnerable-api/app.py está intencionalmente roto con fines educativos — no lo copies en ningún sistema real.MIT — libre de usar, compartir y adaptar con fines educativos con atribución.