Неопределённое поведение и безопасность памяти: как ломается и как не ломать
В большинстве языков вопрос «а что будет, если выйти за границу массива» имеет ответ: исключение, паника, IndexError. В C ответа нет — и это не пробел в документации, а сознательное проектное решение, зафиксированное в стандарте. Программа с неопределённым поведением не «делает что-то странное в этом месте»: у неё вообще нет определённого поведения, ни до, ни после, ни в соседних функциях.
Отсюда и практический вывод, ради которого пишется эта статья. Отладка (06) отвечает на вопрос «почему эта программа сломалась». Здесь вопрос другой: почему целые классы программ ломаются предсказуемо, и какая дисциплина снижает вероятность ошибки до приемлемой. Цифры, из-за которых тема перестала быть академической: около 70% исправленных CVE в продуктах Microsoft — ошибки безопасности памяти; та же доля в Chromium; CISA и NSA прямо рекомендуют производителям иметь дорожную карту перехода на безопасные по памяти языки. Предполагается, что вы прошли указатели, раскладку структур и динамическую память.
Абстрактная машина: откуда вообще берётся UB
Стандарт C описывает не ваш процессор, а абстрактную машину: гипотетический исполнитель, у которого есть объекты, их время жизни и последовательность вычислений. Компилятор обязан воспроизвести не ваш код, а только наблюдаемое поведение этой машины — обращения к volatile-объектам, ввод-вывод и состояние на момент завершения. Это «правило как если бы» (as-if rule), и именно оно разрешает выкидывать переменные, разворачивать циклы и держать значения в регистрах.
Обязательство действует только для программ, которые абстрактная машина в принципе способна исполнить. Как только в программе происходит UB, обязательства снимаются целиком. Формулировка C17, 3.4.3:
undefined behavior — поведение при использовании непереносимой или ошибочной конструкции либо ошибочных данных, на которое настоящий стандарт не накладывает никаких требований.
Стандарт (черновик C17 N2310) различает четыре степени неопределённости, и путать их дорого:
| Категория | Что гарантировано | Примеры | Как жить |
|---|---|---|---|
| Undefined behavior | ничего | выход за границы, знаковое переполнение, разыменование NULL, гонка данных, UAF | избегать; ловить санитайзерами |
| Unspecified behavior | одна из нескольких допустимых альтернатив, документировать не обязаны | порядок вычисления аргументов f(g(), h()), значения байтов-заполнителей |
не полагаться на конкретный выбор |
| Implementation-defined | реализация выбирает и документирует | sizeof(int), знаковость char, >> для отрицательных, порядок байтов |
читать доки компилятора; фиксировать в CI |
| Locale-specific | зависит от локали | поведение isalpha вне ASCII, strcoll |
задавать локаль явно |
Разница практическая. Unspecified — это «может быть так, а может иначе, но программа продолжит работать по правилам». UB — это «дальше не существует правил»: компилятор вправе удалить ваш код, оставить, заменить на бесконечный цикл или скомпилировать в вызов чужой функции. Ни один из этих исходов не будет ошибкой компилятора.
Почему UB не убрали из языка
Соблазнительно назвать UB исторической неряшливостью. Это неверно: у каждого пункта была цена, которую в 1989 году не готовы были платить, а во многих нишах не готовы и сейчас.
Разнообразие железа. C должен был компилироваться и на дополнительном коде, и на прямом, и на обратном; на машинах с 9-битным байтом; там, где невыровненное чтение вызывает ловушку, а не просто медленный доступ. Определить единственное поведение — значит заставить одну из архитектур эмулировать чужую семантику. Стоимость проверок. Проверка каждого индекса, каждого сложения и каждого разыменования — это ветвления в самых горячих циклах; в языке, на котором пишут ядро и обработчики прерываний, такой налог не проходит.
Оптимизации. UB — это не «разрешение сломаться», а предположение, которое компилятор считает истинным. Классический пример — расширение индекса до машинного слова:
// sum-signed.c
double sum(const double *a, int n) {
double s = 0;
for (int i = 0; i < n; i++) s += a[i]; // i — знаковый: переполнение = UB
return s; // => i не переполнится => i можно
} // держать сразу в 64-битном регистре
$ gcc -O2 -S -masm=intel -o - sum-signed.c # индукционная переменная в rax, адрес шагает на 8
$ # заменим int на unsigned — переполнение определено, оборачивание надо воспроизвести:
$ gcc -O2 -S -masm=intel -o - sum-unsigned.c | grep -c 'mov\s\+eax, eax' # усечение до 32 бит
Со знаковым int компилятор вправе считать счётчик математическим целым и векторизовать цикл. С unsigned он обязан воспроизвести оборачивание при переполнении, что в некоторых схемах доступа к памяти мешает векторизации. Разница на реальных числодробилках — единицы-десятки процентов. Именно поэтому знаковое переполнение до сих пор UB, а не «оборачивание».
Итог: UB — это контракт. Вы обещаете не делать перечисленных вещей, компилятор взамен генерирует код без страховки. Проблема в том, что контракт односторонне проверяемый: нарушение обычно не диагностируется, а вознаграждается кодом, который «работает».
Механика: как предположение съедает ваш код
Компилятор не «мстит за UB». Внутри работает цепочка из десятков проходов, каждый из которых делает локально корректное преобразование, опираясь на факты, выведенные предыдущими. Разрушительный эффект получается на стыке — и каждый проход при этом прав, виновата исходная строка, нарушившая контракт.
затем проверяется p != NULL"] --> B["Frontend → SSA/IR"] B --> C["Инлайнинг: проверка и разыменование
оказались в одной функции"] C --> D["Value Range Propagation:
разыменование ⇒ p не NULL
иначе была бы UB"] D --> E["Constant Folding:
условие p == NULL ⇒ ложь"] E --> F["Dead Code Elimination:
ветка обработки ошибки удалена"] F --> G["Codegen: в бинаре нет ни проверки,
ни лога, ни возврата -1"] --> H{"Что видит инженер"} H --> I["на -O0 работает, на -O2 segfault
или тихая порча памяти — и ни одного
предупреждения: формально компилятор прав"]
Три канонических случая, которые стоит уметь узнавать в чужом коде.
1. Проверка после факта
// check-after-use.c
static int divide(int a, int b) { return a / b; } // b == 0 => UB
int handle(int a, int b) {
int r = divide(a, b); // компилятор ЗНАЕТ: b != 0
if (b == 0) { // ...значит условие ложно
log_error("деление на ноль"); // ...значит ветка мертва
return -1;
}
return r;
}
$ gcc -O2 -S -masm=intel -o - check-after-use.c | sed -n '/^handle:/,/ret/p'
handle:
mov eax, edi
cdq
idiv esi # ни cmp esi, 0, ни вызова log_error в коде нет
ret
Ровно этот шаблон дал CVE-2009-1897 в Linux: в tun_chr_poll указатель разыменовывали до проверки на NULL, GCC убрал проверку, и в связке с неверно настроенным mmap_min_addr это стало локальным повышением привилегий. Правило простое: проверка обязана стоять до использования, всегда.
2. Проверка переполнения через само переполнение
if (a + b < a) return -1; // плохо: проверка сама и есть UB,
// -O2 удаляет её как всегда-ложную
int r;
if (__builtin_add_overflow(a, b, &r)) return -1; // хорошо: GCC/Clang, сворачивается в jo
// C23: ckd_add(&r, a, b) из <stdckdint.h>
if (b > 0 && a > INT_MAX - b) return -1; // хорошо без расширений: проверка ДО
if (b < 0 && a < INT_MIN - b) return -1; // операции, а не после неё
3. Предположение о завершении циклов
C11 6.8.5p6 разрешает считать, что цикл завершается, если его управляющее выражение — не константа, а тело не делает ввода-вывода, не трогает volatile и не синхронизируется:
unsigned spin(unsigned n) {
unsigned i = 0;
while (i != n) i += 2; // при нечётном n цикл вечен, но побочных эффектов нет
return i; // => компилятор вправе считать, что мы вышли => return n
}
// $ gcc -O2 -S -masm=intel -o - spin.c | sed -n '/^spin:/,/ret/p'
// spin: mov eax, edi ; ret — цикла в коде нет вообще, просто вернули n
Важное исключение: цикл с константным управляющим выражением (while (1), for (;;)) из-под этого правила выведен. Поэтому вечный for(;;) в main микроконтроллера — легальная конструкция, и это ровно то, на что опирается встраиваемый C.
UB умеет «путешествовать во времени»
Раз требований к исполнению нет вовсе, их нет и к тому, что происходило до UB. Компилятор вправе переместить код через точку неопределённости, а буферизованный stdout довершит картину:
printf("шаг 1 пройден\n"); // может не появиться в выводе
return *p; // p == NULL — падение здесь
Отсюда практическое правило отладки: последняя строка в логе не означает, что программа дошла именно до неё. Диагностический вывод перед подозрительным местом делайте через fprintf(stderr, ...) (небуферизованный) или с явным fflush.
Каталог: UB, которое встречается в реальном коде
Приложение J.2 стандарта перечисляет свыше двухсот пунктов. В промышленном коде системно повторяются полтора десятка — их стоит знать наизусть.
| Конструкция | Почему UB | Что делает компилятор | Как правильно |
|---|---|---|---|
a + b при переполнении int |
6.5p5 | считает, что не переполняется; удаляет проверки | __builtin_add_overflow, ckd_add, беззнаковые типы |
INT_MIN / -1, x / 0 |
6.5.5p6 | idiv даёт SIGFPE на x86 |
проверять делитель и INT_MIN |
1 << 31 для int |
6.5.7p4 | значение «как получится» | 1u << 31 |
x << n при n >= 32 |
6.5.7p3 | x86 берёт n % 32 — иллюзия «работает» |
проверять n |
uint16_t a=0xFFFF; a*a |
продвижение до int |
знаковое переполнение UB | (uint32_t)a * a |
*(uint32_t*)(buf+1) |
6.3.2.3p7, выравнивание | на x86 «работает», на ARM SIMD — ловушка | memcpy(&v, buf+1, 4) |
*(float*)&i |
строгий алиасинг 6.5p7 | переупорядочивает чтение и запись | memcpy или union |
p[i] за границей |
6.5.6p8 | арифметика вне объекта — UB даже без чтения | размерные API, проверка индекса |
p2 - p1 для разных объектов |
6.5.6p9 | результат бессмыслен | не сравнивать чужие указатели |
печать p после free(p) |
6.2.4p2 | значение неопределённое | p = NULL; сразу после free |
char c; isspace(c) |
7.4p1 | индекс таблицы отрицателен | isspace((unsigned char)c) |
char *s = "x"; s[0]='y' |
6.4.5p7 | .rodata — SIGSEGV |
const char *, -Wwrite-strings |
i = i++ + 1 |
6.5p2 | значение и число присваиваний не определены | разбить на две инструкции |
| чтение неинициализированной | 6.2.4, 3.19.2 | значение «дрожит» между чтениями | инициализировать, -ftrivial-auto-var-init=zero |
| гонка данных | 5.1.2.4p25 | видимость и порядок не гарантированы | мьютексы, _Atomic — см. 09 |
Три пункта заслуживают отдельного разбора, потому что их регулярно понимают неправильно.
Строгий алиасинг
Компилятор предполагает, что объект читается только через lvalue совместимого типа (плюс тип со знаком/без знака, плюс любой символьный тип). Из этого предположения строится type-based alias analysis: если типы несовместимы, запись через один указатель не может повлиять на чтение через другой, значит чтение можно вынести из цикла.
void scale(float *f, int *i, int n) {
for (int k = 0; k < n; k++) {
*i = 1; // компилятор: int-запись не трогает float,
f[k] *= 2.0f; // значит *i = 1 можно вынести из цикла
}
}
static inline uint32_t float_bits(float f) { // правильный каламбур типов
uint32_t u;
memcpy(&u, &f, sizeof u); // -O2 сворачивает в один movd — стоимость нулевая
return u;
}
memcpy здесь не «медленный обходной путь», а канонический способ: GCC и Clang распознают его как перекладывание байтов. Чтение через union в C легально (в C++ — нет, там формально только memcpy или std::bit_cast, см. 12). Флаг -fno-strict-aliasing отключает анализ целиком — так собирается ядро Linux, потому что переписать миллионы строк невозможно. Цена — потерянные оптимизации, а не безопасность. Предупреждение -Wstrict-aliasing ловит лишь простейшие случаи и полагаться на него нельзя.
Неинициализированные значения «дрожат»
Неинициализированный объект имеет неопределённое значение (3.19.2): либо произвольный набор битов, либо ловушечное представление. Практическое следствие для оптимизирующего компилятора — значение не обязано быть постоянным между двумя чтениями, потому что переменная может жить в регистре, который заново распределяют:
unsigned char x; // не инициализирована
if (x < 10 && x > 20) f(); // может вызваться: два чтения дали разные значения
Это не гипотетика, а известное поведение GCC и Clang на IR-уровне (значение undef/poison). Поэтому «мусор всё равно какой-то один» — неверная модель. В проде класс закрывается флагом -ftrivial-auto-var-init=zero (GCC 12+, Clang), которым собирается ядро Linux; в отладке — MemorySanitizer.
Значение указателя после освобождения
После free (и после успешного realloc) старое значение указателя становится неопределённым — UB не только разыменовать его, но и просто использовать значение: сравнить, напечатать, скопировать в структуру. Компиляторы этим пользуются: известны случаи, когда p == q после realloc давало неожиданный результат, потому что два «одинаковых» адреса считались указателями на разные объекты.
Отсюда вся дисциплина владения: копия указателя — это не «ещё одна переменная», а второй вход в тот же объект, за которым нужно следить так же строго.
Безопасность памяти: разбор классов
Термин «memory safety» распадается на две независимые гарантии. Пространственная безопасность — доступ не выходит за границы того объекта, на который указывает указатель; ломается переполнениями буфера, off-by-one и ошибками в арифметике размеров. Временная безопасность — доступ происходит внутри времени жизни объекта; ломается use-after-free, double free, возвратом указателя на локальную переменную и use-after-scope. Инструменты и меры защиты у них разные, поэтому и разбирать их надо порознь. Диаграмма ниже — это и есть модель владения, которую в C приходится держать в голове и в соглашениях команды, а в Rust её проверяет компилятор (13).
Пространственная сторона: анатомия классической поломки
Ключевая асимметрия: стек растёт к младшим адресам, а strcpy/memcpy пишут к старшим — то есть прямо на служебные данные кадра. Порядок затирания важен: сначала гибнут локальные указатели, потом канарейка, и только потом адрес возврата. Значит испорченный локальный указатель успевает быть использован до проверки канарейки — атакующему не всегда нужен адрес возврата.
Переполнение в куче устроено иначе: там за пользовательскими данными лежат заголовки соседних чанков (04), и порча одного поля size отдаёт контроль над разметкой всей кучи. Отдельно стоит класс «переполнение внутрь структуры»: запись за границу поля name[32] в соседнее поле is_admin — ни ASan, ни аппаратные меры этого не увидят, потому что формально доступ остался внутри выделенного блока.
Корень чаще всего в целочисленной арифметике
Почти каждая пространственная ошибка начинается с неверно посчитанного размера:
// 1. переполнение умножения: n от пользователя, n * sizeof(T) обернулось => буфер мал
struct item *arr = malloc(n * sizeof *arr); // дыра
size_t bytes;
if (__builtin_mul_overflow(n, sizeof *arr, &bytes)) return NULL; // либо calloc,
struct item *arr = malloc(bytes); // он обязан проверять переполнение
// 2. усечение: uint32 из пакета положили в int => отрицательная длина,
int len = get_length_from_packet(); // при вызове превращается в size_t
memcpy(buf, src, len); // => копируем почти 4 ГБ
// 3. знаковое сравнение: len == -1 становится SIZE_MAX, условие проходит
if (len < sizeof buf) memcpy(buf, src, len); // ловится -Wsign-compare
// 4. off-by-one: strncpy при усечении НЕ ставит '\0'
strncpy(name, src, sizeof name);
name[sizeof name - 1] = '\0'; // обязательная вторая строчка
// лучше snprintf(name, sizeof name, "%s", src): возвращает нужную длину — видно усечение;
// или strlcpy (BSD, в glibc с 2.38)
Дисциплина: все размеры считать в size_t, все внешние длины проверять до использования, включать -Wconversion -Wsign-conversion и относиться к их выводу как к списку задач.
Кейс Heartbleed: как маленькое UB становится утечкой памяти на 64 КБ
CVE-2014-0160 — учебник в одном примере. Клиент присылает heartbeat-запрос, в котором сам объявляет длину полезной нагрузки; сервер этой длине верил. Три урока из разбора ниже переносятся на любой парсер бинарных данных.
с фактическим размером не сверена S->>M: buffer = OPENSSL_malloc(1 + 2 + payload_len) S->>M: memcpy(bp, pl, payload_len) — читаем 65535 байт Note over M: за пределами запроса лежат
приватные ключи, куки, пароли M-->>S: 65535 байт памяти процесса S-->>A: heartbeat-ответ с чужими данными Note over A,S: в логах ничего: соединение штатное,
ни одного падения
- Длина, пришедшая снаружи, — не длина, а предположение. Исправление в OpenSSL — две строки: отбросить пакет, если
1 + 2 + payload_len + 16 > s->s3->rrec.length. - Собственный аллокатор ослепляет инструменты. OpenSSL держал свой freelist поверх
malloc, поэтому «освобождённая» память не возвращалась в систему, и ни valgrind, ни ASan не видели выхода за границу: с их точки зрения это был один большой живой блок. Свой аллокатор (04) — сознательный размен производительности на потерю диагностики; в отладочной сборке его надо уметь отключать (OPENSSL_NO_BUF_FREELISTSпоявился именно тогда) или обучать санитайзеры через интерфейс аннотаций контейнеров. - Чтение опаснее записи по последствиям обнаружения. Запись обычно рано или поздно роняет процесс, чтение не оставляет следов вовсе.
Дисциплина: как ошибаться реже
Ни одно из правил ниже не про «быть внимательным». Все они про то, чтобы сделать неправильный код неудобным или невозможным.
Одно владение, зафиксированное в именах и типах
struct cfg *cfg_new(const char *path); // отдаёт владение вызывающему
void cfg_free(struct cfg *c); // забирает владение, терпит NULL
int cfg_get(const struct cfg *c, ...); // только заимствует, не освобождает
// единая точка выхода: goto cleanup — идиоматичный C, а не «плохой стиль»
int load(const char *path, struct cfg **out) {
int rc = -1;
FILE *f = NULL;
char *buf = NULL;
if (!(f = fopen(path, "rb"))) goto done;
if (!(buf = malloc(SZ))) goto done;
if (fread(buf, 1, SZ, f) != SZ) goto done;
*out = parse(buf);
rc = 0;
done:
free(buf); // free(NULL) легален — проверки не нужны
if (f) fclose(f);
return rc;
}
Расширение GCC и Clang __attribute__((cleanup)) доводит это до автоматизма — так написан весь systemd, и это ровно то, что в C++ называется RAII, только вручную и без гарантий на исключениях (12):
static inline void freep(void *p) { free(*(void **) p); }
#define _cleanup_free_ __attribute__((cleanup(freep)))
int process(void) {
_cleanup_free_ char *buf = malloc(4096); // free вызовется на любом выходе
if (!buf) return -ENOMEM; // из области видимости, включая ранний
... // return и переход по goto
}
Указатель без длины — незаконченный тип
typedef struct { const uint8_t *p; size_t n; } bytes;
// Ни одна функция парсера не берёт голый указатель: граница едет вместе с данными,
// а курсор невозможно сдвинуть, не пройдя единственную проверку длины.
static bool take(bytes *in, size_t n, bytes *out) {
if (in->n < n) return false;
*out = (bytes){ in->p, n };
in->p += n; in->n -= n;
return true;
}
static bool take_u16(bytes *in, uint16_t *v) { // поверх take строится всё
bytes b; // остальное: числа читаются
if (!take(in, 2, &b)) return false; // побайтово, без каламбуров
*v = (uint16_t)((b.p[0] << 8) | b.p[1]); // типов и проблем выравнивания
return true;
}
С таким слоем Heartbleed невыразим: длину из пакета невозможно применить, не пройдя через take. Приём переносится на любой разбор бинарных форматов и стоит нескольких десятков строк один раз.
Ещё семь правил, которые окупаются
p = NULLсразу послеfree— превращает часть UAF в честныйSIGSEGVпо нулевому адресу. Не панацея (алиасы остаются), но бесплатно.- Арены вместо россыпи
malloc. Сто объектов с одним временем жизни — одно освобождение. Класс временных ошибок схлопывается до одного места. - Дескриптор «индекс + поколение» вместо указателя в долгоживущих таблицах: обращение к устаревшему дескриптору — проверяемая ошибка, а не UB.
- Никаких VLA и
allocaс размером, зависящим от входа: это stack clash.-Wvla, а при необходимости-fstack-clash-protection. constвезде, где не пишете, плюс ассерты на инварианты в отладке и_Static_assertна предположения о размерах и раскладке в любой сборке.memsetструктуры перед заполнением, если она уходит наружу: байты-заполнителя иначе утекают вместе с данными — целый класс инфолейков в ядрах.
Инструменты как слои: что каждый ловит и чего не ловит
Ключевое свойство: слои не заменяют друг друга, у каждого своя слепая зона.
| Слой | Что ловит | Слепая зона |
|---|---|---|
| Предупреждения компилятора | опечатки, знаковость, форматы, неиспользуемые результаты | всё, что зависит от данных |
Статический анализ (-fanalyzer, clang-tidy, CodeQL) |
UAF и утечки на достижимых путях, разыменование NULL | ложные срабатывания, межмодульные пути |
| ASan / valgrind | реальные выходы за границу, UAF, double free | только исполненные пути; внутриструктурные переполнения |
| UBSan | переполнение, сдвиги, выравнивание, NULL | алиасинг, гонки |
| MSan | чтение неинициализированного | требует сборки всей программы, включая libc |
| TSan | гонки данных по happens-before | однопоточные ошибки |
| Фаззинг | новые пути для всех предыдущих слоёв | логические ошибки без падения |
| Закалка в проде | превращает эксплуатацию в падение | не находит баг, а лишь удорожает атаку |
| Аппаратные меры (MTE, CET, CHERI) | пространственная и временная безопасность почти без замедления | нужна поддержка железа и всей цепочки |
Механика запуска санитайзеров, чтение их отчётов и валгринд разобраны в 06; здесь важна стратегия — какие сборки существуют в проекте.
# --- отладка и тесты: диагностика важнее скорости
CFLAGS_DEV="-std=c17 -g3 -O1 -fno-omit-frame-pointer -Werror \
-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wcast-align \
-Wcast-qual -Wpointer-arith -Wvla -Wformat=2 -Wnull-dereference -Wwrite-strings \
-Wstrict-prototypes -Wmissing-prototypes \
-fsanitize=address,undefined -fno-sanitize-recover=all"
# --- прод: закалка, near-zero overhead, см. руководство OpenSSF
CFLAGS_REL="-std=c17 -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong \
-fstack-clash-protection -fcf-protection=full -ftrivial-auto-var-init=zero -fPIE"
LDFLAGS_REL="-pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack"
# --- проверка, что закалка действительно попала в бинарь
$ checksec --file=./app # или hardening-check из devscripts
$ readelf -d ./app | grep -E 'BIND_NOW|FLAGS'
$ readelf -n ./app | grep -A2 'GNU_PROPERTY' # IBT/SHSTK для CET
Матрица в CI — не «одна сборка с санитайзерами», а несколько несовместимых между собой (13-tests-in-ci):
strategy: # ASan+UBSan, TSan и MSan делят одну теневую память —
matrix: # совместить их в одной сборке нельзя, нужны разные джобы
include:
- { cc: gcc, san: "address,undefined" }
- { cc: clang, san: "address,undefined" }
- { cc: clang, san: "thread" }
- { cc: clang, san: "memory" } # нужна libc, собранная под MSan
- { cc: gcc, san: "", extra: "-fanalyzer -Werror" }
- { cc: clang, san: "", extra: "-m32" } # другая модель данных ловит переносимость
env:
ASAN_OPTIONS: detect_leaks=1:detect_stack_use_after_return=1:abort_on_error=1
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
Два дополнения. Первое — сборка под другую модель данных (-m32, big-endian через qemu): половина непортируемых предположений про размеры и порядок байтов вылезает сразу. Второе — фаззинг как поставщик путей: санитайзер видит только исполненное, поэтому связка «фаззер + санитайзер» на порядок эффективнее любого из них по отдельности. Для открытых проектов есть OSS-Fuzz, для своих — AFL++ или libFuzzer с корпусом в репозитории и ночной джобой; точка входа LLVMFuzzerTestOneInput показана в 06.
Есть и радикальный слой — звуковой анализ: Frama-C с плагином EVA и контрактами ACSL, CBMC, TrustInSoft Analyzer. Они дают не «нашли N багов», а «UB отсутствует на всех входах» для проверенного фрагмента. Цена — аннотации и месяцы работы, поэтому применяют точечно: криптопримитив, парсер, драйвер в авионике. Академическая база темы — работы группы MIT: «Undefined Behavior: What Happened to My Code?» и «Towards Optimization-Safe Systems», где анализатор STACK нашёл сотни удалённых компилятором проверок в реальном системном коде. Наконец, экономику вопроса меняет железо: ARM MTE (Memory Tagging Extension) присваивает каждым 16 байтам 4-битный тег и сверяет его на каждом доступе — единицы процентов накладных расходов вместо двукратных у ASan, что впервые делает проверку памяти пригодной для прода, а CHERI/Morello (проект Кембриджа) заменяет указатели на аппаратные капабилити с границами и правами.
Когда без UB не обойтись: договоры с компилятором
Иногда переписать нельзя — легаси, чужой протокол, требования производительности. Тогда UB превращают в implementation-defined поведение конкретного компилятора, явными флагами:
| Флаг | Что обещает компилятор | Кто применяет |
|---|---|---|
-fwrapv |
знаковое переполнение оборачивается по дополнительному коду | ядро Linux, часть криптокода |
-fno-strict-aliasing |
не использовать типы для анализа алиасинга | ядро Linux, старые кодеки |
-fno-delete-null-pointer-checks |
не удалять проверки на NULL после разыменования | ядро Linux |
-fno-strict-overflow |
комбинация ослаблений вокруг переполнения | легаси-сборки |
Это не «правильные флаги, которые надо включить всем», а осознанный размен: вы теряете часть оптимизаций и переносимость (обещание действует только для GCC и Clang), но получаете предсказуемость там, где код уже написан. Обязательное условие — записать причину в систему сборки, иначе через год флаг снимут «за ненадобностью». Подробности — в документации GCC по опциям оптимизации.
Отдельно про volatile: он запрещает компилятору кэшировать и переупорядочивать обращения к самому объекту, но не даёт ни атомарности, ни барьеров памяти. Для разделяемых между потоками данных нужен _Atomic (09); volatile — для регистров периферии и для переменных, изменяемых обработчиком сигнала (там правильный тип volatile sig_atomic_t, см. 08).
Типичные заблуждения
| Миф | Реальность |
|---|---|
| «UB — это когда падает» | Падение — лучший исход. Худший — тихая порча данных или утечка ключей |
| «Собралось без предупреждений — значит без UB» | Диагностика UB в общем случае неразрешима; предупреждения ловят единицы процентов |
«На -O0 работает — значит баг в оптимизаторе» |
На -O0 предположения просто не применялись. Ошибка в вашем коде |
| «Тесты зелёные под ASan — памяти ничего не грозит» | Санитайзеры видят только исполненные пути. Нужен фаззинг и покрытие |
| «Беззнаковые типы безопасны» | Оборачивание определено, но n - 1 при n == 0 даёт SIZE_MAX и огромный memcpy |
| «valgrind чист — значит всё хорошо» | valgrind не видит переполнений внутри кадра стека, внутри структуры и почти всё арифметическое UB |
| «Это не эксплуатируется» | Любая порча памяти считается потенциально эксплуатируемой, пока не доказано обратное |
| «Достаточно проверять входные данные на границе» | Проверять надо там, где значение используется: между границей и использованием оно успевает измениться |
| «Перепишем всё на безопасный язык» | Данные Google по Android: плотность уязвимостей падает с возрастом кода, поэтому окупается безопасный новый код, а не переписывание старого |
Отдельного упоминания заслуживает Annex K (функции _s: strcpy_s, memcpy_s). Идея была в безопасной замене строковых функций, но за пределами MSVC они почти не реализованы, а отчёт о полевом опыте N1967 прямо предлагал изъять приложение из стандарта. Практический вывод: не стройте переносимую защиту на Annex K — стройте на размерных API и snprintf.
Итог: чек-лист
Про язык. UB — это контракт, а не сбой: компилятор считает, что вы его соблюдаете, и строит на этом оптимизации. Различайте undefined, unspecified и implementation-defined — первое неприемлемо, второе требует не полагаться на выбор, третье означает «прочитать документацию и зафиксировать в сборке».
Про код. Проверка всегда до использования, никогда после. Переполнение — через __builtin_*_overflow или ckd_*, а не «сложим и посмотрим». Указатель наружу — только вместе с длиной, лучше в одной структуре. Один владелец, p = NULL после free, goto cleanup или __attribute__((cleanup)). Каламбуры типов и невыровненный доступ — через memcpy. Все размеры в size_t, все внешние длины проверены до применения.
Про процесс. Отладочная сборка всегда с -fsanitize=address,undefined -fno-sanitize-recover=all. Продовая — с _FORTIFY_SOURCE=3, канарейками, RELRO, PIE, CET, и проверять результат через checksec, а не верить флагам. В CI — раздельные джобы под несовместимые санитайзеры плюс сборка под другую модель данных. Фаззинг непрерывно, потому что санитайзер бесполезен на неисполненном пути. Новый код там, где ставки высоки, — по возможности на языке с проверяемым владением; C оставлять там, где он действительно оправдан.
Источники
- Черновик стандарта C17 N2310 — раздел 3.4 и приложение J.2 со списком всех видов UB.
- Черновик C23 N3096 —
<stdckdint.h>, обязательный дополнительный код, смягчение правил нулевых длин. - Крис Латтнер, What Every C Programmer Should Know About Undefined Behavior — три части, взгляд со стороны оптимизатора.
- Джон Регер, A Guide to Undefined Behavior in C and C++ — самый полный практический обзор.
- SEI CERT C Coding Standard — правила с примерами «плохо/хорошо» и оценкой риска.
- OpenSSF Compiler Options Hardening Guide for C and C++ — актуальный набор флагов закалки с обоснованием.
- UndefinedBehaviorSanitizer, опции статического анализатора GCC и Wang et al., Towards Optimization-Safe Systems — сколько проверок компилятор удаляет в реальных проектах.
- Смежные треки портала: безопасная разработка, тестирование безопасности, управление памятью в ОС, изоляция и защита в ОС, оптимизации компилятора, представление данных.
Что дальше
Мы разобрали, какую цену C берёт за отсутствие страховки и какой дисциплиной эта цена снижается. Значительная часть этой дисциплины — владение, парные конструктор и деструктор, размерные представления вместо голых указателей — в C++ поднята на уровень языка: деструкторы вызываются автоматически, std::unique_ptr кодирует единственного владельца в типе, std::span не даёт указателю оторваться от длины. Что из этого действительно помогает, что добавляет собственных ловушек и какие UB остаются с вами и там — в следующей статье.
C++ после C: RAII, классы, шаблоны, умные указатели — что меняется