Pull to refresh
173.5
МойОфис
Платформа для работы с документами и коммуникаций

4 типовые ловушки в работе со строками в С и С++ и как их избежать

Reading time8 min
Views4.6K

Некорректное использование строк может стать настоящей ахиллесовой пятой защиты программы. Поэтому так важно применять актуальные подходы к их обработке. Под катом разберём несколько паттернов ошибок при работе со строками, поговорим о знаменитой уязвимости Heartbleed и узнаем, как сделать код безопаснее.


Привет Хабр! Меня зовут Владислав Столяров. Я аналитик безопасности продуктов в компании МойОфис. Сегодня мы поговорим о самых часто встречающихся ловушках в работе с типовыми строками в языках C и C++. А во введении я поделюсь ссылками на несколько отличных материалов для тех, кто хочет освежить знания или узнать что-то новое о строках.

Сразу скажу, что в статье мы будем в основном говорить о двух видах строк:

  • Cтроки, которые обрабатываются как массивы символов с нулевым символом в конце, известным как EOL или нулевой символ. Это так называемые C-Style строки.

  • Класс std::basic_string предоставляемый стандартной библиотекой языка программирования С++. Так, например, широко используемый std::string является алиасом std::basic_string, а std::wstring алиасом std::basic_string<wchar_t>.

Конечно, за долгое время существования С++ появилось множество кастомных классов строк. Их писали большие и маленькие проекты. Чаще всего это было связанно с несовершенством класса строки в стандартной библиотеке. Вот несколько примеров, которые живы и по сей день:

Однако, с новыми стандартами C++ класс std::basic_string стал гораздо приятнее, и теперь заметна обратная тенденция: проекты возвращаются к классу строки из стандартной библиотеки. Подробнее об этом можно узнать тут: C++ Russia 2017: Антон Полухин, Как делать не надо: C++ велосипедостроение для профессионалов.

Немного истории

В начале я хотел бы рассмотреть знаменитую типовую уязвимость, связанную со строками — Heartbleed. Она была обнаружена в апреле 2014 года в криптобиблиотеке OpenSSL. Heartbleed позволяла злоумышленникам извлекать случайные фрагменты памяти сервера, включая чувствительные данные, такие как пароли, закрытые ключи и другие. Делалось это через манипуляцию запросами Heartbeat Extension в протоколе TLS.

Алгоритм атаки выглядел так:

  1. Отправка Heartbeat-запроса: когда клиент и сервер взаимодействуют по протоколу TLS, клиент может отправить запрос на Heartbeat Extension. Этот запрос содержит данные и их длину, которые клиент ожидает получить в ответе от сервера.

  2. Манипуляция длиной запроса: атакующий мог специально изменить длину данных в запросе на большее значение, чем длина фактически отправленных данных. Например, клиент мог указать, что ожидает 500 байт данных, хотя отправил всего 10 байт.

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

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

Heartbleed была обнаружена командой безопасности Google и исследователями фирмы Codenomicon. Независимо друг от друга они выявили, что функция Heartbeat Extension в протоколе TLS, при некорректной настройке сервера, позволяла клиентам получать случайные данные из памяти сервера, включая конфиденциальную информацию.

Исследователи предупредили OpenSSL и обеспечили детали уязвимости, после чего был выпущен патч для её устранения. Уязвимость вызвала широкий резонанс в медиа и сообществе специалистов по безопасности, став одной из наиболее известных и серьезных уязвимостей в истории. Обнаружение Heartbleed спровоцировало масштабную обновительную кампанию для серверов и систем, использующих OpenSSL. Само описание уязвимости с кодом можно посмотреть тут.

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

Ловушка 1. Переполнение буфера

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

Давайте напишем такой пример:

#include <cstring>

void process_string(const char* source)
{
    char buffer[10];
    strcpy(buffer, source);
}

int main()
{
    const char* input = "This is a long string that exceeds the buffer size.";
    process_string(input);
    return 0;
}

У строки input длина превышает размер буфера buffer. При выполнении функции strcpy, содержимое строки input будет скопировано в buffer, и буфер переполнится. Это может привести к очень нехорошим последствиям: от аварийного завершения программы до выполнения вредоносного кода.

Одна из рекомендаций по исправлению кода — использование безопасных функций для работы со строками, таких, как strncpy. Давайте попробуем переписать этот фрагмент:

#include <iostream>
#include <cstring>

void process_string(const char* input)
{

    char buffer[10];
    strncpy(buffer, input, sizeof(buffer));
    std::cout << "Processed string: " << buffer << std::endl;
}

int main()
{
    const char* input = "This is a long string that exceeds the buffer size.";
    process_string(input);
    return 0;
}

Функция process_string использует функцию strncpy для копирования строки input в буфер buffer с размером 10 символов. Однако, функция strncpy не гарантирует, что буфер будет корректно завершён нулевым символом, если исходная строка превышает размер буфера (есть кастомные функции, которые дают такую гарантию). Это также может привести к неожиданному поведению и непредсказуемым результатам.

В таком случае, если передать длинную строку в функцию process_string, strncpy скопирует только часть строки в буфер, но не добавит нулевой символ в конец. Результатом может стать некорректная работа функций, которые ожидают нуль-терминированные строки.

Вариант, как это исправить — использовать std::string, как более безопасную альтернативу C-Style строкам. Давайте попробуем скомбинировать оба варианта (и с std::string, и с strncpy):

#include <iostream>
#include <string>
#include <cstring>

void process_string(const std::string& input)
{
    char buffer[10];
    std::string str = input.substr(0, 10);
    strncpy(buffer, str.c_str(), sizeof(buffer));
    std::cout << "Processed string: " << buffer << std::endl;
}

