Undefined Behavior: как компилятор становится вашим противником

Undefined Behavior: как компилятор становится вашим противником

В 2009 году в ядре Linux обнаружили уязвимость, которая не была ошибкой программиста в привычном смысле. Патч в драйвере tun сначала использовал указатель tun, а затем проверял его на NULL. Программист просто перепутал порядок. Но в результате произошло нечто интересное. Компилятор GCC, увидев, что указатель уже был разыменован, рассудил: «Разыменование NULL — это неопределённое поведение. Неопределённое поведение не может произойти. Значит, указатель не NULL. Значит, проверку на NULL можно удалить». И удалил. Полностью.

Результат — CVE-2009-1897: локальное повышение привилегий в ядре Linux. Проверка безопасности, написанная программистом, была стёрта компилятором на законных основаниях.

Добро пожаловать в мир неопределенного поведения (undefined behavior).

Что такое Undefined Behavior

Спецификация языка C (и C++) описывает поведение программ в терминах трёх категорий. Определённое поведение (defined behavior) — стандарт точно говорит, что произойдёт. Поведение, определяемое реализацией (implementation-defined) — результат зависит от платформы, но он документирован и воспроизводим. И неопределённое поведение (undefined behavior, UB) — стандарт не накладывает никаких ограничений. Программа может сделать что угодно: вернуть мусорное значение, упасть с сегфолтом, молча повредить данные или работать «правильно» годами, пока не изменится версия компилятора или уровень оптимизации.

UB — не просто «ну, будет какой-то мусор». Это своего рода индульгенция для компилятора на предположение, что неопределённое поведение никогда не происходит, и разрешение на основании этого предположения трансформировать код. Именно здесь начинаются настоящие проблемы.

Каталог катастроф

UB в C/C++ — не абстрактная теоретическая опасность. Это источник реальных уязвимостей с реальными CVE.

Удаление проверок на NULL

CVE-2009-1897, описанная выше, — каноничный пример. Код ядра Linux:

struct tun_struct *tun = ...;
int val = tun->flags;   // разыменование (UB если tun == NULL)
if (!tun)               // компилятор удаляет эту проверку
    return -EBADFD;

Компилятор рассуждает: раз tun разыменован, а разыменование NULL — UB, значит, tun != NULL — инвариант. Проверка if (!tun) — мёртвый код. После этого инцидента ядро Linux навсегда включило флаг -fno-delete-null-pointer-checks.

Удаление проверок переполнения

CVE-2019-14973: в библиотеке libtiff множественные проверки на целочисленное переполнение вида a + b < a были удалены компилятором. Знаковое переполнение — UB в C, поэтому компилятор интерпретировал a + b < a как b < 0, то есть — всегда false для беззнакового b. Проверки исчезли, библиотека стала уязвимой.

Удаление обнуления паролей

Программист обнуляет буфер с паролем после использования:

char password[32];
// ... используем password ...
memset(password, 0, sizeof(password));
// функция завершается

Компилятор видит: после memset буфер не читается. Значит, memset — мёртвая запись (dead store). При оптимизации вызов удаляется. Пароль остаётся в памяти. Статья «Dead Store Elimination (Still) Considered Harmful» (USENIX Security 2017) систематически исследовала эту проблему и нашла множество реальных случаев в криптографических библиотеках.

Средства защиты: explicit_bzero() (Linux/BSD), SecureZeroMemory() (Windows), volatile-запись. Стандартная функция memset_s() из C11 Annex K гарантирует, что вызов не будет удалён, но Annex K не поддерживается GCC и Clang.

Toyota и бесконтрольный разгон

В 2013 году экспертиза показала, что программное обеспечение системы управления двигателем Toyota Camry 2005 года содержало обширное неопределённое поведение: переполнение стека, выход за границы буферов, тысячи глобальных переменных с потенциальными гонками данных. Присяжные признали Toyota виновной в гибели человека. Этот случай остаётся одним из самых цитируемых примеров UB в критически важных системах.

Носовые демоны и путешествия во времени

В сообществе C-программистов есть два термина, описывающих природу UB.

«Носовые демоны» (nasal demons) — мем, возникший в начале 1990-х на Usenet. Кто-то заметил, что раз UB позволяет буквально что угодно, компилятор теоретически может заставить демонов вылететь из вашего носа. Термин прижился как напоминание: рассуждать о том, что UB «должно» делать, бессмысленно, поскольку стандарт не налагает ни одного ограничения.

«Путешествие во времени» (time travel) — ещё более контринтуитивное свойство. UB может повлиять на код, который выполняется до строки с неопределённым поведением. Это не мистика: компилятор, предполагая, что UB не происходит, выполняет оптимизации на уровне всей функции — переупорядочивает инструкции, удаляет ветки, подставляет известные значения переменных. В результате код до строки с UB меняет поведение. Со стороны программиста выглядит так, будто ошибка «потянулась назад во времени» и сломала предшествующую логику.

