Часто в программе необходимо хранить приватные данные. Например: пароли, ключи и их производные. Очень часто после использования этих данных, необходимо очистить оперативную память от их следов, чтобы злоумышленник не мог получить доступ к ним доступ. В этой заметке пойдет речь о том, почему для этих целей нельзя пользоваться функцией memset().

memset()


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

Стек


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

Напишем код, который работает с паролем:
#include <string>
#include <functional>
#include <iostream>

//Приватные данные
struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

//Функция что-то делает с паролем
void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

//Функция для ввода и обработки пароля
int funcPswd()
{
  PrivateData data;
  std::cin >> data.m_pswd;

  doSmth(data);
  memset(&data, 0, sizeof(PrivateData));
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}

Пример достаточно условен, он полностью синтетический.

Если мы соберем отладочную версию и выполним такой код под отладчиком (я использовал Visual Studio 2015), то увидим, что все в порядке. Пароль и вычисленный хэш стираются после использования.

Посмотрим на ассемблерный код под отладчиком Visual Studio:
.... 
    doSmth(data);
000000013F3072BF  lea         rcx,[data]  
000000013F3072C3  call        doSmth (013F30153Ch)  
  memset(&data, 0, sizeof(PrivateData));
000000013F3072C8  mov         r8d,70h  
000000013F3072CE  xor         edx,edx  
000000013F3072D0  lea         rcx,[data]  
000000013F3072D4  call        memset (013F301352h)  
  return 1;
000000013F3072D9  mov         eax,1  
....

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

Казалось бы, на этом можно закончить, но нет, попробуем собрать релиз-версию с оптимизацией кода. Посмотрим в отладчике, что у нас получилось:
.... 
000000013F7A1035  call
        std::operator>><char,std::char_traits<char> > (013F7A18B0h)  
000000013F7A103A  lea         rcx,[rsp+20h]  
000000013F7A103F  call        doSmth (013F7A1170h)  
    return 0;
000000013F7A1044  xor         eax,eax   
.... 

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

Куча


А вот теперь давайте погрузимся глубже. Проверим, а что будет с данными которые будут размещены в динамической памяти с помощью функции malloc или оператора new.

Модифицируем наш код для работы с malloc:
#include <string>
#include <functional>
#include <iostream>

struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

int funcPswd()
{
  PrivateData* data = (PrivateData*)malloc(sizeof(PrivateData));
  std::cin >> data->m_pswd;
  doSmth(*data);
  memset(data, 0, sizeof(PrivateData));
  free(data);
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}

Будем проверять Release-версию, так как в Debug все вызовы находятся на своих местах. После компиляции в Visual Studio 2015 посмотрим ассемблерный код:
.... 
000000013FBB1021  mov         rcx,
        qword ptr [__imp_std::cin (013FBB30D8h)]  
000000013FBB1028  mov         rbx,rax  
000000013FBB102B  lea         rdx,[rax+8]  
000000013FBB102F  call
        std::operator>><char,std::char_traits<char> > (013FBB18B0h)  
000000013FBB1034  mov         rcx,rbx  
000000013FBB1037  call        doSmth (013FBB1170h)  
000000013FBB103C  xor         edx,edx  
000000013FBB103E  mov         rcx,rbx  
000000013FBB1041  lea         r8d,[rdx+70h]  
000000013FBB1045  call        memset (013FBB2A2Eh)  
000000013FBB104A  mov         rcx,rbx  
000000013FBB104D  call        qword ptr [__imp_free (013FBB3170h)]  
    return 0;
000000013FBB1053  xor         eax,eax  
.... 

Как видим, в этом случае с Visual Studio все в порядке, наша очистка данных работает. Но давайте посмотрим, что будут делать другие компиляторы. Попробуем использовать gcc версии 5.2.1 и clang версии 3.7.0.

