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