В апреле 2026 года Canonical раскрыла 44 CVE в uutils — реализации GNU coreutils на Rust, которая поставляется по умолчанию с версии 25.10. Большинство из уязвимостей обнаружилось при внешнем аудите, проведённом перед выпуском 26.04 LTS.

Я изучил список и решил, что из него можно многому научиться.

Примечательно то, что все эти баги оказались в кодовой базе на Rust, написанной людьми, которые знают, что делают, и ни один из багов не был отловлен механизмом проверки заимствований, clippy lints и cargo audit.

Я пишу эту статью не для того, чтобы покритировать команду разработчиков uutils. Ровно наоборот: мне хочется поблагодарить её за публикацию результатов аудита с подробностями, благодаря которым все мы можем научиться чему-то новому.

Кроме того, на нашем подкасте Rust in Production недавно был вице-президент по разработке Ubuntu Джон Сигер, заслуживший похвалы слушателей за честный рассказ о состоянии Rust в Canonical.

Если вы пишете системный код на Rust, то эта статья будет самым сжатым анализом того, где сейчас заканчивается безопасность Rust.

Не доверяйте пути между двумя системными вызовами

Это самый крупный кластер багов в аудите. Кроме того, это причина того, что cpmv и rm по-прежнему реализованы в GNU в Ubuntu 26.04 LTS.

Паттерн всегда выглядит одинаково: мы выполняем один системный вызов для проверки какой-то информации о пути, а затем ещё один системный вызов для выполнения действия по тому же пути. Между этими двумя вызовами нападающий, который имеет доступ на запись в родительскую папку, может заменить компонент пути на символьную ссылку. При втором вызове ядро выполнит повторный ресолвинг пути с нуля, и привилегированное действие будет выполнено по отношению к выбранной нападающим цели.

Из-за стандартной библиотеки Rust совершить эту ошибку становится очень легко. Эргономичные API, к которым мы обращаемся в первую очередь (fs::metadata, File::create, fs::remove_file, fs::set_permissions) каждый раз выполняют повторный ресолвинг пути, а не берут дескриптор файла, чтобы работать относительно него. Для обычной программы это вполне нормально, но если вы пишете инструмент с высокими привилегиями, который нужно защитить от локальных нападающих, то следует быть внимательным.

Пример: CVE-2026-35355

Вот упрощённый баг из src/uu/install/src/install.rs.

// 1. Сбрасываем цель
fs::remove_file(to)?;

// ...

// 2. Создаём цель. Здесь выполняется повторный ресолвинг пути!
let mut dest = File::create(to)?; // следование по символьным ссылкам, отсечение
copy(from, &mut dest)?;

Между шагами 1 и 2 любой пользователь с доступом на запись в родительскую папку может вставить to в качестве символьной ссылки, например, на /etc/shadow. Затем File::create пройдёт по символьной ссылке и привилегированный процесс перепишет /etc/shadow тем, что содержится в from.

Для устранения бага применяется OpenOptions::create_new(true):

fs::remove_file(to)?;

let mut dest = OpenOptions::new()
    .write(true)
    .create_new(true)
    .open(to)?;
copy(from, &mut dest)?;

Из документации create_new (выделение моё):

В целевом местоположении не допускается существования файла, а также (висящей) символьной ссылки. В таком случае при успешном выполнении вызова файл гарантированно будет новым.

Правило: привязка к дескриптору файла

&Path в Rust походит на значение, но нужно помнить, что для ядра это просто имя. Между системными вызовами это имя может указывать на разное. Привязывайте операции к дескриптору файла.

create_new() помогает только в случае создания нового файла. Для всего остального один раз откройте родительскую папку и работайте относительно этого дескриптора.

Если вы дважды выполняете действие по тому же пути, предполагайте наличие бага TOCTOU (Time Of Check To Time Of Use), пока не доказано обратное.

Задавайте разрешения во время создания, а не после

Эта проблема очень близка к TOCTOU. Допустим, вам нужна папка с ограниченными разрешениями, поэтому вы пишете примерно такой код.

// Создание с разрешениями по умолчанию
fs::create_dir(&path)?;
// Изменение разрешений
fs::set_permissions(&path, Permissions::from_mode(0o700))?;

