Что может произойти практически при неопределенном поведении в C

Я читал много статей, рассказывающих о неопределенном поведении (UB), но все говорят о теории. Мне интересно, что может произойти на практике , потому что программы, содержащие UB, могут работать.

Мои вопросы касаются unix-подобных систем, а не встроенных систем.

Я знаю, что не следует писать код, основанный на неопределенном поведении. Пожалуйста, не отправляйте ответы следующим образом:

  • Все может случиться
  • Демоны могут летать из носа
  • Компьютер мог прыгать и загораться

Особенно для первого, это неправда. Очевидно, вы не можете получить root, выполнив переполнение целого числа со знаком. Я прошу об этом только в образовательных целях.

Вопрос А)

Источник

поведение, определяемое реализацией: неопределенное поведение, когда каждая реализация документирует, как делается выбор

Является ли implementation компилятором?

Вопрос B)

 *"abc" = '\0'; 

Для чего-то еще, кроме segfault, мне нужно, чтобы моя система была сломана? Что может произойти, даже если это не предсказуемо? Может ли первый байт быть установлен на ноль? Что еще и как?

Вопрос C)

 int i = 0; foo(i++, i++, i++); 

Это UB, потому что порядок, в котором оцениваются параметры, не определен. Правильно. Но, когда программа запускается, кто решает, в каком порядке оцениваются параметры: is – это компилятор, ОС или что-то еще?

Вопрос D)

Источник

 $ cat test.c int main (void) { printf ("%d\n", (INT_MAX+1) < 0); return 0; } $ cc test.c -o test $ ./test Formatting root partition, chomp chomp 

По мнению других пользователей SO, это возможно. Как такое могло произойти? Нужен ли мне сломанный компилятор?

Вопрос E)

Используйте тот же код, что и выше. Что могло бы произойти, за исключением выражения (INT_MAX+1) дающего случайное значение?

Вопрос F)

-fwrapv GCC -fwrapv определяет поведение переполнения целого числа со -fwrapv или делает только GCC предполагать, что он будет обернут, но на самом деле он не может быть обернут во время выполнения?

Вопрос G)

Это касается встроенных систем. Конечно, если компьютер перейдет в неожиданное место, два выхода могут быть соединены вместе и создать короткое замыкание (например).

Но при выполнении кода, подобного этому:

 *"abc" = '\0'; 

Разве ПК не был бы привязан к общему обработчику исключений? Или что мне не хватает?

На практике большинство компиляторов используют неопределенное поведение одним из следующих способов:

  • Распечатайте предупреждение во время компиляции, чтобы сообщить пользователю, что он, вероятно, допустил ошибку
  • Вывести свойства значений переменных и использовать их для упрощения кода
  • Выполнять небезопасные оптимизации, если они только нарушают ожидаемую семантику неопределенного поведения

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

A) Да. Компилятор должен документировать, какое поведение он выбрал. Но обычно это трудно предсказать или объяснить последствия UB.

B) Если строка фактически создается в памяти и находится на доступной для записи странице (по умолчанию она будет доступна только для чтения), то ее первый символ может стать нулевым символом. Скорее всего, все выражение будет выброшено как мертвый код, потому что это временное значение, которое исчезает из выражения.

C) Обычно порядок оценки определяется компилятором. Здесь он может решить преобразовать его в i += 3 (или i = undef если он глуп). ЦП может переупорядочить инструкции во время выполнения, но сохранить порядок, выбранный компилятором, если он сломает семантику своего набора команд (компилятор обычно не может переместить семантику C дальше). Инкремент регистра не может коммутироваться или выполняться параллельно с другим приращением этого же регистра.

D) Вам нужен глупый компилятор, который печатает «Форматирование корневого раздела, chomp chomp», когда он обнаруживает неопределенное поведение. Скорее всего, он напечатает предупреждение во время компиляции, заменит выражение константой по своему выбору и создаст двоичный файл, который просто выполнит печать с этой константой.

E) Это синтаксически правильная программа, поэтому компилятор, безусловно, создаст «рабочий» двоичный файл. Этот бинарный метод теоретически может иметь такое же поведение, как и любые бинарные файлы, которые вы могли бы загрузить в Интернете и что вы запускаете. Скорее всего, вы получаете двоичный файл, который немедленно выйдет, или распечатайте вышеупомянутое сообщение и немедленно выйдите.

