用内网穿透安全地部署 LLM 服务

First Post:
Last Update:
Word Count:
2.9k
Read Time:
12 min
Page View: loading...

写在前面:vllm 潜在的高危漏洞

先说结论:不要直接把 LLM 服务(vLLM、Ollama、Jupyter 等)通过 FRP 的 tcp 模式暴露到公网

  1. vLLM 默认无认证:只要能连上端口就能调 API。
  2. vLLM 历史 RCE 漏洞:CVE-2025-62164、CVE-2025-9141、CVE-2025-66448 等,CVSS 都在 8.0+,通过 prompt embedding、tool call、auto_map 都能 RCE。
  3. 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

整条路径上有四把锁:

  1. SSH 密钥登录(防爆破)
  2. FRP token(防恶意客户端注册)
  3. stcp secretKey(防未授权访问 LLM)
  4. 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 客户端部署脚本(stcp 模式 + 多重加固)
# 用法:
# 1. 把下面三个变量从环境变量或外部文件读入,不要硬编码到脚本里
# export FRP_SERVER_ADDR="your.server.ip"
# export FRP_AUTH_TOKEN="..." # openssl rand -hex 32
# export FRP_STCP_SECRET="..." # openssl rand -hex 32
# 2. bash deploy_frpc.sh
# ============================================================

# ---------- 1. 校验必需的环境变量 ----------
: "${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)}"

# ---------- 2. FRP 版本与下载校验 ----------
FRP_VERSION="0.61.0" # 用较新版本,老版本有 CVE
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}"
# 从 GitHub Release 页面 hashes 文件里复制对应版本的 sha256,避免供应链劫持
EXPECTED_SHA256="<paste-sha256-from-github-release-here>"

# ---------- 3. FRP 服务端配置 ----------
SERVER_PORT=7000

# ---------- 4. 转发的本地 LLM 服务 ----------
PROXY_NAME="llm-service"
LOCAL_IP="127.0.0.1" # 关键:vLLM 必须只监听 127.0.0.1
LOCAL_PORT=8000

# ---------- 5. 下载 + 校验 ----------
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"

# ---------- 6. 生成 frpc.toml(stcp 模式)----------
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

# ---------- 7. 用 systemd 托管,不要 nohup ----------
# nohup 启动的进程没有重启策略、没有日志规范、没有用户隔离
# 强烈建议改成 systemd 服务(见下文 systemd 单元文件示例)
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
# ~/frp/frpc-visitor.toml
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" # 要和 GPU 主机的 proxy name 一致
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" # openssl rand -hex 32

# 强制 TLS,拒绝非 TLS 的 frpc 连入
transport.tls.force = true

# 限制可分配端口范围;stcp 用不到,但万一以后加 tcp 代理时也有约束
allowPorts = [
{ start = 10000, end = 10010 }
]

# Dashboard 仅本地可访问,需要时通过 SSH 隧道看
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
# /etc/systemd/system/frps.service
[Unit]
Description=FRP Server
After=network.target

[Service]
Type=simple
User=frps # 专用低权限用户,不要用 root
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

# 只放 SSH(建议加源 IP 限制,<your-home-ip> 改成你的固定出口 IP)
sudo ufw allow from <your-home-ip> to any port 22

# FRP 控制端口(如果你的 GPU 主机出口 IP 固定,也加 from 限制)
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
# 1. 用稳定版(截至 2026-05,至少 0.11.1+,避免已知 RCE)
pip install --upgrade vllm

# 2. 用专用低权限用户启动,不要 root
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)"
'

三个要点:

  1. --host 127.0.0.1:只监听本地,所有外部访问必须经过 FRP,多一道控制。
  2. --api-key:vLLM 默认无认证,必须显式启用。
  3. 专用用户启动:万一 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-66448auto_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 模式怎么办

这不是危言耸听——建议立刻:

  1. 先停服务pkill -f frpc && pkill -f vllm
  2. 检查恶意进程ps auxf | grep -E "softirq|kobjotw|rondo|xmrig",重点看伪装成内核线程但不带方括号的进程。
  3. 检查持久化/etc/init.d//etc/cron.d//etc/systemd/system/、所有用户的 crontab -l
  4. 如果发现感染,重装系统比清理更稳妥——挖矿木马是表象,攻击者拿到 root 后可能装了 rootkit、SSH 后门、PAM 后门,手动清理几乎不可能彻底。
  5. 重装后按本文方案重新部署。
If you find this helpful, please give my project a ⭐on GitHub! =w=/