В течение короткого периода времени path существует со стандартными разрешениями. Любой другой пользователь системы может открыть (open()) его во время этого окна. После получения дескриптора файла последующая chmod не отбирает его.

Правила: устанавливайте разрешения при создании, а не после него

Используйте OpenOptions::mode() и DirBuilderExt::mode(), чтобы файл или папка создавались с нужными вам разрешениями. Ядро применит поверх вашу umask, поэтому если она важна, тоже задавайте её в явном виде.

Равенство строк на путях не эквивалентна идентификации в файловой системе

Изначальная проверка --preserve-root в chmod буквально выглядела так:

if recursive && preserve_root && file == Path::new("/") {
    return Err(PreserveRoot);
}

Это сравнение можно обойти тем, что ресолвится в /, но не прописывается, как /. То есть /..//.//usr/.. или символьной ссылкой, указывающей на /. Если выполнить chmod -R 000 /../, то она обойдёт проверку и заблокирует всю систему.

Исправление выглядит так:

fn is_root(file: &Path) -> bool {
    matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))
}

if recursive && preserve_root && is_root(file) {
    return Err(PreserveRoot);
}

Правило: перед сравнением путей выполняйте их ресолвинг

canonicalize ресолвит ... и символьные ссылки в реальные абсолютные пути. Они гораздо лучше, чем сравнение строк.

Возможно, вам непонятна эта строка:

matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))

Думаю, это более сложная реализация такого кода:

// Сначала ресолвим путь в его канонический вид
if let Ok(p) = fs::canonicalize(file) {
    // Если удалось, то проверяем, равен ли канонический путь "/"
    p == Path::new("/")
} else {
    false
}

В конкретном случае --preserve-root это работает, потому что у / нет родительской папки, поэтому нападающий не может заменить ничего ниже. Однако в более общем случае сравнения двух произвольных путей на идентичность в файловой системе необходимо открыть оба и сравнить их пары (dev, inode), как это делает GNU coreutils. (Проверяем идентичность, а не равенство строк.)

Кстати, мой любимый баг в этой группе — CVE-2026-35363:

rm .    # ❌
rm ..   # ❌
rm ./   # ✅ 
rm ./// # ✅ 

. и .. отбрасываются, зато принимаются ./ и .///, а затем удаляется текущая папка и выводится Invalid input.

Оставайтесь в пределах байтов на границах Unix

String и &str в Rust всегда хранятся в UTF-8. В 99% случаев это отличный выбор, но пути, переменные окружений, аргументы и входные данные Unix, передаваемые через инструменты наподобие cut, comm и tr, находятся в запутанном мире байтов.

Каждый раз, когда программа на Rust пересекает эту границу, у неё есть три варианта.

  1. Преобразование с потерями при помощи from_utf8_lossy переписывает недопустимые байты на U+FFFD, не сообщая об этом. Это всего лишь усложнённое повреждение данных.

  2. Строгое преобразование при помощи unwrap или ? приводит к сбоям или отказывается работать.

  3. Оставаться в байтах при помощи OsStr или &[u8]; именно этот вариант вам обычно и следует использовать.

При аудите были найдены баги в первых двух категориях.

Пример: comm (CVE-2026-35346)

Это оригинальный код из src/uu/comm/src/comm.rs.

// ra, rb - это &[u8], сырые байты из входящих файлов.
print!("{}", String::from_utf8_lossy(ra));
print!("{delim}{}", String::from_utf8_lossy(rb));

GNU comm работает с двоичными файлами, потому что она просто обрабатывает байты. Версия uutils заменяет всё, что не относится к валидному UTF-8, на U+FFFD, то есть втихомолку повреждает выходные данные.

Вот исправление: остаёмся в пределах байтов.

let mut out = BufWriter::new(io::stdout().lock());
out.write_all(ra)?;
out.write_all(delim)?;
out.write_all(rb)?;

print! вынуждает выполнять передачу UTF-8 через Display. Метод Write::write_all этого не делает, он записывает сырые байты напрямую в stdout.

Правило: выбирайте подходящий тип по ситуации

