返回所有文章
发布于 · 作者 Renaud Deraison

蠕虫开源了

2026 年 5 月 11 日那一周的某个时刻,Shai-Hulud 背后的人——这条自 2025 年 9 月以来一直在吞噬维护者账号的自复制 npm 供应链蠕虫——把自己的源代码泄露了。到了周末,OX Security 在同一个账号下发现了四个 typosquat npm 包,其中一个几乎是泄露蠕虫的逐字拷贝,另一个是 Golang DDoS 僵尸程序,剩下两个是把 SSH 密钥和加密货币钱包发往廉价 C2 的普通信息窃取器。供应链攻击的复刻门槛刚刚被大幅拉低,而最有可能装上这些包的人,已经不再是人。

上周的某个时刻,Shai-Hulud 背后的人——那条自 2025 年 9 月以来一直 在啃食维护者账号的 npm 蠕虫——把自己的源代码泄露了。到了周末,一个 叫 deadcode09284814 的 npm 账号已经发布了四个复用这段代码的 typosquat。其中一个就是蠕虫本身,几乎一字不差。一个是 Golang DDoS 僵尸程序。两个是普通信息窃取器,会把你的 SSH 密钥、你的 ~/.aws/credentials 和你的 MetaMask 钱包 POST 到一个租来的 IP。 在两千六百七十八次安装之后,问题已经不再是“会不会有人把这次泄露 武器化”。问题是:今天下午在你终端里敲 npm install 的那些 agent 中,有几个会顺手把它们装进来。

在攻击性软件领域,周期性地会发生这样的事:某个封闭工具被丢上某个 论坛,能用上它的人群,从“写它的那一支队伍”一下子变成了“任何一个 有 VPS 的少年”。Mimikatz。EternalBlue。Conti 的源代码。每一次, 能力本身并没有变化——只是不再稀缺了。上周的头条新闻,由 BleepingComputer 基于 OX Security 的研究做了报道:Shai-Hulud——也就是我们之前 写过的那条六分钟内吞下四十二个 @tanstack 包的蠕虫——也加入了这份名单。

复刻并非空谈。到了周日,OX 已经记录到从同一个名为 deadcode09284814 的 npm 账号发布的四个恶意包:

  • chalk-tempalte——chalk-template 的 typosquat,携带了泄露的 Shai-Hulud 源代码的近乎一字不差的拷贝,包括 Shai-Hulud 把窃取 到的凭据上传为公开的自动生成 GitHub 仓库这一标志性行为。OX 的 判断是:“这个 Shai-Hulud 恶意代码几乎是泄露源代码的精确拷贝, 没有任何混淆手法”,说明这是一个连序列号都懒得磨掉的新威胁行为 者。
  • axois-utils——axios-utils 的 typosquat,投递的是 OX 称之为 Phantom Bot 的 Golang payload:HTTP、TCP、UDP 以及 reset 洪水 攻击,通过 Windows 启动文件夹和计划任务实现持久化,C2 位于 b94b6bcfa27554.lhr.life。你的开发机,被征召入伍。
  • @deadcode09284814/axios-util——又一个 typosquat,又一种 payload:SSH 密钥、环境变量、AWS/GCP/Azure 凭据,统统发往 80.200.28.28:2222。用 OX 的话说,“相当直白”。
  • color-style-utils——一个简单的信息窃取器,抓取你的 IP、 地理位置、加密钱包数据并 POST 到 edcf8b03c84634.lhr.life

OX 撰写这份报告时,这四个包的合计周下载量是 2,678。

这里其实有两个故事缠在一起,值得分开看。无趣的那个是:npm 上有 typosquat。它一直有;它将来也一直会有,原因和狗公园里有一堆叫 Bench 的狗一样——名字便宜,命名空间又是扁平的。有趣的那个是: 运行一条 Shai-Hulud 级别的蠕虫的门槛刚刚被拉低了。直到上周, 你还需要原班人马的工具链、原班人马的基础设施,以及原班人马那种 不被抓到的纪律性。今天,你只需要一份公开仓库的 GitHub 克隆、一条 lhr.life 隧道,以及敲下 npm publish 的耐心。OX 找到的这四个 包,合起来就是证明。

为什么行为者数量比包数量更重要。

