Некорректное использование строк может стать настоящей ахиллесовой пятой защиты программы. Поэтому так важно применять актуальные подходы к их обработке. Под катом разберём несколько паттернов ошибок при работе со строками, поговорим о знаменитой уязвимости 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++ велосипедостроение для профессионалов.
О переосмыслении класса строки в языке программирования C++ есть отличный доклад: C++Now 2017: Mark Zeren “Rethinking Strings
Если хочется посмотреть про строки на русском языке, то я бы порекомендовал крутой доклад: Магистерский курс C++ (МФТИ, 2022-2023). Лекция 1. Строки.
Немного истории
В начале я хотел бы рассмотреть знаменитую типовую уязвимость, связанную со строками — Heartbleed. Она была обнаружена в апреле 2014 года в криптобиблиотеке OpenSSL. Heartbleed позволяла злоумышленникам извлекать случайные фрагменты памяти сервера, включая чувствительные данные, такие как пароли, закрытые ключи и другие. Делалось это через манипуляцию запросами Heartbeat Extension в протоколе TLS.
Алгоритм атаки выглядел так:
Отправка Heartbeat-запроса: когда клиент и сервер взаимодействуют по протоколу TLS, клиент может отправить запрос на Heartbeat Extension. Этот запрос содержит данные и их длину, которые клиент ожидает получить в ответе от сервера.
Манипуляция длиной запроса: атакующий мог специально изменить длину данных в запросе на большее значение, чем длина фактически отправленных данных. Например, клиент мог указать, что ожидает 500 байт данных, хотя отправил всего 10 байт.
Сервер возвращает данные из памяти: когда сервер получал такой запрос, он пытался отправить обратно клиенту указанное количество байт данных из своей памяти. Однако, из-за ошибки в проверке длины данных, сервер мог случайно отправить не только запрошенные данные, но и дополнительные фрагменты памяти, которые находились за пределами ожидаемых данных.
Извлечение чувствительных данных: злоумышленник мог многократно отправлять такие запросы, каждый раз получая разные фрагменты случайных данных из памяти сервера. Эти данные могли включать в себя пароли, закрытые ключи, информацию о сеансе и другую конфиденциальную информацию.
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++, зачастую, обязательное условие для корректной работы и защиты приложений. Неверное обращение со строковыми данными может привести к критическим уязвимостям и угрозам безопасности. Поэтому, чтобы создавать устойчивые к атакам приложения, нужны строгая валидация, безопасные функции и методы, а также экранирование ввода пользователей. Современные подходы к обработке строк помогут вам обеспечить безопасность, целостность и надежность кода и гарантируют защиту от угроз.
Комментарии (13)
SpiderEkb
30.07.2024 12:51+6Тут не про строки, тут про то, как сознательно убиться головой об стену.
100% защиты от дурака не существует - найдет лазейку и любую обойдет.
lepota
30.07.2024 12:51Я не понял, как третий пример (с `std::string`) решает проблему ловушки 1.
domix32
30.07.2024 12:51+1Если вы про пример со strncopy, то третьим параметром там передаётся размер целевого буфера, а не строки. Зачем там substring магической длины правда непонятно.
lamer84
30.07.2024 12:51+1Видимо, чтобы потом получить null-terminated c-строку с 10 значимыми символами после метода c_str(). И все равно не скопировать нуль в конце!
firehacker
30.07.2024 12:51+7Привет Хабр! Меня зовут Владислав Столяров. Я аналитик безопасности продуктов в компании МойОфис.
Оффтопик, но зачем это нужно в статье? Как же бесят эти шаблонные куски у определенного подмножества авторов
Информация об авторе есть над статьей и под статьей. Если статья годная и я почитаю её до конца, я увижу имя автора. А в начале статьи эта информация зачем? Придать свои словам больший вес? Порекламировать контору?
JordanCpp
30.07.2024 12:51+1Юзайте std::string и не юзайте си наследие в виде printf, strcpy и т.д
И да прибудут с вами, новые стандарты С++.
tolich_the_shadow
30.07.2024 12:51+1У меня есть своя специфическая ошибка: в одной программе значение поля из результата запроса SQL преобразовывалось в std::string без проверки на нулевой указатель. Мы знали: если программа на старте падает, значит таблица сломалась, и из неё читаются значения NULL.
9241304
30.07.2024 12:51+2Что за ловушки? Кого они ловят?
4 примера говнокода, которые были актуальны 20 лет назад? Ну ок.
Для начала, в 2024 году надо прекратить писать статьи про C++, приводя внутри чисто сишный код. Потом можно задуматься о том, что строки в плюсах бывают разными. А ещё можно не высасывать примеры из одного места. Функция поиска не может возвращать бул, она может возвращать индекс, итератор, но не бул. Как функция поиска может возвращать что-то другое? Логика вышла из чата.
В случае неудачи, функции возвращают статическую константу npos.
Когда хотел написать что-то умное, а получилось... Функция возвращает индекс, а если вхождение не найдено, то возвращает -1, он же npos в виде unsigned. Интуитивно понятно, и не надо пытаться высасывать примеры.
У нас остался последний пример, с потоком ввода? Интересно, а какое отношение сия проблема имеет к строкам и вообще плюсам? Правильно, никакого. Проверка ввода - сложная и нетривиальная задача. Но надо ж что-то высосать аналитику
lamer84
30.07.2024 12:51+1В исправленном коде ... Также мы увеличили размер буфера на 1 и установили
нуль-терминатор в конце буфера, чтобы гарантировать корректное
завершение строки.В третьем примере буфер не увеличили на 1, как был buffer[10], так и остался. И нулевого символа в конце все равно не будет.
А если говорить вообще, то смешивать С и С++ код - плохая практика.
AshBlade
30.07.2024 12:51Еще можно добавить, что
strlen(NULL)
, вызывает SEGFAULT, хотя этого в мане не описано
diakin
Пипец какой-то.