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

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

    Дмитрий Мещеряков,
    департамент продуктов для разработчиков
    ABBYY
    135.46
    Решения для интеллектуальной обработки информации
    Share post

    Comments 15

      +12
      На самом деле в реальном мире всё намного страшнее. Даже когда хотят, многие программисты не могут правильно написать правильную очистку памяти. Я эту ошибку регулярно встречаю в разных проектах, натравляя на них PVS-Studio. Она просто везде. Может даже статью как нибудь напишу про этот паттерн ошибки.

      Например, это может выглядеть так:

      #define MEMSET_BZERO(p,l)	memset((p), 0, (l))
      char *SHA384_End(SHA384_CTX* context, char buffer[]) {
        ...
        MEMSET_BZERO(context, sizeof(context));
        ...
      }


      Пример взят из ReactOS, который я как раз в данный момент изучаю. Скоро будет очередная статья с блюющим единорогом.
        +9
        Какой такой SecureZeroMemory… Тут в ночи злодеи путают местами аргументы. Ещё код из ReactOS на эту тему:

        #define RtlFillMemory(Destination, Length, Fill) \
          memset(Destination, Fill, Length)
        
        #define IOPM_FULL_SIZE          8196
        
        HalpRestoreIopm(VOID)
        {
          ...
          RtlFillMemory(HalpSavedIoMap, 0xFF, IOPM_FULL_SIZE);
          ...
        }
        

        Заполнили 0xFF байт вместо 8196. Тут SecureZeroMemory не поможет. :)
          +2
          Хорошие примеры, да пост не об этом. Да, при вызове функции перезаписи можно ошибиться. А можно не ошибиться, но вызвать не ту функцию, и тогда оптимизирующий компилятор может удалить вызов.
            +1
            Я понимаю, что просто про пользу очитки памяти. Я просто хотел показать, что даже если написан код для очистки, далеко не факт что он работает. И не потому, что компилятор что-то выбросит, а просто из-за того, что программисты не тестируют, чистится память или нет. Вроде есть memset — значит и так сойдет.

            P.S. Я очень сомневаюсь, что адекватный компилятор может выбросит вызов функции memset.
              +1
              (Куда-то исчез мой комментарий. Прошу прощения, если будет дубль.)
              Я понимаю, что пост про пользу очитки памяти. Я хотел показать, что даже если код очистки написан, далеко не факт что он работает. И не потому, что компилятор что-то выбросит, а просто из-за того, что программисты не тестируют, очищается память или нет. Вроде есть memset — значит и так сойдет.

              P.S. Я очень сомневаюсь, что адекватный компилятор может выбросит вызов функции memset.
                +4
                >Я очень сомневаюсь, что адекватный компилятор может выбросит вызов функции memset

                Очень даже может. Например, Visual C++ 9 при компиляции такого кода с включенной оптимизацией (/O2)

                int main()
                {
                WCHAR buffer[1000] = {};
                MessageBox( 0, buffer, buffer, 0 );
                memset( buffer, 9, sizeof( buffer ) );
                return 0;
                }


                начисто удаляет второй вызов memset() — видно в машинном коде.

                На это компилятор имеет полное право — такое изменение не влияет на наблюдаемое поведение, которое описано в Стандарте (1.9/6) как последовательность вызова функций ввода-вывода и чтения-записи volatile данных.
                  +4
                  1) Ужасы © «Городок»
                  2) Место для раздумий по поводу статического анализа. Спасибо за интересную информацию.
                    0
                    Очень хороший пример оптимизации. Думаю, нелишним будет добавить его в статью в качестве иллюстрации того, что будет если не использовать SecureZeroMemory.
                      0
                      Все же пост не об этом. Да, вопрос «почему memset() не годится для перезаписи» очень правильный, но пост о том, зачем вообще нужна перезапись.
            +1
            Добавьте тэг «безопасность» или что-либо в этом духе.
              +8
              Кстати говоря, бывают случаи, когда использование SecureZeroMemory штука спорная. В том плане, что наличие этой функции в импорте приложения для потенциального кодокопателя — как красная тряпка для быка — «хотят стереть что-то секретное». Соответственно, не надо даже особо стараться в поисках «секретных» данных и вникать в код, просто ставим breakpoint на SecureZeroMemory и вуаля, то, что хотели спрятать у нас как на ладони перед самым затиранием. «Ручное затирание» в этом отношении выглядит всё же лучше, если, конечно, подсказать компилятору не оптимизировать этот кусочек кода. Кодокопателю в этом случае хотя бы придётся вникать в код и найти это место, а это уже время. Аналогично рассуждая, можно понять, что схожими свойствами обладает и использование CryptoAPI. Но всё же, лучше SecureZeroMemory, чем оставлять данные в памяти.
                0
                Не все так плохо. Обычно функции вроде SecureZeroMemory() реализуют так, чтобы они встраивались или хотя бы статически влинковывались. В этом случае никакого импорта не будет.
                0
                Пост в ИнфоБез бы переместить. Он его достоен.
                Спасибо! Есть куда теперь тыкать носом.
                  0
                  Написать правильную очистку памяти сложно даже хорошему программисту. Это всегда было большой проблемой.
                    +1
                    Опасность чтения из RAM-модулей напрямую мягко говоря преуменьшена.
                    cryptome.org/0003/RAMisKey.pdf

                    Но при кривых руках — вся система сплошная дыра

                    Only users with full accounts can post comments. Log in, please.