Стандарт C++ прямо допускает это: неопределённое поведение может изменять результат кода, который предшествует ему в потоке исполнения.

Сколько это стоит в цифрах

Неопределённое поведение — источник целого класса уязвимостей, объединённых термином «нарушение безопасности памяти» (memory safety violations): переполнение буфера, использование после освобождения, двойное освобождение, чтение неинициализированной памяти.

ИсточникДоля уязвимостей из-за memory safetyГод
Microsoft~70 % CVE2019
Google Chromium~70 % критических багов2020
Google Project Zero75 % эксплуатируемых CVE2024
CISA (США)70 % уязвимостей2024

В Android доля уязвимостей, связанных с памятью, упала с 76 % в 2019 году до 24 % в 2024-м — после перевода нового кода на Rust и Java.

В феврале 2024 года Белый дом опубликовал доклад «Back to the Building Blocks», призвав отказаться от C/C++ в пользу языков с гарантиями безопасности памяти. CISA и ФБР установили дедлайн — 1 января 2026 года — на подготовку организациями дорожных карт перехода к memory-safe языкам.

А стоит ли оно того? Исследование PLDI 2025

Главный аргумент в защиту UB — производительность. Компиляторы эксплуатируют неопределённое поведение для агрессивных оптимизаций, и без этого код будет медленнее. Но насколько?

В 2025 году на конференции PLDI Лучиан Попеску и Нуну Лопеш из Лиссабонского университета представили первое масштабное эмпирическое исследование: «Exploiting Undefined Behavior in C/C++ Programs for Optimization: A Study on the Performance Impact». Они выделили 5 категорий UB (18 подвидов), реализовали 12 новых флагов в Clang/LLVM для выборочного отключения эксплуатации UB и измерили сквозную производительность.

Вывод: реальный прирост производительности от эксплуатации UB минимален. Рецензент Павел Панчеха назвал это «самой впечатляющей статьёй конференции».

Это не значит, что UB бесполезен для оптимизатора. Это значит, что цена — нестабильность, уязвимости и непредсказуемость — непропорционально высока по сравнению с выигрышем.

Как ядро Linux защищается от собственного компилятора

Ядро Linux — крупнейший C-проект в мире. Его разработчики десятилетиями выстраивают оборону против оптимизаций, основанных на UB:

  • -fno-delete-null-pointer-checks — запрещает удалять проверки на NULL после разыменования. В ядре адрес 0 может быть валидным отображением;
  • -fno-strict-overflow (подразумевает -fwrapv) — превращает знаковое переполнение из UB в определённое поведение (обёртка по модулю 2^n);
  • -ftrivial-auto-var-init=zero — инициализирует все локальные переменные нулями. Предотвращает утечку данных через неинициализированную память;
  • KASAN, UBSan, KMSAN — санитайзеры для обнаружения use-after-free, UB и чтения неинициализированной памяти в тестах.

В 2024 году Кис Кук (Kees Cook) из команды безопасности ядра инициировал новый подход: раз -fwrapv превращает переполнение в определённое поведение и UBSan перестаёт его ловить, были добавлены новые опции CONFIG_UBSAN_SIGNED_WRAP и CONFIG_UBSAN_UNSIGNED_WRAP. Джастин Ститт разработал для Clang новый флаг -fsanitize=signed-integer-wrap, инструментирующий арифметику даже при включённом -fwrapv. По данным Кука, в ядре обнаруживается примерно 6 серьёзных целочисленных ошибок в год.

Линус Торвальдс сформулировал позицию прямо: «Не удаляйте проверки в ядре на основании “так говорит стандарт”. Оптимизации на основании “аппаратура делает то же самое” — нормально. Оптимизации на основании “стандарт говорит, что это UB, значит, можно удалить” — нет».

Как C и C++ пытаются решить проблему

C23

Стандарт C23 (ISO/IEC 9899:2024, финализирован в октябре 2024 года) убрал или переклассифицировал более 32 случаев UB. Многие превращены из неопределённого поведения в нарушения ограничений (constraint violations), требующие диагностики на этапе компиляции. Добавлен макрос unreachable() для явного указания недостижимого кода. Сформирована рабочая группа по безопасности памяти.

C++26

Стандарт C++26 (завершён 28 марта 2026 года) пошёл дальше:

  • Неинициализированные переменные — чтение больше не UB, а «ошибочное поведение» (erroneous behavior). Компилятор обязан произвести определённый результат и может (но не обязан) выдать предупреждение. Чтобы воспользоваться этим, достаточно перекомпилировать существующий код как C++26;
  • Hardened standard library — проверка границ для string, vector, array, span, optional и других контейнеров. Развёрнуто в Google и Apple. Данные Google: исправлено более 1000 багов, прогнозируемое предотвращение 1000-2000 багов в год, снижение частоты сегфолтов на 30 % — при средних накладных расходах 0,3 %;
  • Контракты — предусловия, постусловия и contract_assert. Приняты голосованием 114 за, 12 против.

Херб Саттер назвал C++26 «самым значительным релизом со времён C++11».

Как современные языки решают проблему

UB в C/C++ — не неизбежность. Rust, Go и Zig доказывают, что системное программирование возможно без неопределённого поведения (или с его радикальным ограничением).

Скачать исходники демонстраций: C | Rust | Go | Zig

Целочисленное переполнение

В C знаковое переполнение — UB. Компилятор может на его основании удалить проверки.

// C: UB — компилятор предполагает, что переполнения не бывает
int x = INT_MAX;
if (x + 1 > x) { ... } // может быть удалено при -O2

Три языка решают это по-разному:

// Rust: паника в debug, обёртка в release. Или явный выбор:
let x: i32 = i32::MAX;
x.checked_add(1)    // → None (переполнение обнаружено)
x.wrapping_add(1)   // → -2147483648 (явная обёртка)
x.saturating_add(1) // → 2147483647 (насыщение)
// Go: обёртка — определённое поведение (не UB, но и не ошибка)
var x int32 = math.MaxInt32
fmt.Println(x + 1) // -2147483648, молча
// Zig: паника в safe-режиме. Явная обёртка — оператор +%
const x: i32 = std.math.maxInt(i32);
const y = x +% 1; // -2147483648, явно запрошенная обёртка
const z = x +| 1; // 2147483647, насыщение

NULL и Optional

В C нет защиты от разыменования NULL — это UB. Rust использует Option<T>, Go паникует с трейсом, Zig использует ?T (optional):

// Rust: null не существует. Есть Option.
let v = vec![10, 20, 30];
match v.get(5) {
    Some(val) => println!("{val}"),
    None => println!("Нет элемента"), // компилятор требует обработать
}
// Zig: optional требует явной проверки
var opt_ptr: ?*i32 = null;
if (opt_ptr) |ptr| {
    // доступ к ptr.*
} else {
    // null — обработан
}

Память и владение

Проблема C/C++RustGoZig
Use-after-freeСистема владения (ошибка компиляции)Сборщик мусораDebug-аллокатор ловит в рантайме
Double freeОдин владелец, один dropGCDebug-аллокатор
Buffer overflowПроверка границ (паника)Проверка границ (паника)Проверка в safe-режиме (паника)
Неинициализированные переменныеОшибка компиляцииНулевая инициализация по умолчаниюЯвный undefined, ловится санитайзером
Гонка данныхBorrow checker (ошибка компиляции)Детектор -race (рантайм)Ответственность программиста

Вывод из примеров

Три языка занимают разные точки на спектре. Rust предотвращает большинство ошибок на этапе компиляции ценой кривой обучения. Go делает ставку на рантайм-проверки и сборщик мусора. Это проще в использовании, но гонки данных всё ещё возможны. Zig даёт максимум контроля: проверки включены в safe-режиме, отключаемы в релизе — философия «лучшего C», а не «безопасного языка».

Ложные представления об UB

Предраг Груевски составил список заблуждений программистов о неопределённом поведении. Вот ключевые:

  • «UB проявляется только при -O2 и выше». Нет. UB — свойство программы, а не уровня оптимизации. Просто при -O0 эффекты часто маскируются;
  • «Переменная с UB будет содержать “какое-нибудь” значение». Нет. Переменная может вести себя так, будто имеет разные значения в разных точках программы;
  • «По крайней мере, UB не повредит не связанную с ним память». Нет. UB снимает все гарантии.

Выводы

Undefined Behavior — это не баг и не фича. Это архитектурное решение эпохи, когда C проектировался как «портируемый ассемблер» для PDP-11. Идея была в том, что компилятор не должен вставлять проверки, которые не нужны на конкретной машине. Полвека спустя эта идея превратилась в системную угрозу: компиляторы стали настолько умными, что используют UB для оптимизаций, которые создатели языка не могли предвидеть.

Статистика — 70 % уязвимостей из-за нарушений безопасности памяти — говорит сама за себя. Правительства требуют перехода на безопасные языки. Стандарты C++26 и C23 латают дыры. Исследование PLDI 2025 показывает, что прирост производительности от эксплуатации UB минимален.

При этом C и C++ никуда не денутся. Ядра операционных систем, встроенные системы, драйверы, криптографические библиотеки — миллиарды строк кода, которые невозможно переписать за одно поколение. Но каждый новый проект — это выбор. И если ваш код не обязан быть написан на C, стоит задуматься: готовы ли вы, чтобы компилятор стал вашим противником?

Источники

Предыдущий
Наверх