Модификация временной безопасности памяти в C++
Примечание: Этот пост был первоначально опубликован в Google Security Blog.
Безопасность памяти в Chrome — это постоянные усилия по защите наших пользователей. Мы постоянно экспериментируем с различными технологиями, чтобы опередить злоумышленников. В этом духе эта публикация рассказывает о нашем опыте использования технологий сканирования кучи для улучшения безопасности памяти в C++.
Но начнем с самого начала. На протяжении всего срока службы приложения его состояние, как правило, представлено в памяти. Временная безопасность памяти касается проблемы гарантии того, что к памяти всегда обращаются с учетом самой актуальной информации о ее структуре и типе. На жаль, C++ не предоставляет таких гарантий. Несмотря на стремление к использованию языков с более сильными гарантиями безопасности памяти, такие крупные кодовые базы, как Chromium, будут использовать C++ в обозримом будущем.
auto* foo = new Foo();
delete foo;
// Местоположение памяти, на которое указывает foo, больше не
// представляет объект Foo, так как объект был удален (освобожден).
foo->Process();
В приведенном выше примере foo
используется после того, как его память была возвращена операционной системе. Указатель, который больше не обновлен, называется висячим указателем, и любое обращение через него вызывает доступ к освобожденной памяти. В лучшем случае такие ошибки приводят к четко определенным сбоям, в худшем случае они вызывают тонкие сбои, которые могут быть использованы злоумышленниками.
Ошибки использования освобожденной памяти часто трудно обнаружить в больших кодовых базах, где владение объектами передается между различными компонентами. Общая проблема настолько широко распространена, что по сей день как индустрия, так и академическое сообщество регулярно разрабатывают стратегии смягчения. Примеры бесконечны: умные указатели C++ всех видов используются для более четкого определения и управления владением на уровне приложений; статический анализ в компиляторах используется, чтобы предотвратить компиляцию проблемного кода; там, где статический анализ терпит неудачу, динамические инструменты, такие как санитайзеры C++, могут перехватывать доступы и обнаруживать проблемы в конкретных сценариях выполнения.
Использование C++ в Chrome, к сожалению, ничем не отличается, и большинство высокосерьезных уязвимостей безопасности связаны с использованием освобожденной памяти. Чтобы обнаруживать проблемы до их попадания в продакшн, применяются все вышеперечисленные методы. В дополнение к регулярным тестам, фаззеры гарантируют, что всегда есть новые данные для работы с динамическими инструментами. Chrome идет еще дальше и использует сборщик мусора для C++ под названием Oilpan, который отличается от обычной семантики C++, но обеспечивает временную безопасность памяти, где он используется. Там, где такие отклонения необоснованны, недавно был введен новый тип умного указателя под названием MiraclePtr, который детерминированно завершает выполнение при доступе к висячим указателям. Oilpan, MiraclePtr и решения на основе умных указателей требуют значительных изменений в коде приложения.
За последнее десятилетие другой подход достиг некоторого успеха: изоляция памяти. Основная идея заключается в том, чтобы помещать явно освобожденную память в карантин и делать ее доступной только при достижении определенного условия безопасности. Microsoft внедрила версии данной меры в своих браузерах: MemoryProtector в Internet Explorer в 2014 году и его преемник MemGC в (докромиумной) версии Edge в 2015 году. В ядре Linux использовался вероятностный подход, где память в конечном итоге просто перерабатывалась. Этот подход также привлек внимание в академических кругах в последние годы с работой MarkUs. Остальная часть этой статьи резюмирует наш опыт экспериментов с карантинами и сканированием кучи в Chrome.
(На этом этапе можно задаться вопросом, где же память с тегированием вписывается в эту картину – продолжайте читать!)
Карантинирование и сканирование кучи, основы
Основная идея обеспечения временной безопасности с помощью карантинирования и сканирования кучи заключается в том, чтобы избежать повторного использования памяти до тех пор, пока не будет доказано отсутствие (висячих) указателей, ссылающихся на нее. Чтобы избежать изменения пользовательского кода C++ или его семантики, перехватывается распределитель памяти, обеспечивающий new
и delete
.
При вызове delete
память фактически помещается в карантин, где она становится недоступной для повторного использования в последующих вызовах new
приложением. В какой-то момент запускается сканирование кучи, которое сканирует всю кучу, подобно сборщику мусора, чтобы найти ссылки на блоки памяти в карантине. Блоки, на которые нет входящих ссылок из обычной памяти приложения, возвращаются в распределитель, где их можно повторно использовать для последующих выделений.
Существуют различные варианты ужесточения, которые сопровождаются затратами на производительность:
- Перезапись памяти из карантина специальными значениями (например, нулями);
- Остановка всех потоков приложения во время выполнения сканирования или сканирование кучи параллельно;
- Перехват записей в память (например, с помощью защиты страниц) для отслеживания обновлений указателей;
- Сканирование памяти слово за словом для возможных указателей (консервативный подход) или использование дескрипторов для объектов (точный подход);
- Разделение памяти приложения на безопасные и небезопасные секции для исключения объектов, которые либо критичны для производительности, либо доказано, что их можно пропустить без риска;
- Дополнительно сканировать стек выполнения, а не только память кучи;
Мы называем набор различных версий этих алгоритмов StarScan [stɑː skæn], или сокращенно *Scan.
Проверка реальностью
Мы применяем *Scan к неуправляемым частям процесса рендерера и используем Speedometer2 для оценки влияния на производительность.
Мы экспериментировали с различными версиями *Scan. Однако, чтобы минимизировать накладные расходы на производительность, мы оцениваем конфигурацию, которая использует отдельный поток для сканирования кучи и избегает немедленной очистки памяти в карантине при вызове delete
, а очищает её при выполнении *Scan. Мы включаем всю память, выделенную с помощью new
, и не различаем места и типы выделений для упрощения первой реализации.
Заметьте, что предложенная версия *Scan не является полной. Конкретно, злоумышленник может воспользоваться состоянием гонки с потоком сканирования, перемещая висячий указатель из неотсканированной области в уже отсканированную область памяти. Устранение этой гонки требует отслеживания записей в блоки уже отсканированной памяти, например, с помощью механизмов защиты памяти для перехвата таких доступов, либо остановки всех потоков приложения в точках безопасности, чтобы полностью исключить изменения графа объектов. В любом случае решение этой проблемы связано с затратами на производительность и интересным компромиссом между производительностью и безопасностью. Обратите внимание, что такой вид атаки не является универсальным и не работает для всех случаев Use-After-Free. Проблемы, описанные во введении, не подвержены таким атакам, поскольку висячий указатель не копируется.
Поскольку преимущества в области безопасности действительно зависят от гранулярности таких точек безопасности, и мы хотим экспериментировать с самой быстрой версией, мы полностью отключили точки безопасности.
Запуск нашей базовой версии на Speedometer2 снижает общий балл на 8%. Обидно...
Откуда такой перерасход? Неудивительно, что сканирование кучи зависит от памяти и является довольно дорогим, так как весь объем пользовательской памяти должен быть просмотрен и проверен на ссылки потоком сканирования.
Чтобы уменьшить регрессию, мы реализовали различные оптимизации, которые улучшают скорость сканирования. Естественно, самый быстрый способ сканировать память — это не сканировать ее вообще, поэтому мы разделили кучу на два класса: память, которая может содержать указатели, и память, которую мы можем статически доказать как не содержащую указатели, например, строки. Мы избегаем сканирования памяти, которая не может содержать каких-либо указателей. Обратите внимание, что такая память все еще находится в карантине, она просто не сканируется.
Мы расширили этот механизм, чтобы также покрыть выделения, которые служат базовой памятью для других распределителей, например, зоны памяти, управляемые V8 для оптимизирующего компилятора JavaScript. Такие зоны всегда одновременно удаляются (ср. управление памятью на основе областей), и временная безопасность обеспечивается другими средствами в V8.
Сверху мы применили несколько микрооптимизаций для ускорения и устранения вычислений: используем вспомогательные таблицы для фильтрации указателей; применяем SIMD для цикла сканирования, связанного с памятью; и минимизируем количество запросов и команд с префиксом блокировки.
Мы также улучшаем начальный алгоритм планирования, который просто начинает сканировать кучу при достижении определенного предела, регулируя, сколько времени мы проводим на сканировании по сравнению с выполнением кода приложения (см. использование мутатора в литературе по сборке мусора).
В итоге алгоритм все еще ограничен памятью, и сканирование остается заметно дорогостоящей процедурой. Оптимизации помогли сократить регрессию Speedometer2 с 8% до 2%.
Хотя мы улучшили время чистого сканирования, тот факт, что память находится в карантине, увеличивает общий рабочий набор процесса. Чтобы дополнительно оценить это накладные расходы, мы используем выбранный набор реальных браузерных тестов Chrome, чтобы измерить потребление памяти. *Scan в процессе рендеринга приводит к увеличению потребления памяти примерно на 12%. Именно это увеличение рабочего набора приводит к тому, что больше памяти загружается из подкачки, что заметно на быстрых путях приложения.
Аппаратное тегирование памяти приходит на помощь
MTE (Memory Tagging Extension) — это новое расширение архитектуры ARM v8.5A, которое помогает выявлять ошибки при использовании памяти программным обеспечением. Эти ошибки могут быть пространственными (например, доступ вне границ) или временными (использование после освобождения). Расширение работает следующим образом. Каждые 16 байт памяти назначаются 4-битным тегом. Указатели также назначаются 4-битным тегом. Аллокатор отвечает за возвращение указателя с тем же тегом, что и у выделенной памяти. Инструкции загрузки и хранения проверяют, совпадают ли теги указателя и памяти. Если теги местоположения памяти и указателя не совпадают, возникает аппаратное исключение.
MTE не предлагает детерминированной защиты от использования после освобождения. Поскольку количество бит тегов ограничено, существует вероятность совпадения тегов памяти и указателя из-за переполнения. С 4 битами достаточно всего 16 повторных выделений, чтобы теги совпали. Злоумышленник может воспользоваться переполнением теговых битов, чтобы получить доступ к использованию после освобождения, просто подождем, пока тег неиспользуемого указателя снова совпадет с памятью, на которую он указывает.
*Scan можно использовать для исправления этой проблематичной ситуации. При каждом вызове delete
тег для базового блока памяти увеличивается с помощью механизма MTE. Большую часть времени блок будет доступен для повторного выделения, так как тег можно увеличивать в пределах 4-битного диапазона. Устаревшие указатели будут ссылаться на старый тег и, таким образом, надежно вызывать сбой при разыменовании. После переполнения тега объект помещается в карантин и обрабатывается *Scan. После того как сканирование подтверждает отсутствие устаревших указателей на этот блок памяти, он возвращается обратно аллокатору. Это сокращает количество сканирований и их сопутствующую стоимость примерно в 16 раз.
На следующем рисунке изображен этот механизм. Указатель на foo
изначально имеет тег 0x0E
, что позволяет ему быть увеличенным еще раз для выделения bar
. При вызове delete
для bar
тег переполняется, и память фактически помещается в карантин *Scan.
Мы получили доступ к некоторому реальному оборудованию с поддержкой MTE и повторили эксперименты в процессе рендеринга. Результаты обещают быть перспективными, так как регрессия на Speedometer была в пределах шума, и мы снизили потребление памяти всего на 1% на реальных сценариях просмотра Chrome.
Это действительно бесплатный обед? Оказалось, что MTE имеет некоторые затраты, которые уже были оплачены. В частности, PartitionAlloc, который является базовым аллокатором Chrome, по умолчанию уже выполняет операции управления тегами для всех устройств с поддержкой MTE. Кроме того, по соображениям безопасности память действительно должна быть обнулена заранее. Чтобы количественно оценить эти затраты, мы провели эксперименты на раннем прототипе оборудования, поддерживающем MTE в нескольких конфигурациях:
A. MTE отключен и без обнуления памяти; B. MTE отключен, но с обнулением памяти; C. MTE включен без *Scan; D. MTE включен с *Scan;
(Мы также знаем, что существует синхронный и асинхронный режим MTE, который также влияет на детерминированность и производительность. В рамках этого эксперимента мы продолжали использовать асинхронный режим.)
Результаты показывают, что MTE и обнуление памяти имеют некоторые затраты, которые составляют около 2% на Speedometer2. Обратите внимание, что ни PartitionAlloc, ни оборудование пока не оптимизированы для этих сценариев. Эксперимент также показывает, что добавление *Scan поверх MTE обходится без измеримых затрат.