
Все чаще отдел информационной безопасности 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
Заключение
Мы разобрали пять ярких уязвимостей кода С++, которые могут осложнить работу программисту. Рассмотрели варианты того, как такие ошибки избежать; я поделилась тем, как можно ловить эксплойты с помощью опций разных компиляторов.
Если кому-то этот материал окажется полезен, буду рада.
Устойчивого кода и надежных бэкапов!
Melpomenna
Спасибо за статью!
Для MSVC Address Sanitazer тоже есть -
/fsanitize=address, в целом даже работающий, TSun так и не добавлен...Для отслеживания переполнений для gcc (для clang вроде тоже) так же есть макрос FORTIFY_SOURCE который можно указать через -DFORTIFY_SOURCE=уровень проверки
Различные другие тулзы так же вполне спасают: clang-tidy, cppcheck (даже внедрять вполне легко для sln, cmake)
Если говорить про опции их достаточно много как для msvc, так и для gcc, clang и других, стоит просто немного почитать, очень даже интересно и исчерпывающе