
Все чаще отдел информационной безопасности Bercut при сканировании сторонних или наших библиотек, как минимум, не рекомендует их к использованию внутри компании. А то и вовсе запрещает. На первый взгляд код может выглядеть безопасным, однако мне захотелось разобраться, какие именно ошибки программирования способны снизить защищенность системы. Почему сканеры безопасности придираются, и насколько серьезны указанные ими проблемы?
В статье разобрала популярные ошибки программистов, которые злоумышленник может использовать для взлома системы. Вот что получилось.
Строковые функции C
Какими бы скучным не казались ошибки работы со строками, они лидируют в списке эксплойтов программного обеспечения. Обойти их стороной было бы преступлением.
Правило: не использовать strcpy/strcat/sprintf/gets и прочие небезопасные С‑функции, предпочтение отдавать std::string/std::vector и snprintf со строгими пределами.
// Bad style char buf[64]; sprintf(buf, "%s", user_input); // риск переполнения и format-string
Как это влияет: при наличии возможности записывать данные за пределами буфера можно добиться исполнения произвольного программного кода или отказа в обслуживании.
// Good style char buf[64]; snprintf(buf, sizeof(buf), "%s", user_input); // фиксированный формат + лимит // Идеально- использовать стандартную библиотеку std::string std::string s = user_input;
Пример атаки:
#include <stdio.h> #include <string.h> void vulnerable(char *input) { char buffer[64]; strcpy(buffer, input); // плохо printf("Вы ввели: %s\n", buffer); } int main() { char input[128]; printf("Введите строку: "); gets(input); // плохо vulnerable(input); return 0; } Ввод злоумышленника [64 байта мусора][4 байта — указатель кадра][адрес shellcode]
Механизм атаки:
Переполнение буфера: злоумышленник вводит строку длиной более 64 байт, например, 80 символов.
Перезапись адреса возврата. В стеке за буфером находятся:
- Сохраненный указатель кадра (4 байта).
- Адрес возврата (4 байта) — адрес в памяти, куда процессор должен вернуться после завершения выполнения функции или подпрограммы.
При переполнении эти значения перезаписываются.Контроль выполнения: если злоумышленник включит в строку машинный код (например, shellcode) и укажет адрес возврата на этот код, процессор начнет его выполнять.
Если shellcode расположен в начале строки, адрес возврата устанавливается на
buffer, и после завершения vulnerable управление передается на вредоносный код.
Форматные строки
Правило: не подставлять пользовательский ввод в сам формат. Использовать строку формата как константу, а пользовательский ввод — как аргумент функции.
Иначе злоумышленник может ввести специальные символы (например, %x, %n, %s), которые заставят функцию форматирования читать или записывать данные из памяти, что может привести к следующим последствиям:
Чтение произвольных областей памяти (утечки конфиденциальных данных: пароли, ключи, приватная информация).
Запись произвольных данных в память (повреждение переменных, изменение логики работы программы).
Выполнение произвольного кода (RCE — удаленное исполнение кода), что позволяет полностью контролировать систему.
Выход приложения из строя (крах, падение процесса).
//Bad style printf(user_input); // если внутри есть %n и др. — уязвимость
//Good style printf("%s", user_input);
Пример атак:
//Ввод злоумышленника "%x %x %x %x %x"
Программа выведет содержимое стека, что приведет к утечке информации.
//Ввод злоумышленника "XYZW%n"
%n запишет число выведенных символов (4, «XYZW») в адрес, который находится в стеке (потенциально перезапишет переменную или указатель), что приведет к падению или неопределенному поведению программы.
Уязвимость rand()
Если токен или идентификатор сессии генерируется с помощью rand() или другого нестойкого генератора случайных чисел, злоумышленник может предсказать или перебрать возможные значения токена и получить несанкционированный доступ к сессии.
// Bad style std::string token = std::to_string(rand());
Пример правильной генерации криптографически стойкой случайной последовательности на основе Crypto Library:
//Good style #include <crypto/osrng.h> void generateRandomByte() { CryptoPP::AutoSeededRandomPool prng; // Буфер для случайной последовательности длиной 16 байт const size_t bufferSize = 16; byte buffer[bufferSize]; // Генерация случайных байт prng.GenerateBlock(buffer, bufferSize); }
Пример атаки
Злоумышленник последовательно пробует возможные значения токенов, пока не найдет подходящий. Если токены легко предсказать (например, если они основаны на времени или инкрементных номерах, как у rand()), атакующему понадобится немного попыток, чтобы подобрать действующий токен и «войти» с правами другого пользователя. Если токен — это просто rand(), то злоумышленник знает диапазон возможных значений, а если источник случайности известен (например, время запуска сервера), то подобрать токен становится еще проще.
Проверка своих систем
Вы можете протестировать возможность проникновения с помощью brute force. Сам процесс часто называется пентестом или пентестингом. Несколько ресурсов, которыми вы можете воспользоваться для пентеста своего приложения или системы:
Use-After-Free (UAF) — использование указателя памяти после ее освобождения
Использование указателя после освобождения памяти (UAF) составляет около 48% серьезных уязвимостей в C++, если судить по данным анализа службы инфобезопасности Google Chrome.
Уязвимость дает злоумышленнику контроль над выполнением программы, особенно при использовании виртуальных таблиц.
В качестве правильной, стойкой к уязвимостям, альтернативы рекомендуется применять стандартные умные указатели, такие как unique_ptr, shared_ptr, либо свои аналоги, которые управляют памятью в конструкторах/деструкторах.
Пример плохого кода: после освобождения памяти технически можно использовать указатель на освобожденный объект и получить краш с большой вероятностью.
//Bad style #include <iostream> #include <vector> int main() { std::vector<int>* vec = new std::vector<int>(); vec->push_back(42); delete vec; // Память освобождена, указатель на vec стал испорченным std::cout << vec->at(0) << std::endl; // использование после освобождения вызовет неопределенное поведение и чаще всего краш программы либо выполнение произвольного кода return 0; }
Если же использовать умные указатели, то задумываться об освобождении выделенной памяти требуется минимально. Вероятность использовать невалидный указатель и получить краш сводится к минимуму.
//Good style int main() { std::unique_ptr vec = std::make_unique<std::vector<int>>(); // Используем стандартный умный указатель, который освободит данные в своем деструкторе по выходу из области видимости vec->push_back(42); // delete не требуется — освобождение происходит автоматически std::cout << vec->at(0) << std::endl; // Безопасное использование return 0; }
Пример атаки

