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

他们先把安全的版本撤了下来

8 月 20 日,有人把一个被投毒的 arrayref 推上 crates.io,然后在十六秒之内,把它身后那些安全的版本一一撤下。于是 Cargo 自己的警告,就指向了唯一还站着的现代版本。恶意载荷藏在构建脚本里,所以光是对项目做一次类型检查就足以把它跑起来。Bromure Agentic Coding 把整场行动变成一道它必输的秒表题:被投毒的 crate 只活了 86 分钟,而默认的年龄门是两天。

攻击者根本不需要骗谁去装恶意软件。他让干净的版本消失,剩下的就交给注册表自己的升级 建议去完成。

8 月 20 日 07:15:00 UTC,[email protected] 以它真正维护者的账号出现在 crates.io 上。 二十四秒之后,攻击者开始 yank 掉 0.3.5 到 0.3.9:十六秒的脚本工作,把它身后的货架清空。

yank 是 crates.io 的召回机制,是维护者发现某个发布坏掉时会拉的那根杆子。Cargo 对它的 回应正是你希望的样子。它会警告你锁文件里有个版本已被撤下,并且在重新解析时拒绝选中被 撤下的版本。所有跟在这类警告后面的建议,都叫你去更新。

于是攻击者撤掉安全的版本,只留一个现代版本站着,让“做该做的事”把你送进去。

九十分钟,从头到尾

Rust Security Response WG 的公告StepSecurity 重建的时间线 描述了一场攻击者已经铺垫两天的行动。

8 月 18 日 01:25:58,他们在 crates.io 注册了一个叫 dtolney 的账号,跟 David Tolnay 只 差一个字母;生态里大多数 proc-macro crate 上都挂着那个名字。半小时后,他们发布了一个 干净的 [email protected],那是 proc-macro2 的仿冒名,什么都不做。那个发布存在的 唯一目的,是给这个账号一段历史。

真正的那个五小时后才来。[email protected] 在 8 月 20 日 07:11:15 上线,带着一个恶意 的 build.rs。四分钟之后,[email protected] 从合法所有者的账号发出,里面只有一处改动: 一行新的依赖,指向 proc-macro1 ^1.0.107。Security Response WG 在维护者这件事上很谨慎, 而且谨慎得对。他们“不认为 arrayref 的作者是在恶意行事”,并指出“他的电脑或凭据很可能 已被入侵”。[email protected] 在 07:34:07 跟上,[email protected] 在 07:37:49。

然后它结束得几乎跟开始一样快。Nextron Systems 的研究团队找到了那个恶意 crate 并上报。 crates.io 在 86 分钟后撤下 arrayref,90 分钟后撤下 internment,107 分钟后撤下 append-only-vec,删掉六个攻击者持有的 crate,并锁住那个被入侵的账号。RustSec 没有记录 到任何人对着被投毒的版本构建过的证据。

九十分钟是很快的响应。但对任何在那个窗口里跑过构建的人来说,它依然是事后才到。

两天的铺垫,九十分钟的暴露8月18日 · 01:25注册“dtolney”账号与每个 Rust 开发者早已信任的名字只差一个字母01:55 · 干净诱饵 1.0.1068月20日 · 07:11:15[email protected]proc-macro2 的仿冒名夹带恶意的 build.rs没人会打出这个名字8月20日 · 07:15:00[email protected]真实账号,被窃的凭据库的源码没有改动只多了一行依赖8月20日 · 07:15:24 → 07:15:40 — 十六秒arrayref 0.3.5, 0.3.6, 0.3.7, 0.3.8, 0.3.9 — 全部 yank注册表的召回机制被反过来开:Cargo 现在警告你锁定的版本已被撤下,并在重新解析时拒绝它只剩一个可安装的现代版本。你运行 cargo update 去清掉那个警告。build.rs 会在 cargo build、cargo check 和 cargo test 时执行 — 库里没有任何函数被调用过
这条投递路径从头到尾没碰过开发者的判断。一个仿冒名的构建期依赖,在拉进它的那个 crate 之前四分钟发布;那个 crate 身后所有安全的版本,在十六秒的连发里全被撤下;而 Cargo 自己关于这次撤下的警告,指向了唯一剩下的现代版本。

