Каждый раз, когда очередной уязвимостью в ядре или драйвере пользуются злоумышленники, сообщество разработчиков задает один и тот же вопрос: сколько еще мы будем балансировать на краю пропасти Undefined Behavior? Если вы пишете на C и хотите спать спокойно, игнорировать изменения в стандартах больше не получится — индустрия наконец-то объявила войну молчаливым багам компилятора.

Введение в проблему неопределенного поведения (Undefined Behavior)

Язык программирования C на протяжении десятилетий остается фундаментом современной IT-инфраструктуры. На нем написаны операционные системы, драйверы, базы данных, компиляторы и критически важные компоненты встраиваемых систем. Однако за максимальную производительность, минимальный накладной расход и абсолютный контроль над железом разработчики платят высокую цену. Имя этой цене — неопределенное поведение (Undefined Behavior, UB) (прямо как «работает на моей машине», только в масштабах всей ОС).

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

Долгое время концепция UB рассматривалась как необходимый компромисс. Она давала создателям компиляторов свободу для агрессивных оптимизаций, позволяя не тратить такты процессора на проверку потенциальных ошибок вроде выхода за границы массивов или переполнения целых чисел. Но с ростом сложности программного обеспечения и увеличением числа уязвимостей безопасности этот подход исчерпал себя. В последние годы комитет по стандартизации C (WG14) начал беспрецедентную кампанию по сокращению, ограничению и реклассификации неопределенного поведения. Давайте разберем, какие шаги предпринимаются сегодня и какое будущее ждет классический язык C.

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

Анатомия UB: от переполнения до оптимизаций компилятора

Чтобы понять масштаб проблемы, необходимо взглянуть на то, как неопределенное поведение проявляется на практике. Классическим примером, для которого спецификация языка исторически не устанавливала никаких требований, является переполнение целых чисел (integer overflows). Если знаковое целое число в результате арифметической операции превышает максимально допустимое для своего типа значение, стандарт C квалифицирует это как UB.

int multiply_safely(int a, int b) {
    // Если результат превышает INT_MAX, поведение не определено
    return a * b; 
}

Для разработчика, не погруженного в тонкости стандарта, такой код кажется абсолютно нормальным. Однако современный компилятор (например, GCC или Clang) рассуждает иначе. Заметив операцию со знаковым переполнением, оптимизатор может сделать логический вывод: «Раз стандарт говорит, что этого не может произойти по определению, значит, программист гарантирует мне, что a * b никогда не переполнится». На основе этого допущения компилятор может полностью удалить защитные проверки, идущие следом за этой строчкой, что приводит к критическим уязвимостям.

Поскольку надеяться на то, что компилятор сам додумается защитить ваш код, больше нельзя, разработчикам приходится внедрять защитные механизмы на всех этапах жизненного цикла программы — от написания первой строчки до сборки релиза в CI/CD.

Современные подходы к минимизации рисков

Индустрия не стоит на месте, и борьба с UB ведется сразу на нескольких уровнях — от стандартизации до инструме