一条单独的、复杂的蠕虫,在某种意义上是个可处理的对手。它有破绽。 它有基础设施。它有习惯。可以为它编写检测特征。Aikido、Socket、 Snyk、Wiz——这些跳上 5 月 11 日 @tanstack 事件的 npm 供应链 监控厂商——在几个小时之内就抓到了它,正是因为他们已经盯着同一 家族盯了八个月。

一个由从粘贴站点上下载源代码的人写出的衍生蠕虫家族则是另一种 形态。每一条都会渗漏到不同的 C2,嵌入不同的 RSA 密钥,选择不同 的文件组合去读,并选择不同的 typosquat 空间安家。有些会很小心; 大多数会犯下被迅速抓住的低级错误;其中某一条——下一次我们写这种 帖子的时候——会精巧到一周之内我们都抓不到它。防御者的检测问题 从“认出 Shai-Hulud”扩大到了“认出任何想从 prepare 脚本里读 ~/.ssh/id_ed25519 的东西”。这是一个大得多、大得多的面。

安装路径的形状也变了,而这正是任何在跑编码 agent 的人都应该担心 的部分。一个在 2024 年想要 chalk-template 的人类开发者,会从 某个教程里读出这个包名,敲进去,然后在 chalk-tempalte 返回的 是“两百次下载,发布者是个陌生人”时注意到拼错了。一个在 2026 年 被要求“给我的 CLI 输出加点颜色”的编码 agent 则会装上包管理器扔回来的任何东西。agent 不看发布者字段。agent 不会注意到 README 只有三行。agent 这个小时里要跑三十次 npm install,因为用户在干一支小团队的活儿,而 agent 是按任务 而不是按安装计酬的。

一份未经混淆的 Shai-Hulud 克隆到底做了什么。

chalk-tempalte 这个包,用 OX 的话说,“几乎没有任何修改”地复刻 了泄露的源代码。这意味着我们在 那条把自己写进 .claude 的蠕虫 里走过的那套机制照样适用,只多了一个重要的新花样:渗漏通道就是 GitHub 本身

Shai-Hulud 的标志性手法——被这个仿冒者保留下来,因为复制比重写 容易——是把窃取到的凭据并不送到一台隐藏的接收服务器。它们被 送进一个新建的公开 GitHub 仓库,这个仓库是用恶意代码刚从受害者 那里偷来的 GitHub token 发布的。受害者的机密就这么躺在一个由 受害者自己的 GitHub 账号拥有的公开仓库里,任由世界上的任何人去 抓取,直到有人注意到,仓库被清除掉为止。防御方的标准战术手册—— 封锁渗漏域名,寻找异常的出站 DNS——抓不到这一招,因为出站 DNS 查询的是 api.github.com,你的开发机本来就一小时跟它说两百次话。 凭据包以一次 git push 的伪装,离开了你的笔记本。

一旦机密被公开,任何盯着 GitHub 实时流的人——而确实有好几拨人 正是为了这个原因盯着 GitHub 实时流——就能把它们捡走。蠕虫不需要 让自己的 C2 保持在线。GitHub 就是 C2。

一个 npm 账号 — deadcode09284814 — 四个 PAYLOADnpm 用户deadcode09284814 注册: 2026-05 包数: 4 周下载: 2,678已发布chalk-tempalte ↳ 仿: chalk-templateaxois-utils ↳ 仿: axios-utils@…/axios-util ↳ 带作用域仿冒color-style-utils ↳ 名字听起来很通用四个都带 postinstall或 prepare 脚本chalk-tempalte → SHAI-HULUD 克隆读取: ~/.npmrc, ~/.config/gh, $GH_TOKEN, ~/.aws, ~/.ssh创建: 受害者账号下的公开 GitHub 仓库提交: 用内嵌 RSA 公钥加密的机密axois-utils → PHANTOM BOT (Golang DDoS)持久化: Windows 启动文件夹 + 计划任务能力: HTTP/TCP/UDP 洪水, TCP reset 攻击@…/axios-util → 纯信息窃取器读取: SSH 密钥, 环境变量, AWS/GCP/Azure 凭据POST: 原始数据包, 无加密层color-style-utils → 钱包窃取器读取: IP, 地理位置, 浏览器扩展钱包目标: MetaMask, Phantom, Keplr 本地金库战利品的去向chalk-tempalte: api.github.com (受害者 token) 受害者账号下新建公开仓库 + 87e0bbc636999b.lhr.lifeaxois-utils: b94b6bcfa27554.lhr.life Golang 僵尸, 接收指令@…/axios-util: 80.200.28.28:2222 原始 TCP, 无 TLScolor-style-utils: edcf8b03c84634.lhr.life HTTPS POST三条不同的 .lhr.life 隧道,一个裸 IP, 一个 api.github.com:"封域名"已经罩不住了。
四个 `deadcode09284814` 包把它们读到的机密分别送往何处。chalk-tempalte 用受害者自己被偷的 GitHub token,发布一个新建的公开仓库,把战利品放进去;87e0bbc636999b.lhr.life 上的 C2 只是顺带。axois-utils 把主机拉进一个 Golang DDoS 僵尸网络(Phantom Bot),通过 Windows 启动项和计划任务实现持久化,从 b94b6bcfa27554.lhr.life 接收命令。剩下两个则分别把凭据包 POST 到一个租来的 IP 和一条隧道主机。这四个包都从 `prepare` 或 post-install 里运行自己的 payload,意味着它们在 agent 敲下 `npm install` 的那一刻就执行,而不是等到用户去读 diff 的那一刻。

