Назначение проекта
Проект Trusted-CPP демонстрирует реализацию концепции гарантий безопасной разработки программного обеспечения на C++ на уровне синтаксиса языка с сохранением обратной совместимости с уже существующим исходным кодом.
Это позволяет изменить подход к безопасности программного обеспечения с точечного поиска и исправления отдельных ошибок и уязвимостей на гарантию их отсутствия в исходном коде программы. Другими словами, если код скомпилировался корректно, значит, в нём отсутствуют определённые классы ошибок, а следовательно - и связанные с ними уязвимости.
Проект Trusted-CPP состоит из двух компонентов. Первый компонент - это заголовочный файл «trusted-cpp.h». Он содержит шаблонные программные классы и основные настройки, необходимые для анализатора безопасного кода. Второй компонент - это статический анализатор кода C++ в виде плагина для компилятора clang.
Причём исходный код можно компилировать и любым другим компилятором и без использования специального плагина, так как он нужен только для статического анализа, но никак не модифицирует генерируемый компилятором исполняемый файл.
Детали реализации Trusted-CPP:
Простой пример для начала работы
Загрузите заголовочный файл, плагин компилятора и вспомогательный скрипт запуска, который упрощает использование плагина.
Вспомогательный скрипт trusted-cpp.sh автоматически добавляет аргументы clang для загрузки плагина и передачи ему аргументов командной строки, чтобы каждый раз не писать что-то вроде этого:
$ clang++ -Xclang -load -Xclang ./trusted-cpp_clang.so -Xclang -add-plugin -Xclang trust -Xclang -plugin-arg-trust -Xclang verbose example.cpp
заменить на простой вызов trusted-cpp.sh -trust verbose example.cpp
При компиляции файла invalidate.cpp, содержащего следующий код, не выявится никаких ошибок.
std::vector vect(100000, 0);
auto x = vect.begin();
auto &y = vect[0];
vect = {};
std::sort(x, vect.end()); // Error
y += 1; // Error
Тогда как с помощью плагина будут выведены следующие сообщения с предупреждениями и ошибками об инвалидации ссылочных переменных:
$ ./trusted-cpp.sh -std=c++20 -fsyntax-only invalidate.cpp
../invalidate.cpp:26:5: warning: using main variable 'vect'
26 | vect = {};
| ^
../invalidate.cpp:31:15: error: Using the dependent variable 'x' after changing the main variable 'vect'!
31 | std::sort(x, vect.end()); // Error
| ^
../invalidate.cpp:36:5: error: Using the dependent variable 'y' after changing the main variable 'vect'!
36 | y += 1; // Error
| ^
3 warnings and 2 errors generated.
Описание концепции безопасной разработки на C++
В основе проекта лежит концепция безопасной работы с памятью из языка NewLang (trust-lang), которая была портирована на C++ в виде отдельной библиотеки memsafe и впоследствии расширена, в том числе за счёт реализации части требований STEELMAN, адаптированных для C++.
Под термином «безопасная разработка на C++» имеется в виду:
- безопасные вычисления и преобразования типов
- безопасное управление памятью и ресурсами
- безопасное многопоточное программирование
А так как любое решение для безопасного C++ должно быть экономически выгодным, это означает, что оно обязано иметь обратную совместимость с уже существующим C++ кодом, выявлять ошибки на этапе компиляции программы (т.е. максимально близко к этапу написания кода) и использовать автоматический контроль на уровне исходников, по аналогии со встроенными непосредственно в язык средствами безопасной разработки.
Другими словами, если исходный код C++ скомпилировался корректно, значит, в нём отсутствуют ошибки, а следовательно - и уязвимости из-за:
- некорректных вычислений или приведения (конвертирования) типов данных
- переполнения буфера или ошибок управления ресурсами, включая циклические ссылки
- отсутствуют ошибки многопоточного доступа, приводящие к «состоянию гонки»
Гарантии безопасной разработки программного обеспечения на уровне синтаксиса языка реализуются компилятором C++ за счёт автоматической проверки исходного текста программы и наложения ограничений на использование отдельных фрагментов кода, которые синтаксически корректны, но могут привести к ошибкам или уязвимостям.
Непосредственный анализ исходного кода Trusted-CPP выполняется плагином компилятора, но сам исходный код остаётся обычной программой на C++, и его можно компилировать любым компилятором без использования плагина, а также применять линтеры и дополнительные статические анализаторы.
Анализ исходного кода в Trusted-CPP основан на маркировке элементов с помощью пользовательских C++ атрибутов, которые появились в C++11. Это очень похоже на предложения в профилях безопасности p3038 от Bjarne Stroustrup и P3081 от Herb Sutter, но не требует разработки нового стандарта C++.
В настоящий момент проверка синтаксических правил при подключении плагина активируется автоматически во время компиляции за счёт использования встроенной функции __has_attribute (trust). Если плагин во время компиляции отсутствует, то использование пользовательских атрибутов отключается с помощью макросов препроцессора, чтобы подавить предупреждения вида warning: unknown attribute 'trust' ignored [-Wunknown-attributes].
Принципиальной особенностью Trusted-CPP является возможность маркировки различных элементов с помощью пользовательских C++ атрибутов не только во время определения класса или функции, но и в произвольном месте исходного текста программы (или даже во внешнем конфигурационном файле). Эта особенность позволяет маркировать важные классы, функции или их аргументы уже после их определения и без необходимости изменять ранее написанный код.
Номинальная (именная) типизация typedef
В C/C++ объявление typedef создаёт псевдоним для существующего типа, но при приведении типов тип и его псевдоним не различаются. Добавление номинальной типизации для typedef позволяет предотвратить случайную эквивалентность типов по сравнению со структурной типизацией и означает, что две переменные являются типосовместимыми тогда и только тогда, когда их объявления содержат имена одного и того же типа.
Пример кода с использованием номинальной (именной) типизации typedef
Безопасная работа с памятью
Безопасная работа с памятью полностью совместима с C++ кодом и STL шаблонами и реализована за счёт использования следующих видов адресации (адресных переменных):
-
Сырой (Raw) адрес для прямой адресации данных, который необходим для функционирования итераторов и явной или неявной адресной арифметики. Для сырых адресов работает режим автоматической инвалидации, который предупреждает об изменении адреса в случае модификации состояния объекта, из которого он был получен. Адреса данного типа можно использовать только в локальных (автоматических) переменных с ограниченным временем жизни до конца текущей области видимости. Сырые адреса нельзя сохранять в полях объектов и следует с осторожностью передавать в качестве аргументов функций.
-
Сильная ссылка (наследник от
std::shared_ptr) - в переменной находится только сильный (владеющий) указатель на данные. Копия сильной ссылки создаёт копию указателя и увеличивает счётчик владений. Циклические и перекрёстные сильные ссылки запрещены на уровне типов данных (определений классов), что контролируется во время компиляции программы. -
Слабая ссылка (наследник от
std::weak_ptr) - в переменной находится только слабый (не владеющий) указатель на данные, и создание копии слабой ссылки не увеличивает счётчик владений. -
Locker - временный объект для доступа к данным у обоих видов умных ссылок. Он содержит разыменованный сырой адрес, и с его помощью реализуется механизм RAII для ограничения временем владения захваченным значением только текущей областью видимости, что гарантирует автоматическое освобождение памяти после его удаления. Объекты данного типа можно использовать только в локальных переменных с ограниченным временем жизни до конца текущей области видимости.
Основное отличие сильных и слабых ссылок от соответствующих стандартных шаблонов заключается в способе обращения к объекту при разыменовании ссылки: это выполняется с помощью создания временной переменной, и уже через неё получается непосредственный доступ к самим данным (объекту). Пример кода
Все остальные переменные по значению (variable by value) хранят данные непосредственно в самой переменной. Сздать ссылку на переменную по значению нельзя, и для этих целей нужно использовать умный указатель (ссылочную переменную).
Контроль многопоточного доступа к данным
Безопасное многопоточное программирование - это автоматическое устранение проблем, приводящих к «состоянию гонки данных».
А чтобы минимизировать логические ошибки при захвате объекта синхронизации (если это требуется у переменных с контролем многопоточного доступа), попытка захвата доступа и разыменования ссылки выполняются как одна операция.
Атоматическая переменная доступа к данным является не только временным владельцем сильной ссылки, но и выполняет функции владения объектом межпотоковой синхронизации в стиле std::lock_guard, время жизни которого ограничено текущей областью видимости и управляется компилятором автоматически.
Открытые и закрытые области видимости переменных
Реализация безопасного многопоточного программирования основана на требованиях STEELMAN на основе открытых и закрытых областей видимости для внешних переменных. Фактически это реализация пункта 5G из требований STEELMAN, только доработанная под C++ и ООП.
В открытых областях нет ограничений на использование внешних переменных, тогда как в закрытых областях видимости нелокальные переменные должны быть явно импортированы (перечислены). Область видимости и список импортированных внешних переменных для вложенных областей наследуются от областей верхнего уровня до явного переопределения.
Закрытые области видимости - это по своей сути инверсия ООП (ООП наоборот), когда скрываются не внутренние данные объекта от внешнего окружения, а наоборот: из текущей области видимости ограничивается доступ к переменным из внешнего окружения, доступ к которым возможен только после их явного перечисления в исходном коде программы. (На этой же основе реализованы и чистые функции, когда внешнее окружение становится недоступно из тела функции).
Создание закрытой области видимости или её переопределение происходит с помощью макроса TRUST_USING_EXTERNAL(""), который применяется к определению функции, класса, метода класса или отдельного выражения. Пример использования закрытых областей видимости можно посмотреть в примерах ниже.
Для целей безопасного многопоточного доступа в C++ реализуется следующая схема:
- Межпотоковая безопасность строится на использовании двух атрибутов (условно) THREAD и THREADSAFE.
- Атрибут THREAD применяется (маркирует) функции, которые выполняются в отдельных потоках.
- Атрибут THREADSAFE применяется к переменным, которые обеспечивают синхронизацию доступа при многопоточном программировании, т.е. переменные с THREADSAFE обязаны реализовывать синхронизацию доступа.
- Атрибутами THREAD и THREADSAFE маркируются аргументы у всех функций, которые создают потоки выполнения, т.е. передаваемый в функцию аргумент должен быть промаркирован соответствующим атрибутом.
Маркировка атрибутами происходит однократно и наследуется для производных классов, а дальше компилятор сам автоматически следит за их корректным использованием, т.е. чтобы при создании потока выполнения его тело было отмечено атрибутом THREAD, а передаваемые внутрь потока аргументы были THREADSAFE.
Кроме этого, функция потока становится закрытой для обращения к внешним переменным, а анализатор (компилятор с помощью плагина) будет автоматически проверять, чтобы из функции с атрибутом THREAD любые импортируемые внешние переменные обязательно имели атрибут THREADSAFE.
Пример контроля многопоточного доступа к данным приведён ниже
Дополнительные возможности
Ручной и автоматический контроль стека от переполнения
Ручной и автоматический контроль стека от переполнения - это единственная функциональность, которая вносит изменения в генерируемый код при компиляции программы, поэтому данная часть была выделена в отдельный проект stack-check, который можно использовать как совместно с Trusted-CPP, так и без него.
Формальный анализ доказательства корректности программы *
Фактически, это реализация статической проверки динамических выражений AoRTE (“Absence of Run-Time Errors”), которая не даёт ложных срабатываний, хотя возможны ложные отрицательные результаты. То есть, если ошибки при компиляции отсутствуют, то можно быть уверенными, что проблем в коде нет, тогда как указание на возможную ошибку не всегда соответствует действительности и инструмент может ошибаться.
Формальный анализ не пытается доказать правильность программы в целом. Он используется только для доказательства определяемых пользователем утверждений в разных частях программы и вызовах функций. Причём доказательство правильности выполняется только в той мере, в какой это определено пользователем, а сами утверждения корректно и в полной мере описывают и ограничивают реализацию программы.
Формальный анализ доказательства корректности программы реализуется по принципу gnatprove для языка Ada и использует три макроса для определения предусловий, постусловий и утверждений: TRUST_ASSERT_PRED(), TRUST_ASSERT_POST() и TRUST_ASSERT() соответственно. Это среднее между assert и static_assert, которое выполняется во время компиляции программы, но в выражении могут использоваться неконстантные значения (неконстантные выражения должны быть вычислимы на уровне типов данных или описаны в пред- и постусловиях).
*) - Данная функциональность запланирована для реализации, но пока находится на паузе до завершения основной части проекта
Примеры кода
Пример использования номинальной (именной) типизации typedef
Создание типа данных с номинальной типизацией выполняется с помощью атрибута, который раскрывается при использовании макроса TRUST_NOMINAL или перечисляется в макросе TRUST_NOMINAL_TYPES(…).
typedef int IntType;
int int_value = 0;
IntType IntType_value = 0;
int int_value_cast = IntType_value; // OK
IntType IntType_cast = int_value; // OK
TRUST_NOMINAL typedef int IntSubType; // Номинальная типизация во время определения типа
IntSubType IntSubType_value = 0; // OK
int int_value_cast2 = IntSubType_value; // OK
IntSubType IntSubType_cast = int_value; // ERROR
IntType IntType_cast2 = IntSubType_value; // ERROR
TRUST_NOMINAL_TYPES("IntType"); // Номинальная типизация для существующего типа
IntType IntType_value2 = 0;
IntType IntType_cast3 = int_value; // ERROR
IntType IntType_cast4 = IntSubType_value; // ERROR
Пример использования закрытых областей видимости
Для указания импортируемых переменных можно использовать маску для имени переменной или области имён
TRUST_USING_EXTERNAL("*")- Разрешить доступ к любым переменным - поведение по умолчаниюTRUST_USING_EXTERNAL("")- Запретить доступ ко всем внешним переменнымTRUST_USING_EXTERNAL("ns::*")- Разрешить доступ к любым переменным из области имён ns::
int global = 0;
int func_default() {
// Открытые области видимости по умолчанию
return global;
}
TRUST_USING_EXTERNAL("") // Запретить доступ ко всем внешним переменным
int func_closed() {
// Фактически, это чистая функция без побочных эффектов
return global; // ERROR
}
TRUST_USING_EXTERNAL("global") // Разрешён доступ только к переменной global
int func_using_external() {
return global; // OK
}
Применение безопасного многопоточного программирования
Однократная маркировка функций и классов, создающих отдельные потоки выполнения.
// Установить атрибут 'thread' для первых аргументов конструктора для классов std::thread и std::jthread
TRUST_SET_ATTR_ARGS(thread, std::thread, 1);
TRUST_SET_ATTR_ARGS(thread, std::jthread, 1);
// Установить атрибут 'threadsafe' для всех аргументов конструкторов классов std::thread и std::jthread
TRUST_SET_ATTR_ARGS(threadsafe, std::thread::thread, 0);
TRUST_SET_ATTR_ARGS(threadsafe, std::jthread::jthread, 0);
// Отметить у функции pthread_create атрибутами
// 'thread' третий аргумент и 'threadsafe' четвёртый
TRUST_SET_ATTR_ARGS(thread, pthread_create, 3);
TRUST_SET_ATTR_ARGS(threadsafe, pthread_create, 4);
// Установить атрибут 'threadsafe' для потокобезопасных шаблонов
TRUST_SET_ATTR(threadsafe, std::atomic);
TRUST_SET_ATTR(threadsafe, trust::SyncTimedShared);
Пример кода с контролем функции потока от «состояния гонки»
uint64_t notrust_count = 0; // Без установки атрибута THREADSAFE
void *thread_notrust(void *arg) { // Поток без атрибута THREAD
++notrust_count; // Гонка
return nullptr;
}
std::atomic<uint64_t> trust_count = 0; // Автоматическая маркировка THREADSAFE для std::atomic
TRUST_THREAD void *thread_trust(void *arg) { // Функция потока (маркировка атрибутом THREAD)
trust_count++;
notrust_count++; // ERROR: Expected attribute 'threadsafe' for 'notrust_count'
return nullptr;
}
Пример кода с созданием потоков с контролем потенциальных ошибок «состояния гонки данных»
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_t tid;
pthread_create(&tid, &attr, thread_notrust, nullptr); // ERROR
// error: Expected attribute: 'thread' for 3 argument
// pthread_create(&tid, &attr, thread_notrust, nullptr);
// ^
pthread_create(&tid, &attr, thread_trust, nullptr); // OK
{
std::thread t_lambda([&]() {
for (auto i = 0; i < 1'000'0000; ++i)
++notrust_count; // ERROR
// error: Expected attribute 'threadsafe' for 'notrust_count'
// ++notrust_count;
// ^
});
std::thread t_notrust(thread_notrust, nullptr); // ERROR
// error: Expected attribute: 'thread' for 1 argument
// std::thread t_notrust(thread_notrust, nullptr);
// ^
std::thread t_trust(thread_trust, nullptr);
t_lambda.join();
t_notrust.join();
t_trust.join();
}
Пример разыменования ссылки и захвата блокировки доступа для умных указателей
trust::Shared<int, trust::SyncTimedMutex> var_sync(1); // наследник от std::shared_ptr
trust::Weak< trust::Shared<int, trust::SyncTimedMutex> > var_weak = var_sync.weak(); // наследник от std::weak_ptr
TRUST_THREAD void func_thread(){
try{
// Нельзя захватить в статическую переменную
// static auto static_fail1(var_sync.lock());
auto sync = var_sync.lock(); // или *var_sync
auto weak = var_weak.lock(); // или *var_weak
*sync += *weak;
} catch(...){
// Обработка ошибки блокировки доступа или разыменования ссылки
}
}
Выводы и итоги
Этот документ представляет собой простое описание проекта с примерами решения очевидных и понятных задач безопасного программирования на C++.
Текущее состояние проекта - пока не для продакшена. Скорее всего, в нём есть недочёты и упущения, так как существует множество других интересных и сложных ситуаций, не рассмотренных здесь, но мы будем рады принять ваши предложения по улучшению проекта, если вы захотите что-либо добавить или улучшить.