任何人都能发布那个包
8 月 28 日,有人对一个热门的 OpenAPI 代码生成器开了一个 pull request,在上面留了两个字的评论,然后这个项目自己的发布工作流就把十个被下毒的版本推上了 npm,还带着有效的来源证明。攻击者自始至终没有握有任何凭据。第一波完全没有安装脚本,而是把载荷藏在 node-gyp 会拿 Python 去求值的地方。整个窗口一共三小时十一分钟,而 Bromure Agentic Coding 的存在时间闸门默认就开着、设在两天,在对这场攻击一无所知的情况下就赢过了这段时间。
有人从一个 fork 开了 pull request,在上面留了两个字的评论。项目的发布流水线 读了那条评论,把他们的代码取出来,执行,然后为跑出来的成品签了名。走到这一步, 他们什么都没偷。
@7nohe/openapi-react-query-codegen 会把 OpenAPI 模式变成 TanStack Query 的
hooks。你把它指向一份规范,它就替你写出客户端代码。Aikido 统计到每周超过
150,000 次下载。你会加进这种包,是因为它替你省下一个下午,而你的编码代理连问都
不会多问一次就装了。
8 月 28 日,它开始出货一支凭据窃取程序。
Aikido Security 当天就发表了分析, SafeDep 也发表了自己的一份, 里面有工作流的机制,还有一份逐分钟的时间线。这支恶意程序在自己的字符串里给自己 起了名:Trinitite。两个团队都把它的手法归进 Mini Shai-Hulud 家族,也就是 今年一整年都在一个个维护者账号之间穿行的那只会自我复制的 npm 蠕虫。
真正值得一读的,是它走上 npm 的那条路。
发布流水线接受了一个陌生人的指令
项目的 release.yml 有一个 issue_comment 触发器。那是 GitHub Actions 的一个
事件,只要有人在 issue 或 pull request 上留言就会触发,而且是个常见的便利做法:
维护者把它接起来,好让自己敲一个词就能发版,而不必在界面上一路点下去。
这一个没有作者身份的检查。GitHub 会告诉工作流,写下这条评论的人是不是仓库的 所有者、是不是成员、是不是过去的贡献者,或者根本就是个陌生人,而工作流得自己 去看。这一个没有去看。
SafeDep 把整个顺序追了出来。从 fork 开一个 pull request。在上面评论
npm publish。流水线醒过来,取出攻击者 fork 里的那份代码,然后跑
pnpm install,这一跑就在项目自己的 runner 上执行了攻击者的 preinstall hook。
那个 job 带着 id-token: write,足够铸出一枚 npm 的 OIDC 信任发布令牌。
十个恶意版本就这样出去了,每一个都带着有效的来源证明,因为每一个确实都是 项目自己的流水线建出来、发出去的。Aikido 那句话最值得留着:“当工作流本身被 攻陷时,那张证书就成了一个不可靠的信任信号。”
去查一个包的来源证明,你只会知道一件事:它是不是从那个仓库的流水线里出来的。 在这里,它就是。没有人伪造任何东西,而你可以握着一个正确的签名,底下盖的却是 恶意程序,两件事看起来都不奇怪。五月我们 看过同样的结局,是通过一枚被偷走的 OIDC 令牌抵达的。 这一次,攻击者连偷都省了。
第一波根本没有安装脚本可以找
拿那前面八个版本去扫危险的安装 hook,你会空手而回。它们的 package.json 里没有
声明任何会执行的东西。
载荷在 binding.gyp 里,也就是 node-gyp 用来编译原生模块的那份构建描述文件。
Aikido 解释了这个花招:“当 npm install 处理一个含有 binding.gyp 的包时,
它会叫起 node-gyp 去编译原生模块。node-gyp 会用 Python 对文件里的
conditions 字段求值,也就是说任意的 Python 表达式都可以放在那里,而且会在安装
期间被执行,哪怕 package.json 里完全没有声明 preinstall 脚本也一样。”
于是攻击者把一段 Python 表达式写进了构建文件。它沿着 Python 内部的子类树一路走
到 catch_warnings,用那个类摸到 __builtins__,从那里导入 os,再对随
tarball 一起出货的一支 JavaScript 文件调用 os.system()。SafeDep 指出,这段遍历
是用 Unicode 转义写的,所以拿 os.system 去 grep 一样是空的。
底下那支 JavaScript 会拆掉三层 —— XOR、接着 AES-128-GCM、再接着一套商用混淆器
—— 然后才从 GitHub 把 Bun 1.4.0 抓下来,放进一个以 trinnyyyy- 为前缀的临时
目录,去跑真正的载荷。
十九分钟之后,攻击者出了第二波,把 preinstall 又加回 package.json 当备选。
SafeDep 把那一波的时间标在第一份公开安全报告发出后不到一分钟。操盘的人一边
读着披露内容,一边针对它出货。
三小时十一分钟
SafeDep 的时间线用的是 UTC。最后一个干净的版本 3.0.2 在 8 月 11 日出去。 第一波落在 8 月 28 日 20:00,20:02 结束。第二波跑的是 20:19 到 20:21。 npm 大约在 23:11 把十个版本全数下架。
这留下一个三小时十一分钟的暴露窗口。一个用 ^3.0.0 的项目,也就是 npm 替你写的
那个再普通不过的插入符范围,会直接解析进去。如果你在那个窗口里跑了一次干净安装,
或者你的 CI 跑了,或者你的编码代理跑了,你就拿到了那只蠕虫。
一旦跑起来,它就用超过 150 个 glob 模式把家目录扫了一遍:SSH 私钥、.env 文件、
~/.docker/config.json、AWS、Azure 与 GCP 的凭据,npm、PyPI 与 RubyGems 的令牌,
Kubernetes 的服务账号令牌,加密货币钱包。同一份清单上还坐着
~/.claude.json、~/.claude/ 和 ~/.claude/mcp.json,也就是编码代理自己的
配置,以及它会去讲话的每一台 MCP 服务器的地址。
在够得着的仓库里,它会写下一份 .claude/settings.json,带着一个 SessionStart
hook,每当开发者用 Claude Code 打开这个项目时就跑一次 setup.mjs。用刚刚
收集到的注册表令牌,它把自己重新发布到受害者在 npm、PyPI 和 RubyGems 上维护的
每一个包里。这就是蠕虫的那一部分:每一个被它落脚的开发者,都成了下一次发版的
候选人。
两天赢过三小时
现在把一个 Bromure Agentic Coding 的配置文件放进这条路径。
Bromure 把每个配置文件都跑成 Apple Silicon 上自己的一台虚拟机,而代理抓的每一个 包(npm、PyPI、Cargo、RubyGems、Maven、NuGet、Go 模块、Packagist)在任何一个 字节抵达客户机之前,都要先经过 hypervisor 上 Mac 那一侧的代理。
那个代理第一件检查的事,就是这个版本有多新。**Supply Chain → 存在时间闸门默认
就开着,最少两天。**浮动的引用(latest、插入符范围、波浪号范围)会安安静静地
解析到早于门槛的最新版本。你要是钉住某个太新的版本,就会拿到一个 451,附上清楚
的错误信息。
把这个拿去对上三小时十一分钟的敌对窗口。8 月 28 日 20:30 UTC,攻击正在进行时,
你的清单里写着 ^3.0.0,你跑 npm install,你的代理解析到的是
8 月 11 日的 3.0.2。它压根不会知道 3.0.3 存在。
这道闸门走到那一步,没有用到签名、没有用到信誉分数,也没有用到任何公告。它读的 是一个发布日期。这在这里很重要,因为第二波是在第一条公开警告发出后不到一分钟就 出货的,而任何要等报告的防御,都已经是慢了一步才进场。
就算它还是跑了,那趟扫描会找到什么
假设你为某个包把闸门关掉了,或者那个版本在有人发现之前就已经放到过了门槛。载荷 还是得做四件事,而在一个 Bromure 配置文件里,每一件都会撞上一个住在 Mac 上、 客户机够不着的控制。
它得跑起来。 Supply Chain → Strip install scripts 会即时把 preinstall、
install、postinstall 和 prepare 从 npm 的 tarball 里拿掉,重写 tarball,并
更新注册表的元数据哈希,好让 npm 自己的校验照样过关。第二波就这样没了。你还可以
往上叠更多检查:OSV 漏洞查询,以及通过 socket.dev 的包过滤,它那个
“拦截被攻陷的包”的检查正好覆盖这一类东西(流氓安装脚本、恶意程序),或者用
Delpi 当替代注册表。这些没有一个会去问“有没有证明”,而当证明是真的时候,
这一点就很要紧。
它得找到东西。 那趟 150 个 glob 的扫描,跑的是虚拟机里的 /home/ubuntu,
不是你 Mac 上的家目录。文件夹共享是明确指定的,上限是八个。它真的找到的凭据都是
诱饵:客户机里的令牌是 brm_… 这样的占位串,由宿主机上的代理在链路上换成真正的
值,所以真正的材料从来不会进到 VM 的地址空间。SSH 私钥
根本不在客户机里:宿主机上每个配置
文件各有一支 ssh-agent 负责签名,而 ~/.docker/config.json 里放的是假的 base64。
kubeconfig 是合成的,配上用完即弃的客户端证书。AWS 的请求会在宿主机端重新签名,
所以任何绕过代理的东西拿回来的是 InvalidSignatureException,而不是通过验证。
给某个凭据打开 Require approval to use,每一次替换都会在你的 Mac 上弹出一个
对话框,给出有时限的授权。
它得往外打电话。 Guardrails → Outbound connections 是一套 pf 风格的规则:
一个动作、一个协议(tcp、udp、web 或 any)、一个主机名或一段 IPv4 CIDR、
端口,而如果是 web,还有你允许的 HTTP 方法。规则自上而下匹配,先中的先赢,而
把 Unmatched traffic 设成 Deny,就把这份清单变成了允许清单。同一份策略会被
两个彼此独立的层评估两次:虚拟交换机用目的 IP 和从 DNS 偷听到的主机名,跨所有
协议;宿主机端的代理则用 TLS SNI 和方法。去抓一个 Bun 的二进制文件,和把偷到的
机密推进一个死信箱仓库,两件事都需要一个目的地,而目的地必须在清单上。
它得发布出去。 Guardrails → GitHub 设成 Read-only 时,git-receive-pack
会被当成写入,REST 的写入也会被拦,返回一个硬邦邦的 403,代理读起来就像一次普通
的 API 失败。没有被下毒的提交,没有死信箱,也没有接下去的发版。同样这几种模式也
覆盖 GitLab、Bitbucket、Kubernetes、AWS、DigitalOcean 和容器注册表。
它本来想留给下一次的那个 SessionStart hook,坐在一个由 Resources → Storage
底下的 Erase home… 抹掉的家目录里。写在家目录之外的东西,则由
Reset to base… 做同样的事。这一路上的每一个决定、每一个被放行或被拒绝的目的
地、每一次对包的裁决、每一次凭据的替换,都会落进宿主机上的 Security Log
窗口,而客户机的代码编辑不了它,因为客户机的代码够不着它。
为什么这件事一再发生
信任发布和来源证明是值得有的东西。它们把一年前最主流的那种攻击,也就是窃取维护者 的 npm 令牌,给堵掉了。这周的这次攻陷从旁边绕了过去,连一枚令牌都没碰到:一个 陌生人够得着发布流程,于是流水线就在敌意的代码上签了一份千真万确的证明。
你没办法靠审计走出这件事,因为下一个出事的工作流会是别人的。你能做的,是别再当 第一台去跑任何东西新版本的机器。
打开一个配置文件,看看 Supply Chain,确认存在时间闸门是开着的。它本来就该是, 设在两天,而且不需要你动过手。然后再花两分钟:打开 OSV vulnerability check, 有密钥的话选 socket.dev 或 Delpi,打开 Strip install scripts。在 Guardrails 里,把 Unmatched traffic 设成 Deny,配一份短短的允许清单,并把 任何不负责出货的配置文件的 GitHub 设成 Read-only。在 Credentials 里,对任何 能花钱的东西打开 Require approval to use。
然后就让代理去装它需要的东西吧。它会拿到 3.0.2。 安装 Bromure Agentic Coding,然后别去动那道存在时间闸门。