这张图里有个小细节值得停下来多看一眼。Shai-Hulud 克隆,也就是 chalk-tempalte,甚至不靠自己的基础设施来接收战利品。它使用 受害者自己的 GitHub 身份,把受害者自己被偷的机密,发布到 GitHub 上的一个公开仓库里。lhr.life 上的那个 C2 只是备份。 主要通道是 git push。对一个盯着出站流量的防御者来说,这与开发 者正常的 CI 完全不可区分。对 GitHub 来说,这——直到有人举报这个 仓库为止——是一个由真实用户拥有的合法公开项目。渗漏的过程被 受害者自己的身份洗白了。

你滑过这段文字的时候,agent 正在干什么。

如果你的终端里有一个编码 agent——Claude Code、Cursor's CLI、 Codex CLI、Aider,你挑一个——那么在你读完上一段的这段时间里, agent 很有可能已经替你跑过一次 npm install。也许两次。编码 agent 不会停下来欣赏依赖树。你买它,就是因为它会停。

OX 抓到的那些包是 typosquat,这是一类极其适合机器速度的错误。 一个必须亲手敲 chalk-template 的人,会把字母顺序敲对,因为 他敲过一百次了。一个吃下了地球上每一篇 Stack Overflow 帖子的 模型,在同一份训练语料里看到过 chalk-templatechalk-tempalte——后者通常出现在别人犯错的截图里——给一个像 “给我的 CLI 加上彩色输出”这样的 prompt,有时候会原封不动地把 拼错的版本吐出来。agent 不会眨眼。包管理器不会眨眼。prepare 脚本就开跑了。

这不是一种假想的失败模式。这正是 Shai-Hulud 家族被设计来攻击的 那种失败模式。最初的蠕虫通过窃取维护者 token,然后用这些 token 重新发布那些合法热门包的更多被污染版本来传播。仿冒者还没有拿到 维护者的 token;他们手里的是 typosquat 命名空间,而 agent 恰好 极其擅长一脚踩进去。

Bromure 在这个故事里站在哪儿。

Bromure Agentic Coding 的配置方式是:编码 agent 跑在一个每任务一次性的 Linux 虚拟机内部,项目文件夹 被挂载进去,出站流量由代理中转,凭据被保留在 hypervisor macOS 宿主侧。这套架构我们在 Bitwarden CLI 那篇文章@tanstack 那篇文章 里都详细走过一遍。下面是在这个边界内,这四个包各自具体会发生 什么。

