代码没问题,出问题的是发布者
两条注册表记录,相差一个版本号。axios 1.14.0 由 GitHub Actions 通过可信发布者发布,并带有来源证明(provenance)。而 2026 年 3 月 31 日 00:21 UTC 出现的 axios 1.14.1,却是手动发布的,不具备上述任何一项。这个差异,就是 axios 在 npm 上被攻陷这整件事的核心,而它当时就摆在注册表的元数据里。[2]
简要回顾事实。axios 1.14.1 和 0.30.4 这两个版本相隔 39 分钟发布,都新增了同一个依赖 plain-crypto-js@4.2.1,其 postinstall 脚本会在任何执行了安装的机器上投放远程访问木马。npm 在 03:15 UTC 之前下架了这两个版本。[1][2] Google 以高置信度将此次行动归因于与朝鲜有关联的 UNC1069 组织,Microsoft 则将该攻击者追踪为 Sapphire Sleet。[3][4]
我把这称为一次信任链攻击。软件供应链攻击的方式,是在您审查过的每一行代码都不变的情况下,通过攻陷依赖项的交付渠道,来改变您实际运行的代码。没有人利用 axios 源码中的漏洞。链条上的每一环都是某个人做出的信任决定,而这正是理解这起事件最有用的角度。以下所有内容我都没有亲自复现过;本分析基于维护者事后复盘、各厂商报告,以及截至 2026 年 10 月 7 日的注册表记录。
axios@1.14.0 published by CI
_npmUser GitHub Actions
trustedPublisher github
gitHead 46bee3dea75ef53a8eae49f3b7487e6341de6074
dist.attestations SLSA provenance v1
axios@1.14.1 2026-03-31T00:21:58Z, published by hand
_npmUser the lead maintainer's account
trustedPublisher absent
gitHead absent
GitHub commit/tag none; the release exists only on npm这条链从一台笔记本电脑开始,也在一台笔记本电脑上结束
第一环既不是 npm,也不是 GitHub。据维护者的事后复盘,攻击者通过一次有针对性的社会工程学行动和 RAT 恶意软件,攻陷了首席维护者的电脑,从而获得了用于发布的 npm 账户凭据。[1] 开发者机器上的 RAT 产生了令牌,这个令牌又在每一台安装了被投毒版本的开发者机器和 CI Runner 上,产生了新的 RAT。链条两端是同一类恶意软件。
维护者对此态度坦率:直接从个人账户发布本是一种可以避免的风险,他们如今正在采用的 OIDC 流程和不可变发布,本应在事件发生之前就已就位。[1] 我很认同这句话。它点明了真正的教训,而这个教训与 axios 本身无关,关乎的是发布权限究竟掌握在谁手里。
我把这起事件理解为六个环节,如下图所示。在每一个环节上,原本都有一项控制措施可以斩断这条链,而每个环节的责任方也各不相同——有些属于发布者,大多数则属于您自己。下面我按顺序逐一说明。
1 maintainer laptop social engineering + RAT -> npm credentials
2 registry gate direct token publish accepted -> axios 1.14.1, 0.30.4
3 decoy dependency plain-crypto-js 4.2.0 -> 4.2.1 -> new line in package.json
4 your resolver fresh install, no cooldown -> poisoned tarball fetched
5 install script postinstall: node setup.js -> dropper runs
6 runner or laptop reachable secrets, open egress -> RAT, then whatever it can reach为什么 OIDC 可信发布没能挡住被盗的令牌?
因为它并不是唯一的入口。可信发布机制让注册表只接受来自指定 CI 工作流的发布,使用的是短生命周期的 OIDC 凭据,而不是存储的密钥。axios 1.x 版本一直就走这条路径,这也是 1.14.0 带有可信发布者标记和来源证明的原因。[2][6] 而恶意的 1.14.1 版本是直接从某个账户发布的:没有可信发布者标记,没有 gitHead,仓库里也找不到对应的提交或标签。[2]
存在一条良好的发布路径,并不会消除那条糟糕的路径。只要软件包仍然接受令牌发布,任何持有令牌的人就能完全绕过工作流。真正能堵住这条路的,是软件包层面的一项设置——Require two-factor authentication and disallow tokens(要求双因素认证并禁用令牌)。npm 文档建议在配置好可信发布者之后开启这项设置,而开启之后,已配置的可信发布者仍可照常工作。[6] StepSecurity 把缺失的元数据解读为一个被盗的长期经典令牌;而维护者只表示攻击者获取了账户凭据。[1][2] 无论是哪种情况,这次发布都从未经过正规工作流。
我的看法是:只打开 OIDC 却不关闭令牌,只能算半套控制措施,而很多团队做完前半部分就勾选了“已完成”。如果您发布软件包,请今天就检查这两部分是否都已到位。如果您是使用方,注册表的元数据就是一个可以读取的信号:一个过去由可信发布者发布的版本,突然不再如此,就值得暂停并核实。npm 在另一侧也在收紧:从 2027 年 1 月起,可以绕过双因素认证的细粒度令牌将失去直接发布的能力。[7]
一个没有任何代码引用的依赖,本身就是一项发现
攻击者提前一天铺好了路。plain-crypto-js@4.2.0 于 3 月 30 日 05:57 UTC 发布,作为一个干净的诱饵:它是合法 crypto-js 代码的拷贝,没有任何安装钩子,唯一的作用是给这个包一段发布历史,让它看起来不像是全新创建的。恶意的 4.2.1 版本紧随其后,于 23:59 UTC 发布,比 axios 1.14.1 早 22 分钟。[2]
现在来看破绽所在。StepSecurity 检查了 axios@1.14.1 全部 86 个文件,发现 plain-crypto-js 在任何地方都没有被 import 或 require 过。[2] axios 发布的代码根本用不到这个依赖。这个版本还丢失了 husky 的 prepare 脚本,这与一次绕过常规发布工具链的手动发布相吻合。[2] 说白了:一个补丁版本新增了一个运行时依赖,而这个包从未使用过它。遇到这样一行改动,人工审查者理应停下来多看一眼。
这正是我会第一个自动化的检查项。每次升级时,都比对新旧版本之间的 manifest 差异。补丁版本号跳动中出现的新依赖,应当交给人来审查,然后问三个问题:这个包有多老?谁在维护它?上层的包是否真的用到了它?下面的命令能在一分钟之内回答这三个问题。您的自动更新机器人不会替您问这些问题。
# what changed in the manifest between two versions
npm diff --diff=<pkg>@<old> --diff=<pkg>@<new> package.json
# does the package actually use the new dependency?
npm pack <pkg>@<new> && tar -xzf <pkg>-<new>.tgz && grep -R "<new-dep>" package/
# how old is the new dependency, and who maintains it?
npm view <new-dep> time maintainers --json为什么光是一条 npm install,就足以交出整台机器?
因为 npm 会在安装阶段执行代码。生命周期脚本,是软件包在 package.json 中声明、由包管理器自动执行的命令;plain-crypto-js@4.2.1 就声明了 postinstall: node setup.js。[3] 这条脚本在您的应用导入任何内容之前就已经运行,所以即便一个项目在运行时从未真正用到这个库,依然会暴露在风险之下。StepSecurity 观察到,npm install 执行后两秒内就出现了对攻击者服务器的首次请求。[2]
setup.js 的混淆手法其实比看起来要单薄。字符串先被反转,再从 Base64 解码,然后与密钥 OrDeR_7077 和常量 333 进行异或运算。[5] StepSecurity 的去混淆分析显示,这个密钥会经过 Number() 处理:字母会变成 NaN,与零异或不会改变任何东西,因此实际生效的密钥是 0,0,0,0,0,0,7,0,7,7。[2] 这只是用来糊弄字符串扫描器的小花招,算不上加密。由此得到的教训是:对安装脚本做静态分析是一个有用的绊线,但本身是一道薄弱的边界。
接下来,这个投放器会根据平台分支执行,并向攻击者的服务器请求第二阶段载荷。POST 请求体会告诉服务器该发送哪一个:product0 对应 macOS,product1 对应 Windows,product2 对应 Linux。[2] 下表展示了每个版本最终落在哪里。三个变体都会每隔 60 秒发送一次信标,内容是 Base64 编码的 JSON,User-Agent 固定为运行在 Windows XP 上的 Internet Explorer 8。[3] 由于 RAT 是实时拉取的,压缩包里本身并不包含严格意义上的恶意代码——这个包其实只是一个下载器。这也使出站流量成为真正关键的控制点,而来自构建 Runner 的 IE8 User-Agent,则是您能写出的最廉价的告警规则之一。[3]
platform stage two lands in started by
win32 %TEMP%\6202033.ps1 VBScript; powershell.exe copied to %PROGRAMDATA%\wt.exe
darwin /Library/Caches/com.apple.act.mond Mach-O binary, chmod +x, zsh in the background
linux /tmp/ld.py nohup python3
all POST http://sfrclak[.]com:8000/6202033
body: packages.npm.org/product0 (macOS), product1 (Windows), product2 (Linux)
beacon: Base64 JSON every 60 seconds
User-Agent: mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
win32 persistence: HKCU Run key "MicrosoftUpdate" and %PROGRAMDATA%\system.bat为什么事后文件夹看起来是干净的?
因为投放器做了清理工作。执行完成后,setup.js 会删除自身和恶意的 package.json,再把一个预先放好的 package.md 重命名为 package.json。[2] 这份替换后的清单文件声明的是不带任何安装钩子的 4.2.0 版本。此后,工程师打开 node_modules/plain-crypto-js 看到的只是无害的 4.2.0,尽管实际运行的是 4.2.1。[2] 那个提前一天埋下的诱饵,就这样第二次发挥了作用。
因此我总是建议团队检查锁定文件,而不是 node_modules。锁定文件中出现 plain-crypto-js(无论是 4.2.0 还是 4.2.1)的记录,就意味着您的安装图谱中包含了这次攻击;按照 Google 的建议,从这一刻起就应把该主机视为已被攻陷。[3] 关于该保留哪些证据、如何证明恢复完成,我在之前关于 Axios 构建证据的文章中已经讨论过,这里只聚焦于攻击机制本身。
那么这个 RAT 到底做了什么?Google 的分析把这段载荷命名为 WAVESHAPER.V2,其能力包括系统侦察、目录列举、任意命令执行和二进制文件注入。[3] 这是“获得访问权限”,还不等于“数据被窃取”。攻击者拿到开发者笔记本电脑或 CI Runner 的访问权限之后,通常会做的,是读取那里存放的令牌、密钥和 .env 文件;OpenAI 的案例展示了其中的利害关系:其 macOS 应用签名流程中的一个 GitHub Actions 工作流运行了 axios 1.14.1,而签名证书和公证相关材料就在触手可及之处。OpenAI 没有发现用户数据被访问或软件被篡改的证据,但出于谨慎轮换了证书。[12]
到底谁才需要真正担心这件事?
受影响的范围,比标题党的说法要小得多,也精确得多。被打包交付的应用程序本身并不是受害者。由于没有任何代码导入 plain-crypto-js,由 axios 1.14.1 构建出的前端 bundle 没有理由包含这段载荷;真正的受害者,是执行了安装过程的那台机器。[2] 时间窗也很短:从 00:21 UTC 到 03:15 UTC 下架,不到三个小时。[1]
要让这件事真正影响到您,需要同时满足三个条件:在这段时间窗内,一次全新安装解析到了 axios 1.14.1 或 0.30.4——也就是没有匹配锁定文件的安装,或者自动更新机器人在几小时内完成了合并;安装脚本被允许运行,而这恰好是默认行为;并且那台机器上存有一些值得攻击者去获取的东西。在事件期间,Microsoft 的建议是锁定精确版本号,并暂停自动化的依赖更新机器人。[4] 如果您能针对每一台 Runner 和每一台笔记本电脑回答清楚这三个问题,就了解了自己的暴露面;如果答不出来,这本身才是真正值得关注的发现。
哪项控制措施能斩断哪一环?
每个环节对应一项控制措施,下表就是完整的模型。只要斩断其中一环,就足以阻止某一次具体的攻击。但您无法决定下一个攻击者会绕开哪一环,所以应当同时覆盖多个环节。
第五环今年发生了变化。npm 12 自 2026 年 7 月 8 日起全面可用,除非项目明确允许,否则不再运行依赖项的 preinstall、install 和 postinstall 脚本,相关许可记录在一份需要提交到仓库的 allowScripts 列表中。[7][8] 在 npm 12 下,plain-crypto-js 的钩子如果没有被显式允许,是不会运行的。但请务必检查您的 CI 实际使用的是哪个版本:截至 2026 年 10 月 7 日,node:24-alpine 镜像自带的是 npm 11.19.0,而 npm 11 默认仍会执行依赖脚本。pnpm 从第 10 版起默认屏蔽这类脚本,但即便是这项设置,也需要加以核实:在 10.26.0 之前,托管在 Git 上的依赖仍然可以在安装期间执行代码,这是一个高危的绕过漏洞。[9][10] 只有当您亲眼见过某项设置确实拦下了什么,它才算得上是一道真正的边界。
第四环也有一项成本低廉的控制措施。npm 11.10.0 于 2026 年 2 月引入了 min-release-age(以天为单位的冷静期)。pnpm 的 minimumReleaseAge 以分钟为单位,在 pnpm 11 中默认值为一天。[10][11] 被投毒的 axios 版本存活时间不到三小时,所以哪怕只设置一天的冷静期,也足以躲过这类攻击。不过,冷静期本质上是在赌有人会在这段时间窗内发现问题,面对耐心的攻击者则毫无作用。可以搭配 pnpm 的 trustPolicy: no-downgrade 一起使用,它会拒绝信任等级相对以往版本有所下降的新版本。[10] 这项设置正是为 1.14.1 这类情形量身定做的,但由于 1.14.1 已经从注册表中下架,我没有机会用它重放这次攻击,请在您自己掌控的软件包上进行测试。
link control owner
1 maintainer laptop no publish credential on a workstation publisher
2 registry gate trusted publisher + "disallow tokens" publisher
3 decoy dependency diff the manifest; question new deps in patches you
4 fresh resolution npm ci + lockfile; min-release-age / pnpm cooldown you
5 install script npm 12 allowScripts; pnpm 10+; --ignore-scripts you
6 reachable secrets short-lived creds, split jobs, egress allow-list you
+ trust downgrade pnpm trustPolicy: no-downgrade you下一次执行 npm install 之前,先做八项检查
以下是这篇文章可以落地执行的版本。先从自己的代码仓库入手,再到 CI 镜像,最后是您自己发布的软件包。下面的清单按见效速度排序。
当我审计一个代码库时,依赖清单、锁定文件和发布配置都在审查范围之内,因为真正重要的信任边界,往往就藏在这些地方,而不是您自己写的代码里。这部分正是常规渗透测试通常触及不到的链条环节。如果您想要一双攻击者的眼睛,来审视您的产品如何信任它的依赖和发布路径,欢迎告诉我您正在发布什么产品。如果您怀疑自己已经中招,请在重建环境之前先保全证据,并请专门的事件响应人员介入——那是与我的工作不同的另一项专业分工。
1 lockfile grep -nE "plain-crypto-js" package-lock.json # 4.2.0 or 4.2.1 means act
2 CI install npm ci, never npm install; no bot merges within hours of a release
3 scripts npm 12: review allowScripts | older npm: --ignore-scripts, allow by name | pnpm 10+: allowBuilds
4 cooldown npm config set min-release-age 3 | pnpm: minimumReleaseAge
5 trust pnpm: trustPolicy: no-downgrade
6 publishing your packages: trusted publisher on, "disallow tokens" on, no publish token on a laptop
7 egress build runners: default-deny outbound; alert on node spawning curl, powershell or python3
8 secrets the install job holds no deploy, signing or cloud credentials参考来源
- [1] Axios 维护者:事件事后复盘
- [2] StepSecurity:axios 在 npm 上被攻陷,技术分析
- [3] Google Threat Intelligence:与朝鲜有关联的攻击者针对 axios
- [4] Microsoft:axios npm 供应链攻击的缓解措施
- [5] JFrog Security Research:plain-crypto-js 恶意软件分析
- [6] npm 官方文档:可信发布者
- [7] GitHub 更新日志:npm 安装阶段安全与弃用可绕过 2FA 的令牌
- [8] npm 官方文档:npm 12 中的安装脚本
- [9] pnpm 安全公告 GHSA-379q-355j-w6rj:生命周期脚本屏蔽的绕过方式
- [10] pnpm 官方文档:供应链安全设置
- [11] npm CLI v11.10.0 发布说明:min-release-age
- [12] OpenAI:开发工具 axios 被攻陷事件
需要在您自己的产品中检查这个问题吗?
安全代码审计想学会自己发现这类漏洞吗?
安全代码审计课程(一对一)