博客 / 安全资讯

axios 在 npm 上被攻陷:信任链是如何断裂的

axios 在 npm 上被攻陷,并不是代码本身的漏洞。本文梳理信任链上的六个环节、能够斩断每个环节的控制措施,以及 npm 12 带来了哪些改变。

已完成信息安全方向博士研究生学业已通过校内答辩 · 最终学位论文答辩尚待进行

代码没问题,出问题的是发布者

两条注册表记录,相差一个版本号。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 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

参考来源

需要在您自己的产品中检查这个问题吗?

安全代码审计

想学会自己发现这类漏洞吗?

安全代码审计课程(一对一)
全部文章

08联系

即将上线处理用户数据的产品?

给我发一份简要需求。我会在三天内回复问题或方案大纲,价格在开始前以书面形式确定。