给 QQ bot 加个功能,顺着一排 migration 抓出两个矿工:我这需求又做偏了
Table of Contents
2026 年 9 月 28 日,我本来在给一个 QQ bot 加功能。
她叫 Krypton,平时叫“傻氪”。当天的目标是让她能发语音、自己查网页,处理长任务时知道还剩多少预算,必要时先回一句,再继续干活。
然后我看了一眼服务器:怎么这么多 migration,CPU 还这么高?
几个小时后,交付物里多了两条入侵时间线、一份矿工隔离归档,以及一篇原本完全没打算写的事件记录。
机器人还没学会自主汇报,服务器已经用一百多个核,向我汇报了自己的副业。
这次仍然是我和 Codex 一起排查。本文从当天的现场记录整理,时间均为北京时间。账号和基础设施信息已经脱敏;处置状态截至当天晚间,调查尚未结案。下面会把当时看到的、后来查明的,以及还不知道的部分分开。
开头确实只是一个不会发语音的 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。真正显眼的是另外两个进程:
| 进程名 | 实际所在位置 | 采样 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 日发生变更。它们都早于 9 月 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 快照 | 通过 | SecurityError |
这是提取函数的边界验证,没有启动完整应用,也没有通过 HTTP 重放攻击。它支持这条历史修复的判断,不能当作整个版本、全部工作流或其他模板漏洞都安全的证明。main 快照固定为 d64b84c。
21:12,我发布了 issue #20324,请求补上修复版本、实际补丁引用和 CWE,并请维护者考虑分析或修复验证署名。截至发稿,issue 仍开放,尚无回复,原公告也尚未更新。
它是一份公告更正请求。漏洞并不是我们新发现的,修复也不是我们写的;把已经存在的补丁重新提交一遍,不会凭空变成新的安全贡献。署名是否合适,同样由维护者判断。
现场材料后续可以用于更完整的脱敏分析,但本文没有附原始日志、恶意工作流、攻击指令或矿工样本。更深的工作流验证还没有完成,也不能先写进“已验证”一栏。
什么时候才能说清理完了
目前能确认的,是已知矿工和守护进程已经结束,确认的线上样本及持久化入口已移除,受感染应用容器已删除,异常 SSH 登录入口已限制。导致这次高 CPU 的直接原因已经处理。
还不能确认的,包括是否存在更早的入侵、是否发生宿主机提权或横向移动、共享凭据是否外泄,以及是否有其他数据被读取或带走。完整恢复信任还要靠凭据轮换、业务迁移或重建、剩余入口核查和持续观察。
这次最让我记住的,是排查里那些必须停下来分清楚的地方:进程名与实际程序、账号身份与账号主人、模板执行与模型行为、容器 root 与宿主机 root,还有 CPU 降下来与环境恢复可信。
我本来只是想让 bot 在长任务里别一直沉默。现在她有没有学会适时汇报,还得继续观察;服务器的额外业务倒是先停了。
需求完成度暂且不论,需求范围的自主涌现,算是体验到了。