Для gcc и clang я немного модифицировал исходный код, была добавлена распечатка содержимого, находящегося в выделенной памяти, до очистки и после очистки памяти. Я распечатал содержимое по указателю уже после освобождения памяти. В реальных программах такого делать нельзя, так как совершенно неизвестно, как поведет себя программа в таком случае. Но для эксперимента я позволил себе такую вольность.
....
#include "string.h"
....
size_t len = strlen(data->m_pswd);
for (int i = 0; i < len; ++i)
  printf("%c", data->m_pswd[i]);
printf("| %zu \n", data->m_hash);
memset(data, 0, sizeof(PrivateData));
free(data);
for (int i = 0; i < len; ++i)
  printf("%c", data->m_pswd[i]);
printf("| %zu \n", data->m_hash);
.... 

Итак, фрагмент ассемблерного кода, созданный компилятором gcc:
movq (%r12), %rsi
movl $.LC2, %edi
xorl %eax, %eax
call printf
movq %r12, %rdi
call free

Сразу после распечатки содержимого (printf) мы видим вызов функции free(), а вызов функции memset() удален. Если исполнить код и ввести произвольный пароль (например «MyTopSecret»), то мы получим следующий вывод на экран:

MyTopSecret| 7882334103340833743

MyTopSecret| 0

Хэш изменился. Видимо это побочный эффект работы менеджера памяти. Наш же секретный пароль «MyTopSecret», остался в неприкосновенном виде в памяти.

Теперь проверим для clang:
movq (%r14), %rsi
movl $.L.str.1, %edi
xorl %eax, %eax
callq printf
movq %r14, %rdi
callq free

Наблюдаем аналогичную картину, вызов memset() удален. Вывод на экран выглядит таким же образом:

MyTopSecret| 7882334103340833743

MyTopSecret| 0

В данном случае, и gcc, и clang решили оптимизировать код. Так как память после вызова функции memset() освобождается, то компиляторы считают этот вызов ненужным и удаляют его.

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

Ну и напоследок проверим как поведут себя компиляторы при выделении памяти с помощью new.

Еще раз модифицируем код:
#include <string>
#include <functional>
#include <iostream>
#include "string.h"

struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

int funcPswd()
{
  PrivateData* data = new PrivateData();
  std::cin >> data->m_pswd;
  doSmth(*data);
  memset(data, 0, sizeof(PrivateData));
  delete data;
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}

Visual Studio добросовестно чистит память:
000000013FEB1044  call        doSmth (013FEB1180h)  
000000013FEB1049  xor         edx,edx  
000000013FEB104B  mov         rcx,rbx  
000000013FEB104E  lea         r8d,[rdx+70h]  
000000013FEB1052  call        memset (013FEB2A3Eh)  
000000013FEB1057  mov         edx,70h  
000000013FEB105C  mov         rcx,rbx  
000000013FEB105F  call        operator delete (013FEB1BA8h)  
    return 0;
000000013FEB1064  xor         eax,eax  

Компилятор gcc в этом случае также решил оставить код для очистки памяти:
call printf
movq %r13, %rdi
movq %rbp, %rcx
xorl %eax, %eax
andq $-8, %rdi
movq $0, 0(%rbp)
movq $0, 104(%rbp)
subq %rdi, %rcx
addl $112, %ecx
shrl $3, %ecx
rep stosq
movq %rbp, %rdi
call _ZdlPv

Соответственно изменился и вывод на экран, наши данные удалены:

MyTopSecret| 7882334103340833743

| 0

А вот clang решил опять оптимизировать наш код и вырезал «ненужную» функцию:
movq (%r14), %rsi
movl $.L.str.1, %edi
xorl %eax, %eax
callq printf
movq %r14, %rdi
callq _ZdlPv

Распечатаем содержимое памяти:

MyTopSecret| 7882334103340833743

MyTopSecret| 0

Пароль остался жить в памяти и ждать, когда его украдут.

Подведем итоги. В результате нашего эксперимента выяснилось, что компилятор, оптимизируя код, может убрать вызов функции memset() при использовании любой памяти, как стековой, так и динамической. Несмотря на то, что Visual Studio не удаляла вызовы memset() при использовании динамической памяти, рассчитывать на это ни в коем случае нельзя. Возможно, при использовании других флагов компиляции, эффект проявит себя. Из нашего маленького исследования вытекает, что для очистки приватных данных нельзя полагаться на функцию memset().