int main()
{
    std::string input = "This is a long string that exceeds the buffer size.";
    process_string(input);
    return 0;
}

В исправленном коде мы использовали метод copy класса std::string, который копирует указанное количество символов из строки input в буфер buffer. Также мы увеличили размер буфера на 1 и установили нуль-терминатор в конце буфера, чтобы гарантировать корректное завершение строки.

Но в этом фрагменте кода есть ещё одна проблема. Если размер input будет меньше 10, то оставшаяся часть буфера заполниться мусорными значениями.

Избежать этого можно так:

#include <iostream>
#include <string>

void process_string(const std::string& input)
{
    char buffer[11];
    size_t copied = input.copy(buffer, sizeof(buffer) - 1);
    buffer[copied] = '\0';
    std::cout << "Processed string: " << buffer << std::endl;
}

int main()
{
    std::string input = "This is a long string that exceeds the buffer size.";
    process_string(input);
    return 0;
}

Ловушка 2. Помеченные данные

Анализ помеченных данных или taint analysis — это анализ пользовательского ввода для выявления ситуаций, которые обычно возникают, когда программа использует пользовательский ввод без надлежащих проверок.

Рассмотрим уязвимость «удалённое выполнение кода» (Remote Code Execution) в упрощённом виде. Например, у нас есть приложение, которое обрабатывает запросы, включая передачу команд в систему.

#include <iostream>
#include <string>
#include <cstdlib>

void processRequest(const std::string &input) {
    std::string command = "ls " + input;

    std::cout << "Command run: " << command << std::endl;
    system(command.c_str());
}

int main() {
    std::string userInput;
    std::cout << "Enter directory or file name: "
      << std::flush;
    std::cin >> userInput;

    processRequest(userInput);

    return 0;
}

Злоумышленник может, например, ввести следующее:

; rm -rf --no-preserve-root /

В этом случае программа выполнит команду ls, а затем, поскольку встретила ;, выполнит команду rm -rf --no-preserve-root /, что приведёт к удалению всех файлов на корневом уровне системы. Атака подобного типа может привести к выполнению злонамеренного кода на сервере или в системе и является серьёзной угрозой для безопасности.

Чтобы предотвратить эти действия, нужно строго валидировать и экранировать пользовательский ввод перед использованием в операциях с системой. Избежать подобных уязвимостей помогут более безопасные альтернативы, такие, как функции exec с аргументами. Кстати, недавно интересная уязвимость такого вида была обнаружена и в Windows.

Ловушка 3. Уязвимость форматной строки

По-английски — format string vulnerability. Это тип уязвимости в программном коде, который возникает при неправильном использовании функций форматирования строк. Уязвимость появляется, когда пользовательский ввод неправильно обрабатывается как форматная строка в таких функциях, как:

  • fprintf

  • printf

  • sprintf

  • snprintf

  • vfprintf

  • vprintf

  • vsprintf

  • vsnprintf

Уязвимость форматной строки может привести к серьезным последствиям — перезапись памяти, получение конфиденциальных данных, выполнение произвольного кода и даже удалённое выполнение кода злоумышленником. Это происходит из-за того, что функции форматирования строк принимают форматную строку, в которой определяются типы и порядок аргументов для вывода или ввода. Атакующий может внедрить злонамеренные форматы, которые будут неправильно интерпретироваться.

Вот некоторые параметры формата, которые можно использовать, и их последствия при некорректном применении:

  • «%x» Чтение данных из стека

  • «%s» Чтение C-строки символов из памяти процесса

  • «%n» Запись целого числа в ячейки памяти процесса.

Пример уязвимости форматной строки выглядит так:

#include <stdio.h>

int main(int argc, char** argv) 
{
    char buffer[100];
    sprintf(buffer, argv[1]);
    printf(buffer);
    return 0;
}

Здесь пользовательский ввод передаётся в функцию sprintf без проверки или ограничения Это позволяет злоумышленнику передать форматную строку, которая может стать причиной проблемы. Если атакующий передаст форматную строку, например "%s", то при попытке вывода содержимого буфера с помощью printf, произойдет обращение к памяти, которое может привести к ошибкам или утечкам данных.

Для предотвращения уязвимости форматной строки важно всегда контролировать и проверять строки, использовать безопасные методы форматирования (например, std::cout, std::stringstream, std::format в C++20 и выше) и ограничивать пользовательский ввод для предупреждения неправильной интерпретации форматов. Также рекомендуется использовать специальные функции для безопасного форматирования строк, такие, как snprintf. Они позволяют указать максимальную длину вывода.

Подробнее об этой уязвимости можно почитать в статье.

Ловушка 4. std::string::npos

Классы std::string и std::string_view предоставляют методы для поиска определенных символов или символьных последовательностей внутри строки. При успешном поиске эти функции возвращают позицию первого символа, соответствующего заданному образцу. В случае неудачи, функции возвращают статическую константу npos. Рассмотрим пример кода:

#include <string>

auto foo(std::string str) noexcept
{
  return str.find("42");
}

Одна из самых частых ошибок — рассматривать возвращаемые значения функций поиска в строчке, как переменную типа bool. Фрагмент кода выше содержит логическую ошибку, которая может привести к неожиданным итогам. А результат вызова функций поиска нужно сравнивать с std::string::npos.

Вот список этих функций:

  • find

  • rfind

  • find_first_of

  • find_last_of

  • find_first_not_of

  • find_last_not_of

Исправленный фрагмент кода выглядел бы так:

#include <string>

auto foo(std::string str) noexcept
{
  return str.find("42") != std::string::npos;
}

Выводы

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

Tags:
Hubs:
+22
Comments5

Articles

Information

Website
myoffice.ru
Registered
Founded
2013
Employees
1,001–5,000 employees
Location
Россия
Representative
МойОфис