30 сентября

C++: Коварство и Любовь, или Да что вообще может пойти не так?

Блог компании SimbirSoftИнформационная безопасностьПрограммированиеC++ООП


“C позволяет легко выстрелить себе в ногу. На C++ это сделать сложнее, но ногу оторвёт целиком” — Бьёрн Страуструп, создатель C++.

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



Мы в SimbirSoft тесно сотрудничаем с проектом Secure Code Warrior, обучая других разработчиков создавать безопасные решения, и хотим поделиться примером.

Итак, к коду!


Здесь представлен небольшой фрагмент абстрактного кода на C++. Этот код был специально написан с целью демонстрации всевозможных проблем и уязвимостей, которые потенциально можно встретить на вполне реальных проектах. Как вы можете заметить, это код из Windows DLL (это важный момент). Предположим, что кто-то собирается использовать этот код в некоем (безопасном, разумеется) решении.

Приглядитесь к коду. Что, на ваш взгляд, в нём может пойти не так?

Код
class Finalizer
{
    struct Data
    {
        int i = 0;
        char* c = nullptr;
        
        union U
        {
            long double d;
            
            int i[sizeof(d) / sizeof(int)];
            
            char c [sizeof(i)];
        } u = {};
        
        time_t time;
    };
    
    struct DataNew;
    DataNew* data2 = nullptr;
    
    typedef DataNew* (*SpawnDataNewFunc)();
    SpawnDataNewFunc spawnDataNewFunc = nullptr;
    
    typedef Data* (*Func)();
    Func func = nullptr;
    
    Finalizer()
    {
        func = GetProcAddress(OTHER_LIB, "func")
        
        auto data = func();
        
        auto str = data->c;
        
        memset(str, 0, sizeof(str));
        
        data->u.d = 123456.789;
        
        const int i0 = data->u.i[sizeof(long double) - 1U];
        
        spawnDataNewFunc = GetProcAddress(OTHER_LIB, "SpawnDataNewFunc")
        data2 = spawnDataNewFunc();
    }
    
    ~Finalizer()
    {
        auto data = func();
        
        delete[] data2;
    }
};

Finalizer FINALIZER;

HMODULE OTHER_LIB;
std::vector<int>* INTEGERS;

DWORD WINAPI Init(LPVOID lpParam)
{
    OleInitialize(nullptr);
    
    ExitThread(0U);
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
    static std::vector<std::thread::id> THREADS;
    
    switch (fdwReason)
    {
        case DLL_PROCESS_ATTACH:
            CoInitializeEx(nullptr, COINIT_MULTITHREADED);
            
            srand(time(nullptr));
            
            OTHER_LIB = LoadLibrary("B.dll");
            
            if (OTHER_LIB = nullptr)
                return FALSE;
            
            CreateThread(nullptr, 0U, &Init, nullptr, 0U, nullptr);
        break;
        
        case DLL_PROCESS_DETACH:
            CoUninitialize();
            
            OleUninitialize();
            {
                free(INTEGERS);
                
                const BOOL result = FreeLibrary(OTHER_LIB);
                
                if (!result)
                    throw new std::runtime_error("Required module was not loaded");
                
                return result;
            }
        break;
        
        case DLL_THREAD_ATTACH:
            THREADS.push_back(std::this_thread::get_id());
        break;
        
        case DLL_THREAD_DETACH:
            THREADS.pop_back();
        break;
    }
    return TRUE;
}

__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()
{
    for (int i : integers)
        i *= c;
    
    INTEGERS = new std::vector<int>(integers);
}

int Random()
{
    return rand() + rand();
}

__declspec(dllexport) long long int __cdecl _GetInt(int a)
{
    return 100 / a <= 0 ? a : a + 1 + Random();
}


Возможно, вы сочли этот код простым, очевидным и достаточно безопасным? Или, может быть, вы нашли в нем некоторые проблемы? А может быть, даже дюжину или две?

Что ж, на самом деле в этом фрагменте более 43 потенциальных угроз различной степени значимости!



На что стоит всё же обратить внимание


1) sizeof(d) (где d — это long double) не обязательно кратен sizeof(int)

int i[sizeof(d) / sizeof(int)];

Такая ситуация не проверяется и не обрабатывается здесь. Например, размер long double может быть 10 байт на некоторых платформах (что неверно для компилятора MS VS, но верно для RAD Studio, в прошлом известного как C++ Builder).

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

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



