Исследование одного неопределённого поведения

    В статье исследуются возможные проявления неопределённого поведения, возникающего в c++ при завершении не-void функции без вызова return с подходящим значением. Статья носит больше научно-развлекательный характер, чем практический.

    Кому не нравится весело скакать по граблям — проходим мимо, не задерживаемся.

    Введение


    Всем известно, что при разработке c++-кода следует не допускать неопределённого поведения.
    Однако:

    • неопределённое поведение может казаться не достаточно опасным из-за абстрактности возможных последствий;
    • не всегда понятно, где грань.

    Попробуем конкретизировать возможные проявления неопределённого поведения, возникающего в одном довольно простом случае — в не-void функции отсутствует return.

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

    Исследования под Linux будут проводиться с помощью Compiler Explorer. Исследования под Windows и macOs X — на непосредственно доступном мне железе.

    Все сборки будут делаться для x86-x64.

    Никаких мер для усиления либо подавления предупреждений/ошибок компиляторов предприниматься не будет.

    Будет много дизассемблированного кода. Его оформление, к сожалению, разношёрстное, т.к. приходится использовать несколько разных инструментов (хорошо хоть удалось добиться везде синтаксиса Intel). К дизассемблированному коду я буду давать в меру подробные комментарии, которые, однако, не избавляют от необходимости знания регистров процессора и принципов работы стека.

    Читаем Стандарт


    C++11 final draft n3797, C++14 final draft N3936:
    6.6.3 The return statement

    Flowing off the end of a function is equivalent to a return with no value; this results in undefined
    behavior in a value-returning function.


    Достижение конца функции эквивалентно выражению return без возвращаемого значения; для функции, у которой возвращаемое значение предусмотрено, это приводит к неопределённому поведению.

    C++17 draft n4713
    9.6.3 The return statement

    Flowing off the end of a constructor, a destructor, or a function with a cv void return type is equivalent to a return with no operand. Otherwise, flowing off the end of a function other than main (6.8.3.1) results in undefined behavior.


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

    Что это значит на практике?

    Если сигнатура функции предусматривает возвращаемое значение:

    • её выполнение должно завершаться выражением return с экземпляром подходящего типа;
    • иначе — неопределённое поведение;
    • неопределённое поведение начинается не с момента вызова такой функции и не с момента использования возвращённого значение, а с момента ненадлежащего завершения такой функции;
    • если функция содержит как корректные, так и некорректные пути выполнения — неопределённое поведение будет возникать только на некорректных путях;
    • рассматриваемое неопределённое поведение не затрагивает выполнение инструкций, содержащихся в теле функции.

    Фраза про функцию main не является новшеством c++17 — в предыдущих версиях Стандарта аналогичное исключение было описано в разделе 3.6.1 Main function.

    Пример 1 — bool


    В c++ нет ни одного типа с состоянием более простым, чем bool. Вот с него и начнём.

    #include <iostream>
    
    bool bad() {};
    
    int main()
    {
        std::cout << bad();
    
        return 0;
    }
    

    MSVC выдаёт на такой пример ошибку компиляции C4716, поэтому для MSVC код придётся слегка усложнить, предоставив хотя бы один корректный путь выполнения:

    #include <iostream>
    #include <stdlib.h>
    
    bool bad()
    {
        if (rand() == 0) {
            return true;
        }
    }
    
    int main()
    {
        std::cout << bad();
    
        return 0;
    }
    

    Компиляция:

    Платформа Компилятор Результат компиляции
    Linux x86-x64 Clang 10.0.0 warning: non-void function does not return a value [-Wreturn-type]
    Linux x86-x64 gcc 9.3 warning: no return statement in function returning non-void [-Wreturn-type]
    macOs X Apple clang version 11.0.0 warning: control reaches end of non-void function [-Wreturn-type]
    Windows MSVC 2019 16.5.4 Оригинальный пример — error C4716, усложнённый — warning C4715: not all control paths return a value

    Результаты выполнения:
    Оптимизация Program return Console output
    Linux x86-x64 Clang 10.0.0
    -O0 255 No output
    -O1, -O2 0 No output
    Linux x86-x64 gcc 9.3
    -O0 0 89
    -O1, -O2, -O3 0 No output
    macOs X Apple clang version 11.0.0
    -O0, -O1, -O2 0 0
    Windows MSVC 2019 16.5.4, оригинальный пример
    /Od, /O1, /O2 No build No build
    Windows MSVC 2019 16.5.4, усложнённый пример
    /Od 0 41
    /O1, /O2 0 1

    Даже в этом простейшем примере четыре компилятора продемонстрировали как минимум три варианта проявления неопределённого поведения.

    Идём разбираться, что же там эти компиляторы накомпилировали.

    Linux x86-x64 Clang 10.0.0, -O0


    image

    Последняя инструкция в функции bad() — ud2.

    Описание инструкции из Intel 64 and IA-32 Architectures Software Developer’s Manual:
    UD2—Undefined Instruction
    Generates an invalid opcode exception. This instruction is provided for software testing to explicitly generate an invalid opcode exception. The opcode for this instruction is reserved for this purpose.
    Other than raising the invalid opcode exception, this instruction has no effect on processor state or memory.

    Even though it is the execution of the UD2 instruction that causes the invalid opcode exception, the instruction pointer saved by delivery of the exception references the UD2 instruction (and not the following instruction).

    This instruction’s operation is the same in non-64-bit modes and 64-bit mode.

    Если кратко — это специальная инструкция для генерации исключения.

    Надо обернуть вызов bad() в блок try… catch !?

    Как бы не так. Это не c++-исключение.

    Можно ли отловить ud2 в рантайме?
    Под Windows для этого следует использовать __try, под Linux и macOs X — обработчик сигнала SIGILL.

    Linux x86-x64 Clang 10.0.0, -O1, -O2


    image

    В результате оптимизации компилятор просто взял и выбросил как тело функции bad(), так и её вызов.

    Linux x86-x64 gcc 9.3, -O0


    image

    Пояснения (в обратном порядке, т.к. в данном случае цепочку проще разбирать с конца):

    5. Вызывается оператор вывода в stream для bool (строка 14);

    4. В регистр edi помещается адрес std::cout — это первый аргумент оператора вывода в stream (строка 13);

    3. В регистр esi помещается содержимое регистра eax — это второй аргумент оператора вывода в stream (строка 12);

    2. Обнуляются три старших байта eax, значение al при этом не меняется (строка 11);

    1. Вызывается функция bad() (строка 10);

    0. Функция bad() должна поместить возвращаемое значение в регистр al.

    Вместо этого в строке 4 — nop (No Operation, пустышка).

    В консоль выводится один байт мусора из регистра al. Программа завершается штатно.

    Linux x86-x64 gcc 9.3, -O1, -O2, -O3


    image

    Компилятор всё повыбрасывал в результате оптимизации.

    macOs X Apple clang version 11.0.0, -O0


    Функция main():

    image

    Путь булевского аргумента оператора вывода в поток (на сей раз в прямом порядке):

    1. В регистр edx помещается содержимое регистра al (строка 8);

    2. Зануляются все биты регистра edx, кроме младшего (строка 9);

    3. В регистр rdi помещается указатель на std::cout — это первый аргумент оператора вывода в stream (строка 10);

    4. В регистр esi помещается содержимое регистра edx — это второй аргумент оператора вывода в stream (строка 11);

    5. Вызывается оператор вывода в stream для bool (строка 13);

    Функция main ожидает получить результат выполнения функции bad() из регистра al.

    Функция bad():

    image

    1. В регистр al помещается значение из следующего, ещё не выделенного, байта стека (строка 4);

    2. Зануляются все биты регистра al, кроме младшего (строка 5);

    В консоль выводится один бит мусора из нераспределённого стека. Так получилось, что при тестовом запуске там оказался ноль.

    Программа завершается штатно.

    macOs X Apple clang version 11.0.0, -O1, -O2


    image

    Булевский аргумент оператора вывода в stream обнуляется (строка 5).

    Вызов bad() выброшен при оптимизации.

    Программа всегда выводит в консоль ноль и завершается штатно.

    Windows MSVC 2019 16.5.4, усложнённый пример, /Od


    image

    Видно, что функция bad() должна предоставить возвращаемое значение в регистре al.

    image

    Значение, возвращённое функцией bad(), помещается сначала на стек, а потом в регистр edx для вывода в stream.

    В консоль выводится один байт мусора из регистра al (если чуть точнее — то младший байт результата rand()). Программа завершается штатно.

    Windows MSVC 2019 16.5.4, усложнённый пример, /O1, /O2


    image

    Компилятор принудительно заинлайнил вызов bad(). Функция main():

    • копирует в ebx один байт из памяти, находящейся по адресу [rsp+30h];
    • в случае, если rand() вернул ноль, копирует единицу из ecx в ebx (строка 11);
    • копирует это же значение в dl (точнее, его младший байт) (строка 13);
    • вызывает функцию вывода в stream, осуществляющую вывод значения dl (строка 14).

    В stream выводится один байт мусора из оперативной памяти (из адреса rsp+30h).

    Вывод по примеру 1


    Результаты рассмотрения листингов дизассемблера приведены в таблице:
    Оптимизация Program return Console output Причина
    Linux x86-x64 Clang 10.0.0
    -O0 255 No output ud2
    -O1, -O2 0 No output Вывод в консоль и вызов функции bad() выброшены в результате оптимизации
    Linux x86-x64 gcc 9.3
    -O0 0 89 Один байт мусора из регистра al
    -O1, -O2, -O3 0 No output Вывод в консоль и вызов функции bad() выброшены в результате оптимизации
    macOs X Apple clang version 11.0.0
    -O0 0 0 Один бит мусора из оперативной памяти
    -O1, -O2 0 0 Вызов функции bad() заменён нулём
    Windows MSVC 2019 16.5.4, оригинальный пример
    /Od, /O1, /O2 No build No build No build
    Windows MSVC 2019 16.5.4, усложнённый пример
    /Od 0 41 Один байт мусора из регистра al
    /O1, /O2 0 1 Один байт мусора из оперативной памяти

    Как оказалось, компиляторы продемонстрировали не 3, а целых 6 вариантов неопределённого поведения — просто до рассмотрения листингов дизассемблера мы не могли различить некоторые из них.

    Пример 1a — управление неопределённым поведением


    Попробуем немного порулить неопределённым поведением — повлиять на значение, возвращаемое функцией bad().

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

    Linux x86-x64 gcc 9.3, -O0


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

    Очевидно, что это можно сделать с помощью вызова любой другой функции, возвращающей bool. Но также это можно сделать с помощью функции, возвращающей, например, unsinged char.

    Полный код примера
    #include <iostream>
    
    bool bad() {}
    
    bool goodTrue()
    {
        return rand();
    }
    
    bool goodFalse()
    {
        return !goodTrue();
    }
    
    unsigned char goodChar(unsigned char ch)
    {
        return ch;
    }
    
    int main()
    {
        goodTrue();
        std::cout << bad() << std::endl;
    
        goodChar(85);
        std::cout << bad() << std::endl;
    
        goodFalse();
        std::cout << bad() << std::endl;
    
        goodChar(240);
        std::cout << bad() << std::endl;
    
        return 0;
    }
    


    Вывод в консоль:
    1
    85
    0
    240

    Windows MSVC 2019 16.5.4, /Od


    В примере для MSVC функция bad() возвращает младший байт результата rand().

    Без модификации функции bad() внешний код может повлиять на возвращаемое ею значение, изменяя результат rand().

    Полный код примера
    #include <iostream>
    #include <stdlib.h>
    
    void control(unsigned char value)
    {
        uint32_t count = 0;
        srand(0);
        while ((rand() & 0xff) != value) {
            ++count;
        }
    
        srand(0);
        for (uint32_t i = 0; i < count; ++i) {
            rand();
        }
    }
    
    bool bad()
    {
        if (rand() == 0) {
            return true;
        }
    }
    
    int main()
    {
        control(1);
        std::cout << bad() << std::endl;
    
        control(85);
        std::cout << bad() << std::endl;
    
        control(0);
        std::cout << bad() << std::endl;
    
        control(240);
        std::cout << bad() << std::endl;
    
        return 0;
    }
    


    Вывод в консоль:
    1
    85
    0
    240


    Windows MSVC 2019 16.5.4, /O1, /O2


    Чтобы повлиять не значение, «возвращаемое» функцией bad(), достаточно создать одну стековую переменную. Чтоб запись в неё не была выброшена при оптимизации, следует пометить её как volatile.
    Полный код примера
    #include <iostream>
    #include <stdlib.h>
    
    bool bad()
    {
      if (rand() == 0) {
        return true;
      }
    }
    
    int main()
    {
      volatile unsigned char ch = 1;
      std::cout << bad() << std::endl;
    
      ch = 85;
      std::cout << bad() << std::endl;
    
      ch = 0;
      std::cout << bad() << std::endl;
    
      ch = 240;
      std::cout << bad() << std::endl;
    
      return 0;
    }
    


    Вывод в консоль:
    1
    85
    0
    240


    macOs X Apple clang version 11.0.0, -O0


    Надо перед вызовом bad() вписать определённое значение в ту ячейку памяти, которая будет на единицу младше вершины стека в момент вызова bad().

    Полный код примера
    #include <iostream>
    
    bool bad() {}
    
    void putToStack(uint8_t value)
    {
        uint8_t memory[1]{value};
    }
    
    int main()
    {
        putToStack(20);
        std::cout << bad() << std::endl;
    
        putToStack(55);
        std::cout << bad() << std::endl;
    
        putToStack(0xfe);
        std::cout << bad() << std::endl;
    
        putToStack(11);
        std::cout << bad() << std::endl;
    
        return 0;
    }
    

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

    При этом переменная memory должна быть не просто единичным значением, а массивом — иначе компилятор располагает её в регистр процессора, а не на стек, как нам надо.

    Пример не является универсальным, т.к. вообще компиляторы могут выделять на стеке больше памяти, чем необходимо для пользовательских переменных — тогда функция putToStack в текущем виде будет промахиваться.

    Вывод в консоль:
    0
    1
    0
    1

    Вроде получилось: удаётся менять выдачу функции bad(), и при этом учитывается только младший бит.

    Вывод по примеру 1a


    Пример позволил убедиться в корректности трактовки листингов дизассемблера.

    Пример 1b — сломанный bool


    Ну подууууумаешь, в консоль выведется «41» вместо «1»… Разве это опасно?

    Проверять будем на двух компиляторах, предоставивших целый байт мусора.

    Windows MSVC 2019 16.5.4, /Od


    Полный код примера
    #include <iostream>
    #include <stdlib.h>
    #include <set>
    #include <unordered_set>
    
    bool bad()
    {
        if (rand() == 0) {
            return true;
        }
    }
    
    int main()
    {
        bool badBool1 = bad();
        bool badBool2 = bad();
    
        std::cout << "badBool1: " << badBool1 << std::endl;
        std::cout << "badBool2: " << badBool2 << std::endl;
    
        if (badBool1) {
          std::cout << "if (badBool1): true" << std::endl;
        } else {
          std::cout << "if (badBool1): false" << std::endl;
        }
        if (!badBool1) {
          std::cout << "if (!badBool1): true" << std::endl;
        } else {
          std::cout << "if (!badBool1): false" << std::endl;
        }
    
        std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
                  << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
                  << std::endl;
        std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
                  << std::set<bool>{badBool1, badBool2, true, false}.size()
                  << std::endl;
        std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
                  << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
                  << std::endl;
    
        return 0;
    }
    


    Вывод в консоль:
    badBool1: 41
    badBool2: 35
    if (badBool1): true
    if (!badBool1): false
    (badBool1 == true || badBool1 == false || badBool1 == badBool2): false
    std::set<bool>{badBool1, badBool2, true, false}.size(): 4
    std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): 4

    Неопределённое поведение привело к возникновению булевской переменной, которая ломает как минимум:
    • операторы сравнения булевских значений;
    • хеш-функцию булевского значения.


    Windows MSVC 2019 16.5.4, /O1, /O2


    Полный код примера
    #include <iostream>
    #include <stdlib.h>
    #include <set>
    #include <unordered_set>
    
    bool bad()
    {
      if (rand() == 0) {
        return true;
      }
    }
    
    int main()
    {
      volatile unsigned char ch = 213;
      bool badBool1 = bad();
      ch = 137;
      bool badBool2 = bad();
    
      std::cout << "badBool1: " << badBool1 << std::endl;
      std::cout << "badBool2: " << badBool2 << std::endl;
    
      if (badBool1) {
        std::cout << "if (badBool1): true" << std::endl;
      }
      else {
        std::cout << "if (badBool1): false" << std::endl;
      }
      if (!badBool1) {
        std::cout << "if (!badBool1): true" << std::endl;
      }
      else {
        std::cout << "if (!badBool1): false" << std::endl;
      }
    
      std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
        << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
        << std::endl;
      std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
        << std::set<bool>{badBool1, badBool2, true, false}.size()
        << std::endl;
      std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
        << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
        << std::endl;
    
      return 0;
    }
    


    Вывод в консоль:
    badBool1: 213
    badBool2: 137
    if (badBool1): true
    if (!badBool1): false
    (badBool1 == true || badBool1 == false || badBool1 == badBool2): false
    std::set<bool>{badBool1, badBool2, true, false}.size(): 4
    std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): 4

    Работа с испорченной булевской переменной не изменилась при включении оптимизации.

    Linux x86-x64 gcc 9.3, -O0


    Полный код примера
    #include <iostream>
    #include <stdlib.h>
    #include <set>
    #include <unordered_set>
    
    bool bad()
    {
    }
    
    unsigned char goodChar(unsigned char ch)
    {
      return ch;
    }
    
    int main()
    {
      goodChar(213);
      bool badBool1 = bad();
    
      goodChar(137);
      bool badBool2 = bad();
    
      std::cout << "badBool1: " << badBool1 << std::endl;
      std::cout << "badBool2: " << badBool2 << std::endl;
    
      if (badBool1) {
        std::cout << "if (badBool1): true" << std::endl;
      }
      else {
        std::cout << "if (badBool1): false" << std::endl;
      }
      if (!badBool1) {
        std::cout << "if (!badBool1): true" << std::endl;
      }
      else {
        std::cout << "if (!badBool1): false" << std::endl;
      }
    
      std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
        << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
        << std::endl;
      std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
        << std::set<bool>{badBool1, badBool2, true, false}.size()
        << std::endl;
      std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
        << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
        << std::endl;
    
      return 0;
    }
    


    Вывод в консоль:
    badBool1: 213
    badBool2: 137
    if (badBool1): true
    if (!badBool1): true
    (badBool1 == true || badBool1 == false || badBool1 == badBool2): false
    std::set<bool>{badBool1, badBool2, true, false}.size(): 4
    std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): 4


    По сравнению с MSVC, в gcc добавилась ещё и некорректная работа оператора not.

    Вывод по примеру 1b


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

    Почему так произошло?

    Потому что некоторые операции с булевскими переменными реализованы в предположении, что true — это строго единица.

    В дизассемблере этот вопрос рассматривать не будем — статья и так получилась объёмной.

    В очередной раз уточним таблицу с поведением компиляторов:
    Оптимизация Program return Console output Причина Последствия использования результата bad()
    Linux x86-x64 Clang 10.0.0
    -O0 255 No output ud2
    -O1, -O2 0 No output Вывод в консоль и вызов функции bad() выброшены в результате оптимизации
    Linux x86-x64 gcc 9.3
    -O0 0 89 Один байт мусора из регистра al Нарушение работы:
    not; ==; !=; <; >; <=; >=; std::hash.
    -O1, -O2, -O3 0 No output Вывод в консоль и вызов функции bad() выброшены в результате оптимизации
    macOs X Apple clang version 11.0.0
    -O0 0 0 Один бит мусора из оперативной памяти
    -O1, -O2 0 0 Вызов функции bad() заменён нулём
    Windows MSVC 2019 16.5.4, оригинальный пример
    /Od, /O1, /O2 No build No build No build
    Windows MSVC 2019 16.5.4, усложнённый пример
    /Od 0 41 Один байт мусора из регистра al Нарушение работы:
    ==; !=; <; >; <=; >=; std::hash.
    /O1, /O2 0 1 Один байт мусора из оперативной памяти Нарушение работы:
    ==; !=; <; >; <=; >=; std::hash.

    Четыре компилятора дали 7 различных проявлений неопределённого поведения.

    Пример 2 — struct


    Возьмём пример чуть посложнее:

    #include <iostream>
    #include <stdlib.h>
    
    struct Test
    {
        Test(uint64_t v)
            : value(v)
        {
            std::cout << "Test::Test(" << v << ")" << std::endl;
        }
        ~Test()
        {
            std::cout << "Test::~Test()" << std::endl;
        }
    
        uint64_t value;
    };
    
    Test bad(int v)
    {
        if (v == 0) {
            return {42};
        } else if (v == 1) {
            return {142};
        }
    }
    
    int main()
    {
        const auto rnd = rand();
        std::cout << "rnd: " << rnd << std::endl;
    
        std::cout << bad(rnd).value << std::endl;
    
        return 0;
    }
    

    Структура Test требует для конструирования один параметр типа int. Из её конструктора и деструктора производится вывод диагностических сообщений. Функция bad(int) имеет два корректных пути выполнения, ни один из которых не будет реализован при единственном вызове.

    На этот раз — сначала таблица, потом разбор дизассемблера по непонятным пунктам.
    Оптимизация Program return Console output Причина
    Linux x86-x64 Clang 10.0.0
    -O0 255 rnd: 1804289383 ud2
    -O1, -O2 0 rnd: 1804289383
    Test::Test(142)
    142
    Test::~Test()
    Проверка if (v == 1) не производится. Блок else if превратился в просто else.
    Linux x86-x64 gcc 9.3
    -O0 0 rnd: 1804289383
    4198608
    Test::~Test()
    nop вместо вызова конструктора на некорректном пути выполнения.
    value содержит мусор из стека.
    -O1, -O2, -O3 0 rnd: 1804289383
    Test::Test(142)
    142
    Test::~Test()
    Проверка if (v == 1) не производится. Блок else if превратился в просто else.
    macOs X Apple clang version 11.0.0
    -O0 The program has unexpectedly finished. rnd: 16807 ud2
    -O1, -O2 0 rnd: 16807
    Test::Test(142)
    142
    Test::~Test()
    Проверка if (v == 1) не производится. Блок else if превратился в просто else.
    Windows MSVC 2019 16.5.4
    /Od /RTCs Access violation reading location 0x00000000CCCCCCCC rnd: 41 Побочный эффект MSVC stack frame run-time error checking
    /Od, /O1, /O2 0 rnd: 41
    8791061810776
    Test::~Test()
    Мусор из ячейки памяти, адрес которой оказался в rax

    Опять мы видим множество вариантов: кроме уже известного ud2 есть ещё как минимум 4 разных поведения.

    Весьма интересно обращение компиляторов с конструктором:

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

    Linux x86-x64 Clang 10.0.0, -O1, -O2


    image

    В коде производится только одно сравнение (строка 14), и присутствует только один условный переход (строка 15). Компилятор проигнорировал второе сравнение и второй условный переход.
    Это наводит на подозрение, что неопределённое поведение началось раньше, чем предписывает Стандарт.

    Но проверка условия второго if не содержит побочных эффектов, и логика компилятора сработала следующим образом:

    • если второе условие окажется верным — надо вызвать конструктор Test с аргументом 142;
    • если второе условие окажется не верным — произойдёт выход из функции без возрата значения, что означает неопределённое поведение, при котором компилятор может сделать всё, что угодно. В том числе — вызвать тот же конструктор с тем же аргументом;
    • проверка является лишней, вызов конструктора Test с аргументом 142 можно производить без проверки условия.

    Посмотрим, что произойдёт, если вторая проверка будет содержать условие с побочными эффектами:

    Test bad(int v)
    {
        if (v == 0) {
            return {42};
        } else if (v == rand()) {
            return {142};
        }
    }
    

    Полный код
    #include <iostream>
    #include <stdlib.h>
    
    struct Test
    {
        Test(uint64_t v)
            : value(v)
        {
            std::cout << "Test::Test(" << v << ")" << std::endl;
        }
        ~Test()
        {
            std::cout << "Test::~Test()" << std::endl;
        }
    
        uint64_t value;
    };
    
    Test bad(int v)
    {
        if (v == 0) {
            return {42};
        } else if (v == rand()) {
            return {142};
        }
    }
    
    int main()
    {
        const auto rnd = rand();
        std::cout << "rnd: " << rnd << std::endl;
    
        std::cout << bad(rnd).value << std::endl;
    
        return 0;
    }
    


    image

    Компилятор честно воспроизвёл все положенные побочные эффекты, вызвав rand() (строка 16), чем развеял сомнения о неподобающе раннем начале неопределённого поведения.

    Windows MSVC 2019 16.5.4, /Od /RTCs


    Опция /RTCs включает stack frame run-time error checking. Эта опция доступна только в debug-сборке. Рассмотрим дизассемблированный код участка main():

    image

    Перед вызовом bad(int) (строка 4) производится подготовка аргументов — в регистр edx копируется значение переменной rnd (строка 2), и в регистр rcx загружается эффективный адрес какой-то локальной переменной, расположенной по адресу rsp+28h (строка 3).

    Предположительно, rsp+28 — адрес временной переменной, хранящей результат вызова bad(int).

    Это предположение подтверждается строками 19 и 20 — эффективный адрес этой же переменной загружается в rcx, после чего вызывается деструктор.

    Однако в интервале строк 4 — 18 к этой переменной нет обращения, несмотря на вывод в stream значения её поля данных.

    Как мы видели из прошлых листингов MSVC, аргумент для оператора вывода в поток следует ожидать в регистре rdx. В регистр rdx попадает результат разыменования адреса, находящегося в rax (строка 9).

    Таким образом, вызывающий код ожидает от bad(int):

    • заполнения переменной, адрес которой передан через регистр rcx (тут мы видим RVO в действии);
    • возврат адреса этой переменной через регистр rax.

    Переходим к рассмотрению листинга bad(int):

    image

    • в eax заносится значение 0xCCCCCCCC, которое мы видели в сообщении Access violation (строка 9) (обратите внимание — только 4 байта, в то время как в сообщении AccessViolation адрес состоит из 8 байт);
    • вызывается команда rep stos, осуществляющая 0xC циклов записи содержимого eax в память начиная с адреса rdi (строка 10). Это 48 байтов — ровно столько, сколько выделено на стеке в строке 6;
    • на корректных путях выполнения в rax заносится значение из rsp+40h (строки 23, 36);
    • значение регистра rcx (через который main() передал адрес назначения) помещается на стек по адресу rsp+8 (строка 4);
    • в стек впихивается rdi, что приводит к уменьшению rsp на 8 (строка 5);
    • на стеке выделяется 30h байт путём уменьшению rsp (строка 6).

    Таким образом, rsp+8 в строке 4 и rsp+40h в остальной части кода — одно и то же значение.
    Код довольно запутанный, т.к. в нём не применяется rbp.

    В сообщении Access Violation есть целых две случайности:

    • нули в старшей части адреса — там мог быть любой мусор;
    • адрес случайно оказался некорректным.

    Судя по всему, опция /RTCs включила затирание стека определёнными ненулевыми значениями, а сообщение Access Violation — лишь случайный побочный эффект.

    Посмотрим, чем отличается код со включённой опцией /RTCs от кода без неё.

    image

    Код участков main() отличается только адресами локальных переменных на стеке.

    image

    (для наглядности я разместил рядом два варианта функции bad(int) — с /RTCs и без)
    Без /RTCs исчезла инструкция rep stos и подготовка аргументов для неё в начале функции.

    Пример 2a


    Снова попробуем поуправлять неопределённым поведением. На этот раз только для одного компилятора.

    Windows MSVC 2019 16.5.4, /Od /RTCs


    С опцией /RTCs компилятор вставляет в начало функции bad(int) код, заполняющий младшую половину rax фиксированным значеним, что может приводить к Access violation.

    Чтобы изменить это поведение, достаточно заполнить rax каким-либо корректным адресом.
    Этого можно добиться очень простой модификацией: добавить в тело bad(int) вывод чего-нибудь в std::cout.

    Полный код примера
    #include <iostream>
    #include <stdlib.h>
    
    struct Test
    {
        Test(uint64_t v)
            : value(v)
        {
            std::cout << "Test::Test(" << v << ")" << std::endl;
        }
        ~Test()
        {
            std::cout << "Test::~Test()" << std::endl;
        }
    
        uint64_t value;
    };
    
    Test bad(int v)
    {
      std::cout << "rnd: " << v << std::endl;
      
      if (v == 0) {
            return {42};
        } else if (v == 1) {
            return {142};
        }
    }
    
    int main()
    {
        const auto rnd = rand();
    
        std::cout << bad(rnd).value << std::endl;
    
        return 0;
    }
    


    rnd: 41
    8791039331928
    Test::~Test()

    operator<< возвращает ссылку на stream, что реализуется как размещение адреса std::cout в rax. Адрес корректный, его можно разыменовывать. Access violation предотвращён.

    Вывод


    На простейших примерах нам удалось:

    • собрать порядка 10 различных вариантов проявления неопределённого поведения;
    • в подробностях узнать, как именно эти варианты будут исполняться.

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

    Зачастую проявление зависит от тонких нюансов: стоит добавить или убрать одну, казалось бы, не относящуюся к делу строку кода — и поведение программы существенно меняется.

    Очевидно, что проще не писать такой код, чем потом разгадывать ребусы.
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 5

      0
      Для варианта «Windows MSVC 2019 16.5.4, усложнённый пример, /O1, /O2» первого примера логика работы описана неправильно.
      — копирует это же значение в ebx (строка 11);

      Нет. Там же не mov, а cmove — условное копирование, если ZF=1. Поэтому вывод
      В stream выводится единица.

      неверен. В stream выводится 1, если rand() вернул 0, а в противном случае выводится младший байт значения EBX, установленного в строке 6 командой «movzx ebx,byte ptr [rsp+30h]» — т.е. первый байт стека над адресом возврата.
        0
        Спасибо, в самом деле, моя ошибка.
        Исправил статью.
        И дополнил в соответствии с возможностью поуправлять ещё одним возвращаемым значением.
        Плюсик Вам в карму.
      +3

      Интересный анализ, практически небольшой детектив. Озвучу пару заметок:


      • Как видно из примеров (фактически, это наброски и демонстрации простых эксплоитов), данное UB вполне может использоваться в качестве потенциальной уязвимости в составной атаке.
      • Даже на высоких уровнях предупреждений и с максимальным количеством включенных флагов-warning'ов компиляторы не всегда способны определить наличие ошибочного пути выполнения, на котором не происходит возврата значения (и предупредить программиста). Особенно легко получить такую ситуацию в сложном шаблонном коде: буквально неделю назад пришлось 2 часа дебажить с gdb превращение памяти в неотлаживаемое месиво из за тупого пропуска слова return в функции-однострочнике.
        0
        Странно. Почему оптимизирующий компилятор не превратил код
        bool bad()
        {
        if (rand() == 0) {
        return true;
        }
        }

        в

        bool bad()
        {
        rand();
        return true;
        }

        Функция rand() имеет побочный эффект, поэтому выбросить её вызов нельзя, а вот возврат true — это разрешённый стандартом вариант неопределённого поведения. И результирующий код короче, и известное возвращаемое значение позволяет дальнейшую оптимизацию.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое