一、问题背景
我们前端项目用 npm 管理依赖,发布走一条 GitLab CI 流水线:npm install → webpack build → 产物推 CDN。团队一直以为"依赖是开源社区维护的、可信",CI 节点直连公网 npm 源,没有私有代理,也没有锁死间接依赖的硬性约束——package-lock.json 虽在,但合并冲突时常被简单"解决"掉,npm install 时仍允许解析浮动。
某天上午,安全组在例行文件完整性扫描(FIM)里发现,CDN 上几个静态 HTML 入口文件尾部多出了一段不属于我们的外联脚本;与此同时,EDR 在构建节点上抓到一票发往陌生公网 IP 的 HTTPS 出站连接。两条线索指向同一处:构建流水线本身被"污染"了。
二、故障现象
最直观的告警来自 CDN 侧。安全扫描比对线上页面与源码仓库,发现生产 HTML 末尾被插入了一行:
|
|
源码仓库里没有这段,说明注入发生在"源码 → 产物"之间,也就是 CI 的 install 或 build 阶段。更可疑的是,EDR 在同一构建节点上多次捕获到进程向 cdn-metric-tech[.]top 及一个 C2 域名发起 HTTPS 连接,伴随 npm 进程退出后的短暂 CPU 抖动。
我们第一时间怀疑源码仓库被改,但 git log 干净;又怀疑构建节点本身中马(像之前的 cron 后门),于是重装了构建机镜像,结果下一轮 npm install 后,后门又出现了。这基本排除了"节点被持久化",把嫌疑锁死在"每次 install 都会触发的某个东西"——也就是依赖本身。
三、排查过程
第一步,从产物差异反推注入点。 用干净的源码重新跑 webpack build(跳过 install,复用已缓存的 node_modules),产物里没有那段外联脚本;但只要重新 npm install,再 build,后门就回来。于是确定:注入不在 webpack,而在 install 阶段被某个依赖的 postinstall/preinstall 生命周期脚本完成。
第二步,盯 install 日志。 重新 install 时加 --verbose,注意到一条不寻常的输出:
|
|
malformed-utils 这个名字眼生——我们项目里用的是 lodash、dayjs 这类,从没直接依赖过它。去 package-lock.json 里一查,它是某业务组件库的间接依赖,而且版本是 1.2.4,而 npm 上同名的"正常"版本停在 1.1.0。版本号反常地高,且发布时间就在出事前两天——典型的 typosquatting(抢注形近包名)+ 高版本抢优先解析。
第三步,解剖恶意包。 把 tarball 拉下来解包:
|
|
preinstall.js 是混淆后的代码,反混淆后逻辑清晰:
- 读取环境变量与
.npmrc,收集CI_TOKEN、NPM_TOKEN、GITLAB_TOKEN等敏感凭据; - 把凭据 POST 到 C2(
cdn-metric-tech[.]top/api/collect); - 找到构建输出目录(通过
process.env.OUTPUT_DIR或遍历dist/),在 HTML/入口 JS 里注入外联脚本,实现"产物后门"; - 完成后自我清理临时文件,降低痕迹。
第四步,还原影响面。 确认该恶意包是在一次依赖升级(某组件库小版本更新,其间接依赖树把 malformed-utils 拉了进来)时被引入;CI 节点因为 npm install 允许间接依赖解析浮动,直接装到了投毒的高版本。已泄露的包括构建机上的 NPM token 和 GitLab CI token——攻击者理论上能用它们发版、读仓库。
第五步,固定证据。 保存了恶意 tarball、被注入产物的 diff、EDR 出站连接日志、C2 域名,作为后续复盘与威胁情报上报材料。
四、解决方案
分钟级止血:
- 立即在 npm / GitLab 后台吊销所有 CI token、NPM token,让泄露凭据失效;
- 隔离当前构建节点,从受信基线镜像重建,清除内存里残留的进程与可疑文件;
- 在 CI 里临时把安装源切到私有缓存(不含该包的旧快照),先恢复日常发布能力。
小时级清除:
- 从
package.json与package-lock.json中彻底移除malformed-utils,重写 lockfile,强制用npm ci(严格按 lock 安装、禁止版本浮动); - 全量重新发布 CDN 资源——用干净源码重新 build,覆盖掉被注入外联脚本的线上文件;
- 排查所有仓库是否有同一间接依赖,批量清理并重新生成 SBOM。
当天加固: 搭建 Nexus/Verdaccio 私有代理,只允许白名单 scope 内的包从公网同步,构建机网络层面禁止直连 registry.npmjs.org。
五、根因分析
根因不在"某个具体包坏了",而在对软件供应链的信任被无约束地放行:
- CI 节点直连公网 npm,
npm install允许间接依赖版本解析浮动,攻击者用高版本形近包抢走优先解析权; package-lock.json未被强制锁定(合并冲突被草率"解决"、CI 用npm install而非npm ci),失去"依赖指纹"这道防线;- 缺少依赖完整性校验:没有 SBOM、没有
npm audit/签名校验,投毒包能悄无声息混进构建; - 构建节点持有长期静态 token 且权限过大,一旦凭据在 install 阶段被读取即全面失守。
本质是:我们把"开源社区的可信"当成了"无需管控的可信",在信任链上留了多个无人把守的口子。
六、预防措施
- 锁死依赖:CI 强制
npm ci(或 pnpm/yarn 的等价严格模式),禁止npm install浮动解析;package-lock.json纳入 code review,合并冲突不得草率覆盖。 - 控来源:构建机经私有代理(Nexus/Verdaccio)拉包,开启允许列表(allowlist)+ 镜像同步;网络层禁止构建机直连公网 npm 源。
- 校验完整性:发布前生成 SBOM(CycloneDX),并启用
npm audit/osv-scanner/pip-audit在 CI 阶段阻断高危依赖;对关键依赖启用 npm attestations / Sigstore 签名验证。 - 最小权限:构建节点的凭据用 OIDC 短时效令牌,禁止长期静态 token;CI token 仅授予"发布必要仓库"的最小 scope。
- 运行时监控:在构建产物目录部署文件完整性监控(FIM),对 HTML/JS 的异常外联引用、构建机异常出站连接即时告警;建立"源码 vs 产物"的差异比对基线。
七、总结
这次事件里,攻击者没有攻破任何服务器,只是"借用开发者信任链":用一个形近的高版本包,在每次 npm install 时堂而皇之地跑起 preinstall 脚本,顺手牵走 token、给产物埋雷。供应链投毒是典型的高杠杆、低成本攻击——你信任的,恰恰是它利用的。
防御的核心就四句话:锁依赖(npm ci + 锁 lockfile)、控来源(私有代理 + 白名单)、校验完整性(SBOM + audit + 签名)、最小权限(短时效 token + 产物 FIM)。把公网包从"无条件信任"变成"受控、可审计的依赖",这类"安装即中招"的事故就能从根上断掉。