Использование неинициализированной памяти c как исправить

I’m learning C and I have one problem right now.

This is my part of code for a guessing number game, it’s simple:

int secretNumber = 13;
int guess;

while (guess != secretNumber) {
    printf("Enter a number: ");
    scanf("%d", &guess);
}
printf("Win!");

And I receive the following errors:

  • Using uninitialized memory
  • uninitialized locar variable ‘guess’ used.
  • return value ignored ‘scanf’.

Adrian Mole's user avatar

Adrian Mole

49.4k155 gold badges49 silver badges79 bronze badges

asked Apr 17, 2020 at 19:00

evil laugh's user avatar

11

Your problem is because the guess is uninitialized and you are comparing it in the while loop in the beginning.

In this case, it is better to construct it using in the do {} while() form. This way, the comparison is done after you got the value for the guess variable:

int secretNumber = 13;
int guess;

do {

    printf("Enter a number: ");
    scanf("%d", &guess);
} while (guess != secretNumber);

printf("Win!");

You are going to have some warning in the scanf() as well because you are not checking the return value.

To fix that as well, you can check like this:

if (scanf("%d", &guess) != 1) {
    print("invalid input, try againn");
}

answered Apr 17, 2020 at 19:05

campescassiano's user avatar

2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#include <iostream>
 
using namespace std;
 
bool used[1024];
 
int j = 0;
int r = 0;
int i = 0;
int k = 0;
 
void dfs(int **iArr, int n, int m, int t) {
 
    used[t] = true;
 
    int p;
 
    for (i = k; i < n; i++)
    {
        j = r;
        if ((iArr[i][j] != 0) && (!used[i]))
        {
            used[i] = true;
            p = i;
 
            cout << i << " ";
 
            for (j = 0; j < m; j++)
            {
                i = p;
                if (iArr[i][j] != 0)
                {
                    r = j;
 
                    for (k = 0; k < n; k++)
                    {
                        j = r;
 
                        if ((iArr[k][j] != 0) && (!used[k]))
                        {
                            dfs(iArr, n, m, i);
                        }
                    }
                }
            }
        }
    }
}
 
int _src[12][14] = 
{
    {1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0},
    {1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0},
    {0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0},
    {0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0},
    {0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1},
    {0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1}
};
 