库里没有任何东西需要被调用

恶意载荷住在构建脚本里,而构建脚本会在编译期以你的权限运行。The Hacker News 报道称载荷会 在 cargo buildcargo checkcargo test 时触发。中间那个是谨慎的选项:你的编辑器保存时会跑的那个,你想听听编译器意见但什么都不想 执行时会敲的那个。在这里,它就够了。

脚本本身一旦摊在你面前,其实又短又不含蓄。它从 base64 片段重组出 https://23.254.165[.]112:9089/,关掉 TLS 证书校验,下载一份匹配你操作系统与架构的载荷, 然后分离运行。漂亮的一手是 std::mem::forget(child),丢掉那个句柄,好让进程逃出 Cargo 的 job object。植入体继续跑,而编译器以一次绿色的构建收尾。

接着下来的是一个信息窃取器。 Wiz 的分析 说它会用 HTTPS POST 向 /49890878 回连,收集主机名、用户名、操作系统与已安装应用,并查询 Chrome、Brave 与 Edge 的 SQLite 登录数据库里保存的凭据。它在 Windows 上以注册表 Run 键 持久化,在 macOS 上用 LaunchAgent,在 Linux 上用 systemd 用户服务;如果 C2 失联,就退回一 套域名生成算法,每五天生成十个 .com 域名。Wiz 把这批基础设施跟朝鲜的活动联系起来:与 Mastra npm 行动相同的 /49890878 端点、共用的证书签发者,以及反复使用的同一段 Hostwinds 地址。

它发出的构建覆盖 x86_64 的 Linux、Windows 和 macOS,外加 aarch64 的 macOS。最后那个是一台 Apple Silicon 笔记本。有人想过谁在编译 Rust。

你从没选过的那个依赖

arrayref 累计有 2.45 亿次下载,而几乎没有人把它写进 Cargo.toml。StepSecurity 数出 406 个依赖它的 crate 版本,arrayref 坐在 winit 底下,因而也在 eguiiced 底下,在 blake3blake2b_simd 底下,还在相当一部分 Ethereum 与 Solana 工具链底下。internmentappend-only-vec 两者又加上 1900 万次下载。

所以“加依赖前先审一遍”这条建议在这里够不着。没有人加过它。它出现在四层之下,在一个你确实 加过的 crate 底下,在一份一滑而过的锁文件差异里。读库的源码同样帮不上忙。三个被投毒的 crate,没人动过它们的源码;整个改动就是一行依赖,点名一个两天大的 crate,而发布它的账号 名字看起来是对的。

一个谈智能体编码的博客为什么在乎

再看一遍那个触发点。构建输出里跳出一条 yank 警告。某个东西跑了 cargo update 把它清掉。

那个东西,如今常常已经不是人。清掉警告是智能体在奔向你交代的事情途中顺手做的家务:它看到 一个被撤下的版本,它知道怎么修,它就修。它在你睡觉时工作,在循环里工作,横跨整批待办。它 因为构建失败而刷新锁文件,因为某个功能需要而加进新的 crate。

一个无人看管在工作的智能体,是最可能落在那 86 分钟窗口里的角色,也是最不可能为了一个由它 认得的名字发布、自己却没见过的传递依赖而停下来的角色。给它一个被入侵的维护者账号、一个跟 David Tolnay 只差一个字母的仿冒名,再加上一条建议升级的注册表警告,这条链上就没有任何一环 留给判断去拦。

你还是活得下来,因为判断本来就是决定这件事的糟糕地方。

Bromure 把它变成一道秒表题

