Все чаще отдел информационной безопасности 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]

Механизм атаки:

  1. Переполнение буфера: злоумышленник вводит строку длиной более 64 байт, например, 80 символов.

  2. Перезапись адреса возврата. В стеке за буфером находятся:

    - Сохраненный указатель кадра (4 байта).

    - Адрес возврата (4 байта) — адрес в памяти, куда процессор должен вернуться после завершения выполнения функции или подпрограммы.

    При переполнении эти значения перезаписываются.​

  3. Контроль выполнения: если злоумышленник включит в строку машинный код (например, 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

Заключение

Мы разобрали пять ярких уязвимостей кода С++, которые могут осложнить работу программисту. Рассмотрели варианты того, как такие ошибки избежать; я поделилась тем, как можно ловить эксплойты с помощью опций разных компиляторов.

Если кому-то этот материал окажется полезен, буду рада.

Устойчивого кода и надежных бэкапов!

Комментарии (9)


  1. Melpomenna
    12.12.2025 10:20

    Спасибо за статью!
    Для MSVC Address Sanitazer тоже есть - /fsanitize=address , в целом даже работающий, TSun так и не добавлен...

    Для отслеживания переполнений для gcc (для clang вроде тоже) так же есть макрос FORTIFY_SOURCE который можно указать через -DFORTIFY_SOURCE=уровень проверки

    Различные другие тулзы так же вполне спасают: clang-tidy, cppcheck (даже внедрять вполне легко для sln, cmake)

    Если говорить про опции их достаточно много как для msvc, так и для gcc, clang и других, стоит просто немного почитать, очень даже интересно и исчерпывающе