>
>
>
Безопасная очистка приватных данных

Роман Фомичев
Статей: 2

Безопасная очистка приватных данных

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