Автор: Роман Фомичев

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

memset ()

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

Стек

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

Вот фрагмент кода, который обрабатывает пароль:

#include <string>
#include <functional>
#include <iostream>
//Private data
struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};
//Function performs some operations on password
void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;
  data.m_hash = hash_fn(s);
}
//Function for password entering and processing
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 () не нужен, так как буфер не используется дальше в программе, поэтому удаление этого вызова не может повлиять на его поведение. Итак, наши личные данные остаются не очищенными, и это очень плохо.

Куча

А теперь давайте копнем глубже. Давайте посмотрим, что происходит с данными, когда мы размещаем их в динамической памяти с помощью функции 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;
}

Мы будем тестировать релизную версию, так как в отладочной версии все вызовы находятся там, где мы хотим. После компиляции в 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. Кроме того, при необходимости вы можете реализовать собственную безопасную функцию; в сети можно найти множество примеров и руководств. Вот некоторые из них.

Решение Нет. 1 .

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;
}

Решение Нет. 2 .

void secure_zero(void *s, size_t n)
{
    volatile char *p = s;
    while (n--) *p++ = 0;
}

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

Вывод

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