В системной коде в стиле Unix используйте Path и PathBuf для путей файловой системы, OsString для переменных окружения и Vec<u8> или &[u8] для содержимого потоков. Хочется передавать их через String для удобства форматирования, но это приводит к повреждению.

UTF-8 отличный выбор по умолчанию для строк приложений, но его совершенно точно не стоит по умолчанию использовать для операций с сырыми байтами, которые выполняют инструменты Unix.

Обрабатывайте каждую panic!, как отказ в обслуживании

В CLI каждая unwrap, каждая expect, каждый slice index, каждая непроверенная арифметическая операция, каждая from_utf8 — это потенциальный отказ в обслуживании, если нападающий может управлять входящими данными. Причина этого в том, что panic! раскручивает стек и прекращает процесс. Если ваш инструмент выполняется в заданиях cron, в конвейере CI или в скрипте оболочки, то это приводит к полному завершению работы. Хуже того: иногда может возникнуть цикл сбоя, парализующий целую систему.

Каноничным примером этого из аудита стал sort --files0-from (CVE-2026-35348). Этот флаг считывает из файла список имён файлов, разделённых NUL, но для каждого преобразования имени в UTF-8 парсер вызывал expect():

// Упрощённый код внутри sort.rs
let path = std::str::from_utf8(bytes)
    .expect("Could not parse string from zero terminated input.");

GNU sort обрабатывает имена файлов, как сырые байты, аналогично тому, как поступает ядро. Версия uutils требовала UTF-8 и прерывала весь процесс при обнаружении первого пути, не соответствующего UTF-8:

$ python3 -c "open('list0','wb').write(b'weird\xffname\0')"
$ coreutils sort --files0-from=list0
thread 'main' panicked at uu_sort-0.2.2/src/sort.rs:1076:18:
Could not parse string from zero terminated input.: Utf8Error { valid_up_to: 5, error_len: Some(1) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

(Я воспроизвёл это для coreutils 0.2.2 в macOS. Однострочник на Python здесь нужен потому, что большинство современных оболочек отказывается создавать имя файла, не состоящее из UTF-8.)

Правило: превращайте плохой ввод не в панику, а в ошибки

В коде, обрабатывающем ненадёжные входные данные, каждая unwrapexpect, индексирование и преобразование as вполне может стать CVE. Используйте ?, get, checked_*, try_from, чтобы выявить реальную ошибку. Выражайте несогласие на границе своего приложения, чтобы последствиями занималась вызывающая сторона.

Хорошая основа lint для выявления этого проблемы в CI:

[lints.clippy]
unwrap_used      = "warn"
expect_used      = "warn"
panic            = "warn"
indexing_slicing = "warn"
arithmetic_side_effects = "warn"

Они создают шум в тестовом коде, где нам как раз нужна паника при плохих данных. Проще всего ограничить их не относящимся к тестам кодом, поместив #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing, clippy::arithmetic_side_effects))] наверх каждого корня крейта или добавив #[allow(...)] в отдельные модули #[cfg(test)].

Выполняйте распространение ошибок, а не отбрасывайте их

Эта проблема тесно связана с предыдущим пунктом: часть CVE была вызвана игнорированием или утерей информации ошибок.

chmod -R и chown -R возвращали код выхода последнего обработанного файла, а не худшего. Поэтому chmod -R 600 /etc/secrets/* может выполниться со сбоем для половины файлов, но всё равно совершить выход с 0. Ваш скрипт будет считать, что всё в порядке.

dd вызывала Result::ok() для своего вызова set_len() , чтобы имитировать поведение GNU для /dev/null. Задумка была логичная, но тот же код выполнялся и для обычных файлов, поэтому если диск был заполнен, втихомолку создавался наполовину записанный целевой файл.

Причина заключалась в том, что кто-то хотел избавиться от Result и пользовался .ok(), .unwrap_or_default() или let _ =.

Правило: не отбрасывайте значимую информацию ошибок

Вот очень простой паттерн, позволяющий избежать этого:

// Не выходите при первой ошибке, а запоминайте наихудшую.
let mut worst = 0;
for file in files {
    if let Err(e) = chmod_one(file) {
        worst = worst.max(e.exit_code());
    }
}
process::exit(worst);

Кроме того, если вы пишете .ok(), чтобы отбрасывать Result, то оставьте комментарий, объясняющий, почему этот конкретный сбой можно безопасно игнорировать.

Обеспечьте точное соответствие поведению исходного инструмента

На удивление большое количество CVE сообщает не о том, что код делает что-то небезопасное, а о том, что код делает что-то иначе, чем GNU, а скрипт оболочки полагается на поведение GNU.

Наиболее наглядный пример — это kill -1 (CVE-2026-35369). GNU считывает -1, как «сигнал 1» и запрашивает PID. uutils считывает это, как «отправить сигнал по умолчанию на PID -1», что в Linux означает все процессы, которые можно увидеть. Неприятно!

Опечатка превращается в аварийный выключатель всей системы.

Правило: совместимость багов — это фича безопасности

Если вы пишете новую реализацию проверенного годами инструмента, то фичей безопасности будет совместимость багов кодов выхода, сообщений об ошибках, пограничных случаев и семантики опций. (Привет, закон Хайрама; и здесь обязательно нужно дать ссылку на XKCD 1172!)

Если поведение вашего инструмента отклоняется от поведения оригинала, то чей-нибудь скрипт оболочки обязательно примет неправильное решение.

Теперь для uutils выполняется апстрим-набор тестов GNU coreutils в CI. Это правильный масштаб защиты от подобного класса багов.

Ресолвите входящие данные до пересечения границы доверия

CVE-2026-35368 — худший баг во всём аудите. Это локальное исполнение рут-кода в chroot. Баг заметен, если знаешь, где искать (chroot, за которой следует вызов функции, загружающий динамическую библиотеку), но при первом чтении кода он совершенно неочевиден.

Вот упрощённый паттерн из утилиты chroot.

chroot(new_root)?;
// По-прежнему uid 0, но теперь внутри управляемой нападающим файловой системы.
let user = get_user_by_name(name)?;
setgid(user.gid())?;
setuid(user.uid())?;
exec(cmd)?;

Хм, выглядит невинно.

Ловушка заключается в том, что для ресолвинга имени пользователя get_user_by_name в итоге загружает общие библиотеки из новой корневой файловой системы. Нападающий, способный подсунуть файл в chroot, сможет запустить код, как uid 0.

GNU chroot ресолвит пользователя до вызова chroot. Здесь используется такое же исправление.

let user = get_user_by_name(name)?;

chroot(new_root)?;
setgid(user.gid())?;
setuid(user.uid())?;
exec(cmd)?;

Правило: выполняйте ресолвинг до пересечения границ

После пересечения границы вызов любой библиотеки может выполнить код нападающего. И нет, статическая компиляция здесь не поможет, потому что get_user_by_name идёт через NSS, который выполняет dlopen модулей libnss_* в среде исполнения, вне зависимости от того, был ли двоичный файл скомпонован статически.

Какие баги Rust отловил

Возможно, вы дочитали до этого места и подумали: «Ого, это же целая куча багов! Может, Rust не так безопасен, как я думал?».

Это был бы очень ошибочный вывод.

Стоит учитывать, что не произошло ничего из нижеперечисленного:

  • Переполнение буфера.

  • Использование после освобождения.

  • Двойное освобождение памяти.

  • Гонки данных для общего изменяемого состояния.

  • Разыменование нулевых указателей.

  • Чтение неинициализированной памяти.

Благодаря этому, несмотря на то, что инструменты были (и, вероятно, по-прежнему остаются) забагованными, в них никогда не было бага, который можно использовать для чтения произвольной памяти.

В GNU coreutils присутствовали CVE в каждой из этих категорий. Можно взглянуть на файл GNU NEWS за последние несколько лет:

  • Переполнение буфера pwd на глубоких путях длиннее 2 * PATH_MAX (9.11, 2026 год)

  • Чтение numfmt out-of-bounds на пустых символах в конце (9.9, 2025 год)

  • Переполнение буфера кучи unexpand --tabs (9.9, 2025 год)

  • Записи od --strings -N байтов NUL после буфера кучи (9.8, 2025 год)

  • Чтение sort одного байта перед буфером кучи при смещении ключа SIZE_MAX (9.8, 2025 год)

  • Вылеты ls -Z и ls -l с SELinux, но без поддержки xattr (9.7, 2025 год)

  • Перезапись кучи split --line-bytes (CVE-2024-0684, 9.5, 2024 год)

  • Чтение b2sum --check нераспределённой памяти при неверно сформированных входящих данных (9.4, 2023 год)

  • Переполнение буфера стека tail -f при множестве файлов и высоком ulimit -n (9.0, 2021 год)

…и список продолжается дальше. В переписанном на Rust коде нет ни одной такой ошибки при том, что время работы над ним было сравнимым1. Именно такие ошибки в основном вызывали проблемы в кодовой базе на C.

Остался наиболее интересный класс багов. Он живёт на границе между нашим контролируемым окружением Rust и запутанным, хаотичным внешним миром, где пути, байты, строки и системные вызовы смешались в один печальный клубок. И это стало новой границей безопасности современного системного кода2.

Если вы пишете системный код на Rust, то считайте этот список CVE чеклистом. Поищите в своей кодовой базе from_utf8_lossy, случайные вызовы unwrap(), игнорируемые Result, File::create и строковые сравнения с "/".

Кроме того, по близкой теме я написал пост Patterns for Defensive Programming in Rust.

Корректный Rust — это идиоматический Rust

Когда я думаю об «идиоматическом Rust», то первым делом вспоминаю не о корректности. В конце концов, разве её обеспечение не задача компилятора? В первую очередь я вспоминаю об изящных паттернах итераторов, эргономичных сигнатурах методов, неизменяемости или об умной работе с выражениями. Но ничто из этого не имеет смысла, если код работает неправильно, а компилятор справляется с обеспечением корректности далеко неидеально. Именно поэтому у нас есть идиомы не только для написания более изящного кода, но и для написания корректного кода. Они стали сутью опыта сообщества, которое узнавало, зачастую болезненным образом, какой код выживает в контакте с реальностью, а какой нет.

Реальность редко бывает столь идеальной, как абстракции, которые мы с ней ассоциируем. Признак надёжных систем на любом языке — готовность отражать эту неидеальность вместо того, чтобы пытаться её скрыть. Rust даёт нам невероятные инструменты для этого, а компилятор выполняет большую долю работы за нас. Но ту часть, которая ему недоступна, границу между нашей программой и всем остальным, правильно реализовать по-прежнему должны мы. Система типов может кодировать многое, но не те условия, которые не находятся под её контролем, например, время, прошедшее между двумя системными вызовами.

Таким образом, идиоматический Rust — это не просто код, одобряемый механизмом проверки заимствований или принимаемый clippy. Это код, типы, имена и поток управления которого сообщают истину о системе, в которой он исполняется. И эта правда иногда бывает неприглядной. Она может подразумевать, что вместо путей используются дестрипторы файлов,OsStr вместо String? вместо unwrap, а вместо чистой семантики необходимо обеспечить совместимость багов. Всё это не так красиво, как версия, которую вы рисовали на белой доске, зато более честно.

Примечания

  1. Но надо быть честным по отношению к GNU: GNU coreutils уже сорок лет, и у разработчиков было уже много времени для обнаружения и устранения этого класса багов. И мы не знаем, точно ли нет багов безопасности памяти в переписанном на Rust коде, нам лишь известно, что ни один из них не обнаружился при аудите. Тем не менее, разница заметна при сравнении того же времени активности разработки.

  2. Стоит отметить, что класса багов TOCTOU Path/PathBuf в каком-то смысле проще избегать на C, чем на Rust. Код на C естественным образом использует дескриптор открытого файла и семейство системных вызовов *at (openat, fstatat, unlinkat, mkdirat), а большинство системных вызовов создания принимают аргумент mode напрямую. Высокоуровневые API std::fs языка Rust абстрагируют дескриптор файла и работают со значениями &Path, из-за чего вызов с повторным ресолвингом на основе пути используется по принципу наименьшего сопротивления. Использующие дескрипторы API есть на всех Unix-платформах, Rust просто не ставит их на первое место.