Источник: Medium.com

Между прочим, в отличие от C++, в современном C каламбур типизации полностью допустим (вы же понимаете, что C++ и C – разные языки, и вы не должны ожидать, что будете знать C, если вы знаете C++, и наоборот, не так ли?)

Решение: используйте static_assert для контроля всех подобных предположений во время компиляции. Он предупредит вас, если вдруг что-то с размерами типов пойдет не так:

static_assert(0U == (sizeof(d) % sizeof(int)), “Houston, we have a problem”);

2) time_t — это макрос, в Visual Studio он может ссылаться на 32-битный (старый) или 64-битный (новый) целочисленный тип

time_t time;

Доступ к переменной этого типа из разных исполняемых модулей (например, исполняемого файла и библиотеки DLL, которую он загружает) может привести к чтению/записи за границами объекта, в случае если эти два двоичных модуля скомпилированы с разным физическим представлением этого типа. Что, в свою очередь, приведёт к повреждению памяти или считыванию мусора.



Решение: убедитесь, что для обмена данными между всеми модулями используются одинаковые типы строго определенного размера:

int64_t time;

3) B.dll (хэндл которой хранит переменная OTHER_LIB) еще не загружена в момент, когда мы обращаемся к указанной выше переменной, поэтому мы не сможем получить адреса функций данной библиотеки

4) проблема с порядком инициализации статических объектов (SIOF): (объект OTHER_LIB в коде используется раньше, чем он был инициализирован)

func = GetProcAddress(OTHER_LIB, "func");

FINALIZER — это статический объект, который создается перед вызовом функции DllMain. В его конструкторе мы пытаемся использовать библиотеку, которая еще не загружена. Проблема усугубляется тем, что статический объект OTHER_LIB, который используется статическим объектом FINALIZER, размещается в единице трансляции ниже по коду. Это означает, что инициализирован (обнулен) он также будет позже. Т. е. на момент, когда к нему будут обращаться, он будет содержать некоторый псевдослучайный мусор. WinAPI в целом должен нормально отреагировать на это, потому что с высокой степенью вероятности загруженного модуля с таким дескриптором просто не будет вовсе. И даже если произойдет совершенно невероятное совпадение и он всё таки будет — вряд ли в нём будет присутствовать функция по имени «Func».

Решение: общий совет — избегать использования глобальных объектов, особенно сложных, особенно если они зависят друг от друга, особенно в DLL. Однако, если они все же нужны вам по какой-либо причине, будьте предельно внимательны и осторожны с порядком их инициализации. Чтобы контролировать этот порядок, поместите все экземпляры (определения) глобальных объектов в одну единицу трансляции в нужном порядке, чтобы обеспечить их корректную инициализацию.

5) возвращенный ранее результат не проверяется перед использованием

auto data = func();

func — это указатель на функцию. И указывать он должен на функцию из B.dll. Однако, поскольку мы полностью провалили все действия на предыдущем шаге, это будет nullptr. Таким образом, пытаясь разыменовать его, вместо ожидаемого вызова функции мы получим ошибку нарушения прав доступа (access violation) или ошибку защиты памяти (general protection fault) или что-то в этом духе.

Решение: при работе с внешним кодом (в нашем случае с WinAPI) всегда проверяйте результат возврата вызываемых функций. Для надежных и отказоустойчивых систем это правило распространяется даже на функции, для которых существует строгий договор [о том, что и в каком случае они должны возвращать].

6) считывание/запись мусора при обмене данными между модулями, скомпилированными с разными alignment/padding настройками

auto str = data->c;

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



Решение: убедиться, что все совместно используемые структуры данных имеют строгое, явно определенное и очевидное физическое представление (используют типы с фиксированным размером, явно указанное выравнивание и т.д.) и/или двоичные модули, взаимодействующие между собой, были скомпилированы с одинаковыми глобальными настройками выравнивания/заполнения.


7) использование размера указателя на массив вместо размера самого массива

memset(str, 0, sizeof(str));

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

Решение:

  • никогда не путайте sizeof (<полный тип объекта>) и sizeof (<тип указателя на объект> );
  • не игнорируйте предупреждения компилятора;
  • вы также можете использовать немного шаблонной магии С++, комбинируя typeid, constexpr и static_assert, чтобы гарантировать правильность типов на этапе компиляции (здесь ещё могут быть полезны type traits, в частности, std::is_pointer).

