В апреле 2026 года Canonical раскрыла 44 CVE в uutils. Это переписанная на Rust версия GNU coreutils, которая в Ubuntu идёт по умолчанию с 25.10. Раскрытие пришло из внешнего аудита, заказанного перед релизом 26.04 LTS. Большую часть уязвимостей нашли обычным ревью кода. Ни borrow checker, ни проверки clippy, ни cargo audit не поймали ни одной.
Этот аудит, пожалуй, самый чёткий из существующих примеров того, что Rust ловит, а что нет. Самый внятный разбор списка сделал Маттиас Эндлер в посте «Bugs Rust Won’t Catch» от 29 апреля. Эндлер ведёт консалтинг corrode и подкаст Rust in Production; недавно у него в гостях был Джон Сигер, вице-президент по инженерии в Canonical. Пост построен как разбор того самого раскрытия: 44 CVE распределены по восьми категориям; к большинству приложен git diff фикса.
Ниже разберу каркас Эндлера и добавлю два аргумента сверху. Первый: один из мейнтейнеров GNU coreutils в HN-треде показал бенчмарк, на котором рекомендованный Эндлером фикс не выживает. Второй: структурный аргумент про то, что 40 лет наслоённых POSIX-шрамов делают с любой переписью, независимо от языка.
Что Rust поймал, а что нет
Эндлер перечисляет восемь категорий. Форма везде одна: идиома Rust, одобренная системой типов, применяется в контексте, где система типов не видит, что не так.
TOCTOU на путях
Это самый большой кластер. Из-за него cp, mv и rm в 26.04 LTS по-прежнему из GNU, а не из uutils. Шаблон такой: один системный вызов проверяет, второй действует, оба принимают &Path. Между ними атакующий с правами записи в родительский каталог подменяет компонент пути на симлинк, ядро повторно резолвит путь во втором вызове, и привилегированное действие приземляется по адресу, выбранному атакующим.
Самый понятный пример — CVE-2026-35355 в install:
// 1. Удаляем целевой файл fs::remove_file(to)?; // ... // 2. Резолвит путь заново. Идёт по симлинку, обрезает. let mut dest = File::create(to)?; copy(from, &mut dest)?;
Любой пользователь с правами записи в родительский каталог между шагами 1 и 2 может подменить to на симлинк, ведущий в /etc/shadow. Привилегированный процесс с радостью перезапишет его содержимым from. Лечится через OpenOptions::create_new(true). В документации этого вызова написано прямо: «никакого файла по целевому пути быть не должно, в том числе и (висячих) симлинков».
Permission-set-after-create
Близкий родственник TOCTOU. Шаблон fs::create_dir(&path)?; fs::set_permissions(&path, ...)?; оставляет окно, в котором каталог уже существует с правами по умолчанию, и любой локальный пользователь может вызвать open() и получить файловый дескриптор, который переживёт последующий chmod. Лечится через OpenOptions::mode() и DirBuilderExt::mode(), чтобы файл или каталог сразу рождался с нужными правами.
Равенство строк пути не равно идентичности в файловой системе
Изначально проверка --preserve-root в chmod выглядела буквально так: if recursive && preserve_root && file == Path::new("/") { return Err(PreserveRoot); }. Эту проверку обходит всё, что резолвится в /, но пишется иначе: /../, /./, симлинк, что угодно, что бы canonicalize свёл к корню. Запустите chmod -R 000 /../ и наблюдайте, как блокируется вся система.
Самый абсурдный случай в этой категории — CVE-2026-35363:
rm . # ❌ отклоняется rm .. # ❌ отклоняется rm ./ # ✅ принимается rm ./// # ✅ принимается
rm отклонял голые . и .., но спокойно принимал ./, удалял текущий каталог, а потом печатал «Invalid input». Сравнение строк споткнулось о завершающий слеш.
UTF-8 против сырых байтов на границах Unix
В Rust типы String и &str всегда UTF-8. Unix-пути, переменные окружения и байтовые потоки, идущие через cut, comm, tr, UTF-8 не гарантируют. Везде, где программа на Rust сталкивается с этим, у неё три варианта: преобразование с потерями (молча подменяет невалидные байты на U+FFFD; Эндлер называет это «нарядной порчей данных»), строгое преобразование (падает на первом же не-UTF-8 байте) или остаться в OsStr / &[u8]. Аудит нашёл баги в обоих первых вариантах.
CVE-2026-35346 в comm использовал String::from_utf8_lossy, и пропуск бинарного файла через comm молча искажал вывод. Фикс заменил print! на BufWriter::write_all и оставил данные в байтах.
Panic как DoS
Любой unwrap, любой expect, любая индексация слайса, любая арифметика без проверок в коде, обрабатывающем входные данные, способны превратиться в отказ в обслуживании, если атакующий управляет формой входных данных.
CVE-2026-35348 в sort --files0-from вызывал expect() при 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.
GNU sort обращается с именами файлов как с сырыми байтами, потому что имена файлов и есть байты. Версия uutils падает на первом же не-UTF-8 пути. По выражению Эндлера: «ваша ночная cron-задача мертва, прощай выходные».
Проигнорированные ошибки
chmod -R и chown -R возвращали exit code последнего обработанного файла, а не худшего. chmod -R 600 /etc/secrets/* мог упасть на половине файлов и выйти с кодом 0. dd сбрасывал ошибку своего set_len() через Result::ok(), чтобы воспроизвести поведение GNU для /dev/null. Но тот же код срабатывал и на обычных файлах, и забитый диск молча обрывал запись на середине файла-получателя.
Несовместимость поведения с GNU
Эндлер замечает: «Заметная часть этих CVE не про то, что код делает что-то небезопасное, а про то, что код делает что-то отличное от GNU, и какой-то shell-скрипт где-то полагался именно на поведение GNU».
Самый яркий случай: CVE-2026-35369 в kill. GNU читает kill -1 <PID> как «послать сигнал номер 1 указанному PID». uutils читает то же самое как «послать сигнал по умолчанию процессу с PID -1», что на Linux означает каждый процесс, который ты видишь. Опечатка превращается в общесистемный рубильник.
Резолви до того, как пересечёшь границу
CVE-2026-35368 — самый серьёзный отдельный баг в аудите. Это локальный root через 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 идёт через NSS, а NSS вызывает dlopen для модулей libnss_* в рантайме. После chroot эти модули загружаются уже из нового корневого каталога. Атакующий, у которого получилось положить файл в chroot, исполняет свой код с uid 0. GNU chroot резолвит пользователя до chroot. Фикс точно такой же. Статическая линковка не помогает: NSS динамичен в любом случае.
Это восемь классов и 44 CVE. Ни одного из них borrow checker не ловит.
Что ответили мейнтейнеры GNU coreutils
Тред на HN под постом Эндлера растянулся на 361 комментарий. Самый полезный комментарий стоит первым. Один из мейнтейнеров GNU coreutils оспаривает одно из правил Эндлера и показывает, во что оно обходится.
Эндлер рекомендует фиксить баги с равенством строк пути через fs::canonicalize: разворачивать .., . и симлинки в реальный абсолютный путь до сравнения. У мейнтейнера в возражении есть конкретный бенчмарк на глубоко вложенном каталоге:
$ mkdir -p $(yes a/ | head -n $((32 * 1024)) | tr -d '\n') $ while cd $(yes a/ | head -n 1024 | tr -d '\n'); do :; done 2>/dev/null $ echo a > file $ time cp file copy real 0m0.010s $ time uu_cp file copy real 0m12.857s
GNU cp отрабатывает за 10 миллисекунд. Версия uutils с логикой canonicalize справляется с тем же почти за тринадцать секунд. В 1200 раз медленнее на искусственном, но правдоподобном пути. Общая мысль мейнтейнера: GNU «работает изо всех сил, чтобы избегать произвольных лимитов». Именно на патологические на вид входы и натыкаются продакшн-скрипты, а 12-секундный cp в глубоком дереве сборки уже сам по себе превращается в инцидент.
Тот же комментарий поправляет и одно из самых сильных утверждений Эндлера. В посте сказано: «Перепись на Rust не отгрузила ни одного из этих [memory-safety] багов за сопоставимый период активности». Мейнтейнер указывает на GHSA-w9vv-q986-vj7x, реальный memory-safety advisory в Rust uutils. Уязвимость пофиксили до релиза LTS, но «ноль» теперь под вопросом, и формулировка Эндлера от этого ослабевает.
Тред не закрывает аргумент Эндлера. Категории граничных багов, которые он перечисляет, реальны. А вот категоричное заявление о победе над memory-safety съезжает с «ноль, точка» на «близко к нулю, с оговорками». Разница меньше, чем то, на чём держится спор «переписывать или оставить»; но это уже не лозунг, а измерение.
Структурный аргумент: сорок лет POSIX-шрамов
Вторая ветка HN-треда утверждает, что ось «Rust против C» вообще не та. Один из участников треда сформулировал так: «читают они сигнатуру функции, а нужны им шрамы». Линия GNU coreutils тянется от пакетов fileutils / textutils / shellutils конца 80-х (объединились в coreutils в 2002). За ней десятилетия точечных фиксов: переполнение буфера в pwd на путях длиннее 2 * PATH_MAX, запись за пределы кучи в od --strings -N, чтение неаллоцированной памяти в b2sum --check. Каждый из них хранит большой пласт знания вида «вот так не делай, вот почему», и живёт это знание в кодовой базе шрамами, а не документацией. Перепись с нуля заново зарабатывает часть этих шрамов уже на машинах пользователей.
У другого комментатора формулировка жёстче: «Они умели писать на Rust, но явно недостаточно знали Unix API, его семантику и подводные камни. Большинство этих ошибок — крайне любительские с точки зрения тех, кто давно работает над coreutils». Третий разворачивает это в претензию к процессу: «У переписи неизбежно на порядки больше багов и уязвимостей, чем у кода, который поддерживают десятилетиями. Аргумент про безопасность работал только для долгосрочного перехода, а не для торопливого».
Контраргумент тоже есть, и пост Эндлера — его сильнейшая версия. Перепись на Rust не отгрузила класс memory-safety багов. Список CVE из аудита не содержит ни переполнений буфера, ни use-after-free, ни double-free, ни гонок данных по разделяемому изменяемому состоянию, ни разыменований нулевого указателя, ни чтений неинициализированной памяти. А у GNU coreutils в каждой из этих категорий за последние пару лет находились CVE:
Класс уязвимости (memory-safety) | GNU coreutils CVE |
|---|---|
Переполнение буфера ( | 9.11, 2026 |
OOB read ( | 9.9, 2025 |
Heap buffer overflow ( | 9.9, 2025 |
Запись за буфер в куче ( | 9.8, 2025 |
Чтение одного байта до буфера в куче ( | 9.8, 2025 |
Heap overwrite ( | CVE-2024-0684, 9.5, 2024 |
Stack buffer overrun ( | 9.0, 2021 |
Что бы вы ни думали о решении Canonical поставить uutils по умолчанию в Ubuntu, сравнение, которое предлагает Эндлер, честное. Список CVE из переписи на Rust читается как иной класс провала по сравнению со списком GNU. Поверхность атак та же, привилегированные системные утилиты, а класс багов уже другой.
Где сместилась граница безопасности
Если положить все 44 CVE рядом, картина не «Rust не поймал баги». Картина другая: «Rust поймал тот класс багов, который ловит, а аудит нашёл следующий по соседству».
Следующий класс живёт на границе между программой на Rust и операционной системой. Эндлер описывает её прямо: «Она проходит на стыке между нашим контролируемым окружением Rust и грязным внешним миром, где пути, байты, строки и системные вызовы намотаны на один вечный клубок печали. Это и есть новая граница безопасности современного системного кода».
Стандартная библиотека Rust в нескольких местах сделана так, что путь наименьшего сопротивления оказывается не на той стороне этой границы. Параметры &Path везде; String там, где на самом деле байтовая последовательность; вызвать unwrap или expect ничего не стоит. API на дескрипторах (openat, fstatat, unlinkat) есть на каждом Unix; std::fs не выводит их на передний план.[^1]
Это не критика языка Rust. Это наблюдение про дизайн std::fs, и само это решение тянется к кросс-платформенным ограничениям стандартной библиотеки Rust 1.0. Один из комментаторов на HN сформулировал так: «std::fs страдает от того, что это наименьший общий знаменатель. Языку нужно было что-то к 1.0, и, к сожалению, это что-то так и осталось». Та же форма повторяется почти в каждом языке с переносимой абстракцией над файловой системой; это не особенность одного только Rust. Но довод в пользу переписи на Rust в духе «мы переписали на Rust, поэтому это безопаснее» зависит от того, делает ли язык больше, чем std::fs сейчас умеет на границе с системными вызовами.
Сама история про безопасность Rust остаётся реальной и полезной. Она просто заканчивается на системном вызове.
Категории из аудита (TOCTOU, выставление прав после создания, равенство строк пути, UTF-8 против байтов, panic как DoS, проигнорированные ошибки, совместимость с GNU, резолв до пересечения границы) и есть новый чек-лист для любой переписи привилегированной утилиты на Rust. Ни одну из них компилятор не ловит. Все ловятся при ревью кода человеком, который читал этот список.
Что Rust ловит, а что — нет: чек-лист
Если уместить выводы аудита на одну страницу:
Класс багов | Что искать в коде | Что использовать вместо |
|---|---|---|
TOCTOU на путях | два сисколла подряд по |
|
Permission-set-after-create |
|
|
Равенство строк пути |
|
|
UTF-8 vs сырые байты |
|
|
Panic как DoS |
|
|
Проигнорированные ошибки |
| агрегация ошибок, экстремум вместо последнего |
Несовместимость с GNU | поведение, отличающееся от GNU на пограничных случаях | прогон тест-сьюта GNU coreutils |
Резолв после пересечения границы |
| резолви всё до пересечения |
Список не каверзный, короткий, и его легко грепать.
Что значит «мы переписали это на Rust»
Один и тот же вывод следует и из поста Эндлера, и из обмена мнениями в HN-треде. Фраза «мы переписали это на Rust» — это связное утверждение об одном конкретном классе багов. Сама по себе она не является утверждением о безопасности переписанного инструмента. Это утверждение про отсутствие классов багов, которые ловит система типов Rust. Классы, которые система типов не ловит (восемь штук, что видно по 44 CVE), должны ловиться чем-то ещё.
Чек-лист Эндлера и есть правильная форма того «чего-то ещё». Грепаем кодовую базу на from_utf8_lossy, неаккуратные unwrap(), проигнорированные Result, File::create, сравнения строк с "/", операции «создать каталог и потом выставить права». Искать всё это не сложнее, чем баг с нарушением memory safety. На это нужен другой тип аудита и другой ревьюер: тот, у кого 40 лет POSIX-шрамов служат документацией, которую upstream не написал.
44 CVE — это не вердикт по Rust, а измерение того, куда смотреть на следующем аудите. Фразу «мы переписали на Rust» читай так же, как фразу «у нас есть юнит-тесты»: полезное утверждение об одном конкретном классе провалов, без претензии на остальные.
Важная сноска от Эндлера: класс багов «путь против дескриптора» в каком-то смысле проще избегать в C, чем в Rust. Код на C естественным образом тяготеет к открытому файловому дескриптору и *at-семейству системных вызовов (openat, fstatat, unlinkat, mkdirat), а большинство сисколлов создания принимают mode напрямую. Высокоуровневые API в std::fs абстрагируются от файлового дескриптора и работают со значениями &Path, поэтому повторно-резолвящий вызов через путь становится самым лёгким вариантом. API на дескрипторах есть на каждой Unix-платформе; Rust просто не выставляет их на передний план.
