写在前面:vllm 潜在的高危漏洞
先说结论:不要直接把 LLM 服务(vLLM、Ollama、Jupyter 等)通过 FRP 的 tcp 模式暴露到公网:
- vLLM 默认无认证:只要能连上端口就能调 API。
- vLLM 历史 RCE 漏洞:CVE-2025-62164、CVE-2025-9141、CVE-2025-66448 等,CVSS 都在 8.0+,通过 prompt embedding、tool call、auto_map 都能 RCE。
- FRP
tcp 模式直接在公网开端口:扫描器几小时内就能扫到 OpenAI 兼容 API。
任何一个单独存在都还能扛,三个叠在一起就是灾难。这篇文章把整套方案重写成安全版本。
整体架构
我们用 FRP 的 stcp(secret tcp) 模式,而不是 tcp 模式。区别:
| 模式 |
公网端口 |
谁能访问 |
安全性 |
tcp |
中转服务器开放固定端口(如 10001) |
任何能扫到这个端口的人 |
❌ 等于裸奔 |
stcp |
不开任何公网端口 |
必须持有 secretKey 的 visitor |
✅ 公网零暴露 |
最终的访问链路:
1 2 3 4 5 6 7 8 9 10 11
| [你的笔记本/工作机] └─ frpc visitor (持有 secretKey) │ 通过中转服务器建立加密隧道 ↓ [中转服务器 frps] ← 只开 SSH (22) 和 FRP 控制端口 (7000),无业务端口 ↑ │ vLLM 主机主动连出 [GPU 主机 vLLM] └─ frpc (持有同样的 secretKey) └─ vLLM 监听 127.0.0.1:8888 (不对外) └─ vLLM 启用 API key
|
整条路径上有四把锁:
- SSH 密钥登录(防爆破)
- FRP token(防恶意客户端注册)
- stcp secretKey(防未授权访问 LLM)
- vLLM API key(即便前面都漏了,API 层还有一道)
一、客户端部署脚本(GPU 主机)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| #!/bin/bash set -euo pipefail
: "${FRP_SERVER_ADDR:?need FRP_SERVER_ADDR}" : "${FRP_AUTH_TOKEN:?need FRP_AUTH_TOKEN (openssl rand -hex 32)}" : "${FRP_STCP_SECRET:?need FRP_STCP_SECRET (openssl rand -hex 32)}"
FRP_VERSION="0.61.0" FRP_TAR_FILE="frp_${FRP_VERSION}_linux_amd64.tar.gz" FRP_DIR="frp_${FRP_VERSION}_linux_amd64" DOWNLOAD_URL="https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${FRP_TAR_FILE}"
EXPECTED_SHA256="<paste-sha256-from-github-release-here>"
SERVER_PORT=7000
PROXY_NAME="llm-service" LOCAL_IP="127.0.0.1" LOCAL_PORT=8000
if [ ! -d "$FRP_DIR" ]; then echo "[+] Downloading FRP ${FRP_VERSION}..." wget -q --show-progress "$DOWNLOAD_URL"
if [ "$EXPECTED_SHA256" != "<paste-sha256-from-github-release-here>" ]; then echo "[+] Verifying SHA256..." echo "${EXPECTED_SHA256} ${FRP_TAR_FILE}" | sha256sum -c - else echo "[!] SHA256 not set. Strongly recommend pinning EXPECTED_SHA256." fi
tar -zxf "$FRP_TAR_FILE" else echo "[=] FRP directory already exists, skipping download." fi
cd "$FRP_DIR"
echo "[+] Generating frpc.toml..." cat > frpc.toml <<EOF serverAddr = "${FRP_SERVER_ADDR}" serverPort = ${SERVER_PORT}
auth.method = "token" auth.token = "${FRP_AUTH_TOKEN}"
# 强制 TLS,加密 frpc <-> frps 的控制信道 transport.tls.enable = true transport.tls.disableCustomTLSFirstByte = false
# 日志(限制级别和大小,避免被攻击者翻历史请求 / 被日志炸盘) log.to = "./frpc.log" log.level = "info" log.maxDays = 7
[[proxies]] name = "${PROXY_NAME}" # 关键:用 stcp(secret tcp)而不是 tcp # 公网不会有任何端口开放,只有持有 secretKey 的 visitor 能连上 type = "stcp" secretKey = "${FRP_STCP_SECRET}" localIP = "${LOCAL_IP}" localPort = ${LOCAL_PORT} EOF
chmod 600 frpc.toml
echo "[+] Stopping existing frpc instances..." pkill -f "frpc -c ./frpc.toml" 2>/dev/null || true
echo "[+] Starting frpc..." nohup ./frpc -c ./frpc.toml > /dev/null 2>&1 &
cat <<EOF ------------------------------------------------------ Deployment complete (stcp mode).
The service is NOT exposed on any public port. To access it from another machine, run an frpc visitor with the same secretKey on that client.
Local logs: $(pwd)/frpc.log Config (600): $(pwd)/frpc.toml ------------------------------------------------------ EOF
|
几个关键变化:
- secret 不再硬编码到脚本里,从环境变量读取。脚本可以放进 Git,密钥不会泄露。
- FRP 版本升级到 0.61.0,并加 SHA256 校验防止下载被劫持。
type = "tcp" → type = "stcp",删除了 remotePort,公网零端口暴露。
- 强制 TLS,加密控制信道。
localIP 必须配合 vLLM 的 --host 127.0.0.1,否则等于白配。
二、访问端的 visitor 配置(你的工作机/笔记本)
stcp 模式需要在访问 LLM 的那台机器上跑一个 visitor。这是新增的、原来没有的步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| serverAddr = "your.server.ip" serverPort = 7000
auth.method = "token" auth.token = "和 GPU 主机相同的 FRP_AUTH_TOKEN"
transport.tls.enable = true
[[visitors]] name = "llm-visitor" type = "stcp" serverName = "llm-service" secretKey = "和 GPU 主机相同的 FRP_STCP_SECRET" bindAddr = "127.0.0.1" bindPort = 8888
|
启动后,本地访问 http://127.0.0.1:8888 就等于访问 GPU 主机的 vLLM。整条链路对公网完全不可见。
三、服务端(中转 VPS)
frps.toml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| bindPort = 7000
auth.method = "token" auth.token = "和客户端相同的 FRP_AUTH_TOKEN"
transport.tls.force = true
allowPorts = [ { start = 10000, end = 10010 } ]
webServer.addr = "127.0.0.1" webServer.port = 7500 webServer.user = "admin" webServer.password = "<another-strong-random-password>"
log.to = "/var/log/frps.log" log.level = "info" log.maxDays = 30
|
注意点:
auth.token 必须用 openssl rand -hex 32 生成的真随机串,不要用 your_secure_token 这种占位符(我之前就是这么挂的)。
webServer.addr = "127.0.0.1":dashboard 只听本地,如果你想看,本地起 SSH 隧道:ssh -L 7500:127.0.0.1:7500 user@your.server.ip。
transport.tls.force = true:拒绝任何明文连接。
systemd 单元文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| [Unit] Description=FRP Server After=network.target
[Service] Type=simple User=frps Group=frps WorkingDirectory=/opt/frp ExecStart=/opt/frp/frps -c /opt/frp/frps.toml Restart=always RestartSec=3
NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/log CapabilityBoundingSet=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install] WantedBy=multi-user.target
|
部署:
1 2 3 4 5
| sudo useradd -r -s /usr/sbin/nologin frps sudo chown -R frps:frps /opt/frp sudo chmod 600 /opt/frp/frps.toml sudo systemctl daemon-reload sudo systemctl enable --now frps
|
中转服务器防火墙(关键!)
1 2 3 4 5 6 7 8 9 10 11
| sudo ufw default deny incoming sudo ufw default allow outgoing
sudo ufw allow from <your-home-ip> to any port 22
sudo ufw allow from <gpu-host-ip> to any port 7000
sudo ufw enable
|
stcp 模式下不需要开放业务端口,这是它最大的安全优势。
四、配套的 vLLM 启动方式
FRP 只是网络层。LLM 服务本身的安全配置同样关键,否则前面的加固都白费:
1 2 3 4 5 6 7 8 9 10 11 12
| pip install --upgrade vllm
sudo useradd -m -s /bin/bash vllmuser sudo -u vllmuser bash -c ' python -m vllm.entrypoints.openai.api_server \ --model your_model \ --host 127.0.0.1 \ --port 8000 \ --api-key "$(openssl rand -hex 32 | tee ~/.vllm_api_key)" '
|
三个要点:
--host 127.0.0.1:只监听本地,所有外部访问必须经过 FRP,多一道控制。
--api-key:vLLM 默认无认证,必须显式启用。
- 专用用户启动:万一 vLLM 出新 CVE 被 RCE,攻击者拿到的也只是
vllmuser 权限,不是 root。这是这次事故的重要教训——我之前的 vLLM 是用 root 跑的,RCE 直接等于 root。
五、安全隐患说明(也就是原版方案为什么会被攻陷)
把原版方案的每个隐患明确列出来,作为反面教材:
隐患 1:type = "tcp" 直接在公网开端口
原版用 tcp 模式,中转服务器会监听 remotePort = 10001。互联网上的端口扫描器(Shodan、Censys、各种自动化爬虫)几小时内就能发现这个端口。一旦识别出是 OpenAI 兼容 API,就会被自动化工具尝试漏洞利用。
修复:改用 stcp,公网零端口。
隐患 2:vLLM 自身无认证 + 已知 RCE
vLLM 默认不需要任何认证。即使你信任所有能扫到端口的人不会乱用算力,也防不住已知漏洞:
- CVE-2025-62164(CVSS 8.8):通过 prompt embedding 触发
torch.load() RCE,影响 0.10.2 ~ 0.11.0
- CVE-2025-9141:tool call 反序列化 RCE,影响 0.10.0 ~ 0.10.1.1
- CVE-2025-66448:
auto_map 配置 RCE,影响所有 < 0.11.1
- ShadowMQ 系列:ZMQ + pickle 反序列化 RCE
修复:升级到 ≥ 0.11.1,启用 --api-key,绑定 127.0.0.1,用低权限用户跑。
隐患 3:硬编码的弱 token
原版的 AUTH_TOKEN="your_secure_token",如果忘了改,任何人都能注册 frpc 客户端连到你的 frps。
修复:从环境变量读取,openssl rand -hex 32 生成强随机串,配置文件 chmod 600。
隐患 4:明文 TCP 控制信道
原版没启用 TLS。frpc 和 frps 之间的控制信道明文传输,中间人可以嗅探 token、看到代理元数据。
修复:客户端 transport.tls.enable = true,服务端 transport.tls.force = true。
隐患 5:nohup 启动 + root 跑
原版用 nohup ./frpc &,问题:进程崩了不会重启、日志无管理、跟 shell 生命周期耦合、通常是以当前用户(可能是 root)跑。
修复:systemd 服务 + 专用低权限用户 + 资源/能力限制。
隐患 6:FRP 版本过旧
原版用 0.56.0。FRP 本身偶尔也出安全更新。
修复:跟进新版本,定期 wget 新 release + SHA256 校验。
隐患 7:中转服务器无防火墙
如果中转 VPS 上还有别的服务(数据库管理面板、redis、什么测试 API),它们也跟 frps 一起暴露在公网。
修复:默认 deny incoming,只白名单放行必要端口。
总结:四道锁的成本
完整方案比原版多了几个步骤,但一次配好可以用很久:
| 加固项 |
一次性成本 |
防住的事 |
| stcp 模式 |
改几行配置 + 客户端配 visitor |
所有公网扫描和未授权访问 |
| FRP TLS |
改两行配置 |
token 嗅探、中间人 |
| vLLM API key + 127.0.0.1 |
启动加两个参数 |
API 层未授权调用 |
| vLLM 低权限用户 |
创建一个用户 |
RCE 时不直接 root |
| systemd + 防火墙 |
几分钟 |
进程稳定性、其他服务暴露 |
代价是几十分钟的部署时间,换来的是不会半夜被矿工把 GPU 卡满。
附:如果你已经在跑 tcp 模式怎么办
这不是危言耸听——建议立刻:
- 先停服务:
pkill -f frpc && pkill -f vllm
- 检查恶意进程:
ps auxf | grep -E "softirq|kobjotw|rondo|xmrig",重点看伪装成内核线程但不带方括号的进程。
- 检查持久化:
/etc/init.d/、/etc/cron.d/、/etc/systemd/system/、所有用户的 crontab -l。
- 如果发现感染,重装系统比清理更稳妥——挖矿木马是表象,攻击者拿到 root 后可能装了 rootkit、SSH 后门、PAM 后门,手动清理几乎不可能彻底。
- 重装后按本文方案重新部署。