F) Он сообщает GCC о том, чтобы объявленные целые числа обертывались в семантике C, используя семантику дополнения 2. Поэтому он должен генерировать двоичный файл, который выполняется во время выполнения. Это довольно легко, потому что в большинстве случаев архитектура имеет эту семантику. Причина того, что C имеет UB, заключается в том, что компиляторы могут принимать a + 1 > a что является критическим для доказательства того, что петли завершают и / или предсказывают ветви. Вот почему использование целочисленной целочисленной переменной индуктивности цикла может привести к более быстрому коду, даже если оно сопоставляется с теми же инструкциями на аппаратном уровне.

G) Неопределенное поведение – неопределенное поведение. Полученный двоичный файл действительно может выполнять любые инструкции, включая переход в неуказанное место … или чистое прерывание. Скорее всего, ваш компилятор избавится от этой ненужной операции.

Очевидно, вы не можете получить root, выполнив переполнение целого числа со знаком.

Почему бы и нет?

Если вы предположите, что подписанное целочисленное переполнение может дать только определенное значение, то вряд ли вы получите root таким образом. Но дело в неопределенном поведении заключается в том, что оптимизирующий компилятор может предположить, что этого не происходит, и генерировать код на основе этого предположения.

У операционных систем есть ошибки. Использование этих ошибок может, среди прочего, вызвать эскалацию привилегий .

Предположим, вы используете стандартную целочисленную арифметику для вычисления индекса в массив. Если вычисление переполняется, вы можете случайно скрыть какой-то произвольный кусок памяти вне предполагаемого массива. Это может привести к тому, что ваша программа будет делать произвольно плохие вещи.

Если ошибка может быть использована специально (и наличие вредоносного ПО ясно указывает на то, что это возможно), по крайней мере, возможно, что это может быть использовано случайно.

Также рассмотрим эту простую надуманную программу:

 #include  #include  int main(void) { int x = INT_MAX; if (x < x + 1) { puts("Code that gets root"); } else { puts("Code that doesn't get root"); } } 

В моей системе он печатает

 Code that doesn't get root 

при компиляции с gcc -O0 или gcc -O1 и

 Code that gets root 

с gcc -O2 или gcc -O3 .

У меня нет конкретных примеров подписанного целочисленного переполнения, вызывающего дефект безопасности (и я бы не опубликовал такой пример, если бы у меня был такой), но это, безусловно, возможно.

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

На мой взгляд, самое худшее, что может случиться перед лицом неопределенного поведения, – это что-то другое завтра .

Мне нравится программировать, но я также наслаждаюсь завершением программы и продолжаю работать над чем-то другим. Я не восхищаюсь постоянно возиться с моими уже написанными программами, чтобы они работали в условиях ошибок, которые они спонтанно развивали по мере того, как менялись аппаратные средства, компиляторы или другие обстоятельства.

Поэтому, когда я пишу программу, для нее недостаточно работать. Он должен работать по правильным причинам. Я должен знать, что он работает, и что он будет продолжать работать на следующей неделе, в следующем месяце и в следующем году. Он не может просто работать, чтобы дать, по-видимому, правильные ответы на: – обязательно конечный набор тестовых примеров, на которых я его использовал.

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

См. Также третью часть этого другого ответа (часть, начинающаяся с «И теперь, еще одна вещь, если вы еще со мной»).

Раньше было, что вы могли рассчитывать на компилятор, чтобы сделать что-то «разумное». Тем не менее, все чаще компиляторы действительно используют свою лицензию, чтобы делать странные вещи, когда вы пишете неопределенный код. Во имя эффективности эти компиляторы вводят очень странные оптимизации, которые не делают ничего близкого к тому, что вы, вероятно, хотите.

Прочтите эти сообщения:

  • Линус Торвальдс описывает ошибку ядра, которая была намного хуже, чем можно было бы предположить, что gcc воспользовался неопределенным поведением
  • Сообщение блога LLVM о неопределенном поведении (первая из трех частей, а также две , три )
  • еще один отличный пост в блоге Джона Реджера (также первый из трех частей: два , три )