
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 % CVE | 2019 |
| Google Chromium | ~70 % критических багов | 2020 |
| Google Project Zero | 75 % эксплуатируемых CVE | 2024 |
| 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++ | Rust | Go | Zig |
|---|---|---|---|
| Use-after-free | Система владения (ошибка компиляции) | Сборщик мусора | Debug-аллокатор ловит в рантайме |
| Double free | Один владелец, один drop | GC | Debug-аллокатор |
| 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, стоит задуматься: готовы ли вы, чтобы компилятор стал вашим противником?
Источники
- CVE-2009-1897: Linux Kernel Null Pointer Dereference — уязвимость ядра Linux из-за удаления проверки на NULL
- Fun with NULL pointers, part 1 (LWN.net) — разбор инцидента с tun-драйвером
- What Every C Programmer Should Know About Undefined Behavior (Chris Lattner, 2011) — каноническое введение в UB от создателя LLVM
- A Guide to Undefined Behavior in C and C++ (John Regehr, 2010) — фундаментальная серия статей
- Falsehoods programmers believe about undefined behavior (Predrag Gruevski) — заблуждения программистов об UB
- Exploiting Undefined Behavior for Optimization: A Study on the Performance Impact (PLDI 2025) — эмпирическое исследование реального прироста производительности от UB
- Dead Store Elimination (Still) Considered Harmful (USENIX Security 2017) — удаление
memsetдля паролей компилятором - Security flaws caused by compiler optimizations (Red Hat) — обзор уязвимостей, созданных оптимизатором
- Better handling of integer wraparound in the kernel (LWN.net) — подход ядра Linux к целочисленному переполнению
- Herb Sutter: C++26 is done! — итоги стандартизации C++26
- Back to the Building Blocks (White House, 2024) — доклад Белого дома о переходе к memory-safe языкам
- Urgent Need for Memory Safety in Software Products (CISA) — рекомендации агентства кибербезопасности США