摘要:Rust的内存安全不等于构建过程安全。恶意crate可借build.rs、过程宏和构建依赖在开发机或CI上执行代码;本文复盘arrayref供应链事件,并给出锁文件、准入代理、SBOM与构建沙箱的工程防线。
## 导语
Rust 的内存安全并不等于构建过程安全。2026 年 8 月 20 日,Rust 安全响应团队确认:恶意 crate proc-macro1 的构建脚本会下载恶意载荷;随后发现 arrayref 0.3.10、internment 0.8.7 和 append-only-vec 0.1.9 被加入了对它的依赖。官方删除相关版本并锁定疑似失陷的维护者账户。RustSec 记录显示,仅 arrayref 0.3.10 在线约 86 分钟,却被下载 2,285 次。
这起事件最值得复盘的不是“又一个拼写仿冒包”,而是攻击者没有等待程序上线:开发者或 CI 只要解析到恶意版本并执行一次 cargo build,代码就已在构建主机上运行。
Cargo 的三类构建期执行边界
build.rs:编译前自动运行的宿主机程序
Cargo 官方文档明确说明:包根目录存在 build.rs 时,Cargo 会先把它编译成可执行文件,并在编译该包前运行。它常用于编译 C 库、探测系统环境和生成代码,但默认并没有安全沙箱。
官方要求正常脚本把产物写入 OUT_DIR,这是开发约定,不是强制访问控制。脚本仍以当前构建用户身份运行,可读取环境变量、访问该用户可见的文件、启动子进程,并在网络策略允许时对外连接。交叉编译也不会改变这一点:build.rs 运行在 host,不是最终二进制的 target。
过程宏:编译器内执行的代码
函数式宏、派生宏和属性宏本质上都是编译期函数。Rust Reference 直言,过程宏拥有与编译器相同的资源和文件访问能力,因此面临与构建脚本相同的安全问题。区别在于:恶意过程宏主体通常要在宏展开时被调用;而 crate 自带的 build.rs 会在该 crate 被构建时自动执行。
构建依赖:攻击代码的工具箱
[build-dependencies] 会为宿主机编译,供 build.rs 使用,不会因为最终程序“不调用它”就失去风险。普通依赖也可能携带自己的 build.rs。因此,审计不能只看业务代码的调用图,还要看完整包图、manifest 变更、过程宏和所有构建脚本。
攻击链是怎样闭合的
本次链路可概括为:
- 攻击者疑似取得合法维护者账户或发布凭据;
- 发布名称近似知名
proc-macro2的proc-macro1,并伪造作者与仓库元数据; - 在热门 crate 的新版本中增加一条对
proc-macro1的依赖,同时将若干旧版本 yank,诱导未锁定解析选择新版本; - Cargo 获取依赖并构建
proc-macro1,自动执行其build.rs; - 脚本按操作系统和架构下载第二阶段载荷并启动。SafeDep 与 StepSecurity 的分析均指出,该脚本拼接、解码远端地址,且下载链路禁用了正常的 TLS 证书校验;Linux/macOS 与 Windows 均有对应投递逻辑。
关键点是:上游 arrayref 的核心库代码可以看起来完全正常。一行 manifest 依赖变化,就足以把攻击面带入整个传递依赖图。
为什么常规代码评审容易漏掉
很多团队把“依赖风险”理解为最终二进制调用了某个危险 API,于是只审查 src/ 差异,或只扫描运行时漏洞。构建期攻击恰好绕开这套模型:声明了一个非可选普通依赖,Cargo 就需要构建它;依赖自己的脚本会先于库编译执行,而恶意逻辑不必出现在上层调用路径中。过程宏还可能读取源码、生成额外代码,或依据构建环境改变输出。
另一个盲区是 CI 权限通常高于应用运行权限。构建 runner 可能持有私有 registry 凭据、制品仓库令牌、源码读取权限、云端临时凭据或发布签名材料。攻击者无需突破已部署服务,只需借编译阶段读取环境和文件并向外传输。因此,供应链风险评估应以“构建主机能看到什么”为资产边界,而不是以最终程序能访问什么为边界。
Cargo.lock 为什么重要,又为什么不够
锁文件会记录精确版本、来源及 registry 包校验和。本次 RustSec 也指出,多数用户因锁文件仍固定旧版而未解析到恶意版本;CI 应提交 Cargo.lock,并使用 cargo build --locked,要求解析结果不得改写。经过受控预取后,--frozen 还能同时启用 locked 与 offline。
但锁文件解决的是“构建哪一份”,不是“这一份是否善意”。如果恶意版本已进入锁文件,其校验和只会证明下载内容与 registry 发布物一致;它不会识别合法发布者账户被盗,也不会阻止有害 build.rs。依赖更新机器人自动合并、手工运行 cargo update,或新项目首次解析,仍可能把恶意版本固定下来。锁文件因此是时间窗口防线,不是执行沙箱。
工程建议:把构建当作不可信代码执行
- 隔离依赖解析与升级。 锁文件入库;生产 CI 使用
--locked。依赖升级单独走评审,重点检查新增 crate、发布者变化、Cargo.toml、build.rs、proc-macro = true以及新增网络/进程类构建依赖。设置最短发布等待期,避免刚发布版本立即进入主干。 - 建立受控来源。 使用
cargo vendor或组织内只读代理/镜像,经过扫描和批准后再晋级。Cargo 官方支持 source replacement;JFrog 也建议用远程 Cargo 仓库代理 crates.io,以获得缓存、一致性和权限治理。镜像不是自动信任,准入策略仍必须独立存在。 - 硬化 CI。 将“拉取/审计依赖”与“编译”分阶段;编译阶段默认断网或仅允许明确目的地。使用一次性、非特权容器或微型虚拟机,根文件系统尽量只读,仅挂载源码只读和独立
target目录;禁止挂载 Docker socket、SSH agent、开发者 HOME 和云凭据,删除无关环境变量,并配合 seccomp、AppArmor/SELinux、最小 capability 与资源限额。仅断网仍挡不住随包携带的本地载荷,所以文件、进程和凭据边界同样必要。 - 让依赖可追踪。 用
cargo metadata、CycloneDX/SPDX 工具生成 SBOM,确保包含普通依赖、构建依赖和过程宏,并把 SBOM、Cargo.lock、编译器版本及产物摘要一同归档。Cargo 原生-Z sbom目前仍是 nightly 实验能力,输出的是每个产物的 SBOM 前置 JSON,不能把它误当成稳定扫描器。SBOM用于定位和响应,不会主动阻止执行。 - 持续检测与应急。 在合并前运行
cargo audit等公告检查和依赖策略工具;记录构建阶段 DNS、出站连接、子进程及临时目录行为。若命中过官方列出的恶意版本,不应只删缓存重编译,还应隔离 runner,轮换其可见的 token、SSH 密钥和云凭据,并按主机失陷流程排查。
结语
Rust 能显著降低运行期内存漏洞,却不会替你判断第三方构建代码是否可信。真正稳健的边界应是:锁文件控制解析,代理与准入控制来源,SBOM提供可见性,而沙箱和最小权限限制最坏结果。对 CI 而言,cargo build 不是“无副作用的编译命令”,而是一次需要被约束、观察和审计的代码执行。
参考资料
- Rust Project Blog:Supply chain attack on arrayref
- RustSec:RUSTSEC-2026-0260
- Cargo Book:Build Scripts、Cargo.toml vs Cargo.lock、Source Replacement
- Rust Reference:Procedural macros
- SafeDep:Malicious Rust Crate arrayref Runs a Build-Time Payload
- StepSecurity:Rust Supply-Chain Attack: arrayref, internment, and append-only-vec
- JFrog:How to Use Cargo Repositories in Artifactory