8) неопределенное поведение при попытке читать иное поле объединения, нежели то, что ранее использовалось для установки значения

9) возможна попытка чтения за пределами допустимой области памяти, если размер long double различается между двоичными модулями

const int i0 = data->u.i[sizeof(long double) - 1U];

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

Решение: не обращаться к другому полю, кроме того, которое было установлено ранее, если вы не уверены, что ваш компилятор обрабатывает это правильно. Убедитесь, что размеры типов общих объектов одинаковы во всех взаимодействующих модулях.


10) даже если B.dll была правильно загружена и функция «func» правильно экспортирована и импортирована, B.dll все равно уже выгружена из памяти к данному моменту (т. к. ранее была вызвана системная функции FreeLibrary в секции DLL_PROCESS_DETACH функции обратного вызова DllMain)

auto data = func();

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

Решение: внедрить в приложение корректную процедуру финализации, гарантирующую, что все динамические библиотеки завершат свою работу/будут выгружены в правильном порядке. Избегайте использования статических объектов со сложной логикой в DLL. Избегайте выполнения каких-либо операций внутри библиотеки после вызова DllMain/DLL_PROCESS_DETACH (когда библиотека перейдёт к своему последнему этапу жизненного цикла — фазе разрушения своих статических объектов).

Необходимо понимать что из себя представляет жизненный цикл DLL:


А) Сторонний модуль вызывает LoadLibrary с целью загрузить библиотеку

  • происходит инициализация статических объектов библиотеки (этот этап должен содержать только очень простую логику, вызывается автоматически)
  • происходит вызов DllMain -> DLL_PROCESS_ATTACH (секция должна содержать только очень простую логику, вызывается автоматически)
  • теперь другие потоки приложения могут начать [параллельно] вызывать DllMain -> DLL_THREAD_ATTACH / DLL_THREAD_DETACH (вызывается автоматически, но см. далее примечания в пункте 30).
  • эти секции, возможно, могут содержать некоторую сложную логику (например, индивидуальную инициализацию генератора псевдослучайных чисел для каждого потока), но будьте аккуратны
  • происходит вызов экспортируемой разработчиком библиотеки функции инициализации библиотеки (содержит в себе всю сложную/тяжелую работу по инициализации, вызывается вручную тем, кто загружает библиотеку)
  • непосредственно работа приложения с библиотекой, для чего она (библиотека) и создавалась
  • происходит вызов экспортируемой разработчиком библиотеки функции ДЕинициализации библиотеки (содержит в себе всю сложную/тяжелую работу по ДЕинициализации, вызывается вручную тем, кто выгружает библиотеку)
  • После этой точки избегайте каких-либо действий в библиотеке: все ранее запущенные потоки библиотеки должны быть завершены, прежде чем произойдет возврат из этой функции

В) Другой модуль вызывает FreeLibrary

  • происходит вызов DllMain -> DLL_PROCESS_DETACH (секция должна содержать только очень простую логику, вызывается автоматически)
  • происходит уничтожение статических объектов библиотеки (должен содержать только очень простую логику, вызываемую автоматически)



11) удаление непрозрачного указателя (компилятор должен знать полный тип, чтобы вызвать деструктор, поэтому удаление объекта с помощью opaque pointer может привести к утечке памяти и другим проблемам)

12) если деструктор DataNew является виртуальным, даже при правильном экспорте и импорте класса и получении полной информации о нем, все равно вызов его деструктора на этом этапе является проблемой — это, вероятно, приведет к чисто виртуальному вызову функции (так как тип DataNew импортируется из уже выгруженного файла B.dll). Эта проблема возможна, даже если деструктор не является виртуальным.

13) если класс DataNew является абстрактным полиморфным типом, а его базовый класс имеет чистый виртуальный деструктор без тела, в любом случае произойдет чистый вызов виртуальной функции.

14) неопределенное поведение, если выделять память через new и удалять, используя delete[]

delete[] data2;

В целом, вы всегда должны быть осторожны при освобождении объектов, полученных от внешних модулей.

Также хорошей практикой является обнуление указателей на разрушенные объекты.

