Как сделать С/С++ безопасным?



Вопрос, который убивает всех любителей C++. Все признают и ругаются на проблему, но мало кто обсуждает решения. "Перепиши на Rust", говорят они.



Кто-то говорит, что надо тестировать санитайзерами, кто-то говорит, что надо написать достойный фаззинг. Если у вас есть код на С/С++, в 2021 стыдно не прогонять его под санитайзерами. Тоже всем советую писать фаззинги для ваших программ, они находят какие-то чудеса.



Накладные расходы у санитайзеров немаленькие, у ASAN (корректность памяти) говорят, что 2х, у UBSAN 2.5-3x. Более точные бенчмарки дают 75% и 160%. Это прям совсем плохо, какой-нибудь Go начинает быстрее работать уже при таких цифрах.



На конференции OSDI'21 вышла достаточно интересная статья SanRazor.



Основная идея статьи, что текущие имплементации санитайзеров достаточно глупые и вставляют проверку на каждый чих, скажем, в Address Sanitizer вставляется проверка на каждую инструкцию (сниппет bzip2):



for(i=0; i < nblock; i++) {

j = eclass8[i]; // ASan1;

k = ftab[j] - 1; // ASan2;

ftab[j] = k; // ASan3;

fmap[k] = i; // ASan4;





Скажем, проверки Asan2 и Asan3 идентичные (семантический анализ). В Asan1 можно отследить, чтобы сделать только проверку при i == nblock - 1 (пользовательская проверка). Компиляторы не понимают этого. Это достаточно специфичный анализ, который не всегда приносит пользы в классической компиляторной теории. Иногда компиляторы оптимизируют идентичные доступы к памяти, но часто это нельзя сделать из-за модели многопоточности, о которой я писал несколько постов назад. И компиляторы их не делают, кэши процессора уже достаточно хороши, чтобы справиться самим.



Эту интуицию решил применить SanRazor -- а давайте напишем специфичные компиляторные оптимизации на уровне LLVM IR в самом санитайзере, которые оптимизируют количество проверок.



По статье она работала не очень хорошо долго. Как ее заставили работать:



Авторы предлагают убрать все проверки указателей, которые смещаются на константу, то есть если есть две проверки:



*ptr

*(ptr+4)



То на практике в 33/38 тестирующих CVE хватает только проверить *ptr. Авторы говорят, что эксплойты, смещённые на константу в разы реже бывают, что, наверное(?), правда.



Идея 2



Давайте возьмём тесты вашего кода, прогоним их. Далее соберём статистику всех инструкций сравнения (место, результат и разделение пришла ли проверка из санитайзера или от пользователя).



Далее возьмём эту статистику и скажем, если пользовательская проверка столько же раз была исполнена, что и от санитайзера, а также статистика исходов совпадает (с точностью до перестановки true и false), объявим проверки эквивалентными. Это имеет false positives, это правда. В реальности сделали, чтобы статистика была значимой.



В итоге план такой:



1. Взять вашу программу, скомпилировать в обычном режиме

2. Прогнать ваши тесты, собрать статистику всех проверок сравнения

3. Убрать проверки от санитайзера отличающиеся на компиляторную константу

4. Перекомпилировать с собранной статистикой сравнений

5. Эквивалентные проверки с точки зрения количества и исхода убрать в одну



В итоге расходы ASAN падают с 75% до 45%, а UBSAN со 160% до 80%.



Идея 3



Большинство накладных расходов идут от проверок, которые находятся в очень жарких кусках кода и исполняются миллиарды раз. Они часто протестированы как следует и вряд ли содержат проблемы. Такую идею предложил ASAP санитайзер, что вы ему даёте бюджет, а он убирает самые дорогие проверки.



Оказалось, что комбинация SanRazor и ASAP даёт бюджет в 7%, чтобы найти 33/38 изученных CVE. А просто бюджет 7% для ASAP пропускает все CVE.



А теперь представьте, что к вам приходят и говорят: Вы готовы пожертвовать 7% перфа ради нахождения 85% security проблем с памятью в проде? Прямо здесь и сейчас?



Я бы согласился тут же. Изумительная статья. Это, кажется, действительно первый серьезный подход к проблеме low overhead безопасности C/С++. Дальше надо увеличивать покрытие, как по мне психологический рубеж пройден.



[1] Статья

[2] Репозиторий SanRazor