Я часто критикую небезопасные при работе с памятью языки, в основном C и C++, и то, как они провоцируют необычайное количество уязвимостей безопасности. Моё резюме, основанное на изучении доказательств из многочисленных крупных программных проектов на С и С++, заключается в том, что нам необходимо мигрировать нашу индустрию на безопасные для памяти языки по умолчанию (такие как Rust и Swift). Один из ответов, который я часто получаю, заключается в том, что проблема не в самих С и С++, разработчики просто неправильно их "готовят". В частности, я часто получаю в защиту C++ ответ типа: "C++ безопасен, если вы не используете унаследованную от C функциональность" [1] или аналогичный ему, что если вы используете типы и идиомы современного C++, то вы будете застрахованы от уязвимостей типа повреждения памяти, от которых страдают другие проекты.
Хотелось бы отдать должное умным указателям С++, потому что они существенно помогают. К сожалению, мой опыт работы над большими С++ проектами, использующими современные идиомы, заключается в том, что этого даже близко недостаточно, чтобы остановить наплыв уязвимостей. Моя цель на оставшуюся часть этой заметки - выделить ряд абсолютно современных идиом С++, которые порождают уязвимости.
Скрытая ссылка и use-after-free
#include <iostream>#include <string>#include <string_view>int main() { std::string s = "Hellooooooooooooooo "; std::string_view sv = s + "World\n"; std::cout << sv;}
Вот что здесь происходит, s + "World\n"
создает
новую строку std::string
, а затем преобразует ее в
std::string_view
. На этом этапе временная std::string
освобождается, но sv
все еще указывает на память,
которая ранее ей принадлежала. Любое последующее использование
sv
является use-after-free уязвимостью. Упс! В С++ не
хватает средств, чтобы компилятор знал, что sv
захватывает ссылку на что-то, где ссылка живет дольше, чем донор.
Эта же проблема затрагивает std::span
, также
чрезвычайно современный тип С++.
Другой забавный вариант включает в себя использование лямбда в С++ для сокрытия ссылки:
#include <memory>#include <iostream>#include <functional>std::function<int(void)> f(std::shared_ptr<int> x) { return [&]() { return *x; };}int main() { std::function<int(void)> y(nullptr); { std::shared_ptr<int> x(std::make_shared<int>(4)); y = f(x); } std::cout << y() << std::endl;}
Здесь [&]
в f
лямбда захватывает
значение по ссылке. Затем в main
, x
выходит за пределы области видимости, уничтожая последнюю ссылку на
данные и освобождая их. В этот момент y
содержит
висячий указатель. Это происходит, несмотря на наше тщательное
использование умных указателей. И да, люди действительно пишут код,
использующий std::shared_ptr&
, часто как попытку
избежать дополнительного приращения и уменьшения количеств в
подсчитывающих ссылках.
Разыменование std::optional
std::optional
представляет собой значение, которое
может присутствовать, а может и не присутствовать, часто заменяя
магические значения (например, -1
или
nullptr
). Он предлагает такие методы, как
value()
, которые извлекают T
, которое он
содержит, и вызывает исключение, если optional
пуст.
Однако, он также определяет operator*
и
operator->
. Эти методы также обеспечивают доступ к
хранимому T
, однако они не проверяют, содержит ли
optional
значение или нет.
Следующий код, например, просто возвращает неинициализированное значение:
#include <optional>int f() { std::optional<int> x(std::nullopt); return *x;}
Если вы используете std::optional
в качестве замены
nullptr
, это может привести к еще более серьезным
проблемам! Разыменование nullptr
дает segfault (что не
является проблемой безопасности, кроме как в старых ядрах). Однако,
разыменование nullopt
дает вам неинициализированное
значение в качестве указателя, что может быть серьезной проблемой с
точки зрения безопасности. Хотя T*
также бывает с
неинициализированным значением, это гораздо менее распространено,
чем разыменование указателя, который был правильно инициализирован
nullptr
.
И нет, это не требует использования сырых указателей. Вы можете получить неинициализированные/дикие указатели и с помощью умных указателей:
#include <optional>#include <memory>std::unique_ptr<int> f() { std::optional<std::unique_ptr<int>> x(std::nullopt); return std::move(*x);}
Индексация std::span
std::span
обеспечивает эргономичный способ передачи
ссылки на непрерывный кусок памяти вместе с длиной. Это позволяет
легко писать код, который работает с несколькими различными типами;
std::span
может указывать на память, принадлежащую
std::vector, std::array<uint8_t, N>
или даже на
сырой указатель. Некорректная проверка границ - частый источник
уязвимостей безопасности, и во многих смыслах span
помогает, гарантируя, что у вас всегда будет под рукой длина.
Как и все структуры данных STL, метод
span::operator[]
не выполняет проверку границ. Это
печально, так как operator[]
является наиболее
эргономичным и стандартным способом использования структур данных.
std::vector
и std::array
можно, по
крайней мере, теоретически безопасно использовать, так как они
предлагают метод at()
, который проверяет границы (на
практике я этого никогда не видел, но можно представить себе
проект, использующий инструмент статического анализа, который
просто запрещает вызовы std::vector::operator[]
). span
не предлагает метод at()
, или любой другой подобный
метод, который выполняет проверку границ.
Интересно, что как Firefox, так и Chromium в бэкпортах
std::span
выполняют проверку границ в
operator[]
, и, следовательно, никогда не смогут
безопасно мигрировать на std::span
.
Заключение
Идиомы современного C++ вводят много изменений, которые могут
улучшить безопасность: умные указатели лучше выражают ожидаемое
время жизни, std::span
гарантирует, что у вас всегда
под рукой правильная длина, std::variant
обеспечивает
более безопасную абстракцию для union
. Однако
современный C++ также вводит новые невообразимые источники
уязвимостей: захват лямбд
с эффектом use-after-free,
неинициализированные optional
и не проверяющие границы
span
.
Мой профессиональный опыт написания относительно современного
С++ кода и аудита Rust-кода (включая Rust-код, который существенно
использует unsafe
) заключается в том, что безопасность
современного С++ просто не сравнится с языками, в который
безопасностью памяти включена по умолчанию, такими как Rust и Swift
(или Python и Javascript, хотя я в реальности редко встречаю
программы, для который имеет смысл выбора - писать их на Python,
либо на C++).
Существуют значительные трудности при переносе существующих больших кодовых баз на C и C++ на другие языки - никто не может этого отрицать. Тем не менее, вопрос просто должен ставиться в том, как мы можем это сделать, а не в том, стоит ли пытаться. Даже при наличии самых современных идиом С++, очевидно, что при росте масштабов просто невозможно корректно использовать С++.
[1] Это надо понимать, что речь идет о сырых указателях, массивах-как-указателях, ручном malloc/free и другом подобном функционале Си. Однако, думаю, стоит признать, что, учитывая, что Си++ явно включил в свою спецификацию Си, на практике большинство Си++-кода содержит некоторые из этих "фич".