Решение:

  • при удалении объекта должен быть известен его полный тип
  • все деструкторы должны иметь тело
  • библиотека, из которой экспортируется код, не должна выгружаться слишком рано
  • всегда правильно использовать различные формы new и delete, не путать их
  • указатель на удаленный объект должен обнуляться.



Также обратите внимание на следующее:
— вызов оператора delete для указателя на void приведет к неопределенному поведению
чисто виртуальные функции не должны вызываться из конструктора
— вызов виртуальной функции в конструкторе не будет виртуальным
— старайтесь избегать ручного управления памятью — вместо этого используйте контейнеры, семантику перемещения и умные указатели

Смотрите также

15) ExitThread — предпочтительный метод выхода из потока в C. В C++ при вызове этой функции поток завершится до вызова деструкторов локальных объектов (и любой другой автоматической очистки), поэтому завершение потока в C++ следует выполнять просто путем возврата из функции потока

ExitThread(0U);

Решение: никогда не используйте вручную эту функцию в C++ коде.

16) в теле DllMain вызов любых стандартных функций, для которых требуются системные DLL, отличные от Kernel32.dll, может привести к различным трудно диагностируемым проблемам

CoInitializeEx(nullptr, COINIT_MULTITHREADED);

Решение в DllMain:

  • избегайте любой сложной (де)инициализации
  • избегайте вызова функций из других библиотек (или, по крайней мере, будьте предельно аккуратны с этим)



17) некорректная инициализация генератора псевдослучайных чисел в многопоточной среде

18) т. к. время, возвращаемое функцией time, имеет разрешение 1 сек., любой поток в программе, который вызывает эту функцию в течение этого периода времени, получит на выходе одинаковое значение. Использование этого числа для инициализации ГПСЧ может привести к возникновению коллизий (например, генерации одинаковых псевдослучайных имён для временных файлов, одинаковых номеров портов и т.д.). Одно из возможных решений — смешать (xor) полученный результат с каким-то псевдослучайным значением, таким как адрес любого стэка или объекта в куче, более точным временем и т.д.

srand(time(nullptr));

Решение: MS VS требует инициализации ГПСЧ для каждого потока. Кроме того, использование времени Unix в качестве инициализатора предоставляет недостаточно энтропии, предпочтительнее использовать более продвинутую генерацию инициализирующего значения.


19) может вызвать тупик или сбой (или создать циклы зависимости в порядке загрузки DLL)

OTHER_LIB = LoadLibrary("B.dll");

Решение: не используйте LoadLibrary в точке входа DllMain. Любая сложная (де)инициализация должна выполняться в определенных экспортируемых разработчиком DLL функциях, таких как, например, «Init» и «Deint». Библиотека предоставляет эти функции пользователю, а пользователь должен их корректно в нужный момент вызвать. Обе стороны должны строго соблюдать этот контракт.



20) опечатка (условие всегда ложно), неправильная логика программы и возможная утечка ресурсов (поскольку OTHER_LIB никогда не выгружается при успешной загрузке)

if (OTHER_LIB = nullptr)
    return FALSE;

Оператор присваивания путём копирования возвращает ссылку левого типа, т.е. if проверит значение OTHER_LIB (которое будет nullptr) и nullptr будет интерпретировано как false.

Решение: всегда используйте обратную форму, чтобы избежать таких опечаток:

if/while (<constant> == <variable/expression>)

21) рекомендуется использовать системную функцию _beginthread для создания нового потока в приложении (особенно если приложение было слинковано со статической версией библиотеки времени выполнения C) в противном случае могут возникнуть утечки памяти при вызове ExitThread, DisableThreadLibraryCalls

22) все внешние вызовы DllMain сериализуются, поэтому в теле этой функции не должны предприниматься попытки создавать потоки/процессы или осуществлять с ними какое-либо взаимодействие, иначе возможно возникновение взаимных блокировок

CreateThread(nullptr, 0U, &Init, nullptr, 0U, nullptr);

23) вызов функций COM во время завершения работы DLL может привести к некорректному доступу к памяти, поскольку соответствующий компонент уже может быть выгружен

CoUninitialize();

24) не существует способа контролировать порядок загрузки и выгрузки внутрипроцессных сервисов COM/OLE, поэтому не вызывайте OleInitialize или OleUninitialize из функции DllMain

OleUninitialize();


