Системное программирование на C Неопределённое поведение и безопасность памяти: как ломается и как не ломать
0%

Неопределённое поведение и безопасность памяти: как ломается и как не ломать

Неопределённое поведение и безопасность памяти: как ломается и как не ломать

В большинстве языков вопрос «а что будет, если выйти за границу массива» имеет ответ: исключение, паника, 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». Внутри работает цепочка из десятков проходов, каждый из которых делает локально корректное преобразование, опираясь на факты, выведенные предыдущими. Разрушительный эффект получается на стыке — и каждый проход при этом прав, виновата исходная строка, нарушившая контракт.

Три канонических случая, которые стоит уметь узнавать в чужом коде.

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 .rodataSIGSEGV 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 давало неожиданный результат, потому что два «одинаковых» адреса считались указателями на разные объекты.

Отсюда вся дисциплина владения: копия указателя — это не «ещё одна переменная», а второй вход в тот же объект, за которым нужно следить так же строго.

Окна валидности указателя во времени: неинициализированный, валидный, висячий после realloc и free

Безопасность памяти: разбор классов

Термин «memory safety» распадается на две независимые гарантии. Пространственная безопасность — доступ не выходит за границы того объекта, на который указывает указатель; ломается переполнениями буфера, off-by-one и ошибками в арифметике размеров. Временная безопасность — доступ происходит внутри времени жизни объекта; ломается use-after-free, double free, возвратом указателя на локальную переменную и use-after-scope. Инструменты и меры защиты у них разные, поэтому и разбирать их надо порознь. Диаграмма ниже — это и есть модель владения, которую в C приходится держать в голове и в соглашениях команды, а в Rust её проверяет компилятор (13).

Пространственная сторона: анатомия классической поломки

Кадр стека при переполнении буфера: канарейка, сохранённый rbp, адрес возврата

Ключевая асимметрия: стек растёт к младшим адресам, а 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-запрос, в котором сам объявляет длину полезной нагрузки; сервер этой длине верил. Три урока из разбора ниже переносятся на любой парсер бинарных данных.

  1. Длина, пришедшая снаружи, — не длина, а предположение. Исправление в OpenSSL — две строки: отбросить пакет, если 1 + 2 + payload_len + 16 > s->s3->rrec.length.
  2. Собственный аллокатор ослепляет инструменты. OpenSSL держал свой freelist поверх malloc, поэтому «освобождённая» память не возвращалась в систему, и ни valgrind, ни ASan не видели выхода за границу: с их точки зрения это был один большой живой блок. Свой аллокатор (04) — сознательный размен производительности на потерю диагностики; в отладочной сборке его надо уметь отключать (OPENSSL_NO_BUF_FREELISTS появился именно тогда) или обучать санитайзеры через интерфейс аннотаций контейнеров.
  3. Чтение опаснее записи по последствиям обнаружения. Запись обычно рано или поздно роняет процесс, чтение не оставляет следов вовсе.

Дисциплина: как ошибаться реже

Ни одно из правил ниже не про «быть внимательным». Все они про то, чтобы сделать неправильный код неудобным или невозможным.

Одно владение, зафиксированное в именах и типах

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. Приём переносится на любой разбор бинарных форматов и стоит нескольких десятков строк один раз.

Ещё семь правил, которые окупаются

  1. p = NULL сразу после free — превращает часть UAF в честный SIGSEGV по нулевому адресу. Не панацея (алиасы остаются), но бесплатно.
  2. Арены вместо россыпи malloc. Сто объектов с одним временем жизни — одно освобождение. Класс временных ошибок схлопывается до одного места.
  3. Дескриптор «индекс + поколение» вместо указателя в долгоживущих таблицах: обращение к устаревшему дескриптору — проверяемая ошибка, а не UB.
  4. Никаких VLA и alloca с размером, зависящим от входа: это stack clash. -Wvla, а при необходимости -fstack-clash-protection.
  5. const везде, где не пишете, плюс ассерты на инварианты в отладке и _Static_assert на предположения о размерах и раскладке в любой сборке.
  6. 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 оставлять там, где он действительно оправдан.

Источники

Что дальше

Мы разобрали, какую цену C берёт за отсутствие страховки и какой дисциплиной эта цена снижается. Значительная часть этой дисциплины — владение, парные конструктор и деструктор, размерные представления вместо голых указателей — в C++ поднята на уровень языка: деструкторы вызываются автоматически, std::unique_ptr кодирует единственного владельца в типе, std::span не даёт указателю оторваться от длины. Что из этого действительно помогает, что добавляет собственных ловушек и какие UB остаются с вами и там — в следующей статье.

C++ после C: RAII, классы, шаблоны, умные указатели — что меняется

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов