给 QQ bot 加个功能,顺着一排 migration 抓出两个矿工:我这需求又做偏了
Table of Contents
下面按当天的现场记录整理,时间都是北京时间,账号和基础设施信息已经脱敏。处置状态截至当天晚上——事情还没结案。
2026年9月28日
我本来在给一个 QQ bot 加功能。
她叫 Krypton,平时我们叫她“傻氪”。那天的目标是:让她能发语音、能自己查网页、做长任务时知道自己还剩多少预算,必要的时候先回一句,再接着干活。
然后我看了一眼服务器。
……怎么这么多 migration?CPU 怎么这么高?
几个小时以后,我手上多了两条入侵时间线、一包隔离起来的矿工,还有这篇原本完全没打算写的东西。
bot 还没学会主动汇报,服务器倒先用一百多个核,跟我汇报了它的副业。
一开始,真的只是一个不会发语音的 bot
最早的报错很具体:语音发不出去,接口返回 ActionFailed。在 tmux 里找到跑 bot 的那个窗口,顺着语音文件的位置和发送链路查下去,是路径对不上。修好以后,QQ 上收到了她的语音。
接下来是聊天体验。她一不确定,就会冒出一句“这部分我还没核实清楚,先不乱下结论”。一两次还好,次次都这样,就像群友聊着聊着突然换上了客服制服。
于是把固定的兜底话术改成结合上下文生成、再核验一遍。我当时定的规矩是:生成或核验失败,那就先不回,别拿模板来凑数。(这一条后来很重要——有些时候她不说话,是失败处理的结果,不是她不想理人。)
还清掉了一个遗留的语音诊断插件。任务早就完成了,它还在响应消息,往一个不存在的临时目录里写文件。删了插件得重启进程——磁盘上的文件是没了,内存里那个实例可不会自己走。
然后给她加了一个叫 Bash 的工具,先只放受限的 curl:公网 HTTP/HTTPS、GET/HEAD、响应大小有上限。命令权限由宿主管着,不能让一条聊天消息把它升级成任意 shell。
她本来就有 Tavily 搜索。我想看的是:给她一个稍微复杂点的任务,她会不会自己把搜索和读网页组合起来,而不是我们事先替她排好步骤。
实际一跑,工具是调了,结果却没读好。页面太长,正文被截断;回答里冒出没有出处的链接;改了几轮,核验还是不过。终端那边忙得热火朝天,QQ 这边安静得像没人上班。
所以又补了正文提取、会话内的页面缓存、按行读取和关键词查找。工具会告诉她总共多少行、下一段从哪开始,核验只认真正返回给她的那些片段。我是说——缓存里有全文,不代表模型读过全文,这件事不能靠她一句“我看完了”糊弄过去。
最后才轮到预算和中途回复:让她看得到剩余轮次、工具额度、时间和长度限制,也允许她自己发一条核验过的进度,然后在原来的预算里继续。
这里我特意要求:什么时候说,让模型自己决定。代码只给环境和约束,不写“剩三轮必须说一句”这种剧本。通路是做出来了,可在一次真实的复杂任务里,她一次也没用,最后还是沉默。所以只能说能力给到了,不能说她已经学会主动用。
我还在琢磨怎么让她更主动一点。服务器上另外两位,早就主动得不需要我琢磨了。
一排 migration,其实都没干什么
18:47 左右,转去看进程。
这台机器有 128 个逻辑 CPU,也就有 128 个 migration/N 线程。它们是 Linux 内核每个 CPU 上的停止线程,干的是任务迁移之类的活,跟哪个应用在“疯狂迁移数据库”没关系,内核源码里能对上这个名字。
现场采样了三秒,这一排线程加起来只用了大约 0.3% 的单核。真正扎眼的是另外两个:
| 进程名 | 实际位置 | 采样 CPU |
|---|---|---|
-bash |
宿主机某个隐藏目录里的可执行文件 | 约 9943%,相当于 99 个逻辑核 |
xmrig |
RAGFlow 容器里的隐藏缓存目录 | 约 2642%,相当于 26 个逻辑核 |
(按单个逻辑 CPU 记 100%,多线程超过 100% 是正常的。为了不把历史平均值当成现在的占用,读的是 /proc 里 CPU 时间的增量。)
一排名字看着可疑的线程几乎在摸鱼,一个名字看着最正经的 shell 却快把机器包场了。看进程名判断服务器健不健康,真的会看错人。
那个 -bash 根本不在系统 Bash 该在的位置,而是藏在 /var/tmp/.ICE-Unix/ 下面。静态字符串里有 RandomX、stratum、XMRig,旁边还放着启动器、保活脚本和 PID 文件。
另一个就是容器里的 XMRig,带着矿池配置和守护脚本。XMRig 本身是开源的挖矿软件,问题在于有人没经过允许就把它部署上来,还往业务启动入口里塞了自动拉起的逻辑。
有一个猜测可以先排除:容器里的矿工 9 月 21 日就已经在跑了,宿主机上的矿工文件 9 月 25 日有过变更,都比 28 日给 bot 加 curl 早。所以“刚加了工具,矿工就进来了”——现有证据不支持。
先让矿工停下来,再问是谁请来的
先保存:进程元数据、可执行文件、配置、crontab、启动脚本,记下哈希。然后暂停已经确认的矿工和守护进程,掐断已经发现的自启动入口。
第一次处置后再采样,整机 CPU 忙碌率降到大约 7.4%,19:04 复查约 6.3%。效果立竿见影——但这只能叫止损。对方是怎么进来的,会不会再回来,一个都没回答。
这时我想起来,有两个账号的密码比较简单。不写真实身份,下面叫账号 A 和账号 B。两个都在 sudo 组里,SSH 当时也开着密码登录。
弱密码是线索,还不是结论。把认证日志、文件时间和启动记录拼到一起,账号 A 这条线才完整浮出来。
第一条线:密码登进来,先把密码改了
下面是认证日志、syslog 和文件元数据能互相对上的时间线,都在 9 月 25 日:
| 时间 | 记录 |
|---|---|
| 21:19:14 | 外部来源甲用密码登录账号 A |
| 21:19:15 | .ssh 和 authorized_keys 变更,多了一把可疑公钥 |
| 21:19:18 | 来源甲再次用密码登录 |
| 21:19:21 | 日志记录账号 A 的密码被修改 |
| 21:19:24 | 来源乙用密码登录同一个账号 |
| 21:25:13 | 矿工程序、启动脚本变更;syslog 记录 crontab 被替换 |
| 21:26:01 | cron 开始运行隐藏目录里的保活脚本,之后每分钟一次 |
在保留下来的认证日志里,来源甲一共留下了 353,477 条密码失败记录。账号 A 仅有的三次 SSH 成功认证,恰好就是上面这三次密码登录。
登录、写公钥、改密码、放矿工、加 cron,六分钟左右走完。到这一步,已经不只是“密码简单,可能被猜中”了——账号 A 被密码爆破进来、然后被拿去部署矿工,证据很强。
当然,文件时间不是逐条命令的审计,我们没法把这几个时间点扩写成攻击者的完整终端录像。
账号 B 也有登录记录,但没有证据把它和这两个来源连起来。不能因为它密码也简单,就顺手把它也判成失陷账号。
19:17,备份并检查过 SSH 配置后:禁止账号 A 新建 SSH 登录,隔离那把可疑公钥;账号 B 改成只认现有公钥。服务 reload,用管理账号重新连上没问题,已有业务没受影响。
还有一件事不能轻轻带过:账号 A 有需要密码的 sudo 权限,而攻击者已经改过它的密码。保留的日志里没找到 sudo 成功的记录——但这证明不了它从来没提过权。
第二条线:一个叫“系统诊断”的 RAGFlow 工作流
容器里的矿工比 9 月 25 日那次 SSH 入侵出现得更早,所以没急着把两件事连成一条线。
RAGFlow 的 Docker 日志大概 23.6 GB。先按矿工文件的变更时间圈出时间窗口,保存相关片段、偏移量和哈希,再跟 Nginx 访问日志、数据库记录对齐。
结果找出 6 个带远程命令载荷的 Canvas 工作流,还有 5 条指向同一台外部服务器的模型配置。最早那个工作流叫 System Diagnostics,9 月 14 日凌晨创建。
叫“系统诊断”,倒也不算名不副实——至少它最后帮我诊断出来了:这个系统已经不只听管理员一个人的话了。
| 时间 | 现场记录 |
|---|---|
| 9 月 14 日 05:21:03 | 外部来源注册了一个普通 RAGFlow 用户,数据库里有对应账号 |
| 05:35:32 | 添加一个指向外部服务器的 OpenAI 兼容模型 |
| 05:35:34 | 创建 System Diagnostics 工作流 |
| 05:35:46—05:35:48 | 应用记录了渲染后的提示词,访问日志记录运行请求完成 |
| 9 月 20 日 05:56—05:57 | 再次运行恶意工作流,矿工目录里的文件同时变更 |
| 9 月 21 日 02:45—02:46 | 又一批注册、模型配置和工作流运行,和缓存目录里矿工落地的时间挨着 |
这里的“普通用户”是应用账号,不是 Linux 账号。也就是说,这条路根本不需要先拿到 SSH 密码。
现场工作流的结构是 Begin → PubMed → LLM → Message。LLM 提示词里的自定义引用规则进了 citation_prompt(),接着被一个普通的 jinja2.Environment 当成模板渲染。
问题就出在这儿:用户能控制的模板,越过了本该有的边界,能摸到 Python 对象、执行系统命令。这和上游已经公开的 CVE-2026-45312 / GHSA-wpg4-h5g2-jxm6 对得上——公告里的例子用的是 DuckDuckGo,现场用的是 PubMed,后面进的是同一个引用模板的位置。
所以这件事不能简单叫“模型被越狱了”。危险操作发生在应用渲染模板的时候,跟模型愿不愿意执行命令没关系。
我们也没光看镜像标签就下结论。现场保存的 generator.py 和官方 v0.23.1 的同名文件 SHA-256 完全一致;应用日志里也留着模板渲染后的结果——原来的表达式被替换成了输出标记,另一次还出现了结果回传接口的响应。
这比一条 HTTP 200 有说服力得多:恶意模板确实进了执行路径。可惜外部命令服务器当时下发的完整响应没存下来,所以没法一条条复原安装矿工的那些指令。
模板里的外部地址,和矿工守护脚本用的下载地址是同一个;几次工作流执行,又紧挨着矿工文件落地。放在一起看,RAGFlow 模板注入和容器矿工之间的关联很强。缺的那段命令内容还是缺着,不能靠推理补成原始日志。
还有一条边界:容器以 root 跑,业务入口脚本又是可写挂载,所以容器能污染宿主机上对应的挂载文件。但这本身既不证明容器逃逸,也不证明拿到了宿主机 root。
到这里,两条线应该这么摆:
| 宿主机账号线 | RAGFlow 容器线 | |
|---|---|---|
| 已定位的入口 | SSH 密码认证 | 应用注册、恶意工作流、模板注入 |
| 关键日期 | 9 月 25 日 | 工作流活动至少追到 9 月 14 日 |
| 执行身份 | 账号 A | 容器内 root |
| 和矿工的关联 | 文件变更、cron 替换、执行记录 | 模板执行输出、相同外部地址、相邻的落地时间 |
| 推不出来的结论 | 已经排除提权 | 已经证明容器逃逸 |
现有证据也证明不了两条线是同一伙人。挤在同一台服务器上而已。
“是不是都找到了?”——问完又多出两个入口
找到两个正在跑的矿工,离“没有遗漏”还远着。接着枚举每个进程的实际可执行文件,翻 cron、systemd、shell 启动文件和容器入口。
RAGFlow 那边,除了 .bashrc、cron 和业务入口脚本,又找到一个 cache-warm.service 和一个登录时执行的 profile 脚本,都指向之前那个矿工守护程序。
这回多出来的是持久化入口,不是新的运行进程。磁盘上的程序得另外算:最后确认有三份哈希不同的矿工可执行文件,其中一份当时没在跑。标题里的“两个矿工”,说的是一开始把 CPU 吃满的那两个进程。
要是我在 CPU 降下来那一刻就宣布清理完毕,半小时后就得给自己的结案报告打补丁了。
宿主机矿工旁边,还有一个伪装进程名的小工具。启动器的副本只在隔离环境里解了包,读到的脚本明确调用它,把矿工显示成 -bash——样本没有运行。
脚本还会挑出别的高 CPU 进程,试着强行结束它们。自己来占算力,还顺手把别人赶下班。不过脚本有这个能力,不代表能证明某次业务退出就是它干的;普通账号能杀哪些进程,也受权限限制。
排查里也冤枉过正常程序。几组从 /tmp 启动的 Node,拿官方发行文件比对,哈希一致。另一处货真价实的数据库 migration,来自 Coze 的容器初始化配置。绕了一大圈,终于碰上一次名副其实的 migration——可惜跟开头那排线程没关系。
这些区分都得留着。运维工具要是把“我不认识”自动翻译成“可以删”,杀伤力未必比矿工小。
要停 RAGFlow,先问问旁边的 ES 在给谁干活
我说,如果没有别的服务依赖,就把 RAGFlow 停了。一查:应用本身和它的 MySQL、MinIO 没发现别的业务在用;可旁边那个 Elasticsearch,素材库、知识库、另一个问答服务都在用。
容器挂在哪套 Compose 名下,和它现在实际给谁服务,是两回事。直接停整套项目,会把不相干的业务一起带走。
19:59,停掉 RAGFlow 主容器和它独占的 MySQL、MinIO,关掉自动重启,在 Compose 里把这几个服务隔离出来。共享的 ES 和 Kibana 留着,数据库和对象存储的数据卷也留着。
然后核对监听端口、业务响应和索引数量:共享服务没被重启,现有业务和处置前一样。ES 本来是 yellow,查完还是 yellow——没被我们“顺便修好”。
这一步关掉了已经确认的应用入口。但失陷的应用可能读到过的共享凭据,不会因为容器停了就自己失效,凭据轮换和共享数据检查还得接着做。
矿工下班了,被装进一个不能双击的包里
接着把确认过的矿工、启动器、保活脚本,以及受感染容器的可写层,归档到本地。文件按内容哈希存放,清单里另记原路径、权限和时间;下载下来以后,归档本身和每个内容对象的哈希都核对过一遍。
归档大约 18.8 MB,115 个独立内容对象,对应 138 条普通文件记录。目录限制访问,归档设成只读,加上系统的隔离标记。说到底它们还是恶意样本,隔离标记只是防手滑,不是消过毒。
20:09,本地校验完,结束还暂停着的宿主机矿工,删除确认过的线上矿工目录和样本副本,移除恶意 crontab,再删掉受感染的 RAGFlow 主容器。没有为了清理把它重新拉起来,业务数据卷也没动。
复核的时候还纠正了一处记录:早先宿主机上的 cron 之所以失效,其实是因为启动脚本被改了名,cron 那一行还在。确认这个账号没有别的定时任务后,才把恶意 crontab 整个移除。
20:11,旧进程、守护进程、原文件和原容器都没了。对选定目录、当前的可执行文件和线上取证副本复查,没再命中已知的矿工名称或哈希。
不过这个结果有范围:跳过了一些依赖缓存,有深度限制,也没把整块数据盘逐字节扫一遍。“这一轮没命中”,不能写成“恶意文件都清干净了”。
停着的数据库里还留着恶意工作流的记录。以后要恢复 RAGFlow,不能把旧服务直接拉起来,就当自己又有了一个可信的环境。
账号 A 也没法顺手删掉。一核对,它名下还跑着三个业务项目,Python 和 Node 环境都在家目录里。所以账号和业务先留着,继续禁止新的 SSH 登录;账号退役要等业务迁走,本地密码、sudo 权限和旧会话也都还要收尾。
这一天下来,越是名字看着能直接处理的东西,背后的依赖越不答应。
代码早修了,公告却还写着“没有修复版本”
我以前给 GitHub Advisory Database 提过贡献,就想着把这次的材料整理一下,看能不能补点分析或修复验证。
先核对公开信息,发现一个很具体的缺口:写这篇的时候,项目公告的修复版本一栏还空着。可 PR #14068 早在 4 月 13 日就合并了,把对应的渲染环境换成了 SandboxedEnvironment,4 月 21 日发布的 v0.25.0 已经带上了修复。
提交的祖先关系也核对过。同一个漏洞的官方 CVE 记录里已经有 CWE-1336,项目公告里对应的字段却是空的。
为了把已知边界再确认一下,从几个版本里提出真实的环境声明和 citation_prompt(),用一个不执行系统命令、不读文件、不联网的属性访问检查去试:
| 源码版本 | 普通模板渲染 | 已知的对象属性遍历 |
|---|---|---|
| v0.23.1 | 通过 | 允许 |
| v0.24.0 | 通过 | 允许 |
| v0.25.0 | 通过 | SecurityError |
| v0.27.2 | 通过 | SecurityError |
当天固定的 main 快照(d64b84c) |
通过 | SecurityError |
这只是对提出来的函数做的边界验证,没有起完整应用,也没有通过 HTTP 重放攻击。它能支撑“这条历史修复确实生效”,但不能拿来证明整个版本、所有工作流或者别的模板漏洞都安全。
21:12,我提了 issue #20324,请他们补上修复版本、实际的补丁引用和 CWE,也请维护者考虑给分析或修复验证署个名。
先说清楚:这是一份公告更正请求。漏洞不是我们新发现的,修复也不是我们写的;把已有的补丁重新交一遍,不会凭空变成新的安全贡献。署不署名,由维护者决定。
现场材料以后可以拿来做更完整的脱敏分析,但这篇没有附原始日志、恶意工作流、攻击指令或矿工样本。更深的工作流验证还没做完,也不能先填进“已验证”那一栏。
什么时候才能说“清理完了”
现在能确认的:已知的矿工和守护进程已经结束,确认过的线上样本和持久化入口已经移除,受感染的应用容器已经删除,异常的 SSH 登录入口已经限制。这次 CPU 被吃满的直接原因,处理掉了。
还不能确认的:有没有更早的入侵,有没有发生宿主机提权或横向移动,共享凭据有没有泄露,有没有别的数据被读走或带走。要真正恢复信任,还得靠凭据轮换、业务迁移或重建、剩下入口的核查,以及持续观察。
这一次让我记得最清楚的,是排查里那些必须停下来分清楚的地方:进程名和实际程序,账号身份和账号的主人,模板执行和模型行为,容器里的 root 和宿主机的 root——还有 CPU 降下来,和环境重新可信。
我本来只是想让 bot 做长任务的时候别一直不吭声。她学没学会适时汇报,还得再看看;服务器上的副业,倒是先停了。
需求做完了多少先不说,需求范围的“自主涌现”,我算是体验到了。
更新(2026-10-28):issue #20324 在 9 月 30 日收到维护者回复,对核对工作表示了感谢;截至今天,GHSA-wpg4-h5g2-jxm6 的修复版本和 CWE 字段仍为空。