В строке 12 утечка происходит по адресу кучи — области памяти, предназначенной для динамического выделения и освобождения памяти во время выполнения программы.
Утекший адрес кучи поможет злоумышленнику легко вычислить размещенный адрес сегмента кучи.
#include <stdio.h> #include <string.h> #include <unistd.h> int main(int argc, char **argv) { char* name = malloc(12); char* details = malloc(12); strncpy(name, argv[1], 12-1); free(details); free(name); printf("Welcome %s\n",name); fflush(stdout); }
Неопределенное поведение
C/C++ допускает множество форм неопределенного поведения (UB, undefined behavior).
UB — это и переполнение знаковых целых чисел, и разыменование нулевых указателей, и доступ за границы массива, и использование неинициализированных переменных. Решение всегда разное, главное подходить к коду бережно.
Например, использовать size_t для размеров и индексов, инициализировать данные, использовать безопасные stl контейнеры и санитайзеры.
Пример 1:
//Модификация переменной в одном выражении int i = 0; i = ++i + 1; // UB: i изменяется более одного раза
Это выражение вызывает UB, так как i модифицируется (++i) и используется в присвоении без точки следования между действиями. Компилятор может оптимизировать такой код неожиданным образом, например, игнорируя часть операций.
Пример 2:
//Разыменование нулевого указателя int* ptr = nullptr; int val = *ptr; // UB: доступ к памяти по нул��вому указателю
Это UB, даже если значение не используется. Компилятор может удалить весь блок кода, содержащий такое выражение, так как оно не определено стандартом.
Пример 3:
//Переполнение знакового целого int max = INT_MAX; int overflow = max + 1; // UB: переполнение signed int
Переполнение int — это UB, в отличие от беззнаковых типов, где оно определено как циклическое.
Пример DoS-атаки (Denial of Service)
Суть плохого кода в примере ниже в том, что сервер не закрывает сокет после вычитывания данных и не освобождает ресурсы (дескриптор, память):
//Bad style #include <iostream> #include <cstring> #include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> int main() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); sockaddr_in serv_addr{}; serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = INADDR_ANY; serv_addr.sin_port = htons(12345); bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)); listen(sockfd, 5); while (true) { int newsockfd = accept(sockfd, nullptr, nullptr); if (newsockfd < 0) continue; char buffer[1024]; // Уязвимость: сервер читает, но не закрывает соединение и не обрабатывает данные read(newsockfd, buffer, 1024); // Сервер "зависает" на этом соединении, не освобождая ресурсы } close(sockfd); return 0; }
Как можно использовать такой эксплойт в коде? Злоумышленник пишет простой код клиентского приложения, который так же будет открывать множество TCP-соединений и не закрывать их. Результат — исчерпание ресурсов сервера и отказ в обслуживании.
//Атака #include <iostream> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <cstring> int main() { sockaddr_in serv_addr{}; serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(12345); inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr); for (int i = 0; i < 10000; ++i) { int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) continue; if (connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)) == 0) { send(sockfd, "Hello", 5, 0); // Отправляем данные // Не закрываем сокет — сервер держит соединение открытым } // Не закрываем и не освобождаем socket, вызывая исчерпание ресурсов сервера } return 0; }
Чтобы закрыть уязвимость для злоумышленника достаточно просто закрыть close(newsockfd); сокет после прочтения данных.
А как можно обнаружить эксплойты?

Знаю, как исправить плохой код. Но как его искать?
Отслеживать уязвимости кода — важная задача. А осуществлять ее можно даже с помощью несложных опций компиляторов.
Такую подборку я присмотрела для своих проектов:
GCC
-fsanitize=undefined (UBSan)
Обнаруживает неопределённое поведение, включая:
Переполнение знаковых целых (
signed int overflow)Деление на ноль
Разыменование
nullptr
-fsanitize=address (ASan)
Выявляет ошибки работы с памятью:
Переполнение буфера в стеке и куче
Использование памяти после освобождения (UAF)
Утечки памяти (при использовании
-fsanitize=leak)
-fsanitize=memory (MSan)
Обнаруживает использование неинициализированной памяти, которое может привести к утечкам или непредсказуемому поведению.
-Wall -Wextra -Wpedantic
Включает широкий спектр предупреждений, включая:
Wuninitialized— использование неинициализированных переменныхWsign-compare— сравнение знаковых и беззнаковых типовWshadow— скрытие переменных в блоках
MSVC
/GS
Включает защиту от переполнения стека, добавляя контрольные значения в локальные переменные.
/sdl (Security Development Lifecycle)
Автоматически включает:
/GSПроверки на переполнение
Инициализацию переменных
Удаление потенциально опасных функций, таких как
gets
Заключение
Мы разобрали пять ярких уязвимостей кода С++, которые могут осложнить работу программисту. Рассмотрели варианты того, как такие ошибки избежать; я поделилась тем, как можно ловить эксплойты с помощью опций разных компиляторов.
Если кому-то этот материал окажется полезен, буду рада.
Устойчивого кода и надежных бэкапов!
