Я часто критикую небезопасные при работе с памятью языки, в основном 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 и другом подобном функционале Си. Однако, думаю, стоит признать, что, учитывая, что Си++ явно включил в свою спецификацию Си, на практике большинство Си++-кода содержит некоторые из этих "фич".
psycha0s
Пример с std::optional просто фееричен по уровню идиотизма. Тип optional и не предоставляет гарантии, что он всегда содержит какое-то значение. На это даже намекает само название типа. Претензии к операторам * и -> только показывают, что автор понятия не имеет о контрактах функций. Если мы посмотрим описание этих операторов на cppreference, то мы увидим, что
Разработчик обязан удовлетворить предусловия функции перед ее вызовом. В данном случае он обязан убедиться, что optional содержит значение, иначе получит неопределенное поведение. Как бы того ни хотел автор, но в любом языке программирования функции будут иметь предусловия, явные или неявные. Не все предусловия можно проверить. Не все проверки предусловий осуществимы за разумное время. Не все осуществимые проверки стоят того, чтобы быть выполненными. Сомневающихся прошу ознакомиться с данным документом. Всегда придется искать какой-то разумный компромисс. Язык C++ нацелен на максимальную производительность, поэтому написанные на нем библиотеки часто перекладывают на пользователя выполнение дополнительных проверок.
Хотя я не отрицаю, что современный C++ превратился в какого-то монстра и использовать его корректно очень и очень сложно, чтобы бросаться переносить существующие наработки, надо сначала создать хорошую замену C++. Языки с VM и сборщиком мусора не являются заменой по определению. Rust выглядит довольно перспективно, но тоже не без проблем.
DarkEld3r
Логично, но люди совершают и будут совершать ошибки. Даже если программистов начнут расстреливать за UB, то ситуация принципиально всё равно не изменится: ошибок, вероятно, станет всё-таки поменьше, но и цена на разработку взлетит.
И насчёт "нацеленности на максимальную производительность" тоже, вроде как, справедливо. Но если быть честным, то насколько замедлится софт написанный на С++, если там включить проверки в std::option и векторе? Гадать дело неблагодарное, но я что-то думаю, что далеко не везде это будет заметно.
В этом плане мне намного больше нравится подход раста, где по умолчанию такие проверки как раз есть. А если вдруг мы видим, что в этом конкретном месте упираемся в производительность, то всегда можно использовать "unchecked" версию соответствующего метода (и желательно обложиться тестами). Да, в С++ есть
at
, но честно говоря не встречал чтобы им активно пользовались. Может мне не повезло, конечно. Но зачастую при выборе между доступом по индексу через квадратные скобки и "неуклюжим" вызовомget_unchecked
выберут первое. В общем, я за "правильные" умолчания.sergegers
Как правило в реализации std::optional стоит assert(), который срабатывает в debug билдах.
tenzink
Но вот у клиента и тестировщика как правило билд не debug. Поэтому assert «никто» не увидит
sergegers
Ну, с вероятностью 99.99% это всё равно приведёт к аварийному завершению программы. А если есть Дося, то зачем платить больше.
DarkEld3r
Ну как бы это неопределённое поведение. Да, скорее всего упадёт, но полагаться на это не стоит.
Antervis
тут не может быть «на полшишечки», либо яп нацелен на максимальную производительность, либо нет.
вам не повезло
alsoijw
0xd34df00d
Ну вот чем меньше у вас в языке функций с неявными и непроверяемый машиной контрактами, тем лучше.
Зависит от языка.
Наличие подобных проверок вообще можно проверять статически и требовать только там, где они на самом деле нужны. Например, если вы выше по стеку вызовов проверили (а вы же проверили, иначе откуда уверенность?), то можно просто носить доказательство этой проверки с собой.
psycha0s
Я конечно далеко не эксперт в разных языках и парадигмах программирования, но тем не менее с этим утверждением я не согласен. Предусловия бывают самые разные, явные и неявные (подразумевающиеся). Как вы проверите, что вам передали указатель на последовательность символов, оканчивающуюся нулем? Если вызывающий код забудет дописать терминирующий ноль, вы этот факт никак не установите. Как вы проверите, что переданный в функцию указатель на структуру действительно указывает на такую структуру? Rust может гарантировать нечто подобное, но если данные пришли из внешнего источника их все равно придется валидировать. Любой самый защищенный язык при работе с низкоуровневыми абстракциями будет вынужден использовать unsafe секции кода. И там будут непроверяемые или сложнопроверяемые предусловия.
DoubleW
введя соответсвующий тип, и имея / владея всеми источниками в момент компиляции — чтобы компилятор вывел что мы соблюдает его/свои правила
psycha0s
А если данные пришли извне? Если вам просто был передан указатель на область памяти, каковы будут ваши действия, чтобы осуществить данную проверку?
0xd34df00d
Тогда я просто возьму и сделаю проверку. Как и должен бы был.
svr_91
А теперь убираем из этой схемы завтипы и ничего не меняется :)
0xd34df00d
Только без завтипов, если я уберу проверку, компилятор и не пикнет, а вот с ними…
Иными слоывами да, если в любой корректной программе стереть типы, то она останется корректной.
svr_91
А зачем вы уберете проверку?
0xd34df00d
Рефакторинг делал, а коллега подошел и отвлек. Или посчитал себя умнее машины и решил убрать то, что считал излишним, но что оказалось необходимым.
svr_91
Очень странно у вас проходит рефакторинг.
У меня обычно бывает противоположная проблема, когда нужно быстро проверить к-нить гипотезу, но вместо этого приходится полдня распутывать неудачно спроектированные типы
0xd34df00d
Почему странно? Нажал
VdCtrl+X, чтобы проверку в другое место перенести, и отвлекся. Или поменял код в друом месте, который теперь стал требовать сильно больше проверок.Мне типы и иерархии классов приходилось распутывать, но обычно это было в плюсах и от фанатов паттернов.
svr_91
Не всегда только паттернами можно испортить код
svr_91
Если уж так волнуетесь за сохранность проверки при оефакторинге, пишете тест до рефакторинга и запускаете после
0xd34df00d
…или изначально пишу код так, чтобы он требовал доказательства. Иногда это даже проще, чем придумывать тесты.
alsoijw
0xd34df00d
Потребую доказательство, что последний элемент массива — ноль. Это как раз просто.
Если вызывающий код его не дописал, то у вызывающего кода не получится построить это самое доказательство.
Я тут, конечно, немного лукавлю: соответствующие техники и инструменты существуют (кстати, давно) только для языков без мутабельности (которые, впрочем, могут быть хорошими метаязыками для мутабельности, и тогда объём кода, которому вам надо доверять, будет очень малым), с мутабельностью всё сложнее (и там есть свои инструменты, но они вне моих интересов, поэтому я про их текущее состояние ничего умного сказать не могу).
Если данные пришли снаружи, то их нужно проверить. И если компилятор от вас это требует и пинает вас по рукам, если вы эту проверку не сделали в рантайме, то это хорошо.
Но вообще общение с внешним миром — это совсем другая и довольно история. Однако, обсуждаемый вопрос об
operator*
уstd::optional
к общению со внешним миром отношения не имеет.Tujh
Запросто, например, получение сетевого пакета без задержек, ситуацию моделирую тут, но почему бы и нет
0xd34df00d
Спросили про последний — я ответил про последний.
А зачем требовать наличие нулевого байта в пакете, полученном по сети, мне не очень понятно.
Вот там, где вы делаете
if (recv_str)
, и где вы выбираете первую ветвь, вы на самом деле неявно имеете доказательство того, чтоoptional
у вас непустой.Более того, здесь никаких завтипов не нужно. Смотрите, как это было бы выражено пусть даже в хаскеле:
У вас здесь проверка наличия содержимого и его извлечение совмещены в одну операцию. Никакого небезопасного
operator*
заведомо не нужно.agalakhov
Это пример неудачно разработанного API: часто используемая функция, могущая вызвать UB при случайном неправильном применении. Очень похожий тип есть в Rust и называется
Option
, но там он свободен от подобной проблемы: у него нет оператора разыменования. Есть либо извлечение с явной проверкой:либо преобразование
Option
в другойOption
через map:либо "разыменование", но с паникой (в C++ это могло бы быть исключение) в случае его некорректности:
Такой API гарантирует, что при некорректном использовании будет не UB, а всего лишь корректная runtime error. Сама вероятность некорректного использования также понижается.
AnthonyMikh
Меня удивляет, что у
std::optional
в C++ нет ничего похожего наOption::map
/Option::and_then
. Вроде ж максимально логичные методы.myxo
Для монадического интерфейса optional есть proposal, но да, это все идет довольно медленно.
sergegers
Есть в бустовском
technic93
В расте может быть
unsafe unwrap_unchecked()
DarkEld3r
Можно, но этот метод надо в unsafe блок заворачивать. В обычном коде никто так не пишет (хотя бы из-за того, что набирать дольше). Ну а если кровь из носу надо, то да — можно. И это хороший подход так как unsafe и на ревью сразу видно и грепнуть можно или даже запретить на уровне модуля/библиотеки.
domix32
Сравните тот же Option из раста. Оно гарантирует либо панику, либо значение. В C++ в лучшем случае будет исключение, в худшем кто-то получит доступ к вашей памяти. То бишь не гарантирует практически ничего, кроме почти безопасного интерфейса. Об это и ведет речь автор. Пока не заведутся полноценные контракты из нового стандарта безопасность приложений останется под угрозой эксплуатирования таких проблем с памятью. Но и контракты не панацея и ждать их в лучшем случае лет 5.