25) вызов free для блока памяти, выделенного с помощью new

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

free(INTEGERS);

Решение: убедитесь, что старый стиль C ручного выделения памяти не смешан с “новым” стилем, принятым в C++. Будьте предельно осторожны при управлении ресурсами в функции DllMain.

27) может привести к тому, что DLL будет использоваться даже после того, как система выполнила свой код завершения

const BOOL result = FreeLibrary(OTHER_LIB);

Решение: не вызывать FreeLibrary в точке входа DllMain.

28) произойдет сбой текущего (возможно, основного) потока

throw new std::runtime_error("Необходимый модуль не был загружен");

Решение: избегайте выбрасывания исключений в функции DllMain. Если DLL не может быть корректно загружена по какой-либо причине, функция должна просто вернуть FALSE. Выбрасывать исключения из секции DLL_PROCESS_DETACH также не следует.

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



Постарайтесь обмениваться между модулями только простыми типами данных (с фиксированным размером и четко определенным бинарным представлением).

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


29) можно выкинуть исключение (например, std::bad_alloc), которое здесь не перехватывается

THREADS.push_back(std::this_thread::get_id());

Поскольку секция DLL_THREAD_ATTACH вызывается из какого-то неизвестного внешнего кода, не особо рассчитывайте увидеть здесь корректное поведение.

Решение: приложите с помощью команды try/catch те инструкции, которые могут выбрасывать исключения, которые вероятнее всего не могут быть обработаны правильно (особенно если они выходят из библиотеки DLL).

Смотрите также

30) UB, если до загрузки этой DLL были представлены потоки

THREADS.pop_back();

Уже существующие на момент загрузки DLL потоки (включая тот, который непосредственно загружает DLL) не вызывают функцию точки входа загружаемой DLL (поэтому они не регистрируются в векторе THREADS во время события DLL_THREAD_ATTACH), в то время как они по-прежнему вызывают его с событием DLL_THREAD_DETACH по завершении.
Это означает, что количество обращений к секциям DLL_THREAD_ATTACH и DLL_THREAD_DETACH функции DllMain будет разным.

31) лучше использовать целочисленные типы фиксированного размера

32) передача сложного объекта между модулями может вызвать сбой, если они скомпилированы с разными настройками и флагами линковки и компиляции (разные версии библиотеки времени выполнения и т. д.)

33) доступ к объекту c по его виртуальному адресу (который является общим для модулей) может вызвать проблемы, если указатели по-разному обрабатываются в этих модулях (например, если модули связаны с различными параметрами LARGEADDRESSAWARE)

__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()



Вряд ли приведенный выше список является полным, поэтому вы наверняка сможете добавить что-нибудь важное в комментариях.

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



34) внутри функции может быть выброшено исключение:

INTEGERS = new std::vector<int>(integers);

при этом спецификация throw() этой функции пуста:

__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()

std::unexpected вызывается средой выполнения C++ при нарушении спецификации исключения: исключение выбрасывается из функции, спецификация исключения которой запрещает исключения этого типа.

Решение: используйте try / catch (особенно при выделении ресурсов, особенно в DLL) или nothrow форму оператора new. В любом случае, никогда не исходите из наивного предположения о том, что все попытки выделения разного рода ресурсов всегда будут заканчиваться успешно.





Проблема 1: формирование такого «более случайного» значения некорректно. Как утверждает центральная предельная теорема, сумма независимых случайных величин стремится к нормальному распределению, а не к равномерному (даже если сами исходные величины распределены равномерно).

Проблема 2: возможное переполнение целочисленного типа (что является неопределенным поведением для целочисленных типов со знаком)

return rand() + rand();

Работая с генераторами псевдослучайных чисел, шифрованием и тому подобными вещами, всегда остерегайтесь использования самодельных «решений». Если у вас нет специализированного образования и опыта работы с этими крайне специфическими областями, очень высоки шансы, что вы просто перехитрите сами себя и лишь усугубите ситуацию.

35) имя экспортируемой функции будет декорировано (изменено), чтобы предотвратить это использование extern «C»

36) имена, начинающиеся с '_', неявно запрещены для C++, так как этот стиль именования зарезервирован для STL

__declspec(dllexport) long long int __cdecl _GetInt(int a)

Несколько проблем (и их возможные решения):

