惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
P
Palo Alto Networks Blog
S
Securelist
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
NISL@THU
NISL@THU
L
Lohrmann on Cybersecurity
有赞技术团队
有赞技术团队
The GitHub Blog
The GitHub Blog
C
Cisco Blogs
B
Blog
Microsoft Azure Blog
Microsoft Azure Blog
Recent Announcements
Recent Announcements
Simon Willison's Weblog
Simon Willison's Weblog
T
Tenable Blog
Know Your Adversary
Know Your Adversary
Spread Privacy
Spread Privacy
WordPress大学
WordPress大学
月光博客
月光博客
Latest news
Latest news
C
CXSECURITY Database RSS Feed - CXSecurity.com
T
Threat Research - Cisco Blogs
Cisco Talos Blog
Cisco Talos Blog
I
InfoQ
D
Darknet – Hacking Tools, Hacker News & Cyber Security
W
WeLiveSecurity
Hacker News - Newest:
Hacker News - Newest: "LLM"
酷 壳 – CoolShell
酷 壳 – CoolShell
U
Unit 42
C
Cybersecurity and Infrastructure Security Agency CISA
博客园 - 聂微东
人人都是产品经理
人人都是产品经理
Google DeepMind News
Google DeepMind News
Apple Machine Learning Research
Apple Machine Learning Research
Attack and Defense Labs
Attack and Defense Labs
罗磊的独立博客
T
The Exploit Database - CXSecurity.com
I
Intezer
GbyAI
GbyAI
Jina AI
Jina AI
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Blog — PlanetScale
Blog — PlanetScale
博客园 - 司徒正美
Google Online Security Blog
Google Online Security Blog
Engineering at Meta
Engineering at Meta
D
Docker
Recent Commits to openclaw:main
Recent Commits to openclaw:main
小众软件
小众软件
云风的 BLOG
云风的 BLOG
爱范儿
爱范儿
Project Zero
Project Zero

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Баги, которые не ловит Rust
PatientZero · 2026-05-06 · via Все публикации подряд на Хабре

Средний

12 мин

14K

В апреле 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 просто не ставит их на первое место.