Как же правильно очистить приватные данные?

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

В Visual Studio, например, можно использовать RtlSecureZeroMemory. Начиная с C11 существует функция memset_s. В случае необходимости вы можете создать свою собственную безопасную функцию. В интернете достаточно много примеров, как её сделать. Вот некоторые из вариантов.

Вариант N1.
errno_t memset_s(void *v, rsize_t smax, int c, rsize_t n) {
  if (v == NULL) return EINVAL;
  if (smax > RSIZE_MAX) return EINVAL;
  if (n > smax) return EINVAL;
  volatile unsigned char *p = v;
  while (smax-- && n--) {
    *p++ = c;
  }
  return 0;
}

Вариант N2.
void secure_zero(void *s, size_t n)
{
    volatile char *p = s;
    while (n--) *p++ = 0;
}

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

Заключение


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


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Roman Fomichev. Safe Clearing of Private Data.

Комментарии (41)


  1. Cheater
    06.04.2016 16:18

    Есть ещё #pragma optimize в разных компиляторах, правда это не портабельно.


  1. gotoxy
    06.04.2016 16:19
    -9

    if (v == NULL)
    Здравствуй, 2016 год.


    1. Andrey2008
      06.04.2016 16:38

      Так ведь это из «CERT C Coding Standard». Так что OK.


  1. ildarz
    06.04.2016 16:25
    -1

    > Из нашего маленького исследования вытекает, что для очистки приватных данных нельзя полагаться на функцию memset().

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


    1. fomichevrv
      06.04.2016 16:33

      Конечно, если далее память используется, то компилятор не уберет вызов memset.


    1. MaximChistov
      06.04.2016 16:35
      +7

      >«достаточно просто прочитать память после вывода memset»

      А потом кто-то другой, работая над этим же местом, увидит «бессмысленный» код и удалит его.


      1. ildarz
        06.04.2016 17:05

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


        1. MaximChistov
          06.04.2016 19:08

          ну почему же молча, это спокойно пройдет код ревью, если про сию оптимизацию знал лишь автор кода. с комментарием типа «removed useless code»


        1. Stas911
          06.04.2016 20:36

          Иногда проекты размазаны во времени и кто его знает, кто будет править этот код через 10 лет.


    1. Andrey2008
      06.04.2016 16:37
      +4

      Смотря, что значит «прочитать». Если прочитать и как-то использовать, это одно. А если положить значение в локальную переменную и затем эту переменную не использовать — это другое. Высока вероятность, что компилятор выбросит эту локальную переменную А потом и memset().
      Не стоит делать подобные «хакерские решения». Нужно вызывать правильные функции. :)


      1. ildarz
        06.04.2016 16:53
        -1

        Если специализированная функция есть в стандарте языка, нет вопросов. А если стандарта нет, то я не улавливаю тонкую грань между «хакерским решением» и «правильной функцией». :)


        1. bolk
          06.04.2016 17:07
          +4

          Прочитайте статью до конца и вопросов у вас не будет.


          1. ildarz
            06.04.2016 17:36
            +1

            Делать на пустом месте предположения о том, что я прочёл, а что — нет, не слишком вежливо с вашей стороны. :) Не могли бы вы пояснить, что вы имеете в виду?


            1. bolk
              06.04.2016 17:37

              Статья:

              Начиная с C11 существует функция memset_s.
              Вы пишете:
              Если специализированная функция есть в стандарте языка, нет вопросов.
              Следовательно вывод такой: если бы вы дочитали статью до конца, вопросов бы не было.


              1. ildarz
                06.04.2016 17:52

                Поразительно. Я пишу «в таком-то упомянутом в статье случае нет вопросов». Вы из этого делаете вывод, что я это не прочитал, и у меня они есть. «Следовательно»? Как? :D

                Раз вы прочитали статью до конца, то должны были заметить — после процитированного вами куска автор даёт две кастомные реализации. И очевидно, что мой комментарий о разнице «хакерских» и «правильных» методов относился именно к ситуации, когда стандартную функцию по какой-то причине использовать нельзя.


              1. 4144
                07.04.2016 10:27

                Если я не ошибаюсь, то memset_s и других «безопасные» функции придумала Микрософт, и пыталась протолкнуть в стандарт.
                Они были добавлены, но как не обязательные. И скорее всего ни один компилятор кроме Visual Studio их не поддерживает.


    1. Antervis
      07.04.2016 10:27

      условно, компилятор может выкинуть memset и сразу записать в читаемую переменную 0


  1. RomanArzumanyan
    06.04.2016 18:07

    Можно как вариант сделать размещающее выделение на «старом месте»?


    1. Andrey2008
      06.04.2016 19:45

      А зачем? Наверняка есть масса хитрых способов заставить компилятор не оптимизировать вызов memset(). Но в чем смысл экзотических подходов?


      1. RomanArzumanyan
        06.04.2016 19:46

        Например, если сервер обслуживает несколько клиентов.


        1. Andrey2008
          06.04.2016 19:55

          Тогда тем более надо взять и затереть наиболее надёжным способом. А не гадать, посчитает компилятор placement new использованием памяти или нет. Как по мне, вполне имеет право оптимизировать. Но я даже и не подумаю пытаться это выяснить и разобраться. Не понимаю я, откуда это желание перебирать движок через выхлопную трубу. :)


        1. vladon
          08.04.2016 17:59

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


          1. RomanArzumanyan
            08.04.2016 20:56

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


  1. BalinTomsk
    06.04.2016 18:10
    +1

    inline void SecureWipeBuffer(char* buf, size_t n){ — linux
    volatile char* p = buf;
    asm volatile(«rep stosb»: "+c"(n), "+D"(p): «a»(0): «memory»);
    }

    // windows
    PVOID SecureZeroMemory(
    _In_ PVOID ptr,
    _In_ SIZE_T cnt
    );


    1. rafuck
      07.04.2016 02:20

      Тогда уж так:
      asm volatime(«mfence»:::«memory»)
      чтобы компилятор не перемещал операции работы с памятью


      1. rafuck
        07.04.2016 02:25

        А, невнимательно прочитал. Возражения снимаются.


  1. slonopotamus
    06.04.2016 21:44
    +1

    Не хватает объяснения — почему компилятор не имеет права выбросить код предложенной memset_s?


    1. Andrey2008
      06.04.2016 21:51
      +1

      Обратимся к первоисточнику с примером кода:

      This compliant solution uses the volatile type qualifier to inform the compiler that the memory should be overwritten and that the call to the memset_s() function should not be optimized out. Unfortunately, this compliant solution may not be as efficient as possible because of the nature of the volatile type qualifier preventing the compiler from optimizing the code at all. Typically, some compilers are smart enough to replace calls to memset() with equivalent assembly instructions that are much more efficient than the memset() implementation. Implementing a memset_s() function as shown in the example may prevent the compiler from using the optimal assembly instructions and can result in less efficient code. Check compiler documentation and the assembly output from the compiler.

      However, note that both calling functions and accessing volatile-qualified objects can still be optimized out (while maintaining strict conformance to the standard), so this compliant solution still might not work in some cases. The memset_s() function introduced in C11 is the preferred solution (see the following solution for more information). If memset_s() function is not yet available on your implementation, this compliant solution is the best alternative, and can be discarded once supported by your implementation.


      1. Andrey2008
        06.04.2016 21:55

        Т.е. да, вариант не идеален, но если нет настоящей memset_s(), то хоть так… :)


        1. rafuck
          07.04.2016 02:41

          Забавный, кстати, вы привели комментарий к memset_s. А вообще, и это уже не очень относится к теме, если задуматься, то вроде бы оптимальней заполнять память единицами, а не нулями. Ведь логический «ноль» — это эффект заряженного «конденсатора». В кавычках потому, что никакого конденсатора нет.


      1. DmitryMe
        07.04.2016 10:28
        +1

        Здесь взаимоисключающие параграфы — в первом сказано (со ссылкой на Стандарт), что доступ к переменным, объявленным с квалификатором volatile, нельзя оптимизировать, а во втором — что он все равно может быть оптимизирован. Причина — в неправильном понимании требований Стандарта. Если переменная сама объявлена с квалификатором volatile, то доступ к ней оптимизировать запрещено, но если сама переменная объявлена без квалификатора volatile и доступ осуществляется через «указатель на volatile», запрета на оптимизацию нет. Последнее утверждение часто вызывает споры (пример), но тем не менее подкрепить возражения требованиями Стандарта никому не удается, а чего нет в Стандарте — не требование, а только точка зрения.

        Поэтому все реализации якобы гарантированной перезаписи памяти через «указатели на volatile» полагаются на поддержку компилятора — если разработчики компилятора готовы сделать немного больше, чем требует Стандарт, то фокус удастся. Главный плюс использования указателей на volatile — они легко опознаваемы как разработчиками, так и компилятором, хорошо передают намерение авторов кода, но тем не менее такое решение не является абсолютно переносимым.


  1. gasizdat
    07.04.2016 07:55
    +1

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


  1. semibiotic
    07.04.2016 09:35

    GCC 10.5 Release notes:

    * -O42 (can't be disabled anymore) — removes all human-writen code, and replaces it with proper implementation googled using original symbol names

    Только мне одному кажется что разработчики компилятора берут на себя слишком много не компилируя прямой _вызов_функции_?


    1. DmitryMe
      07.04.2016 10:09
      +2

      Стандарт C++, тем не менее, позволяет проводить любые преобразования кода при условии, что сохраняется «наблюдаемое поведение» — последовательность чтений-записей в переменные, объявленные с квалификатором volatile, и вызовов функций ввода-вывода.


      1. semibiotic
        07.04.2016 16:23
        -2

        (прошу прощения за офтопик)

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

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

        Подобные изменения, кроме всего, ломают совместимось — де-факто изменяют поведение стандартного API, потенциально делая уязвимыми массу старого ПО. На фоне сохранения в стандарте всяких strncpy() и strncat(), int-ов вместо size_t и off_t, char* вместо void* это выглядит, по меньшей мере, непоследовательно.


        1. DmitryMe
          07.04.2016 17:06
          +3

          Кто будет решать, что «здраво», а что — нет? Стандарт на то и стандарт, что является описанием «как правильно», все доводы, не подкрепленные требованиями стандарта, являются только мнениями, какими бы «здравыми» они ни были.


          1. semibiotic
            07.04.2016 18:10
            -2

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


        1. Gryphon88
          08.04.2016 11:09

          Мне тоже не особо нравится такое поведение, но оно скорее правильное. Единственное, о чём жалею, это что нельзя получить отчёт или набор варнингов типа «тупиковый граф выполнения» и «строки NN не были учтены при генерации кода. Чтобы изменить это поведение используйте #pragma ...».

          Исторические так сложилось, что разрабатывали компиляторы под процессоры, а не наоборот (эх, где ты, SPARC...), а логика работы процессора определяется staсkholder'ами — производителями. Поэтому на наш родной реликт — С (и его внучков, унаследовавших многие черты) — приходится громоздить прагмы типа likely, чтобы приспособить дедушку С, родившемуся на одноядерных процессорах, дешёвом обращении к оперативка и килобайтах оперативки, к многоуровневым кешам, конвеерам и out-of-order выполнению


        1. Antervis
          08.04.2016 11:40

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


          1. semibiotic
            09.04.2016 03:16

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

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


            1. Antervis
              09.04.2016 13:03

              касательно «недостаточной компетентности» — я это не придумал, просто немного другое имел в виду. Далеко не всякий программист может обеспечить оптимальность каждого из написанных им участков кода. Просто потому, что это противоречит требованиям к скорости разработки и читаемости. Так почему бы компилятору не взять на себя самую нудную, монотонную и сложную часть работы?