BROMURE 每任务 VM — 四个 payload 看到的是什么编码 AGENT$ claude> 给我的 cli 加颜色工具: bashnpm i chalk-tempalte↳ postinstall 运行↳ 在客户机内↳ trace 记录下来客户机文件系统 — 桩值和缺失~/.aws/credentialsaws\_secret = stub-aws-…~/.npmrc$GH_TOKENghp_stub_…~/.ssh/id_ed25519没有这个文件或目录MetaMask / Phantom / Keplr 金库未安装浏览器配置客户机里没有宿主浏览器/etc/cron.d, 启动目录在一次性磁盘上PAYLOAD 结果chalk-tempalte: 读到桩值, 尝试访问 api.github.com → 401axois-utils: 僵尸写入客户机 启动目录 → 重置时清空color-style-utils: 没有钱包可读HYPERVISOR — 凭据中转 + 出口代理macOS 宿主 — 真实机密和真实浏览器, 从未跨过边界真实凭据库macOS 钥匙串id_ed25519 (私钥)~/.aws/credentialsAKIA… 真~/.config/gh/hosts.ymlghp_real (本任务范围内)~/.npmrcnpm_… 真发布 token~/Library/.../MetaMask/vault.json (真钱包)宿主 Chromium 配置你真实的浏览器以上从客户机均不可达出口代理 — 仅白名单git push → api.github.com/your/repo 桩 ghp_… ⇒ 真 ghp_… (在白名单内)POST api.github.com/user/repos (创建) 不在白名单 ⇒ 回 401 给客户机POST 87e0bbc636999b.lhr.life 不在白名单 ⇒ 阻断, trace 记录POST 80.200.28.28:2222 不在白名单 ⇒ 阻断, trace 记录爆炸半径 = 一个临时 VM + 已经在挂载的项目文件夹里的内容。重置之后,下一次安装从零开始。
同样地从 deadcode09284814 的某个 typosquat 上跑一次 npm install,但 agent 是在每任务一次的 Bromure VM 里运行。chalk-tempalte 在客户机内读取 ~/.aws、~/.npmrc、~/.ssh,只找到桩值或干脆找不到文件。然后它试着用 $GH_TOKEN 去创建一个公开仓库——桩 token 在出口代理那儿被回应 401,因为代理只对任务请求过的白名单端点替换为真实 token,而“新建一个公开仓库”不在那张列表里。axois-utils 把自己加入 Phantom Bot 的 C2;Golang 的持久化二进制把自己写进客户机的启动目录,而那目录就在一次性磁盘上。color-style-utils 去找一个根本没装的 MetaMask 金库。爆炸半径就是一个临时 VM,加上项目文件夹里本来就有的东西,仅此而已。

把这套架构对着这四个包逐一过一遍,因为细节正是这个架构赚回工钱 的地方。

chalk-tempalte 去摸 $GH_TOKEN

Shai-Hulud 克隆的标志动作,是拿走开发者的 GitHub token,然后用它 在开发者自己的账号下创建一个公开仓库。在 Bromure VM 内部,它读到 的 $GH_TOKEN 是一个桩——一个语法上合法、以 ghp_ 开头、专门 为这一刻存在的字符串。运行体的第一个动作是向 api.github.com 发起 POST /user/repos。宿主侧的出口代理把 api.github.com 识别为白名单端点,但仅限于当前任务实际请求过的操作——git push 到这个任务正在改的仓库,gh pr create 到同一个仓库, gh api repos/that/repo/issues。“在用户账号下新建一个公开 仓库”不在那张列表上,因为用户没有提这个要求。代理拒绝把桩 token 替换成真 token,桩值就这么作为桩值发出去了。GitHub 返回 401。 蠕虫的渗漏通道——那个聪明的、为绕过 DNS 出口过滤而设计的 通道——从未打开。

备用通道,也就是 87e0bbc636999b.lhr.life 上的 lhr.life 隧道,同样不在白名单里。trace 把这次尝试记录在案。数据字节并没 有离开。

axois-utils 安装 Phantom Bot 进行持久化。

那个 Golang 僵尸尝试把自己写进 Windows 启动目录并创建一个计划 任务。Bromure VM 是 Linux 客户机,所以 Windows 特定的持久化 免费就是空操作。在同一 payload 的 Linux 变种里——很快就会有人 做出来——僵尸会把自己写进 /etc/cron.d/~/.config/systemd/user/。这两个路径都在客户机一次性的 copy-on-write 磁盘里。下一次 bromure reset,或者当前任务自然 结束,都会把这块磁盘扔掉。持久化没了,根本不用去猎杀。

与此同时,僵尸对外连接 b94b6bcfa27554.lhr.life 也不在本任务 的出口白名单里,因为没有哪个正经编码任务会去跟一条刚注册的 lhr.life 隧道说话。僵尸朝着一个关闭的 socket 报到了。会话 trace 把这次尝试记录下来——这在明天早上 IOC 列表公布的时候会 派上用场。