37) rand не является потокобезопасным, вместо него нужно использовать rand_r/rand_s

38) rand устарел, лучше использовать современный
C++11 <random>

39) не факт, что функция rand была инициализирована конкретно для текущего потока (MS VS требует инициализации этой функции для каждого потока, где она будет вызываться)

40) существуют специальные генераторы псевдослучайных чисел, и в устойчивых ко взлому решениях лучше использовать именно их (подойдут переносимые решения вроде Libsodium/randombytes_buf, OpenSSL/RAND_bytes и т.д.)

41) потенциальное деление на ноль: может вызвать аварийное завершение текущего потока

42) в одном ряду использованы операторы с разным приоритетом, что вносит хаос в порядок вычисления – применяйте скобки и/или точки следования для задания очевидной последовательности вычисления

43) потенциальное переполнение целочисленного типа

return 100 / a <= 0 ? a : a + 1 + Random();






И это еще не всё!


Представьте, что у вас есть какой-то важный контент в памяти (например, пароль пользователя). Конечно же, вы не захотите хранить его в памяти дольше, чем это реально необходимо, увеличивая таким образом вероятность того, что кто-то может считать его оттуда.

Наивный подход для решения этой проблемы будет выглядеть примерно так:

bool login(char* const userNameBuf, const size_t userNameBufSize,
           char* const pwdBuf, const size_t pwdBufSize) throw()
{
    if (nullptr == userNameBuf || '\0' == *userNameBuf || nullptr == pwdBuf)
        return false;
    
    // Here some actual implementation, which does not checks params
    //  nor does it care of the 'userNameBuf' or 'pwdBuf' lifetime,
    //   while both of them obviously contains private information 
    const bool result = doLoginInternall(userNameBuf, pwdBuf);
    
    // We want to minimize the time this private information is stored within the memory
    memset(userNameBuf, 0, userNameBufSize);
    memset(pwdBuf, 0, pwdBufSize);
}

И это, конечно же, не будет работать так, как бы нам хотелось. Что же тогда делать? :(

Неправильное «решение» №1: если memset не работает, давайте сделаем это вручную!

void clearMemory(char* const memBuf, const size_t memBufSize) throw()
{
    if (!memBuf || memBufSize < 1U)
        return;
    
    for (size_t idx = 0U; idx < memBufSize; ++idx)
        memBuf[idx] = '\0';
}

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


Неправильное «решение» #2: попытаться «улучшить» предыдущее «решение», поигравшись с ключевым словом volatile

void clearMemory(volatile char* const volatile memBuf, const volatile size_t memBufSize) throw()
{
    if (!memBuf || memBufSize < 1U)
        return;
    
    for (volatile size_t idx = 0U; idx < memBufSize; ++idx)
        memBuf[idx] = '\0';
    
    *(volatile char*)memBuf = *(volatile char*)memBuf;
    // There is also possibility for someone to remove this "useless" code in the future
}

Будет ли это работать? Возможно. Например, такой подход используется в RtlSecureZeroMemory (в чём вы можете убедиться самостоятельно, посмотрев фактическую реализацию этой функции в исходниках Windows SDK).

Однако, такой прием будет ожидаемо работать не со всеми компиляторами.

Смотрите также

Неправильное «решение» #3: использовать неподходящую функцию API ОС (например, RtlZeroMemory) или STL (например, std::fill, std::for_each)

RtlZeroMemory(memBuf, memBufSize);

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

И как же всё-таки правильно??


  • использовать корректную функцию API ОС, например, RtlSecureZeroMemory для Windows
  • использовать функцию C11 memset_s:

Кроме того, мы можем помешать компилятору оптимизировать код путем вывода (в файл, консоль или другой поток) значения переменной, но этот способ, очевидно, не очень полезен.

Смотрите также

Подводя итоги


Это, конечно, не полный список всех возможных проблем, нюансов и тонкостей с которыми вы можете столкнуться при написании приложений на C/C++.

Есть также и такие замечательные вещи, как:


И многое другое.



Есть что добавить? Поделитесь своими интересным опытом в комментариях!

Теги:C++безопасностьуязвимостиsecurityкибербезопасность
Хабы: Блог компании SimbirSoft Информационная безопасность Программирование C++ ООП
+4
6k 67
Комментарии 18
Похожие публикации
Лучшие публикации за сутки