Node.js 测试 CI 安全事件全披露:TOCTOU 竞态漏洞分析、攻击链复盘与基础设施加固实践
【免费下载链接】nodejs.orgThe Node.js® Website项目地址: https://gitcode.com/GitHub_Trending/no/nodejs.org
2025 年 3 月 21 日,Node.js 项目通过 HackerOne 漏洞赏金计划收到一份安全报告,披露其多个测试 CI 主机被成功入侵,随后项目官方于 4 月 23 日发布完整披露(原始文档位于 apps/site/pages/en/blog/vulnerability/march-2025-ci-incident.md)。本篇文章以该披露为主体,系统拆解攻击者如何借力 PR 评审流程触发 Jenkins 流水线、利用"检查时间与使用时间不一致"(Time-of-Check-Time-of-Use,TOCTOU)竞态完成代码执行,并完整梳理 Node.js 团队的补救措施、修复模型与逐日时间线。读完本文,你将理解开源项目 CI 基础设施中"可变 Git 引用 + 延迟检出"的经典风险模型,掌握以"提交 SHA 校验"为核心的可信执行加固思路,并了解 300+ 志愿者如何在大规模 CI 集群上平衡安全与开发者体验。
事件摘要:一次针对 CI 基础设施的定向入侵
2025 年 3 月 21 日,Node.js 项目收到一份经 HackerOne 提交的安全报告(披露时该报告链接处于受限状态),内容详述了攻击者对多个 Node.js 测试 CI 主机的成功入侵。报告描述了完整的利用链,项目方在复核后确认报告真实有效。
需要首先明确两个关键边界事实(官方披露原文明确声明):
- 该事件不影响 Node.js 运行时本身,对所有 Node.js 用户不存在运行时风险,用户无需采取任何操作;
- 受影响的是开发基础设施(测试 CI 主机),事件发生后项目方立即限制相关访问并着手整改。
攻击链还原:五步利用 request-ci 标签达成代码执行
根据 HackerOne 报告,攻击者按如下五个步骤完成了攻击:
- 提交一个有效的 pull request到 nodejs/node 仓库;
- 等待维护者添加
request-ci标签——该标签会被添加到每一个包含非文档变更的 PR 上(即 CI 触发授权信号); - 批准之后,使用过时的 Git 提交时间戳更新该 PR;
- 当 Jenkins 流水线触发时,它从 fork 出的 PR 中拉取并执行代码;
- 在 Node.js Jenkins agent 上取得代码执行权限。
下图展示了正常场景下"在 GitHub Pull Request 上启动 Jenkins CI"的完整工作流:
正常流程中:维护者(Maintainer)为 PR 添加request-ci标签 → 系统执行"更安全的验证"(Certify Safer validation,即确认该 PR 是否由维护者请求、且自添加标签以来 PR 内容未发生变化)→ 验证通过后 Jenkins 执行Fetch /PR_ID/HEAD→ 代码分发到node-test-1、node-test-2……node-test-N组成的 Jenkins Test CI 集群执行测试。
而攻击的关键,恰恰发生在"验证"与"取出代码"之间的时间窗口内,如下图的攻击示例所示:
攻击者在 PR 通过验证("just validated")之后、Jenkins 实际检出代码之前,发送一个提交改变该 PR 的 HEAD——此时 Jenkins 取到的已不再是维护者验证过的代码,攻击代码随流水线执行落到 agent 上,形成典型的 TOCTOU 竞态。
关键研判:request-ci 并非攻击的必要条件
Node.js 团队在复核中进一步确认了一个更严峻的事实:request-ci标签步骤简化了攻击,但并非攻击的必要条件。同一类攻击同样适用于commit-queue标签——这意味着攻击者理论上可以通过该标签,未经授权地向主干合入一段代码变更,威胁等级从"CI 机器被控"上升为"仓库内容被篡改"。
根因剖析:触发与检出之间的可变引用窗口
官方披露明确指出,核心问题出在CI 构建发起与 Jenkins 作业检出代码之间存在 TOCTOU 漏洞:
- 此前,CI 作业使用Git 引用(
refs/pull/<pr_id>/head)来定位代码; - 这种可变引用在 CI 触发之后仍然可以被攻击者更改(PR 作者可以持续推送新提交,引用指向随之漂移);
- 因此,发起 CI 的协作维护者并没有做错任何事——CI 被触发时,PR 看起来确实是安全的,问题出在引用"可以变"而非"已变坏"。
补救措施:从止血、重建到机制升级的六项动作
确认漏洞后,Node.js 安全团队采取了一系列措施以缓解风险并加固基础设施:
- 立即限制新 Jenkins CI 运行的发起权限:在团队验证报告期间,暂停一切新的 CI 启动入口,防止进一步失陷;
- 识别、下线并重建全部受感染主机:共 24 台机器被迅速定位、从 Jenkins 移除并重建,以消除初始入侵残留的任何潜在风险;
- 在 Jenkins 作业中实施提交 SHA 校验:作业现在只执行经过验证、可信的代码;
request-ci与commit-queue标签改为依赖验证过的提交 SHA,不再依赖日期比较(此前的"PR 自加标签以来未变化"是基于时间戳推断的,这正是竞态被利用的缝隙);- 对 140 个 Jenkins 作业执行全面审计,优先审计高频作业,以发现并修复漏洞;
- 排查 GitHub 工作流:识别出存在相似攻击面的 GitHub workflows 后先临时禁用,随即打补丁,并以增强后的安全措施重新启用。
修复后的新验证模型:强制审批 + 显式 SHA
官方披露给出了修复后的核心行为变化,这是整套加固中最具参考价值的设计决策:
现在,每个 PR 都必须先经过批准(approval)才能运行 Jenkins CI——或者由协作者显式指定具体的 SHA。
也就是说,CI 执行对象从"一个可变引用"变为"一个不可变的提交标识":要么该 PR 已被授权者批准(信任链前置),要么调用方直接给出期望的提交 SHA,Jenkins 在机器上检出代码后校验实际 SHA 与期望值一致才继续执行。从源码结构看,这等价于把"信任判断"从 CI 触发后的异步时点,前移并固化为触发时点的显式参数,从而从根本上封堵 TOCTOU 窗口。
完整时间线:3 月 21 日—4 月 3 日的处置实录
官方披露附带了逐日处置记录,完整还原了这次事件从收到报告到恢复常态的过程:
| 日期 | 处置内容 |
|---|---|
| 2025-03-21(周五) | 收到 HackerOne 报告;初步分流确认是真实问题;限制启动新 Jenkins CI 的能力,防止进一步机器失陷 |
| 2025-03-24(周一) | 全部 24 台失陷机器被定位并从 Jenkins 移除(待完整重建);开始评估 Jenkins 实例中定义的 140 个作业的脆弱性;着手改造最高频使用的易感作业——要求其接收期望的提交 SHA,仅当机器上检出的代码 SHA 匹配时才继续 |
| 2025-03-25(周二) | 部分受影响主机完成重建;更新后的作业在 macOS 上失败,经调查后再次更新 |
| 2025-03-26(周三) | 更多作业更新完成、受影响主机重建;部分 GitHub 工作流被识别为存在相似攻击面并予以禁用 |
| 2025-03-27(周四) | 再次调整更新后作业的校验逻辑,以允许非 PR 分支上的日常测试;决定禁用所有尚未评估或尚未应用修复的剩余作业;更多机器完成重建 |
| 2025-03-28(周五) | GitHub 工作流完成修补,commit-queue重新启用 |
| 2025-04-01(周二) | 恢复在 Jenkins 及通过request-ci启动作业的能力;部分使用频率较低的作业仍保持禁用 |
| 2025-04-02(周三) | 更多机器完成重建 |
| 2025-04-03(周四) | Benchmarking 与 libuv CI 作业完成更新 |
这条时间线清晰地展示了处置策略的三个阶段:紧急止血(21 日—24 日)→ 分批修复与重建(24 日—27 日)→ 验证后逐步恢复(28 日—4 月 3 日)。值得注意的是"宁可全部禁用、不可带病运行"的决策——27 日团队选择禁用所有尚未完成评估或修复的作业,宁可牺牲 CI 可用性也要保证不再发生二次失陷。
安全与开发者体验的平衡:大规模开源 CI 的现实设计
官方披露给出了项目 CI 体系的量级背景,这有助于理解为什么此类风险难以提前杜绝:
- Node.js 项目由300+ 志愿者共同维护;
- 其流程服务于约 100 台 Jenkins runner,横跨多个操作系统与 CPU 架构;
- 现有的 CI 系统设计本身预设了"可能被攻破"的假设,承认需要在安全性与开发者便利性之间做出权衡。
也就是说,在"任何人可通过 PR 贡献代码 + 维护者快速发起 CI 验证"的高吞吐协作模型下,可变 Git 引用被用作 CI 输入是一种便利性选择;本次事件正是这种便利性的代价,而 SHA 校验模型则是团队在权衡后给出的收敛答案。
志愿者组织的现实约束与安全研究边界
作为志愿者驱动型组织,Node.js 依赖成员将时间投入到 CI 加固、安全报告处理、版本发布等"不显眼"的工作上。披露文档特别指出:
即便是针对生产系统的善意安全研究,也可能严重干扰项目运作。
因此项目方在欢迎包括渗透测试在内的各类贡献的同时,明确要求研究者:在针对生产系统开展任何尝试前先行沟通,并通过 HackerOne 漏洞赏金计划或直接联系 Node.js 技术指导委员会(TSC,tsc@iojs.org)保留可审计的操作记录。
这一约定对任何参与开源安全研究的人都是重要提醒:知情、受控、可审计的研究与"直接打线上系统"之间,存在本质区别;后者即便动机善意,也可能造成服务中断。
对 Node.js 用户的结论与后续信息渠道
- 结论:本次事件不涉及 Node.js 运行时,没有任何 Node.js 用户受影响,也不需要采取任何行动;受影响的开发基础设施原计划在 2025 年 4 月 15 日或更早恢复社区可用,完整披露随后发布。
- 报告 Node.js 漏洞:可遵循 Node.js 官方安全策略中的流程;就本网站仓库(nodejs.org)而言,安全问题的正确上报方式是使用 GitHub Security Advisory 的私有上报流程,而不是公开 issue——具体响应承诺(7 个工作日内确认)与披露渠道见本仓库根目录的 SECURITY.md。
- 持续跟踪安全动态:可订阅低流量、仅发布公告的 nodejs-sec 邮件列表,以第一时间获知 Node.js 及其维护组织内各项目的安全漏洞与安全相关版本发布。
本次事件带来的工程启示
回顾整个披露,可以提炼出几条对任何 CI/CD 基础设施都有普适价值的结论:
- 可变引用不应成为执行信任锚点:
refs/pull/<pr_id>/head这类随时可漂移的引用,天然存在"验证与使用分离"的竞态窗口;以不可变的提交 SHA作为执行凭证,是关闭 TOCTOU 窗口的直接手段。 - 信任判断的时点必须前移:把"谁有权触发、针对哪个版本触发"固化为触发时刻的显式参数(审批 + SHA),优于事后依赖时间戳的推断式校验。
- 攻破面评估要覆盖整条链路:本案中漏洞不仅影响
request-ci,还波及commit-queue与 GitHub workflows——凡是"根据外部事件拉取并执行代码"的环节,都应纳入同一套校验模型。 - 应急响应要敢于"整体降级":在无法逐一确认安全性的情况下,暂时禁用全部未评估作业,是用 CI 可用性换取基础设施可信度的务实决策。
Node.js 项目的这份披露文档,本身也是开源安全事件公开透明的范本:完整的攻击链、明确的根因、可复盘的补救措施与逐日时间线,全部向社区开放。对于任何自建 CI 或参与大型开源项目维护的工程师而言,这份记录都值得精读与对照自查。
延伸阅读:本文分析所依据的官方披露原文见 march-2025-ci-incident.md;两张架构图源文件见 example_test_infra.svg 与 example_attack_test_infra.svg;Node.js 项目常规漏洞披露的公告体例可对照 may-2025-security-releases.md;本仓库的安全上报流程见 SECURITY.md。
【免费下载链接】nodejs.orgThe Node.js® Website项目地址: https://gitcode.com/GitHub_Trending/no/nodejs.org
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考