每一份 Bromure Agentic Coding 配置文件出厂时都开着年龄门,设在两天。主机端的 MITM 代理 看得到 VM 发出的每一次包获取,横跨 npm、PyPI、Cargo、RubyGems、Maven、NuGet、Go 模块 和 Packagist,而比阈值更年轻的版本回不来。

Cargo 拿到的是完整待遇。Bromure 认得 index.crates.iocrates.iostatic.crates.io, 然后在途中改写 crate 的元数据:把太新的版本从版本列表里剔掉,并把 max_versionmax_stable_versionnewest_version 设成通过过滤后最新的那个发布。Cargo 的解析器从来 不会知道有更新的版本存在。

现在把数字并排放。

每个版本最多活到多老,对上两天的界线[email protected]arrayref 拉进它时,它只有 4 分钟大[email protected]在注册表上 86 分钟[email protected]90 分钟[email protected]107 分钟Bromure 年龄门2 天 — 2,880 分钟 — 默认开启,可从 0 调到 90在主机端代理过滤:太新的版本会从 crate 元数据里剔掉,“最新版本”字段在 Cargo 读到之前就被改写。
这场攻击里的每一个版本,都在默认策略根本不会打开的那个窗口里出生又死去。年龄门不是对包做的判断;它是一只钟,而这场行动没办法等它走完而不被发现。

这场行动里没有任何东西活到两天大。arrayref[email protected] 点名为依赖时,它才 四分钟大。对门后面的解析器来说,这两个 crate 都不可见,所以 cargo update 找不到任何新 东西。

你会在屏幕上看到这件事。0.3.5 到 0.3.9 被 yank、0.3.10 被过滤掉之后,对 arrayref 做一次 全新解析,可能会回来说选不出版本——那是一条错误,而不是一次无声的成功。已经钉在 0.3.9 的现有 Cargo.lock 会继续构建,因为 yank 只挡新的解析。无论哪种,你那个周四早上都花在读 一条解析器消息,而不是轮换你手上所有的凭据。

Bromure 还会再查第二次,为的是解析器早就有答案的情况。它拿每一次制品获取,也就是 .crate 文件本身,去比对记录下来的发布时间,并返回一个把年龄说明白的 451发布于 41 分钟前, 策略要求至少 2 天。一份热的元数据缓存,买不到攻击者任何东西。

投放器的第一跳哪儿也去不了

假设你把钟关掉了。有人给某个包开了例外,或者把阈值降低一个下午。构建脚本还是得连上 23.254.165.112 的 9089 端口。

每一份 Bromure 配置文件都带着自己的出站策略,写成一小段 pf 风格的规则集:

allow web crates.io
allow web static.crates.io
allow web index.crates.io
allow tcp api.anthropic.com:443
default deny

你的 Mac 在共用同一份规则的两层上执行它:虚拟交换机,跨所有协议按目的 IP 与从 DNS 里嗅到 的主机名匹配;以及代理,按 TLS SNI 与 HTTP 方法匹配。9089 端口上一个赤裸的 IP,在那个文件 里对不上任何规则,于是落到 default deny,连接在边界的主机这一侧就死了。第二阶段的 DGA, 每五天十个新的 .com 域名,撞上同一面墙,因为生成出来的名字,仍然是没人放进清单里的名字。 每一次拒绝都会以一行 egress.firewall 落在 Window → Security Timeline,带着主机、IP、 端口和判定,写在 VM 里的代码够不着的地方。

那里面没有东西可偷

读一读这起事件附带的恢复清单:轮换 SSH 密钥、云令牌、API 令牌、签名密钥。清掉注册表缓存。 清掉 CI 的缓存层。重建 vendor 目录。在每一台曾在那个窗口里构建过的机器上。

一个 Bromure 工作区用一间空屋子回答了这份清单的大半。

