
Laboratório Docker autossuficiente que demonstra o CVE-2023-24329, uma diferença de parser do Python urllib que contorna filtros de esquema e host de URL, com ambientes vulneráveis e corrigidos para aprendizado prático em segurança.
Apenas para uso educacional. Este laboratório existe para demonstrar uma vulnerabilidade real em um ambiente seguro e isolado. Nunca execute isso contra sistemas que você não possui. Nunca reutilize o código de filtro intencionalmente quebrado em qualquer sistema de produção.
Um laboratório Docker autocontido demonstrando a CVE-2023-24329 — um diferencial de parser no urllib.parse.urlparse() do Python que permite contornar filtros de esquema de URL e host no Python < 3.11.4.
O laboratório mostra uma API que bloqueia explicitamente URLs file:// e nomes de host internos sendo enganada para ler /etc/passwd de seu próprio contêiner e acessar um serviço interno privado — e então prova que o mesmo exploit falha no Python corrigido.
O urlparse() do Python e os buscadores HTTP/arquivo subjacentes discordam sobre como lidar com URLs com espaços iniciais. Nas versões afetadas:
from urllib.parse import urlparse
urlparse(" file:///etc/passwd").scheme # → "" (vazio — filtro passa)
urlparse(" file:///etc/passwd").hostname # → None (vazio — filtro passa)
Mas urllib.request.urlopen(" file:///etc/passwd") remove o espaço e busca file:///etc/passwd de qualquer forma.
Essa lacuna entre o que o parser vê e o que o buscador faz — essa é a vulnerabilidade.
Python 3.11.4 corrigiu isso removendo espaços iniciais e caracteres de controle antes do parsing, fechando a lacuna.
| Cenário | O que você vê | O que ensina |
|---|---|---|
| 1 — Linha de base | file:///etc/passwd → 403 esquema bloqueado | O filtro parece razoável |
| 2 — Exploit | Mesma URL com prefixo de espaço → 200 + conteúdo de /etc/passwd e segredo interno | Um único espaço derruba o filtro inteiro |
| 3 — Correção | Mesmo payload contra Python 3.11.4 → 403 bloqueado | urlparse corrigido remove espaços primeiro; filtro o captura corretamente |
Quatro serviços em uma rede Docker bridge isolada (cve-lab-net):
| Serviço | Versão Python | Função | Porta do host |
|---|---|---|---|
vulnerable-api | 3.11.3 | API alvo com filtro de URL ingênuo | 8000 |
fixed-api | 3.11.4 | Mesmo código, interpretador corrigido | 8000 |
internal-service | 3.12 | Endpoint de metadados interno falso | nenhuma |
attacker | 3.12 | Driver de exploração | nenhuma |
internal-service não tem mapeamento de porta do host — é acessível apenas de dentro da rede Docker, simulando um limite de confiança real.
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 (afetado)
├── docker-compose.fixed.yml # Python 3.11.4 (corrigido)
├── vulnerable-api/
│ ├── app.py # API Flask com o filtro ingênuo
│ ├── requirements.txt
│ └── Dockerfile
├── internal-service/
│ ├── app.py # Endpoint de metadados interno falso
│ ├── requirements.txt
│ └── Dockerfile
└── attacker/
├── exploit.py # Driver de demonstração (baseline / exploit / verify)
├── requirements.txt
└── Dockerfile
Os serviços API vulnerável e corrigido compartilham o mesmo código-fonte — apenas a versão do Python na imagem base difere. Esta é a propriedade de controle científico chave do laboratório.
O filtro da API vulnerável (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) # busca a string original e não modificada
O payload de bypass é um único espaço inicial:
file:///etc/passwd
^
espaço (0x20)
No Python ≤ 3.11.3, urlparse vê esquema vazio e sem hostname → filtro passa. urlopen remove o espaço → busca file:///etc/passwd.
No Python ≥ 3.11.4, urlparse remove o espaço primeiro → vê corretamente scheme=file → filtro bloqueia com 403.
CPython issue #102153 — a correção remove caracteres de controle C0 e espaços do início da URL antes de fazer o parsing. Após a correção, tanto o parser quanto o buscador concordam sobre o que é a URL, então o filtro não pode ser contornado dessa forma.
O padrão defensivo correto independentemente da versão do Python:
# Parse → reconstruir a partir das partes → passar a URL reconstruída downstream.
# Tanto o filtro quanto o buscador operam então na mesma string.
parsed = urllib.parse.urlparse(url)
safe_url = parsed.geturl() # reconstruído a partir dos componentes
urllib.request.urlopen(safe_url)
${jndi:...} do log4j.internal-service ao host.vulnerable-api/app.py está deliberadamente quebrado para fins de ensino — não o copie para nenhum sistema real.MIT — livre para usar, compartilhar e adaptar para fins educacionais com atribuição.