研究
DIVD 入侵复盘:19 分钟告警,19 小时响应
荷兰漏洞披露机构 DIVD 的工单系统被攻破后,监控 19 分钟就发出告警,首次响应却用了 19 小时。在线用户的登录凭证随报错泄露,攻击由此走到服务器最高权限。补丁已经发布,仍在调查的工单外传范围决定了后续需要通知哪些人。

文章导航
19 分钟,监控已经发现异常。19 小时,团队才发起第一次响应并制定控制计划。荷兰漏洞披露机构 DIVD 在自己的入侵复盘里把两个数字并排公布了。告警送到了志愿者 SOC,公开记录没有逐小时说明最初这段等待。之后执行计划又用了 9 小时,其中包括线下会合,以及谨慎隔离数据中心,避免切断应急团队自己的操作通道。
Zammad 是 DIVD 用来处理 CSIRT 邮件的工单系统,里面可能有别人的漏洞、受影响地址和沟通过程。9 月 21 日发生的入侵,9 月 24 日首次公开,数据调查在 10 月继续更新。追清这次事件,需要把两条过程接起来:攻击者怎样取得一台服务器的最高权限,防守方又怎样把一声告警变成能真正阻止它的动作。
1 一条错误消息,带出了其他人的登录凭证
Zammad 的网页通过 WebSocket 保持实时连接,以便接收工单变化。建立连接时,服务器留下一份请求上下文,其中包括浏览器发送的 Cookie。会话 Cookie 能让后续请求继续以已登录用户的身份工作,它因此也是需要严密保管的凭证。
6.5.4 的固定提交 15c7e6d4ffd9 中,lib/websocket_server.rb 的 onopen(),第 60–75 行把每条连接的请求头放进进程共享的 @clients。接到事件时,第 107–123 行又把整份连接表交给事件处理器。处理当前连接所需的一点上下文,由此变成了所有连接的数据。
接下来两个方便开发的做法连在了一起。lib/sessions/event/base.rb 第 3–14 行把传进来的每个键值都存成对象属性,包括那份 @clients。lib/sessions/event.rb 第 6–24 行根据客户端给出的事件名寻找 Ruby 类,出错时把异常的 e.message 直接送回客户端。
Base 是给其他事件继承的基础类,没有自己的 run() 实现,旧分发器却允许把它选作要执行的事件。调用缺失的方法会产生 NoMethodError。在受影响运行环境里,异常文字包含接收对象的展开内容,属性里的其他用户 Cookie 也随之进入回包。Horizon3.ai 在 10 月 7 日的公开复现中确认,未登录的连接就能触发这个错误。被泄露的是同一 WebSocket 进程里同时在线的已登录用户,后续接管管理员身份,还需要这份连接表中恰好存在管理员会话。
普通用户会话能暴露它有权访问的资料,管理员会话还能打开更有力量的功能。Horizon3.ai 随后用泄露的管理员身份调用软件包安装接口,写入会被服务端渲染的邮件模板,再通过密码重置流程触发模板,取得 zammad 服务账户的代码执行权限。这解释了一个看起来像“报错泄密”的缺陷,为什么足以成为整台机器的入侵入口。它是研究者复现的公开路径,DIVD 没有公开自己全部请求和模板的取证记录。
2 服务重启,把普通账户的文件交给 root 执行
得到 zammad 账户后,攻击者还没有取得 Linux 的最高权限。第二个问题出在 DEB/RPM 包的启动方式。厂商 CVE-2026-102490 公告说明,服务先以 root 启动,在降为服务账户之前,执行了由 zammad 拥有且可写的文件。安装目录也允许这个账户写入。于是低权限账户可以改变下一次由高权限进程读取的内容。
自动重启进一步缩短了等待。服务停止后会自动再起,攻击者不需要等管理员来维护机器,就有机会让被修改的文件以 root 权限运行。这个缺陷影响 packager.io 构建的 DEB/RPM 安装,官方容器镜像及源码安装不受同一包启动问题影响,厂商也明确其 SaaS 客户不受本包问题影响。WebSocket 泄密和包启动提权是两项不同的错误,前者提供落脚点,后者把落脚点扩大成主机控制权。
“AI 攻击”是这次新闻最显眼的标签。DIVD 根据短时间内的大量活动、脚本注释和注释中类似代理间交流的内容,判断攻击者取得 root 后使用了 AI 代理。公开材料没有给出模型服务日志或确定的模型身份。
两项修补也需要分开看。WebSocket 加固在 7.2.0 进入发行版本,DEB/RPM 提权首次修复为 10 月 8 日发布的 7.2.2。截至 10 月 13 日,厂商发行页的当前目标是 7.2.2。仍在 6.3.0–6.5.4 的安装需要立即退出旧分支,无法马上更新时先限制对服务的访问,代价是外部用户不能正常使用相应入口。只换到某个 7.x 版本,会漏掉仍存在于 7.2.1 及更早 DEB/RPM 包中的本地提权。
正常升级仍要按官方升级流程核对依赖和发行说明,停服务并备份,跨多个主版本时保留中间主版本的升级步骤。更新后还需检查维护脚本和迁移输出,再验证登录及工单处理。如果更新失败,应继续限制访问,不能把旧程序重新暴露出去。
厂商说明,7.0 及以后版本的运行环境阻断了这条会话泄露利用,7.2.0 又直接修掉危险代码。这个区别解释了 DIVD 为什么把自己仍在运行旧分支列为教训,也避免把“代码相似”和“当前环境可利用”混成一个判断。仅靠软件升级,无法重新确认已被 root 控制的主机是否可信。应保留取证材料并从可信镜像重建,再恢复经过检查的数据和配置,旧的受影响包不宜作为回退目标。
3 补丁让事件只拿到自己的连接
7.2.0 的修补提交 a97f50ab3bfa同时处理了分发、上下文和报错。新 backend_for() 第 38–56 行限制事件名格式,要求解析结果是具体的事件类,排除抽象基础类,并检查它是否实现 run()。因此基础类不再只因“这个 Ruby 类确实存在”就被执行。
随后,lib/websocket_server.rb 第 107–118 行只传入当前 client,替代整张 clients 表。新基础类的构造函数第 17–30 行逐项接收明确参数,不再把任意传入键都变成属性。一个事件即使再次出错,也少了接触其他连接状态的机会。
对外报错最后换成固定文字。内部异常留在服务器日志,客户端收到的是统一的处理失败提示,见 generic_error() 第 61–70 行。只删掉错误详情可以挡住本次泄露,缩小处理器拿到的上下文则降低下一次报错再次带走旁人数据的机会。两项改动互相补充。
# 旧版回包中的错误字段
error: e.message
# 修补后的错误字段
error: __('The event could not be processed.')
验收因此需要看返回内容,也要看正常功能。上游新增测试用一个假会话 Cookie 检查异常回包,覆盖未知事件、抽象基础类、没有 run() 的处理器和执行中主动抛错的情况,同时保留正常事件及数据库连接释放检查。修补后的回包仍可包含发起者自己的 payload,需要阻断的是内部上下文和其他连接的凭证。管理员在隔离副本中确认这一点后,再完成一次正常登录和工单实时更新。生产原始报错日志本身可能含登录凭证,导出给其他人分析前应先脱敏。
4 告警很快,隔离仍需要一条走得通的路
复盘给出了一项具体约束。响应者要去线下会合,并谨慎隔离阿姆斯特丹数据中心,确保自己仍能继续操作。断网可以同时切断攻击者与维护者的连接。需要提前准备的,是一条在业务网络被关闭后仍能使用的管理通道,以及有权下达和实施隔离的人。仅把告警通知更多群组,并不能替代这两件事。
监控检测时间和控制时间应分别演练。后者要走到授权、人员到位、真正切断攻击通道,并确认隔离后的维护访问仍然可用。对于依靠志愿者协作的 DIVD,到场与远程操作本身就是计划的一部分。演练停在告警送达,就会把这段最难的工作留到实际入侵之后。
5 工单里的别人的秘密,还需要逐项找到
最初发出的漏洞通知不在这台工单系统里,回复和后续邮件则存放在其中,包括发给 [email protected] 和 @csirt.divd.nl 地址的内容。DIVD 的数据调查页说明,CSIRT 工单存在部分外传迹象,具体范围仍在调查。与团队往来过的组织,应按相关对话可能被取得来处理,尤其是后续提交的受影响 IP、漏洞细节和掩码凭证摘录。
同一张调查表里,办公 GSuite、IT 支持、项目协作和运行系统数据出现了部分外传迹象,行政资料所在的 GSuite 部分仍在调查。HR、Slack 及源码仓库则没有发现失陷迹象。零日资料的主存储位置没有受到影响,出现在 Zammad 里的副本仍可能暴露。CVE 记录和 CNA 流程位于另一套环境,DIVD 未发现那部分被访问。把这些对象分开,才能知道该通知谁、检查哪份资料,表中的待调查项也不能自动变成已经泄露。
软件升级可以结束危险的执行路径,工单中的信息却不会随之撤回。对收到过 DIVD 邮件的人,更实际的后续是核对自己发送过哪些敏感内容,并留意带着真实往来细节的冒名邮件。一次可信机构的失陷,会让过去真实的交流变成下一次欺骗的材料。恢复服务之后,这项影响仍需要处理。