真正的凭据从来没进过 VM。那个客户机里的 ANTHROPIC_API_KEY 是一个 brm_… 的占位串。 kubeconfig 里放的是用完即弃的客户端证书。容器注册表的认证是一串派生出来的 Basic 字符串。 GitHub 令牌是假的,SSH 密钥是为那个工作区逐配置文件生成的 ed25519 密钥,不是你笔记本上那 把。主机端的 MITM 代理会在出站请求上换成真值,而 AWS 还更进一步:主机会用真正的密钥材料 重新签署 SigV4 请求,所以任何绕过代理的东西拿回来的是 Amazon 的 InvalidSignatureException,而不是一次能用的调用。

一个在那个客户机里扫凭据的信息窃取器,会找到凭据。它们解析得过去,看起来也对,而且一离开 那台机器就一文不值。

它购物清单上的其余部分,落在虚拟机监控器的另一边。Chrome、Brave 和 Edge 把登录数据库留在 你的 Mac 上,放在客户机没有路径可达的用户配置里。它想写的那个 macOS LaunchAgent,也就是它 为什么要出一份 aarch64 macOS 构建的理由,压根没有 macOS 可写。它可以在客户机自己的家目录 里装一个 systemd 用户服务,而那一层有一个按钮:Erase home… 会把配置文件的 /home/ubuntu 重置回刚克隆完的状态,Reset to base… 则从只读的基础镜像重新克隆工作区 的系统盘。

而且你早就有那份清单了

这起事件每一份修复指南的最后一行都是一次 grep:在每一台机器的每一份 Cargo.lock 里搜六个 crate 名字和三个版本字符串,然后去 ~/.cargo/registry/cache 里搜那些 tarball。那是很好的 指示,也是很惨的一个下午,而且它只在你还留着的机器上管用。

Bromure 在事情发生的当下就把答案记了下来。每一次穿过代理的包获取,都会在 Window → Security Timeline 写下一行 supply_chain.fetch,带着生态、包、版本、结果, 以及结果背后的理由,记在主机这一侧,跟每一次凭据替换和每一次防火墙判定并排。等下一条公告 点名某个版本和某个两小时窗口时,“这里有东西抓过那个吗?”就是一次搜索,而不是一场远征。

Bromure Enterprise Manager 把同一道数据流在整个机群上汇总起来,而问题通常正是以那种形式 抵达:昨晚跑智能体的那九十台机器里,有没有哪台构建了它

值得留下的那部分

crates.io 这回做得好。从上报到移除九十分钟,六个攻击者的 crate 消失,被入侵的账号锁住,一 条清楚的公告,而且没有任何人中招的证据。Rust 团队还特意不去责怪一位凭据被偷的维护者,那是 对的直觉,而且不总是常见的直觉。

不过,总会有人重用这套机制,因为它便宜,而且它聪明。yank 是一根公开、即时、只要一行的杆子, 能让一个注册表去建议升级,而每个生态都有一根。跑你构建的那个东西会看到那条建议并照着做,而 如今照着做的那个东西,往往缺了整整一个周四早上的上下文,不知道哪个 crate 是哪个。

所以把这个决定放到不需要上下文的地方。一个版本发布与你的构建之间隔着两天的日光,不是对某个 包、某位维护者,或某个只差一个字母的账号名字下的判断。那是一只钟,而这场行动只有九十分钟。


来源:Rust Security Response WG“Supply chain attack on arrayref”(2026 年 8 月 20 日) · Wiz“Rust Supply Chain Attack on arrayref: Significant Overlap with DPRK Campaigns”(2026 年 8 月 20 日) · StepSecurity“arrayref, internment, and append-only-vec Poisoned by the proc-macro1 Build-Time Dropper”(2026 年 8 月 20 日) · The Hacker News“Rust Supply Chain Attack Puts Build-Time Malware in Crates with 245 Million Downloads”(2026 年 8 月 20 日) · BleepingComputer“Hackers poison arrayref Rust crate to push infostealer malware”(2026 年 8 月 20 日) · Semgrep“Rust crates arrayref & append-only-vec compromised”(2026 年 8 月 20 日)