@deadcode09284814/axios-util POST 原始凭据。

这四个 payload 里最简单的那个,恰好也是能抓到的东西最少的那个。 运行体读取客户机的 ~/.ssh~/.aws、环境变量,然后 POST 到 80.200.28.28:2222。SSH 目录是空的。AWS 文件是个桩。环境变量 要么是桩要么根本没设。目的 IP 不在白名单。要么连接在代理处就被 阻断,要么它带着一堆占位符离开宿主。两种结果都没事。

color-style-utils 去找根本不存在的钱包。

这个加密货币窃取器,是四个包里威胁模型最明确假设开发者自己的 浏览器和它在同一台机器上的一个。它读取像 ~/Library/Application Support/Google/Chrome/Default/Local Extension Settings/<MetaMask-id>/ 这样的路径,以及 Phantom 和 Keplr 的对应路径。这些路径在 Bromure VM 里一个都不存在。VM 里没有你的 Chrome 配置。VM 里没有装钱包 扩展。VM,本来就被设计成不是任何人的主力浏览器。运行体打开一 个空目录,然后转身走了。

这一部分不是一个关于凭据存放在宿主上的故事。钱包之所以在宿主 上,是因为在一台正常的笔记本上,开发者的浏览器和开发者的编码 agent 共用一个文件系统。Bromure 没有把钱包变得更安全;它只是让 钱包对蠕虫正在运行的那个地方变得不可达。蠕虫读不到不在它磁盘上 的东西。

还有哪些地方仍然会疼。

这个故事里有几个角落,Bromure 的每任务 VM 并不算是修复,值得把 它们大声说出来。

项目文件夹是挂载进来的。

蠕虫写进项目文件夹里的文件——包括我们在 @tanstack 那篇文章 里讲过的那种 .claude/router_runtime.js 式的持久化——在任务 重置后依然在,因为挂载项目文件夹本身就是为了这个目的。这里的 防御不是 VM。是 git status,以及在 push 之前花五秒钟看一下 diff。trace 让人更容易看出来,是哪个会话加进了不该出现的 文件。

出口白名单是故意收得窄的。

Bromure 的凭据中转之所以管用,正因为白名单很窄。如果你因为 今天要发版而把 npm publish 加进了自己 scope 的白名单,而你 今天碰巧装上了这四个包之一,蠕虫就会以你的 scope 发布。白名 单只放任务真正需要的东西。一个字节都不多。

为本任务发出的 token 仍然是真 token。

桩 GitHub token 在线路上会被换成真 token,仅限于任务已经加进 白名单的操作。如果 chalk-tempalte 能把 agent 哄去对项目自己 的仓库做一次 git push,那次 push 就会以真 token 通过。边界 保护的是凭据。它不审 diff。请你审 diff。

检测在 trace 下游。

会话 trace 会记录每一条 shell 命令、每一次文件写入、每一次 出站请求。它本身并不会把 87e0bbc636999b.lhr.life 判定为 坏。它只是记下这个请求发生过。等明天早上 OX 公布一份新的 IOC 列表,你的搜索只需要两秒。这才是 trace 带来的价值——不是魔法, 只是凭据。

最后还有一件事。

这次泄露本身不是新闻。新闻是这次泄露在接下来一年里会让什么事变 得很可能发生:大量小规模、半吊子的蠕虫复刻,而即使是它的精巧 原形,npm 生态也只是勉强及时抓住。其中一些复刻会响到足以拿到 一篇报道。大多数会在 registry 里躺一个星期,收上几千次 npm install,然后等到有人终于提交滥用举报后消失。OX 在 deadcode09284814 这几个包上看到的两千六百七十八次安装并不是 异常值。那是平均值。

诚实的问题不是“我的团队能不能避开 npm 上每一个有毒的 typosquat”。 agent 敲得飞快。名字又便宜。诚实的问题是:当 agent 装上一个的 时候——而在接下来一年里,在一支使用 agent 的团队中,这一定会 发生——post-install 脚本找到的,是开发者的双手,还是一台在凭据 文件里装着桩值、并配有一个不认识自己是谁的代理的、一次性 Linux 盒子。

Bromure Agentic Coding 是第二种。它免费、 开源,而且今天就已经在用。复刻树会在变好之前先变得更糟。