Pull to refresh
0
Content AI
Решения для интеллектуальной обработки информации

Перезаписывать память – зачем?

Reading time4 min
Views40K
В недрах Win32 API есть функция SecureZeroMemory с очень лаконичным описанием, из которого следует, что эта функция перезаписывает область памяти нулями и устроена таким образом, что компилятор при оптимизации кода никогда не удаляет вызов этой функции. Там же говорится, что следует с помощью этой функции перезаписывать память, ранее использованную для хранения паролей и криптографических ключей.

Остается один вопрос – зачем это? Можно найти пространные рассуждения о риске записи памяти программы в файл подкачки, файл hibernate или аварийный дамп, где его может найти злоумышленник. Это похоже на паранойю – далеко не всякий злоумышленник имеет возможность наложить руку на эти файлы.

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

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

Итак. В далекой-далекой функции мы получаем ключ шифрования, пароль или номер кредитной карты (далее – просто секрет), используем его и не перезаписываем:
{
    const int secretLength = 1024;
    WCHAR secret[secretLength] = {};
    obtainSecret( secret, secretLength );
    processWithSecret( what, secret, secretLength );
}
В другой, совершенно никак не связанной с предыдущей, функции, наш экземпляр программы запрашивает у другого экземпляра файл с некоторым именем. Для этого используется RPC – древняя как динозавры технология, присутствующая на многих платформах и широко используемая Windows для реализации межпроцессного и межмашинного взаимодействия.

Обычно для использования RPC нужно написать описание интерфейса на языке IDL. В нем будет описание метода примерно такого вида:
//MAX_FILE_PATH == 1024
error_status_t rpcRetrieveFile( [in] const WCHAR fileName[MAX_FILE_PATH], [out] BYTE_PIPE filePipe );
здесь второй параметр имеет специальный тип, дающий возможность передавать потоки данных произвольной длины. Первый параметр – массив символов под имя файла.

Это описание компилируется компилятором MIDL, получается заголовочный файл (.h) с функцией
error_status_t rpcRetrieveFile ( handle_t IDL_handle, const WCHAR fileName[1024], BYTE_PIPE filePipe);

здесь MIDL добавил служебный параметр, а второй и третий параметры те же, что были в предыдущем описании.

Вызываем эту функцию:
void retrieveFile( handle_t binding )
{
      WCHAR remoteFileName[MAX_FILE_PATH];
      retrieveFileName( remoteFileName, MAX_FILE_PATH );
      CBytePipeImplementation pipe;
      rpcRetrieveFile( binding, remoteFileName, pipe );           
}
Все отлично – retrieveFileName() получает строку длиной не более MAX_FILE_PATH−1, завершенную нулевым символом (нулевой символ не забыли), вызываемая сторона получает строку и работает с ней – получает полный путь к файлу, открывает его и передает данные из него.

Все полны оптимизма, с этим кодом делается несколько выпусков продукта, но слона пока никто не заметил. Слон вот. С точки зрения C++, параметр функции
const WCHAR fileName[1024]
это не массив, а указатель на первый элемент массива. Функция rpcRetrieveFile() – всего лишь прослойка, которая сгенерирована тем же MIDL. Она упаковывает все свои параметры и вызывает всегда одну и ту же функцию WinAPI NdrClientCall2(), смысл которой «Windows, выполни, пожалуйста, RPC-вызов вооот с этими параметрами», и передает параметры списком функции NdrClientCall2(). Одним из первых параметров идет строка форматирования, сгенерированная MIDL по описанию в IDL. Очень похоже на старый добрый printf().

NdrClientCall2() внимательно смотрит на полученную строку форматирования и упаковывает параметры для передачи другой стороне (это называется marshalling). Рядом с каждым параметром указан его тип – каждый параметр упаковывается в зависимости от типа. В нашем случае для параметра fileName указан адрес первого элемента массива и в качестве типа – «массив из 1024 элементов типа WCHAR».

Теперь в коде встречаем подряд два вызова:
processWithSecret( whatever );
retrieveFile( binding );
Функция processWithSecret() отъедает 2 килобайта под хранение секрета на стеке, а при завершении забывает о них. Дальше вызывается функция retrieveFile(), она извлекает имя файла длиной 18 символов (18 символов + завершающий нулевой – всего 19, т.е. 38 байт). Имя файла снова хранится на стеке и скорее всего, это будет точно та же область памяти, что была использована под секрет в первой функции.

Дальше происходит удаленный вызов и функция упаковки добросовестно упаковывает весь массив (не 38 байт, а 2048) в пакет и этот пакет затем передается по сети.

КРАЙНЕ НЕОЖИДАННО

Секрет передается по сети. Программа даже не планировала когда-либо передавать секрет по сети, но он передается. Такой дефект может быть гораздо удобнее в «использовании», чем даже просмотр файла подкачки. Кто теперь параноик?

Пример выше выглядит довольно сложным. Вот похожий по смыслу код, который можно опробовать на codepad.org
const int bufferSize = 32;

void first()
{
    char buffer[bufferSize];
    memset( buffer, 'A', sizeof( buffer ) );
}

void second()
{
    char buffer[bufferSize];
    memset( buffer, 'B', bufferSize / 2 );
    printf( "%s", buffer );
}

int main()
{
   first();
   second();
}
В нем неопределенное поведение. На момент написания поста результат работы – строка из 16 символов ‘B’ и 16 символов ‘A’.

Сейчас самое время для размахивания вилами и факелами и гневных возгласов, что никто в своем уме не использует обычные массивы, что нужно использовать std::vector, std::string и класс УниверсальныйВсемогутер, которые «правильно» работают с памятью, и священных войн на не менее чем 9 тысяч комментариев.

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

Кто здесь виноват? Как обычно, виноват разработчик – он неверно понял, как функция rpcRetrieveFile() работает с полученными параметрами. В результате – неопределенное поведение, которое в данном случае приводит к неконтролируемой передаче данных по сети. Это исправляется либо изменением RPC-интерфейса и правкой кода на обеих сторонах, либо использованием массива достаточно большого размера и его полной перезаписью перед копированием в него параметра.

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

Дмитрий Мещеряков,
департамент продуктов для разработчиков
Tags:
Hubs:
+79
Comments15

Articles

Information

Website
www.contentai.ru
Registered
Founded
Employees
101–200 employees
Location
Россия