int main()
{
    int n = std::size(_src);
    int m = std::size(*_src);
    int **iArr= new int *[n];
    for (int i = 0; i < n; ++i)
    {
        iArr[i] = new int[m];
        std::copy(std::begin(_src[i]), std::end(_src[i]), iArr[i]);
    }
 
    for (int i = 0; i < n; i++)
    {
        used[i] = false;
        for (int j = 0; j < m; j++)
            cout << " " << iArr[i][j];
        cout << endl;
    }
 
 
    int from = -1;
    //cout << "From >> ";
    //cin >> from;
 
    cout << "Order: " << endl;
 
    dfs(iArr, n, m, from);
 
    cout << endl;
    for (int i = 0; i < n; ++i)
        delete[] iArr[i];
 
    delete[] iArr;
    return 0;
}

    msm.ru

    Нравится ресурс?

    Помоги проекту!

    >
    c6001: использование неинициализированной памяти. Почему?

    • Подписаться на тему
    • Сообщить другу
    • Скачать/распечатать тему



    Сообщ.
    #1

    ,
    21.01.23, 11:30

      Есть код:

      ExpandedWrap disabled

        int *x;

        x = new int[1000];

        if( … )

        {

        }else

        {

          int xMinMax[2];

          xMinMax[0] = xMinMax[1] = x[0]; // здесь подчёркивает и пишет ‘c6001: Использование неинициализированной памяти “*x”‘

          …

        }

      Пишет такое предупреждение (см. код). VisualStudio 2019. Отчего так?


      grgdvo



      Сообщ.
      #2

      ,
      21.01.23, 14:36

        Member

        **

        Рейтинг (т): 21

        Вокруг

        ExpandedWrap disabled

          x = new int[1000];

        никаких условий нет??
        Все линейно??


        Dushevny



        Сообщ.
        #3

        ,
        21.01.23, 14:40

          Member

          **

          Рейтинг (т): 16

          Потому что после new() для каждого элемента вызывается конструктор по-умолчанию. Для int он ничего не делает, в том числе и не обнуляет переменную. Поэтому каждый элемент этого массива остается непроинициализированным, т.е. содержит мусор, который был в куче на месте этого массива перед его созданием.

          Сообщение отредактировано: Dushevny – 21.01.23, 14:42


          Majestio



          Сообщ.
          #4

          ,
          21.01.23, 15:01

            Злое Солнце

            ***

            Рейтинг (т): 12

            Цитата Dushevny @ 21.01.23, 14:40

            для каждого элемента вызывается конструктор по-умолчанию

            Хе-хе … вопрос интересный! Что-то мне казалось, что для POD-типов нет ни конструкторов (не путать с аллокаторами), не их вызовов, ни по-умоланию и ваапще никак! И тут такой ответ, что я в тупике … :-?

            Вызываю Qraizer‘а!
            >:-[

            user posted image


            Славян



            Сообщ.
            #5

            ,
            21.01.23, 15:52

              Цитата grgdvo @ 21.01.23, 14:36

              никаких условий нет??
              Все линейно??

              Да, всё именно банально так.

              Добавлено 21.01.23, 15:56
              А, тьфу, всё понял! Это, как и написал Dushevny, оттого, что в x[0] то что-то кладётся по условия, а не всегда!

              ExpandedWrap disabled

                int *x;

                x = new int[1000];

                if( … ) x[0] = …;

                if( … )

                {

                }else

                {

                  int xMinMax[2];

                  xMinMax[0] = xMinMax[1] = x[0]; // здесь подчёркивает и пишет ‘c6001: Использование неинициализированной памяти “*x”‘

                  …

                }

              Добавлено 21.01.23, 15:59

              Цитата grgdvo @ 21.01.23, 14:36

              Вокруг

              ExpandedWrap disabled

                x = new int[1000];

              никаких условий нет??

              Получается, ошибся я, – есть условие ‘вокруг’=после. Виноват-с… :blush:


              Majestio



              Сообщ.
              #6

              ,
              21.01.23, 16:01

                Злое Солнце

                ***

                Рейтинг (т): 12

                Славян, на какую именно строку идет “ругня”? Ты выдаешь такие “крохи” инфы, что просто приходится ломать спинной моск в попытках “угадать” >:(
                Ну что может быть проще привести весь проблемный код, и вывести все сообщения об ошибках и предупреждениях в первозданном виде?
                Не, без комментов далее …

                Добавлено 21.01.23, 16:04
                ADD: И даже после решения вопроса я не отказываюсь от своих “фу” и “фи” >:( 8-)


                Dushevny



                Сообщ.
                #7

                ,
                21.01.23, 17:11

                  Member

                  **

                  Рейтинг (т): 16

                  Цитата Majestio @ 21.01.23, 15:01

                  Что-то мне казалось, что для POD-типов нет ни конструкторов (не путать с аллокаторами), не их вызовов, ни по-умолчанию и ваапще никак!

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

                  Сообщение отредактировано: Dushevny – 21.01.23, 17:16


                  Славян



                  Сообщ.
                  #8

                  ,
                  21.01.23, 17:17

                    Цитата Majestio @ 21.01.23, 16:01

                    на какую именно строку идет “ругня”?

                    Строка приведена. Она вся подчёркивается волнистой.
                    Но суть то вся понята, ибо действительно x[0] то ли инициализировался, то ли нет – компилятор не знает, и вот на это использование и идёт ругань. Всё действительно просто.
                    Это как тривиальное:

                    ExpandedWrap disabled

                      int a;

                      int b = a;

                    Добавлено 21.01.23, 17:23
                    Ну, чуть полнее, так:

                    ExpandedWrap disabled

                      int *x, k=0;

                      x = new int[1000];

                      if( … ) x[k++] = …;

                      if( !k )

                      {

                        …

                      }else

                      {

                        int xMinMax[2];

                        xMinMax[0] = xMinMax[1] = x[0]; // здесь подчёркивает и пишет ‘c6001: Использование неинициализированной памяти “*x”‘

                        …

                      }

                    Потому мне то вот понятно, что использован будет инициализированный элемент x[0], а машине – неведомо сие…


                    Majestio



                    Сообщ.
                    #9

                    ,
                    21.01.23, 17:41

                      Злое Солнце

                      ***

                      Рейтинг (т): 12

                      Цитата Славян @ 21.01.23, 17:17

                      Она вся подчёркивается волнистой.

                      Ну я на форуме обычно на все смотрю корпускулярно – не видны эти ваши красные волны! 8-)

                      Добавлено 21.01.23, 17:43

                      Цитата Dushevny @ 21.01.23, 17:11

                      Что нет, что пустой и выкинутый оптимизатором по причине своей пустоты – для выходного кода результат одинаков.

                      Не не не – “точность – вежливость королей”. Это нужно выяснить.

                      Guru

                      Qraizer



                      Сообщ.
                      #10

                      ,
                      21.01.23, 18:17

                        Moderator

                        *******

                        Рейтинг (т): 521

                        Ну конечно нет никаких конструкторов у POD. Но по факту это ничего не меняет.
                        В Стандарте определение объектов без инициализаторов называется default initialization и выполняется по-разному для классов и не-классов.

                        • Для классов вызывается конструктор по умолчанию. Если он не предоставлен программистом, он может быть сгенерирован компилятором. Если он не может быть сгенерирован, ошибка; если может, то он работает по тем же правилам инициализации для всех агрегированных объектов, т.е. все базовые объекты и поля инициализирует default initialization. Аналогично для массивов: default initialization для каждого элемента.
                        • Для не-классов ничего не выполняется. Это оставляет их в неизменном виде. Указатели являются не-классами.
                        • Ссылки и константные объекты не могут быть default initialization, это ошибка. Для агрегатов, содержащих в себе объекты этого типа, конструктор не может быть сгенерирован.

                        Но в Стандарте есть ещё такая штука, как zero initialization. Она не применяется к автоматическим и динамическим объектам, но если применяется, то до любой другой инициализации, в частности и default initialization. Т.к. для классов default initialization вполне себе инициализатор, хоть и зачастую неявный, то работу zero initialization можно увидеть только на статических и локальных в потоках объектах не-классах. И да, она забивает объект нулями. За исключением указателей, которые ставятся в nullptr. (Это на случай, если у вас nullptr не равен побитово целочисленному нулю ;). Ну а вдруг.) И да, включая pad-ы между полями агрегатов. А, ещё для ссылок есть исключение: zero initialization для них не выполняется, но т.к. они обязаны быть value initialized, то этого не видно.

                        Сообщение отредактировано: Qraizer – 21.01.23, 18:19


                        Majestio



                        Сообщ.
                        #11

                        ,
                        21.01.23, 18:20

                          Злое Солнце

                          ***

                          Рейтинг (т): 12

                          Цитата Qraizer @ 21.01.23, 18:17

                          Ну конечно нет никаких конструкторов у POD. Но по факту это ничего не меняет.
                          В Стандарте определение объектов без инициализаторов называется default initialization и выполняется по-разному для классов и не-классов.
                          Для классов вызывается конструктор по умолчанию. Если он не предоставлен программистом, он может быть сгенерирован компилятором. Если он не может быть сгенерирован, ошибка; если может, то он работает по тем же правилам инициализации для всех агрегированных объектов, т.е. все базовые объекты и поля инициализирует default initialization. Аналогично для массивов: default initialization для каждого элемента.
                          Для не-классов ничего не выполняется. Это оставляет их в неизменном виде. Указатели являются не-классами.
                          Ссылки и константные объекты не могут быть default initialization, это ошибка. Для агрегатов, содержащих в себе объекты этого типа, конструктор не может быть сгенерирован.
                          Но в Стандарте есть ещё такая штука, как zero initialization. Она не применяется к автоматическим и динамическим объектам, но если применяется, то до любой другой инициализации, в частности и default initialization. Т.к. для классов default initialization вполне себе инициализатор, хоть и зачастую неявный, то работу zero initialization можно увидеть только на статических и локальных в потоках объектах не-классах. И да, она забивает объект нулями. За исключением указателей, которые ставятся в nullptr. (Это на случай, если у вас nullptr не равен побитово целочисленному нулю . Ну а вдруг.) И да, включая pad-ы между полями агрегатов. А, ещё для ссылок есть исключение: zero initialization для них не выполняется, но т.к. они обязаны быть value initialized, то этого не видно.

                          ЧТД, RTFM 🙂

                          0 пользователей читают эту тему (0 гостей и 0 скрытых пользователей)

                          0 пользователей:

                          • Предыдущая тема
                          • C/C++: Общие вопросы
                          • Следующая тема

                          Рейтинг@Mail.ru

                          [ Script execution time: 0,0678 ]   [ 16 queries used ]   [ Generated: 23.05.23, 09:33 GMT ]  

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

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

                          1. Работа с неинициализированной памятью: история проблемы
                          2. Способы устранения уязвимостей, связанных с неинициализированной памятью
                          3. InitAll – автоматическая инициализация
                          4. Интересные наблюдения, связанные с применением InitAll
                          5. Оптимизации производительности
                          6. Значение для пользователей
                          7. Планы на будущее

                          Эта работа была бы невозможной без тесного сотрудничества между подразделениями Visual Studio, Windows и MSRC.

                          Работа с неинициализированной памятью: история проблемы

                          При создании языков программирования C и C++ упор делался на высокую скорость работы и гибкий контроль со стороны разработчика. По этой причине в этих языках не применяется принудительная инициализация переменных. Работа с неинициализированными переменными ведёт к неопределённому поведению, поэтому они должны быть инициализированы перед использованием, и ответственность за соблюдение этого правила целиком лежит на разработчике.

                          Уязвимости, связанные с неинициализированной памятью, сводятся к двум типам:

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

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

                          Пример использования неинициализированной памяти

                          int size;  
                          GetSize(&size); // что если эта функция забудет задать размер?
                          memcpy(dest, src, size); // в memcpy передаётся
                                                   // неинициализированный размер

                          Проблема здесь в том, что если функция GetSize не присвоит значение переменной ‘size’ во всех ветках программы, то в вызов memcpy будет передан неинициализированный размер. Из-за этого может возникнуть ошибка чтения или записи за пределами буфера, если значение ‘size’ окажется больше, чем размер буфера ‘src’ или ‘dest’.

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

                          struct mystruct {
                                uint8_t field1;
                                uint64_t field2;
                          };
                          mystruct s {1, 5};
                          memcpy(dest, &s, sizeof(s));

                          Допустим, что функция memcpy копирует структуру за пределы доверенной области (т.е. из режима ядра в режим пользователя). На первый взгляд кажется, что структура инициализирована полностью, однако между ‘field1’ и ‘field2’ компилятор вставил байты-заполнители, которые не были инициализированы явным образом.

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

                          Статистика по ошибкам, связанным с неинициализированной памятью

                          Picture 8

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

                          В последние годы число таких ошибок растёт. Вероятно, отчасти это объясняется ростом интереса к ним со стороны исследователей и, как следствие, появлением эффективных инструментов для их поиска.

                          Более подробная классификация этих ошибок выявляет ещё несколько интересных тенденций.

                          Picture 6

                          Примечание: на этой диаграмме использование неинициализированной памяти НЕ включает в себя раскрытие её содержимого.

                          Picture 5

                          Глядя на эти диаграммы, можно сделать следующие выводы:

                          1. Между 2017 и 2018 годами уязвимости, связанные с неинициализированной памятью, составили примерно 5-10% всех уязвимостей в отчётах Microsoft.
                          2. Уязвимостей, связанных с выделением памяти на стеке, и уязвимостей, связанных с выделением памяти в куче/пуле, оказалось почти поровну.
                          3. Случаев раскрытия содержимого неинициализированной памяти больше, чем случаев использования неинициализированной памяти.

                          Дополнительная литература

                          Для более полного ознакомления с темой см. следующие ресурсы:

                          • https://github.com/microsoft/MSRC-Security-Research/blob/master/presentations/2019_09_CppCon/CppCon2019%20-%20Killing%20Uninitialized%20Memory.pdf
                          • https://j00ru.vexillium.org/papers/2018/bochspwn_reloaded.pdf
                          • https://www.blackhat.com/presentations/bh-europe-06/bh-eu-06-Flake.pdf

                          Способы устранения уязвимостей, связанных с неинициализированной памятью

                          Описанные проблемы пытались решить несколькими способами.

                          1. Статический анализ (как во время компиляции, так и после)
                          2. Фаззинг
                          3. Обзор кода
                          4. Автоматическая инициализация

                          Статический анализ

                          В Microsoft используются многочисленные предупреждения статического анализатора для отлова неинициализированных переменных (в том числе C4700, C4701, C4703, C6001, C26494 и C26495). Эти диагностики консервативны, т.е. в целях снижения шума они игнорируют некоторые паттерны, которые могут привести к работе с неинициализированной памятью.

                          Также был написан ряд жёстких правил для статического анализатора Semmle, которые прогоняются на некоторых кодовых базах Windows. Но эти диагностики дают много шума и ими тяжело проверять большие объёмы кода. К тому же соблюдение этих правил и исправление ошибок весьма трудоёмко. В итоге оказалось, что применять их затруднительно и дорого.

                          Фаззинг

                          Фаззинг, как известно, плохо поддаётся масштабированию. Хорошие фаззеры затратны в сопровождении и требуют настройки под конкретные задачи. С кодовой базой таких размеров, как у Microsoft, весьма непросто обеспечить полное её покрытие фаззингом.

                          Даже если бы удалось идеально покрыть ими весь код, фаззеры не умеют обнаруживать раскрытие содержимого неинициализированной памяти, так как оно не приводит к падению программы. Чтобы обнаруживать такие дефекты с помощью фаззинга, требуется одно из двух решений:

                          1. Фаззер, который понимает протокол и способен обнаруживать возврат в него неинициализированной памяти (а точнее, непредвиденных данных).
                          2. Динамический анализатор, способный обнаруживать доступ к неинициализированной памяти.

                          Обзор кода

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

                          Часть кода, в которой мы столкнулись с раскрытием содержимого неинициализированной памяти, была написана ещё во времена 32-битной Windows, и этих ошибок тогда не было. Когда же произошёл переход на 64-битные архитектуры, размер указателей вырос с 32 до 64 бит, из-за чего у некоторых структур появились неинициализированные поля-заполнители.

                          InitAll – Автоматическая инициализация

                          Помимо упомянутых подходов, Microsoft с некоторых пор использует механизм под названием InitAll – он автоматически инициализирует стековые переменные на этапе компиляции.

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

                          Текущие настройки Windows:

                          Автоматически инициализируются следующие типы:

                          1. Скалярные (массивы, указатели, числа с плавающей запятой)
                          2. Массивы указателей
                          3. Структуры (простые структуры данных – POD)

                          Автоматической инициализации не подвергаются следующие типы:

                          1. volatile-переменные
                          2. Массивы других типов, кроме указателей (т.е. массивы целых, массивы структур и т.д.)
                          3. Классы, которые не являются POD

                          В оптимизированных розничных (retail) сборках переменные инициализируются значением 0. Для чисел с плавающей запятой используется значение 0.0.

                          В отладочных (CHK) сборках или сборках для разработчиков (т.е. неоптимизированных розничных) используется значение 0xE2; числа с плавающей запятой инициализируются значением 1.0.

                          InitAll применяется к следующим компонентам:

                          1. Весь код из репозитория Windows, исполняющийся в режиме ядра (т.е. весь код, компилирующийся с ключом /KERNEL)
                          2. Весь код, относящийся к технологии Hyper-V (гипервизор, компоненты режима ядра, компоненты пользовательского режима)
                          3. Ряд других проектов, например сетевые службы пользовательского режима

                          InitAll реализован на стороне фронтэнда компилятора. Все переменные, отвечающие перечисленным выше критериям и не инициализированные программистом, будут инициализированы фронтэндом при объявлении. Один из плюсов этого подхода – в том, что с точки зрения оптимизатора, автоматическая инициализация ничем не отличается от инициализации разработчиком. Из этого следует, что оптимизации, которые мы добавляем для ускорения работы с InitAll, не привязаны только к этой функции и будут работать и в тех случаях, когда вы сами инициализируете переменные при объявлении (или перед использованием).

                          Как мы избегаем проблемы «разветвления языка»

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

                          При инициализации нулём указатель, который не был корректно инициализирован программистом, может попасть в ветку NULL pointer. В результате вы можете получить программу, которая не падает, но и не выдаёт нужные результаты. Если же инициализировать указатель мусорным значением, он не попадёт в ветку NULL pointer и при попытке использовать его приведёт к падению программы.

                          Эту проблему мы решаем использованием ненулевого значения инициализации (0xE2) в CHK-сборках и так называемых сборках для разработчиков, которые зачастую представляют собой неоптимизированные релизные сборки. За счёт этого, с одной стороны, удаётся сохранить высокую производительность кода, поставляемого клиентам, а с другой – получить в сборках, находящихся на тестировании, такое поведение, при котором легче заметить пропущенные инициализации.

                          Замечу, что C++ и так требует автоматической инициализации нулём всех статических членов. Эта семантика помогает разработчикам. Например, увидев статическую переменную с нулевым значением, вы будете знать, что необходимо инициализировать её, так как это первое её использование. InitAll вводит похожую семантику для автоматических (стековых) переменных с одной важной оговоркой: мы стараемся не привязывать разработчиков к конкретным начальным значениям.

                          Как мы выбираем, для каких компонентов задействовать InitAll

                          Изначально InitAll планировали использовать на двух компонентах:

                          1. Код режима ядра – прежде всего из-за большого числа наблюдаемых уязвимостей, связанных с неинициализированной памятью ядра.
                          2. Код Hyper-V – прежде всего из-за его важности для Azure и из-за неутешительной свежей статистики по случаям раскрытия содержимого неинициализированной стековой памяти.

                          Кое-кто в Microsoft узнал о InitAll и начал активно применять его на своих компонентах.

                          Причина, по которой мы не развёртываем InitAll сразу на всём коде, заключается в том, что мы хотим сначала сделать хорошо хоть что-то, а не потерпеть неудачу, пытаясь сделать всё сразу. Чем больше кода мы обрабатываем InitAll за раз, тем труднее отлаживать падения производительности, решать проблемы совместимости и т.д. Теперь, когда мы успешно развернули технологию на самых важных компонентах, можно заняться остальным кодом.

                          Ломает ли InitAll статический анализ?

                          Статический анализ чрезвычайно полезен тем, что напоминает разработчикам о переменных, которые они забыли инициализировать перед использованием.

                          InitAll уведомляет как анализатор PREfast, так и бэкэнд компилятора (оба выдают предупреждения о неинициализированных переменных) о добавленных им инициализациях. Благодаря этому статические анализаторы могут игнорировать такие места и по-прежнему выдавать свои предупреждения. При включённом InitAll вы всё равно будете получать сообщения статического анализатора о неинициализированных переменных – даже если InitAll инициализировал их за вас.

                          Почему мы инициализируем не все типы

                          Во время предварительных тестов мы принудительно инициализировали все типы данных, выделяемых на стеке, и наблюдали падения производительности более чем на 10% в нескольких важных сценариях.

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

                          Мы планируем вернуться к идее инициализации всех типов (особенно теперь, когда у нас появились более мощные оптимизации), просто ещё не дошли до этого.

                          Почему мы инициализируем переменные нулём

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

                          С позиции безопасности

                          Инициализация нулём имеет следующие преимущества:

                          • Нулевой указатель будет выбрасывать SEH-исключение при разыменовывании под Windows (т.е. в худшем случае это грозит ошибкой denial-of-service, но удалённое исполнение кода будет невозможно), что обычно заканчивается падением программы.
                          • Переменная, задающая размер или индекс, получит нулевое значение. Это должно свести к минимуму риск при передаче неинициализированного размера функциям вроде memcpy, работающим с буфером, чей размер задаётся значением переданной переменной.
                          • После проверки нулевого указателя программа исполнит соответствующую ветку и не будет пытаться использовать его. Так, по крайней мере, удастся корректно обработать указатели, которые разработчик забыл инициализировать (поскольку попытка обратиться к памяти по автоматически инициализированному указателю будет всегда приводить к падению).
                          • Переменные булева типа со значением 0 означают «ложь», что в проверках может обозначать состояние ошибки.

                          У инициализации нулём также есть пара недостатков:

                          • Переменная NTSTATUS будет иметь значение STATUS_SUCCESS
                          • Переменная HRESULT будет иметь значение S_OK

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

                          С позиции производительности

                          От выбранного значения инициализации также зависят скорость работы программы и размер кода. Мы не измеряли, насколько хуже результаты при использовании ненулевого значения, так как нас в первую очередь интересовали преимущества в безопасности, которые даёт инициализация нулём, и мы знали, что заодно она положительно скажется на производительности (как скорости работы, так и размере кода). Наши коллеги из Google провели измерения и показали, что на Clang инициализация нулём на данный момент заметно выгоднее, чем инициализация ненулевым значением.

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

                          Пример 1: Инициализация с использованием регистров общего назначения

                          Инициализация нулём:

                          31 c0                            xor    eax,eax
                          48 89 01                         mov    QWORD PTR [rcx],rax

                          Инициализация ненулевым значением:

                          48 b8 e2 e2 e2 e2 e2 e2 e2 e2    movabs rax,0xe2e2e2e2e2e2e2e2
                          48 89 01                         mov    QWORD PTR [rcx],rax

                          В этом примере нас интересуют два момента:

                          Во-первых, установка регистра RAX в ноль занимает 2 байта кода против 10 байт при установке в ненулевое значение. Получается выигрыш как по размеру кода, так и по скорости работы. Многие процессоры считывают команды по 16 байт за раз, поэтому запись в регистр фиксированной константы с помощью команды размером 10 байт препятствует выдаче следующих команд, которые могли бы выполняться параллельно.

                          Во-вторых, прежде чем станет возможным записать значение в регистр RCX, придётся дождаться завершения записи в RAX, что может привести к простаиванию процессора. Последовательности вроде «xor eax, eax» распознаются на самых ранних участках конвейера, и реального выполнения команды XOR не требуется – процессоры просто обнуляют регистр RAX. В результате конвейер простаивает меньше времени и программа работает быстрее.

                          Пример 2: Инициализация с использованием XMM-регистров

                          Для записи более крупных значений компилятор, как правило, использует XMM-регистры (а также YMM или ZMM в зависимости от того, включена ли поддержка наборов инструкций AVX или AVX512). Как правило, за один такт процессоры могут завершить не более одной команды записи, поэтому будет разумно использовать такие команды, которые устанавливают как можно больше байт.

                          Инициализация нулём:

                          0f 57 c0                         xorps  xmm0,xmm0
                          f3 0f 7f 01                      movdqu XMMWORD PTR [rcx],xmm0

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

                          66 0f 6f 04 25 00 00 00 00       movdqa xmm0,XMMWORD PTR ds:0x0
                          f3 0f 7f 01                      movdqu XMMWORD PTR [rcx],xmm0

                          Инициализация ненулевым значением (загружается из фиксированной константы в коде, чего компиляторы не делают):

                          48 ba e2 e2 e2 e2 e2 e2 e2 e2    movabs rdx,0xe2e2e2e2e2e2e2e2
                          66 48 0f 6e c2                   movq   xmm0,rdx
                          0f 16 c0                         movlhps xmm0,xmm0
                          f3 0f 7f 01                      movdqu XMMWORD PTR [rcx],xmm0

                          Как видим, в случае XMM-регистров наблюдается та же картина. При инициализации нулём код получается совсем небольшим.

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

                          Чтобы избежать этого, компиляторы, как правило, сохраняют фиксированную константу в виде глобальной переменной, из которой могут потом считать значение, – так получается гораздо меньше кода. К сожалению, придётся дождаться окончания записи в XMM-регистр, прежде чем он станет доступен для использования. Если глобальная переменная будет выгружена из памяти, операция может занять несколько тысяч тактов. На операцию чтения уходит несколько тактов даже при самом хорошем сценарии, когда данные хранятся в кэше L1. И даже в этом случае код получается намного длиннее, чем если просто обнулить регистр.

                          Тут обнаруживается ещё одно преимущество инициализации нулём: более детерминированные результаты. Время инициализации не зависит от того, находится ли глобальная переменная в кэше L1, L2 или L3, выгружается ли из памяти, и т.д.

                          Интересные наблюдения, связанные с применением InitAll

                          Производительность

                          Windows 10 1903 (выпущена весной 2019 года) стала первой версией, в которой InitAll был включён по умолчанию. До сих пор никаких жалоб на снижение производительности из-за него мы не получали.

                          Совместимость

                          Античиты

                          Вскоре после включения InitAll в Windows нам стали поступать жалобы на падения ядра, вызванные некоторыми античит-программами. Изучив проблему, мы выяснили, что эти программы содержали драйверы режима ядра, которые сканировали образ ядра NT в памяти и искали определённые байтовые последовательности, указывающие на начало недокументированных функций.

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

                          Использование освобождённой памяти в FAT32

                          Вскоре после включения InitAll для скалярных типов данных (т.е. целых чисел, чисел с плавающей запятой и т.д.) мы столкнулись с интересной проблемой в драйвере файловой системы FAT, которая не давала обновлять внутренние сборки Windows с загрузочных USB-флешек.

                          Код, в котором возникла проблема, выглядел примерно так:

                          for(int i = 0; i < size; i++)
                          {
                                int tmp;
                                DoStuff(&tmp, i);
                          }

                          Имеется цикл, внутри которого объявляется переменная. На первой итерации цикла функция DoStuff инициализирует переменную ‘tmp’, адрес которой передаётся ей в качестве аргумента. На каждой последующей итерации переменная ‘tmp’ используется как входной/выходной параметр. Другими словами, её значение сначала считывается, а затем обновляется.

                          Проблема в том, что рассматриваемая переменная в начале каждой итерации цикла входит в его область видимости, а в конце итерации покидает её. InitAll инициализирует эту переменную нулём перед каждой итерацией. Фактически мы получаем уязвимость, связанную с использованием освобождённой памяти (use-after-free). Для нормальной работы кода требуется, чтобы переменная ‘tmp’ сохраняла своё значение на каждой итерации, даже если в конце итерации она выходит из области видимости. К сожалению, эта проблема приводила не к падению драйвера, а к некорректной логике его работы и, как следствие, непредсказуемому поведению файловой системы. В ходе отладки команда, занимающаяся ядром, определила причину проблемы и исправила её, вынеся объявление переменной за пределы цикла.

                          Этот случай – наглядный пример того, как улучшения безопасности могут сломать код, в который не заглядывали годами.

                          Оптимизации производительности

                          Оптимизации производительности, осуществляемые InitAll, преследуют три цели:

                          1. Предоставить разработчикам возможность отключать InitAll для критического кода
                          2. По возможности убрать лишние операции записи
                          3. Максимально ускорить оставшиеся операции записи

                          Отключение InitAll для критического кода

                          Самые очевидные оптимизации заключаются в том, чтобы позволить коду:

                          1. Полностью отключить InitAll
                          2. Отключить InitAll для конкретного типа (т.е. typedef структуры)
                          3. Отключить InitAll для всех операций выделения памяти в функции
                          4. Отключить InitAll для конкретного объявления переменной в функции

                          На сегодняшний момент InitAll отключён (ради повышения производительности) для одного-единственного типа – структуры _CONTEXT, которая хранит значения всех регистров. Принудительная её инициализация приводила к снижению производительности в тестах.

                          Структура _CONTEXT имеет размер более 1000 байт, и этого достаточно, чтобы хранить значения всех регистров. С включённым ETW-логированием для отслеживания переключений контекста каждый раз при смене контекста значения всех регистров заносятся в лог. Структура _CONTEXT в этом случае будет выделяться на стеке, заполняться ассемблерной функцией и затем передаваться в ETW. Из-за того, что структура инициализируется ассемблерной функцией, компилятор не может убрать инициализацию, сделанную InitAll. Поскольку эта структура и так содержит критические данные (состояние каждого регистра), имеет большой размер и используется в чрезвычайно требовательных к производительности ветках, мы решили не применять к ней InitAll.

                          Для всех остальных типов, переменных и функций InitAll не отключалась.

                          Удаление лишних операций записи

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

                          Ниже приводятся примеры разных видов оптимизации, применяемых Visual Studio.

                          Удаление нескольких memset

                          Ссылка на Godbolt: https://msvc.godbolt.org/z/Ldu7AP

                          Следующий паттерн кода (с разными вариациями) чрезвычайно распространён. Первоначальные правила программирования под NT требуют, чтобы все переменные объявлялись в начале функции, а инициализировались как можно позже. В результате мы имеем случаи, когда переменная объявляется в начале функции, а инициализируется только в какой-нибудь одной ветке непосредственно перед использованием.

                          InitAll добавляет свою инициализацию переменной в начале функции. Компилятор может удалить дубликат, но это не всегда легко сделать.

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[160];
                          };
                          
                          void Dummy()
                          {
                              printf("dummy");
                              return;
                          }
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          volatile bool b = true;
                          
                          void f()
                          {
                              MyStruct s;
                              // Этот вызов memset, по сути, идентичен вызову memset, добавленному InitAll
                              memset(&s, 0x0, sizeof(s));
                            
                              if (b) // Проверяем volatile-переменную,
                                     // чтобы не дать компилятору убрать эту ветку 
                                     // (что он сделал бы, если бы мы написали обычную переменную)
                              {
                                  Dummy();
                                  memset(&s, 0x1, sizeof(s));
                                  DoStuff(&s);
                              }
                              return;
                          }

                          Picture 4

                          Кажется, что этот простой пример должен легко оптимизироваться, однако GCC 9.3 и Clang 10.0.0 (самые свежие версии, доступные на Godbolt) неспособны в этом случае убрать лишний вызов memset. Я говорю об этом не для того, чтобы покритиковать эти компиляторы, – они оба очень хорошо оптимизируют код. Я просто хочу показать, что некоторые паттерны могут вызвать трудности даже у самых мощных компиляторов. До появления InitAll и связанных с ним оптимизаций Visual Studio не мог убрать лишний вызов.

                          Ещё более простой пример:

                          Между двумя вызовами memset находится всего один вызов функции без аргументов. Этот паттерн, как и предыдущий, очень часто встречается в коде Microsoft.

                          Ссылка на Godbolt: https://msvc.godbolt.org/z/HqFMx_

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[160];
                          };
                          
                          void Dummy()
                          {
                              printf("dummy");
                              return;
                          }
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          void f()
                          {
                              MyStruct s;
                              // Этот вызов memset, по сути, идентичен вызову memset, добавленному InitAll
                              memset(&s, 0x0, sizeof(s));
                          
                              Dummy();
                          
                              memset(&s, 0x1, sizeof(s));
                          
                              DoStuff(&s);
                          
                              return;
                          }

                          Picture 3

                          MSVC убирает лишний memset в этом примере. Clang 10.0.0 – тоже, а вот у GCC 9.3 по-прежнему не получается. Казалось бы, этот код можно легко оптимизировать, однако для этого компилятору приходится проводить нетривиальный анализ.

                          Проблема здесь (в MSVC) в том, что компилятор применяет анализ достижимости объекта, не зависящий от ветвления или потока исполнения. С точки зрения компилятора, переменная ‘s’ «убегает» из текущей функции (другими словами, её адрес передаётся куда-то за пределы этой функции), так как её адрес передаётся в функцию ‘DoStuff’. Компилятор также видит вызов memset ‘s’, затем – вызов ‘Dummy’, после чего – ещё один вызов memset ‘s’.

                          С точки зрения компилятора, поскольку переменная ‘s’ «убежала» из функции, функция ‘Dummy’ теоретически может считывать содержимое ‘s’ или изменять его до вызова функции ‘DoStuff’. А значит, вызов memset ни до, ни после ‘Dummy’ не может быть удалён.

                          Мы-то видим, что, хотя переменная ‘s’ и «убегает» из текущей функции, происходит это не раньше, чем вызывается функция ‘DoStuff’. Компилятор MSVC теперь тоже понимает это (в той или иной степени) и может убрать первый вызов memset.

                          Уменьшение размера memset

                          Ссылка на Godbolt: https://msvc.godbolt.org/z/fyLVUF

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

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[320];
                          };
                          
                          void Dummy()
                          {
                              printf("dummy");
                              return;
                          }
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          volatile bool b = true;
                          
                          void f()
                          {
                              MyStruct s;
                              // Этот вызов memset, по сути, идентичен вызову memset, добавленному InitAll
                              memset(&s, 0x0, sizeof(s));
                          
                              if (b) // Проверяем volatile-переменную,
                                     // чтобы не дать компилятору убрать эту ветку 
                                     // (что он сделал бы, если бы мы написали обычную переменную)
                              {
                                  Dummy();
                                  memset(&s, 0x0, sizeof(s)-0x160);
                                  DoStuff(&s);
                              }
                          
                              return;
                          }

                          Picture 2

                          MSVC теперь может урезать размер первой memset, чтобы она инициализировала только те элементы в структуре, которые не инициализирует вторая memset. И снова GCC 9.3 и Clang 10.0.0 пока что не умеют проводить такую оптимизацию в этом примере.

                          Более эффективная развёртка memset

                          → Ссылка на Godbolt

                          В следующем примере вызов memset нельзя убрать. Значит, его следует выполнить как можно эффективнее.

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[12];
                          };
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          void f()
                          {
                              MyStruct s;
                              memset(&s, 0x0, sizeof(s));
                              DoStuff(&s);
                              return;
                          }

                          Picture 1

                          MSVC (как и большинство компиляторов) может «разворачивать» небольшие вызовы memset со статически определяемым размером и значением заполнения. То есть вызов memset заменяется последовательностью команд записи непосредственно в память. Благодаря этой оптимизации время выполнения небольших вызовов memset (до 128 байт) сокращается до одной четверти от обычного при меньшем объёме кода (нет необходимости сохранять значения регистров в стек, вызывать memset, а затем восстанавливать состояние регистров).

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

                          Более производительные реализации memset

                          Этот пункт мы подробно разберём в другой раз.

                          Значение для пользователей

                          С тех пор как мы выпустили InitAll, многие из уязвимостей, о которых пользователи сообщали в MSRC, перестали воспроизводиться на свежих версиях Windows. Благодаря InitAll эти уязвимости из «проблем безопасности» превратились в «дефекты кода, на данный момент не имеющие негативных последствий». А значит, нам больше не нужно поставлять обновления безопасности для уже выпущенных операционных систем с установленным InitAll, что избавляет пользователей от головной боли, сопровождающей установку патчей, а Microsoft – от головной боли, сопровождающей их разработку.

                          У себя в активных ветках репозитория мы по-прежнему улучшаем код и исправляем ошибки, а также вносим правки в уже выпущенные операционные системы, в которых InitAll отсутствует и которые поэтому по-прежнему уязвимы. Со временем версии без InitAll перестанут поддерживаться. Когда это произойдёт, ошибки, нейтрализованные с помощью InitAll, будут правиться только в активных ветках разработки, а на текущих системах этот тип дефектов больше не придётся исправлять.

                          Планы на будущее

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

                          1. Изучить и использовать возможность применения InitAll ко всем типам выделяемых данных (т.е. массивам всех типов и всем классам, а не только POD)
                          2. Развернуть InitAll на всём коде Windows.

                          В дальнейшем мы планируем выяснить, возможно ли стандартизировать процесс устранения описанных типов проблем в языке C и C++. Необязательно по умолчанию оставлять переменные неинициализированными ради производительности (особенно если компилятор умеет хорошо оптимизировать избыточные операции записи). Вместо этого было бы лучше требовать от разработчика инициализировать переменные перед использованием, «если такая необходимость доказана», и позволять нарушать это правило, только если применяется специальное ключевое слово для неинициализированных переменных. Такое решение позволило бы сохранить высокую производительность и при этом уберечь программистов от лишних ошибок.

                          Мы планируем опубликовать ещё одну заметку о текущей работе по нейтрализации уязвимостей, связанных с неинициализированной памятью, в механизме выделения пула памяти в ядре Windows.

                          Комментарий переводчика

                          Статья почти не связана с моей родной тематикой статического анализа кода, но мне она показалась интересной и я захотел поделиться переводом с русскоязычной аудиторией. От себя хочу добавить, что проблемы безопасности, связанные с «утечкой» приватных данных, обычно складываются из двух составляющих. Первое: есть место, где приватные данные должны затираться, но этого не происходит (V597). Второе: неочищенные приватные данные как часть неинициализированной памяти, могут быть куда-то переданы (пример).

                          Если вы пишете код на языке C или C++, поиск и устранение ошибок работы с памятью, таких, как утечки, выход за границы массива или обращение к неинициализированной памяти, могут доставить немало хлопот. Существует по крайней мере два инструмента для решения этих проблем — Valgrind (не путать с Vagrant!) и Clang’овский MemorySanitizer. Последний работает исключительно под Linux и показал себя несколько сырым и не слишком гибким инструментом, поэтому поговорим о Valgrind. Он довольно гибок и работает везде. Кроме того, в отличие от MemorySanitizer, Valgrind может находить неинициализированные данные с точностью до одного бита. Из недостатков Valgrind стоит отметить сравнительно низкую скорость работы.

                          Простой пример

                          Перейдем сразу к делу и проверим работу Valgrind на такой программе:

                          #include <stdio.h>
                          #include <stdlib.h>
                          #include <string.h>

                          void run_test(int i)
                          {
                            int delta = 123;
                            char* mem = malloc(1024);
                            strcpy(mem, «i = «);
                            printf(«%s %dn«, mem, i + delta);
                            /* free(mem); */
                          }

                          void main()
                          {
                            int i;
                            for(i = 0; i < 10; i++)
                              run_test(i);
                          }

                          Компилируем с отладочными символами и запускаем ее под Valgrind:

                          gcc -O0 -g vgcheck.c -o vgcheck
                          valgrind ./vgcheck

                          Результат:

                          ==1948== HEAP SUMMARY:
                          ==1948==     in use at exit: 10,240 bytes in 10 blocks
                          ==1948==   total heap usage: 11 allocs, 1 frees, 11,264 bytes allo…
                          ==1948==
                          ==1948== LEAK SUMMARY:
                          ==1948==    definitely lost: 10,240 bytes in 10 blocks
                          ==1948==    indirectly lost: 0 bytes in 0 blocks
                          ==1948==      possibly lost: 0 bytes in 0 blocks
                          ==1948==    still reachable: 0 bytes in 0 blocks
                          ==1948==         suppressed: 0 bytes in 0 blocks
                          ==1948== Rerun with —leak-check=full to see details of leaked memory

                          Видим, что память утекла. Запускаем с --leak-check=full:

                          ==2047== 10,240 bytes in 10 blocks are definitely lost in loss recor…
                          ==2047==    at 0x4C2AF1F: malloc (in /usr/lib/valgrind/vgpreload_mem…
                          ==2047==    by 0x400561: run_test (vgcheck.c:8)
                          ==2047==    by 0x4005AF: main (vgcheck.c:18)

                          Теперь раскомментируем вызов free и уберем инициализацию переменной delta. Посмотрим, увидит ли Valgrind обращение к неинициализированной памяти:

                          ==2102== Conditional jump or move depends on uninitialised value(s)
                          ==2102==    at 0x4E8003C: vfprintf (in /usr/lib/libc-2.25.so)
                          ==2102==    by 0x4E87EA5: printf (in /usr/lib/libc-2.25.so)
                          ==2102==    by 0x4005CA: run_test (vgcheck.c:10)
                          ==2102==    by 0x4005F4: main (vgcheck.c:18)

                          Видит. Запустим с --track-origins=yes чтобы найти, откуда именно пришла неинициализированная переменаая:

                          ==2205== Conditional jump or move depends on uninitialised value(s)
                          ==2205==    at 0x4E800EE: vfprintf (in /usr/lib/libc-2.25.so)
                          ==2205==    by 0x4E87EA5: printf (in /usr/lib/libc-2.25.so)
                          ==2205==    by 0x4005CA: run_test (vgcheck.c:10)
                          ==2205==    by 0x4005F4: main (vgcheck.c:18)
                          ==2205==  Uninitialised value was created by a stack allocation
                          ==2205==    at 0x400586: run_test (vgcheck.c:6)

                          Как видите, Valgrind нашел место объявления неинициализированной переменой с точностью до имени файла и номера строчки.

                          Теперь исправим все ошибки:

                          ==2239== HEAP SUMMARY:
                          ==2239==     in use at exit: 0 bytes in 0 blocks
                          ==2239==   total heap usage: 11 allocs, 11 frees, 11,264 bytes allo…
                          ==2239==
                          ==2239== All heap blocks were freed — no leaks are possible

                          Ну разве не красота?

                          Пример посложнее — запускаем PostgreSQL под Valgrind

                          Рассмотрим, как происходит запуск под Valgrind больших программ, например, PostgreSQL. Работа с памятью в этой РСУБД устроена особым образом. Например, в ней используются иерархические пулы памяти (memory contexts). Для понимания всего этого хозяйства Valgrind’у нужны подсказки. Чтобы такие подсказки появились, нужно раскомментировать строчку:

                          … в файле src/include/pg_config_manual.h, после чего полностью пересобрать PostgreSQL. Затем запуск под Valgrind осуществляется как-то так:

                          valgrind —leak-check=no —track-origins=yes —gen-suppressions=all
                            —read-var-info=yes
                            —log-file=$HOME/work/postgrespro/postgresql-valgrind/%p.log
                            —suppressions=src/tools/valgrind.supp —time-stamp=yes
                            —trace-children=yes postgres -D
                            $HOME/work/postgrespro/postgresql-install/data-master
                            2>&1 | tee $HOME/work/postgrespro/postgresql-valgrind/postmaster.log

                          Полный пример вы найдете в файле valgrind.sh из этого репозитория на GitHub.

                          Обратите внимание на флаг --leak-check=no. Даже с упомянутыми подсказками Valgrind все равно не подходит для поиска утечек памяти в PostgreSQL. Он попросту будет генерировать слишком много ложных сообщений об ошибках. Поэтому здесь Valgrind используется только для поиска обращений к неинициализированной памяти.

                          Флаг --trace-children=yes в приведенной выше команде, как несложно догадаться, говорит Valgrind’у цепляться к процессам-потомкам.

                          Еще стоит отметить флаг --suppressions, который задает файл с описанием ошибок, которые следует игнорировать, а также флаг --gen-suppressions=all, который в случае возникновения ошибок генерирует строки, которые можно добавить в этот самый файл для игнорирования ошибок. Кстати, в файле можно использовать wildcards, в стиле:

                          {
                             <libpango>
                             Memcheck:Leak
                             …
                             obj:/usr/*lib*/libpango*
                          }

                          В зависимости от используемых флагов, make installcheck под Valgrind’ом на моем ноутбуке выполняется от получаса до часа. Для сравнения, без Valgrind’а соответствующий прогон тестов занимает порядка 3.5 минут. Отсюда можно сделать вывод, что программа под Valgrind выполняется в 10-20 раз медленнее.

                          Использование Valgrind совместно с GDB

                          Посмотрев на приведенные выше отчеты Valgrind’а об ошибках, можно заметить, что в определенном смысле они недостаточно информативны. В частности, в них нет имен переменных и информации о том, какие конкретно данные в них лежали на момент возникновения ошибки. Решается эта проблема запуском Valgrind’а с флагами:

                          valgrind —vgdb=yes —vgdb-error=1 дальше_как_обычно

                          Эти флаги говорят Valgrind остановить процесс и запустить gdb-сервер после возникновения первой ошибки. Можно указать и --vgdb-error=0, чтобы подключиться к процессу отладчиком сразу после его запуска. Однако это может быть плохой идеей, если вы также указали --trace-children=yes и при этом программа создает множество дочерних процессов.

                          При возникновении ошибки Valgrind напишет:

                          ==00:00:00:06.603 16153== TO DEBUG THIS PROCESS USING GDB: start GDB…
                          ==00:00:00:06.603 16153==   /path/to/gdb postgres
                          ==00:00:00:06.603 16153== and then give GDB the following command
                          ==00:00:00:06.603 16153==   target remote | vgdb —pid=16153

                          После этого, чтобы подключиться к процессу при помощи GDB, говорим:

                          # где postgres — имя исполняемого файла
                          gdb postgres

                          … и уже в отладчике:

                          target remote | vgdb —pid=16153

                          Из интересных дополнительных команд доступны следующие. Посмотреть список утечек:

                          Узнать, кто ссылается на память:

                          monitor who_points_at (address) (len)

                          Проверка инициализированности памяти (0 — бит инициализирован, 1 — не инициализирован, _ — not addressable):

                          monitor get_vbits (address) (len)

                          Прочее:

                          Дальше отлаживаем, как обычно. Например, говорим continue. Как только произойдет следующая ошибка, программа снова остановится по брейкпоинту. Можно смотреть значения переменных, перемещаться между фреймами стека, ставить собственные брейкпоинты, и так далее.

                          Заключение

                          К сожалению, в рамках одного поста невозможно рассмотреть абсолютно все возможности Valgrind. Например, в него входят инструменты Callgrind и Massif, предназначенные для поиска узких мест в коде и профилирования памяти соответственно. Эти инструменты я не рассматриваю, так как для решения названных задач предпочитаю использовать perf и Heaptrack. Также существует инструмент Helgrind, предназначенный для поиска гонок. Его изучение я вынужден оставить вам в качестве упражнения.

                          Как видите, пользоваться Valgrind крайне просто. Он, конечно, не идеален. Как уже отмечалось, Valgrind существенно замедляет выполнение программы. Кроме того, в нем случаются ложноположительные срабатывания. Однако последняя проблема решается составлением специфичного для вашего проекта файла подавления конкретных отчетов об ошибках. Так или иначе, если вы пишете на C/C++ и не прогоняете код под Valgrind хотя бы в Jenkins или TeamCity незадолго до релиза, вы явно делаете что-то не так!

                          А как вы ищете утечки и обращения к неинициализированной памяти?

                          Метки: C/C++, Отладка.

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

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

                          1. Работа с неинициализированной памятью: история проблемы
                          2. Способы устранения уязвимостей, связанных с неинициализированной памятью
                          3. InitAll – автоматическая инициализация
                          4. Интересные наблюдения, связанные с применением InitAll
                          5. Оптимизации производительности
                          6. Значение для пользователей
                          7. Планы на будущее

                          Эта работа была бы невозможной без тесного сотрудничества между подразделениями Visual Studio, Windows и MSRC.

                          Работа с неинициализированной памятью: история проблемы

                          При создании языков программирования C и C++ упор делался на высокую скорость работы и гибкий контроль со стороны разработчика. По этой причине в этих языках не применяется принудительная инициализация переменных. Работа с неинициализированными переменными ведёт к неопределённому поведению, поэтому они должны быть инициализированы перед использованием, и ответственность за соблюдение этого правила целиком лежит на разработчике.

                          Уязвимости, связанные с неинициализированной памятью, сводятся к двум типам:

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

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

                          Пример использования неинициализированной памяти

                          int size;  
                          GetSize(&size); // что если эта функция забудет задать размер?
                          memcpy(dest, src, size); // в memcpy передаётся
                                                   // неинициализированный размер

                          Проблема здесь в том, что если функция GetSize не присвоит значение переменной ‘size’ во всех ветках программы, то в вызов memcpy будет передан неинициализированный размер. Из-за этого может возникнуть ошибка чтения или записи за пределами буфера, если значение ‘size’ окажется больше, чем размер буфера ‘src’ или ‘dest’.

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

                          struct mystruct {
                                uint8_t field1;
                                uint64_t field2;
                          };
                          mystruct s {1, 5};
                          memcpy(dest, &s, sizeof(s));

                          Допустим, что функция memcpy копирует структуру за пределы доверенной области (т.е. из режима ядра в режим пользователя). На первый взгляд кажется, что структура инициализирована полностью, однако между ‘field1’ и ‘field2’ компилятор вставил байты-заполнители, которые не были инициализированы явным образом.

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

                          Статистика по ошибкам, связанным с неинициализированной памятью

                          Picture 8

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

                          В последние годы число таких ошибок растёт. Вероятно, отчасти это объясняется ростом интереса к ним со стороны исследователей и, как следствие, появлением эффективных инструментов для их поиска.

                          Более подробная классификация этих ошибок выявляет ещё несколько интересных тенденций.

                          Picture 6

                          Примечание: на этой диаграмме использование неинициализированной памяти НЕ включает в себя раскрытие её содержимого.

                          Picture 5

                          Глядя на эти диаграммы, можно сделать следующие выводы:

                          1. Между 2017 и 2018 годами уязвимости, связанные с неинициализированной памятью, составили примерно 5-10% всех уязвимостей в отчётах Microsoft.
                          2. Уязвимостей, связанных с выделением памяти на стеке, и уязвимостей, связанных с выделением памяти в куче/пуле, оказалось почти поровну.
                          3. Случаев раскрытия содержимого неинициализированной памяти больше, чем случаев использования неинициализированной памяти.

                          Дополнительная литература

                          Для более полного ознакомления с темой см. следующие ресурсы:

                          • https://github.com/microsoft/MSRC-Security-Research/blob/master/presentations/2019_09_CppCon/CppCon2019%20-%20Killing%20Uninitialized%20Memory.pdf
                          • https://j00ru.vexillium.org/papers/2018/bochspwn_reloaded.pdf
                          • https://www.blackhat.com/presentations/bh-europe-06/bh-eu-06-Flake.pdf

                          Способы устранения уязвимостей, связанных с неинициализированной памятью

                          Описанные проблемы пытались решить несколькими способами.

                          1. Статический анализ (как во время компиляции, так и после)
                          2. Фаззинг
                          3. Обзор кода
                          4. Автоматическая инициализация

                          Статический анализ

                          В Microsoft используются многочисленные предупреждения статического анализатора для отлова неинициализированных переменных (в том числе C4700, C4701, C4703, C6001, C26494 и C26495). Эти диагностики консервативны, т.е. в целях снижения шума они игнорируют некоторые паттерны, которые могут привести к работе с неинициализированной памятью.

                          Также был написан ряд жёстких правил для статического анализатора Semmle, которые прогоняются на некоторых кодовых базах Windows. Но эти диагностики дают много шума и ими тяжело проверять большие объёмы кода. К тому же соблюдение этих правил и исправление ошибок весьма трудоёмко. В итоге оказалось, что применять их затруднительно и дорого.

                          Фаззинг

                          Фаззинг, как известно, плохо поддаётся масштабированию. Хорошие фаззеры затратны в сопровождении и требуют настройки под конкретные задачи. С кодовой базой таких размеров, как у Microsoft, весьма непросто обеспечить полное её покрытие фаззингом.

                          Даже если бы удалось идеально покрыть ими весь код, фаззеры не умеют обнаруживать раскрытие содержимого неинициализированной памяти, так как оно не приводит к падению программы. Чтобы обнаруживать такие дефекты с помощью фаззинга, требуется одно из двух решений:

                          1. Фаззер, который понимает протокол и способен обнаруживать возврат в него неинициализированной памяти (а точнее, непредвиденных данных).
                          2. Динамический анализатор, способный обнаруживать доступ к неинициализированной памяти.

                          Обзор кода

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

                          Часть кода, в которой мы столкнулись с раскрытием содержимого неинициализированной памяти, была написана ещё во времена 32-битной Windows, и этих ошибок тогда не было. Когда же произошёл переход на 64-битные архитектуры, размер указателей вырос с 32 до 64 бит, из-за чего у некоторых структур появились неинициализированные поля-заполнители.

                          InitAll – Автоматическая инициализация

                          Помимо упомянутых подходов, Microsoft с некоторых пор использует механизм под названием InitAll – он автоматически инициализирует стековые переменные на этапе компиляции.

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

                          Текущие настройки Windows:

                          Автоматически инициализируются следующие типы:

                          1. Скалярные (массивы, указатели, числа с плавающей запятой)
                          2. Массивы указателей
                          3. Структуры (простые структуры данных – POD)

                          Автоматической инициализации не подвергаются следующие типы:

                          1. volatile-переменные
                          2. Массивы других типов, кроме указателей (т.е. массивы целых, массивы структур и т.д.)
                          3. Классы, которые не являются POD

                          В оптимизированных розничных (retail) сборках переменные инициализируются значением 0. Для чисел с плавающей запятой используется значение 0.0.

                          В отладочных (CHK) сборках или сборках для разработчиков (т.е. неоптимизированных розничных) используется значение 0xE2; числа с плавающей запятой инициализируются значением 1.0.

                          InitAll применяется к следующим компонентам:

                          1. Весь код из репозитория Windows, исполняющийся в режиме ядра (т.е. весь код, компилирующийся с ключом /KERNEL)
                          2. Весь код, относящийся к технологии Hyper-V (гипервизор, компоненты режима ядра, компоненты пользовательского режима)
                          3. Ряд других проектов, например сетевые службы пользовательского режима

                          InitAll реализован на стороне фронтэнда компилятора. Все переменные, отвечающие перечисленным выше критериям и не инициализированные программистом, будут инициализированы фронтэндом при объявлении. Один из плюсов этого подхода – в том, что с точки зрения оптимизатора, автоматическая инициализация ничем не отличается от инициализации разработчиком. Из этого следует, что оптимизации, которые мы добавляем для ускорения работы с InitAll, не привязаны только к этой функции и будут работать и в тех случаях, когда вы сами инициализируете переменные при объявлении (или перед использованием).

                          Как мы избегаем проблемы «разветвления языка»

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

                          При инициализации нулём указатель, который не был корректно инициализирован программистом, может попасть в ветку NULL pointer. В результате вы можете получить программу, которая не падает, но и не выдаёт нужные результаты. Если же инициализировать указатель мусорным значением, он не попадёт в ветку NULL pointer и при попытке использовать его приведёт к падению программы.

                          Эту проблему мы решаем использованием ненулевого значения инициализации (0xE2) в CHK-сборках и так называемых сборках для разработчиков, которые зачастую представляют собой неоптимизированные релизные сборки. За счёт этого, с одной стороны, удаётся сохранить высокую производительность кода, поставляемого клиентам, а с другой – получить в сборках, находящихся на тестировании, такое поведение, при котором легче заметить пропущенные инициализации.

                          Замечу, что C++ и так требует автоматической инициализации нулём всех статических членов. Эта семантика помогает разработчикам. Например, увидев статическую переменную с нулевым значением, вы будете знать, что необходимо инициализировать её, так как это первое её использование. InitAll вводит похожую семантику для автоматических (стековых) переменных с одной важной оговоркой: мы стараемся не привязывать разработчиков к конкретным начальным значениям.

                          Как мы выбираем, для каких компонентов задействовать InitAll

                          Изначально InitAll планировали использовать на двух компонентах:

                          1. Код режима ядра – прежде всего из-за большого числа наблюдаемых уязвимостей, связанных с неинициализированной памятью ядра.
                          2. Код Hyper-V – прежде всего из-за его важности для Azure и из-за неутешительной свежей статистики по случаям раскрытия содержимого неинициализированной стековой памяти.

                          Кое-кто в Microsoft узнал о InitAll и начал активно применять его на своих компонентах.

                          Причина, по которой мы не развёртываем InitAll сразу на всём коде, заключается в том, что мы хотим сначала сделать хорошо хоть что-то, а не потерпеть неудачу, пытаясь сделать всё сразу. Чем больше кода мы обрабатываем InitAll за раз, тем труднее отлаживать падения производительности, решать проблемы совместимости и т.д. Теперь, когда мы успешно развернули технологию на самых важных компонентах, можно заняться остальным кодом.

                          Ломает ли InitAll статический анализ?

                          Статический анализ чрезвычайно полезен тем, что напоминает разработчикам о переменных, которые они забыли инициализировать перед использованием.

                          InitAll уведомляет как анализатор PREfast, так и бэкэнд компилятора (оба выдают предупреждения о неинициализированных переменных) о добавленных им инициализациях. Благодаря этому статические анализаторы могут игнорировать такие места и по-прежнему выдавать свои предупреждения. При включённом InitAll вы всё равно будете получать сообщения статического анализатора о неинициализированных переменных – даже если InitAll инициализировал их за вас.

                          Почему мы инициализируем не все типы

                          Во время предварительных тестов мы принудительно инициализировали все типы данных, выделяемых на стеке, и наблюдали падения производительности более чем на 10% в нескольких важных сценариях.

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

                          Мы планируем вернуться к идее инициализации всех типов (особенно теперь, когда у нас появились более мощные оптимизации), просто ещё не дошли до этого.

                          Почему мы инициализируем переменные нулём

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

                          С позиции безопасности

                          Инициализация нулём имеет следующие преимущества:

                          • Нулевой указатель будет выбрасывать SEH-исключение при разыменовывании под Windows (т.е. в худшем случае это грозит ошибкой denial-of-service, но удалённое исполнение кода будет невозможно), что обычно заканчивается падением программы.
                          • Переменная, задающая размер или индекс, получит нулевое значение. Это должно свести к минимуму риск при передаче неинициализированного размера функциям вроде memcpy, работающим с буфером, чей размер задаётся значением переданной переменной.
                          • После проверки нулевого указателя программа исполнит соответствующую ветку и не будет пытаться использовать его. Так, по крайней мере, удастся корректно обработать указатели, которые разработчик забыл инициализировать (поскольку попытка обратиться к памяти по автоматически инициализированному указателю будет всегда приводить к падению).
                          • Переменные булева типа со значением 0 означают «ложь», что в проверках может обозначать состояние ошибки.

                          У инициализации нулём также есть пара недостатков:

                          • Переменная NTSTATUS будет иметь значение STATUS_SUCCESS
                          • Переменная HRESULT будет иметь значение S_OK

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

                          С позиции производительности

                          От выбранного значения инициализации также зависят скорость работы программы и размер кода. Мы не измеряли, насколько хуже результаты при использовании ненулевого значения, так как нас в первую очередь интересовали преимущества в безопасности, которые даёт инициализация нулём, и мы знали, что заодно она положительно скажется на производительности (как скорости работы, так и размере кода). Наши коллеги из Google провели измерения и показали, что на Clang инициализация нулём на данный момент заметно выгоднее, чем инициализация ненулевым значением.

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

                          Пример 1: Инициализация с использованием регистров общего назначения

                          Инициализация нулём:

                          31 c0                            xor    eax,eax
                          48 89 01                         mov    QWORD PTR [rcx],rax

                          Инициализация ненулевым значением:

                          48 b8 e2 e2 e2 e2 e2 e2 e2 e2    movabs rax,0xe2e2e2e2e2e2e2e2
                          48 89 01                         mov    QWORD PTR [rcx],rax

                          В этом примере нас интересуют два момента:

                          Во-первых, установка регистра RAX в ноль занимает 2 байта кода против 10 байт при установке в ненулевое значение. Получается выигрыш как по размеру кода, так и по скорости работы. Многие процессоры считывают команды по 16 байт за раз, поэтому запись в регистр фиксированной константы с помощью команды размером 10 байт препятствует выдаче следующих команд, которые могли бы выполняться параллельно.

                          Во-вторых, прежде чем станет возможным записать значение в регистр RCX, придётся дождаться завершения записи в RAX, что может привести к простаиванию процессора. Последовательности вроде «xor eax, eax» распознаются на самых ранних участках конвейера, и реального выполнения команды XOR не требуется – процессоры просто обнуляют регистр RAX. В результате конвейер простаивает меньше времени и программа работает быстрее.

                          Пример 2: Инициализация с использованием XMM-регистров

                          Для записи более крупных значений компилятор, как правило, использует XMM-регистры (а также YMM или ZMM в зависимости от того, включена ли поддержка наборов инструкций AVX или AVX512). Как правило, за один такт процессоры могут завершить не более одной команды записи, поэтому будет разумно использовать такие команды, которые устанавливают как можно больше байт.

                          Инициализация нулём:

                          0f 57 c0                         xorps  xmm0,xmm0
                          f3 0f 7f 01                      movdqu XMMWORD PTR [rcx],xmm0

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

                          66 0f 6f 04 25 00 00 00 00       movdqa xmm0,XMMWORD PTR ds:0x0
                          f3 0f 7f 01                      movdqu XMMWORD PTR [rcx],xmm0

                          Инициализация ненулевым значением (загружается из фиксированной константы в коде, чего компиляторы не делают):

                          48 ba e2 e2 e2 e2 e2 e2 e2 e2    movabs rdx,0xe2e2e2e2e2e2e2e2
                          66 48 0f 6e c2                   movq   xmm0,rdx
                          0f 16 c0                         movlhps xmm0,xmm0
                          f3 0f 7f 01                      movdqu XMMWORD PTR [rcx],xmm0

                          Как видим, в случае XMM-регистров наблюдается та же картина. При инициализации нулём код получается совсем небольшим.

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

                          Чтобы избежать этого, компиляторы, как правило, сохраняют фиксированную константу в виде глобальной переменной, из которой могут потом считать значение, – так получается гораздо меньше кода. К сожалению, придётся дождаться окончания записи в XMM-регистр, прежде чем он станет доступен для использования. Если глобальная переменная будет выгружена из памяти, операция может занять несколько тысяч тактов. На операцию чтения уходит несколько тактов даже при самом хорошем сценарии, когда данные хранятся в кэше L1. И даже в этом случае код получается намного длиннее, чем если просто обнулить регистр.

                          Тут обнаруживается ещё одно преимущество инициализации нулём: более детерминированные результаты. Время инициализации не зависит от того, находится ли глобальная переменная в кэше L1, L2 или L3, выгружается ли из памяти, и т.д.

                          Интересные наблюдения, связанные с применением InitAll

                          Производительность

                          Windows 10 1903 (выпущена весной 2019 года) стала первой версией, в которой InitAll был включён по умолчанию. До сих пор никаких жалоб на снижение производительности из-за него мы не получали.

                          Совместимость

                          Античиты

                          Вскоре после включения InitAll в Windows нам стали поступать жалобы на падения ядра, вызванные некоторыми античит-программами. Изучив проблему, мы выяснили, что эти программы содержали драйверы режима ядра, которые сканировали образ ядра NT в памяти и искали определённые байтовые последовательности, указывающие на начало недокументированных функций.

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

                          Использование освобождённой памяти в FAT32

                          Вскоре после включения InitAll для скалярных типов данных (т.е. целых чисел, чисел с плавающей запятой и т.д.) мы столкнулись с интересной проблемой в драйвере файловой системы FAT, которая не давала обновлять внутренние сборки Windows с загрузочных USB-флешек.

                          Код, в котором возникла проблема, выглядел примерно так:

                          for(int i = 0; i < size; i++)
                          {
                                int tmp;
                                DoStuff(&tmp, i);
                          }

                          Имеется цикл, внутри которого объявляется переменная. На первой итерации цикла функция DoStuff инициализирует переменную ‘tmp’, адрес которой передаётся ей в качестве аргумента. На каждой последующей итерации переменная ‘tmp’ используется как входной/выходной параметр. Другими словами, её значение сначала считывается, а затем обновляется.

                          Проблема в том, что рассматриваемая переменная в начале каждой итерации цикла входит в его область видимости, а в конце итерации покидает её. InitAll инициализирует эту переменную нулём перед каждой итерацией. Фактически мы получаем уязвимость, связанную с использованием освобождённой памяти (use-after-free). Для нормальной работы кода требуется, чтобы переменная ‘tmp’ сохраняла своё значение на каждой итерации, даже если в конце итерации она выходит из области видимости. К сожалению, эта проблема приводила не к падению драйвера, а к некорректной логике его работы и, как следствие, непредсказуемому поведению файловой системы. В ходе отладки команда, занимающаяся ядром, определила причину проблемы и исправила её, вынеся объявление переменной за пределы цикла.

                          Этот случай – наглядный пример того, как улучшения безопасности могут сломать код, в который не заглядывали годами.

                          Оптимизации производительности

                          Оптимизации производительности, осуществляемые InitAll, преследуют три цели:

                          1. Предоставить разработчикам возможность отключать InitAll для критического кода
                          2. По возможности убрать лишние операции записи
                          3. Максимально ускорить оставшиеся операции записи

                          Отключение InitAll для критического кода

                          Самые очевидные оптимизации заключаются в том, чтобы позволить коду:

                          1. Полностью отключить InitAll
                          2. Отключить InitAll для конкретного типа (т.е. typedef структуры)
                          3. Отключить InitAll для всех операций выделения памяти в функции
                          4. Отключить InitAll для конкретного объявления переменной в функции

                          На сегодняшний момент InitAll отключён (ради повышения производительности) для одного-единственного типа – структуры _CONTEXT, которая хранит значения всех регистров. Принудительная её инициализация приводила к снижению производительности в тестах.

                          Структура _CONTEXT имеет размер более 1000 байт, и этого достаточно, чтобы хранить значения всех регистров. С включённым ETW-логированием для отслеживания переключений контекста каждый раз при смене контекста значения всех регистров заносятся в лог. Структура _CONTEXT в этом случае будет выделяться на стеке, заполняться ассемблерной функцией и затем передаваться в ETW. Из-за того, что структура инициализируется ассемблерной функцией, компилятор не может убрать инициализацию, сделанную InitAll. Поскольку эта структура и так содержит критические данные (состояние каждого регистра), имеет большой размер и используется в чрезвычайно требовательных к производительности ветках, мы решили не применять к ней InitAll.

                          Для всех остальных типов, переменных и функций InitAll не отключалась.

                          Удаление лишних операций записи

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

                          Ниже приводятся примеры разных видов оптимизации, применяемых Visual Studio.

                          Удаление нескольких memset

                          Ссылка на Godbolt: https://msvc.godbolt.org/z/Ldu7AP

                          Следующий паттерн кода (с разными вариациями) чрезвычайно распространён. Первоначальные правила программирования под NT требуют, чтобы все переменные объявлялись в начале функции, а инициализировались как можно позже. В результате мы имеем случаи, когда переменная объявляется в начале функции, а инициализируется только в какой-нибудь одной ветке непосредственно перед использованием.

                          InitAll добавляет свою инициализацию переменной в начале функции. Компилятор может удалить дубликат, но это не всегда легко сделать.

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[160];
                          };
                          
                          void Dummy()
                          {
                              printf("dummy");
                              return;
                          }
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          volatile bool b = true;
                          
                          void f()
                          {
                              MyStruct s;
                              // Этот вызов memset, по сути, идентичен вызову memset, добавленному InitAll
                              memset(&s, 0x0, sizeof(s));
                            
                              if (b) // Проверяем volatile-переменную,
                                     // чтобы не дать компилятору убрать эту ветку 
                                     // (что он сделал бы, если бы мы написали обычную переменную)
                              {
                                  Dummy();
                                  memset(&s, 0x1, sizeof(s));
                                  DoStuff(&s);
                              }
                              return;
                          }

                          Picture 4

                          Кажется, что этот простой пример должен легко оптимизироваться, однако GCC 9.3 и Clang 10.0.0 (самые свежие версии, доступные на Godbolt) неспособны в этом случае убрать лишний вызов memset. Я говорю об этом не для того, чтобы покритиковать эти компиляторы, – они оба очень хорошо оптимизируют код. Я просто хочу показать, что некоторые паттерны могут вызвать трудности даже у самых мощных компиляторов. До появления InitAll и связанных с ним оптимизаций Visual Studio не мог убрать лишний вызов.

                          Ещё более простой пример:

                          Между двумя вызовами memset находится всего один вызов функции без аргументов. Этот паттерн, как и предыдущий, очень часто встречается в коде Microsoft.

                          Ссылка на Godbolt: https://msvc.godbolt.org/z/HqFMx_

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[160];
                          };
                          
                          void Dummy()
                          {
                              printf("dummy");
                              return;
                          }
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          void f()
                          {
                              MyStruct s;
                              // Этот вызов memset, по сути, идентичен вызову memset, добавленному InitAll
                              memset(&s, 0x0, sizeof(s));
                          
                              Dummy();
                          
                              memset(&s, 0x1, sizeof(s));
                          
                              DoStuff(&s);
                          
                              return;
                          }

                          Picture 3

                          MSVC убирает лишний memset в этом примере. Clang 10.0.0 – тоже, а вот у GCC 9.3 по-прежнему не получается. Казалось бы, этот код можно легко оптимизировать, однако для этого компилятору приходится проводить нетривиальный анализ.

                          Проблема здесь (в MSVC) в том, что компилятор применяет анализ достижимости объекта, не зависящий от ветвления или потока исполнения. С точки зрения компилятора, переменная ‘s’ «убегает» из текущей функции (другими словами, её адрес передаётся куда-то за пределы этой функции), так как её адрес передаётся в функцию ‘DoStuff’. Компилятор также видит вызов memset ‘s’, затем – вызов ‘Dummy’, после чего – ещё один вызов memset ‘s’.

                          С точки зрения компилятора, поскольку переменная ‘s’ «убежала» из функции, функция ‘Dummy’ теоретически может считывать содержимое ‘s’ или изменять его до вызова функции ‘DoStuff’. А значит, вызов memset ни до, ни после ‘Dummy’ не может быть удалён.

                          Мы-то видим, что, хотя переменная ‘s’ и «убегает» из текущей функции, происходит это не раньше, чем вызывается функция ‘DoStuff’. Компилятор MSVC теперь тоже понимает это (в той или иной степени) и может убрать первый вызов memset.

                          Уменьшение размера memset

                          Ссылка на Godbolt: https://msvc.godbolt.org/z/fyLVUF

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

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[320];
                          };
                          
                          void Dummy()
                          {
                              printf("dummy");
                              return;
                          }
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          volatile bool b = true;
                          
                          void f()
                          {
                              MyStruct s;
                              // Этот вызов memset, по сути, идентичен вызову memset, добавленному InitAll
                              memset(&s, 0x0, sizeof(s));
                          
                              if (b) // Проверяем volatile-переменную,
                                     // чтобы не дать компилятору убрать эту ветку 
                                     // (что он сделал бы, если бы мы написали обычную переменную)
                              {
                                  Dummy();
                                  memset(&s, 0x0, sizeof(s)-0x160);
                                  DoStuff(&s);
                              }
                          
                              return;
                          }

                          Picture 2

                          MSVC теперь может урезать размер первой memset, чтобы она инициализировала только те элементы в структуре, которые не инициализирует вторая memset. И снова GCC 9.3 и Clang 10.0.0 пока что не умеют проводить такую оптимизацию в этом примере.

                          Более эффективная развёртка memset

                          → Ссылка на Godbolt

                          В следующем примере вызов memset нельзя убрать. Значит, его следует выполнить как можно эффективнее.

                          #include <stdio.h>
                          #include <string.h>
                          
                          struct MyStruct
                          {
                              int array[12];
                          };
                          
                          void DoStuff(MyStruct* s)
                          {
                              printf("hi", (int*)&s); // Передаём указатель "s"
                                                      // в сложную функцию, 
                                                      // чтобы компилятор не cмог полностью 
                                                      // убрать все операции записи в "s"
                              return;
                          }
                          
                          void f()
                          {
                              MyStruct s;
                              memset(&s, 0x0, sizeof(s));
                              DoStuff(&s);
                              return;
                          }

                          Picture 1

                          MSVC (как и большинство компиляторов) может «разворачивать» небольшие вызовы memset со статически определяемым размером и значением заполнения. То есть вызов memset заменяется последовательностью команд записи непосредственно в память. Благодаря этой оптимизации время выполнения небольших вызовов memset (до 128 байт) сокращается до одной четверти от обычного при меньшем объёме кода (нет необходимости сохранять значения регистров в стек, вызывать memset, а затем восстанавливать состояние регистров).

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

                          Более производительные реализации memset

                          Этот пункт мы подробно разберём в другой раз.

                          Значение для пользователей

                          С тех пор как мы выпустили InitAll, многие из уязвимостей, о которых пользователи сообщали в MSRC, перестали воспроизводиться на свежих версиях Windows. Благодаря InitAll эти уязвимости из «проблем безопасности» превратились в «дефекты кода, на данный момент не имеющие негативных последствий». А значит, нам больше не нужно поставлять обновления безопасности для уже выпущенных операционных систем с установленным InitAll, что избавляет пользователей от головной боли, сопровождающей установку патчей, а Microsoft – от головной боли, сопровождающей их разработку.

                          У себя в активных ветках репозитория мы по-прежнему улучшаем код и исправляем ошибки, а также вносим правки в уже выпущенные операционные системы, в которых InitAll отсутствует и которые поэтому по-прежнему уязвимы. Со временем версии без InitAll перестанут поддерживаться. Когда это произойдёт, ошибки, нейтрализованные с помощью InitAll, будут правиться только в активных ветках разработки, а на текущих системах этот тип дефектов больше не придётся исправлять.

                          Планы на будущее

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

                          1. Изучить и использовать возможность применения InitAll ко всем типам выделяемых данных (т.е. массивам всех типов и всем классам, а не только POD)
                          2. Развернуть InitAll на всём коде Windows.

                          В дальнейшем мы планируем выяснить, возможно ли стандартизировать процесс устранения описанных типов проблем в языке C и C++. Необязательно по умолчанию оставлять переменные неинициализированными ради производительности (особенно если компилятор умеет хорошо оптимизировать избыточные операции записи). Вместо этого было бы лучше требовать от разработчика инициализировать переменные перед использованием, «если такая необходимость доказана», и позволять нарушать это правило, только если применяется специальное ключевое слово для неинициализированных переменных. Такое решение позволило бы сохранить высокую производительность и при этом уберечь программистов от лишних ошибок.

                          Мы планируем опубликовать ещё одну заметку о текущей работе по нейтрализации уязвимостей, связанных с неинициализированной памятью, в механизме выделения пула памяти в ядре Windows.

                          Комментарий переводчика

                          Статья почти не связана с моей родной тематикой статического анализа кода, но мне она показалась интересной и я захотел поделиться переводом с русскоязычной аудиторией. От себя хочу добавить, что проблемы безопасности, связанные с «утечкой» приватных данных, обычно складываются из двух составляющих. Первое: есть место, где приватные данные должны затираться, но этого не происходит (V597). Второе: неочищенные приватные данные как часть неинициализированной памяти, могут быть куда-то переданы (пример).

                          Добавить комментарий