[Перевод] Бесстрашная защита. Безопасность памяти в Rust

В прошлом году Mozilla выпустила Quantum CSS для Firefox, который стал кульминацией восьми лет разработки Rust — безопасного для памяти языка системного программирования. Потребовалось более года, чтобы переписать основной компонент браузера на Rust.

До сих пор все основные браузерные движки написаны на C++, в основном по соображениям эффективности. Но с большой производительностью приходит большая ответственность: программисты C++ должны вручную управлять памятью, что открывает ящик Пандоры уязвимостей. Rust не только устраняет такие ошибки, но его методы также предотвращают гонки данных, позволяя программистам более эффективно внедрять параллельный код.

8bb3c13783ff1502b161e92f5818c2aa.jpg


Когда мы говорим о создании безопасных приложений, то часто упоминаем безопасность памяти. Неофициально мы имеем в виду, что ни в каком состоянии программа не может получить доступ к недействительной памяти. Причины нарушений безопасности:

  • сохранение указателя после освобождения памяти (use-after-free);
  • разыменование нулевого указателя;
  • использование неинициализированной памяти;
  • попытка программы дважды освободить одну и ту же ячейку (double-free);
  • переполнение буфера.


Для более формального определения см. статью Майкла Хикса «Что такое безопасность памяти», а также научную статью на эту тему.

Подобные нарушения могут привести к неожиданному сбою или изменению предполагаемого поведения программы. Потенциальные последствия: утечка информации, выполнение произвольного кода и удалённое выполнение кода.


Управление памятью имеет решающее значение для производительности и безопасности приложений. В этом разделе рассмотрим базовую модель памяти. Одно из ключевых понятий — указатели. Это переменные, в которых хранятся адреса памяти. Если мы перейдём по этому адресу, то увидим там некоторые данные. Поэтому мы говорим, что указатель является ссылкой на эти данные (или указывает на них). Так же, как домашний адрес говорит людям, где вас найти, адрес памяти показывает программе, где найти данные.

Всё в программе находится по определенным адресам памяти, включая инструкции кода. Неправильное использование указателей может привести к серьёзным уязвимостям, включая утечку информации и выполнение произвольного кода.

Выделение/освобождение


Когда мы создаём переменную, то программа должна выделить достаточно места в памяти для хранения данных этой переменной. Поскольку у каждого процесса ограниченный объём памяти, конечно, нужен способ освобождения ресурсов. Когда память освобождается, то становится доступной для хранения новых данных, но старые данные живут там до тех пор, пока ячейка не будет перезаписана.

Буферы


Буфер — это непрерывная область памяти, в которой хранится несколько экземпляров одного типа данных. Например, фраза «Мой кот — бэтмен» сохранится в 16-байтовый буфер. Буферы определяются начальным адресом и длиной. Чтобы не повредить данные в соседней памяти, важно убедиться, что мы не читаем и не записываем за пределы буфера.

Поток управления


Программы состоят из подпрограмм, которые выполняются в определённом порядке. В конце подпрограммы компьютер переходит к сохранённому указателю на следующую часть кода (который называется адресом возврата). При переходе на адрес возврата происходит одна из трех вещей:

  1. Процесс продолжается нормально (адрес возврата не изменён).
  2. Процесс аварийно завершает работу (адрес изменён и указывает на неисполняемую память).
  3. Процесс продолжается, но не так, как ожидалось (адрес возврата поменялся и изменён поток управления).


Все языки программирования принадлежат разным частям спектра. С одной стороны спектра — такие языки, как C/C++. Они эффективны, но требуют ручного управления памятью. С другой стороны — интерпретируемые языки с автоматическим управлением памятью (например, подсчёт ссылок и сборка мусора (GC)), но они расплачиваются производительностью. Даже языки с хорошо оптимизированной сборкой мусора не могут сравниться по производительности с языками без GC.

Ручное управление памятью


Некоторые языки (например, C) требуют от программистов вручную управлять памятью: когда и сколько выделять памяти, когда её освобождать. Это даёт программисту полный контроль над тем, как программа использует ресурсы, обеспечивая быстрый и эффективный код. Но такой подход подвержен ошибкам, особенно в сложных кодовых базах.

Ошибки, которые легко сделать:

  • забыть, что ресурсы освобождены и попытаться их использовать;
  • не выделить достаточно места для хранения данных;
  • прочитать память за пределами буфера.


6570a02427a2d03632cb41c73aa5a48d.jpg


Подходящая инструкция по безопасности для тех, кто управляет памятью вручную

Умные указатели


Умные указатели снабжены дополнительной информацией, чтобы предотвратить неправильное управление памятью. Они используются для автоматического управления памятью и проверки границ. В отличие от обычного указателя, умный указатель способен самоуничтожиться и не будет ждать, пока программист удалит его вручную.

Есть разные варианты такой конструкции, которая обёртывает исходный указатель в несколько полезных абстракций. Некоторые умные указатели подсчитывают ссылки на каждый объект, а другие реализуют политику определения контекста (scoping policy) для ограничения времени жизни указателя определёнными условиями.

При подсчёте ссылок ресурсы освобождаются при удалении последней ссылки на объект. Базовые реализации подсчёта ссылок страдают от низкой производительности, повышенного потребления памяти и их трудно использовать в многопоточных средах. Если объекты ссылаются друг на друга (циклические ссылки), то подсчёт ссылок для каждого объекта никогда не достигнет нуля, так что требуются более сложные методы.


В некоторых языках (например, Java, Go, Python) реализована сборка мусора. Часть среды выполнения, которая называется сборщиком мусора (GC), отслеживает переменные и определяет недоступные ресурсы в графе ссылок между объектами. Как только объект становится недоступен, GC освобождает базовую память для повторного использования в будущем. Любое выделение и освобождение памяти происходит без явной команды программиста.

Хотя GC гарантирует, что память всегда используется корректно, он освобождает память не самым эффективным способом — иногда последнее использование объекта происходит гораздо раньше, чем сборщик мусора освободит память. Издержки производительности бывают непомерно высоки для критически важных приложений: чтобы избежать падения производительности, приходится использовать иногда в 5 раз больше памяти.

Владение


В Rust для обеспечения высокой производительности и безопасности памяти используется концепция владения (ownership). Более формально, это пример аффинной типизации. Весь код Rust следует определённым правилам, которые позволяют компилятору управлять памятью без потери времени выполнения:

  1. У каждого значения есть переменная, называемая владельцем.
  2. Одновременно может быть только один владелец.
  3. Когда владелец уходит за пределы области видимости (out of scope), значение удаляется.


Значения можно переносить или заимствовать (borrow) от одной переменной к другой. Эти правила применяет часть компилятора под названием borrow checker.

Когда переменная выходит за пределы области видимости, Rust освобождает эту память. В следующем примере переменные s1 и s2 выходят за пределы области, обе пытаются освободить одну и ту же память, что приводит к ошибке double-free. Чтобы предотвратить это, при переносе значения из переменной предыдущий владелец становится недействительным. Если затем программист попытается использовать недопустимую переменную, компилятор отклонит код. Этого можно избежать, создав глубокую копию данных или используя ссылки.

Пример 1: Перенос владения

let s1 = String::from("hello");
let s2 = s1;

//won't compile because s1 is now invalid
println!("{}, world!", s1);


Другой набор правил borrow checker’а относится к времени жизни переменных. Rust запрещает использование неинициализированных переменных и висячих указателей на несуществующие объекты. Если скомпилировать код из примера ниже, r будет ссылаться на память, которая освобождается, когда x выходит за пределы области видимости: возникает висячий указатель. Компилятор отслеживает отслеживает все области и проверяет допустимость всех переносов, иногда требуя от программиста явного указания времени жизни переменной.

Пример 2: Висячий указатель

let r;
{
  let x = 5;
  r = &x;
}
println!("r: {}", r);


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

  1. Сбой: доступ к недопустимой памяти может привести к неожиданному завершению работы приложения.
  2. Утечка информации: непреднамеренное предоставление приватных данных, включая конфиденциальную информацию, например, пароли.
  3. Выполнение произвольного кода (ACE): позволяет злоумышленнику выполнять произвольные команды на целевой машине. Если это происходит по сети, мы называем это удалённым выполнением кода (RCE).


Другая проблема — утечка памяти, когда выделенная память не освобождается после завершения работы программы. Так можно израсходовать всю доступную память: тогда запросы на ресурсы заблокируются, что приведёт к отказу в обслуживании. Это проблема памяти, которую нельзя решить на уровне ЯП.

В лучшем случае при ошибке памяти приложение аварийно завершит работу. В худшем случае злоумышленник получит контроль над программой через уязвимость (что может привести к дальнейшим атакам).

Злоупотребления освобождённой памятью (use-after-free, double free)


Этот подкласс уязвимостей возникает, когда какой-либо ресурс освобождён, но на его адрес по-прежнему сохранилась ссылка. Это мощный хакерский метод, который может привести к доступу за пределы диапазона, утечке информации, выполнению кода и многому другому.

Языки со сборкой мусора и подсчётом ссылок предотвращают использование недопустимых указателей, уничтожая только недоступные объекты (что может привести к снижению производительности), а языки с ручным управлением подвержены этой уязвимости (особенно в сложных кодовых базах). Инструмент borrow checker в Rust не позволяет уничтожать объекты, пока на него существуют ссылки, так что эти баги устраняются на этапе компиляции.

Неинициализированные переменные


Если переменная используется до инициализации, то в этой памяти могут быть любые данные, включая случайный мусор или ранее отброшенные данные, что приводит к утечке информации (их иногда называют недействительными указателями). Чтобы предотвратить эти проблемы, в языках с управлением памятью часто используется процедура автоматической инициализации после выделения памяти.

Как и в C, большинство переменных в Rust изначально не инициализированы. Но в отличие от C вы не можете их прочитать до инициализации. Следующий код не скомпилируется:

Пример 3: Использование неинициализированной переменной

fn main() {
    let x: i32;
    println!("{}", x);
}


Нулевые указатели


Когда приложение разыменовывает указатель, который оказывается нулевым, обычно он просто обращается к мусору и вызывает сбой. В некоторых случаях эти уязвимости могут привести к выполнению произвольного кода (1, 2, 3). В Rust есть два типа указателей: ссылки и необработанные указатели (raw pointers). Ссылки безопасны, а вот необработанные указатели могут стать проблемой.

Rust предотвращает разыменование нулевого указателя двумя способами:

  1. Избегает указателей, допускающих нулевое значение.
  2. Избегает разыменования необработанных указателей.


Rust позволяет избежать нулевых указателей, заменив их специальным типом Option. Чтобы изменять значение possibly-null в типе Option, язык требует от программиста явной обработки случая с нулевым значением, иначе программа не будет компилироваться.

Что делать, если нельзя избежать указателей, допускающих нулевое значение (например, при взаимодействии с кодом на другом языке)? Попытайтесь изолировать ущерб. Разыменование необработанных указателей должно происходить в изолированном unsafe-блоке. В нём ослаблены правила Rust и разрешены некоторые операции, которые могут вызвать неопределённое поведение (например, разыменование необработанного указателя).

e58bd7b69e8f2f399e431bdb4f6d151f.jpg


— Всё, чего касается borrow chekcer…, а что насчёт вон того тёмного места?
— Это unsafe-блок. Никогда не ходи туда, Симба

Переполнение буфера


Мы обсудили уязвимости, которых можно избежать, ограничив доступ к неопределённой памяти. Но проблема в том, что переполнение буфера неправильно обращается не к неопределённой, а к легально выделенной памяти. Как и баг use-after-free, такой доступ может стать проблемой, потому что обращается к освобождённой памяти, где по-прежнему содержится конфиденциальная информация, которая уже не должна существовать.

Переполнение буфера просто означает доступ за пределы области (out-of-bounds). Из-за того, как буферы хранятся в памяти, они часто приводят к утечке информации, которая может содержать конфиденциальные данные, в том числе пароли. В более серьёзных случаях возможны уязвимости ACE/RCE путём перезаписи указателя инструкции.

Пример 4: Переполнение буфера (код C)

int main() {
  int buf[] = {0, 1, 2, 3, 4};
  
  // print out of bounds
  printf("Out of bounds: %d\n", buf[10]);
  
  // write out of bounds
  buf[10] = 10;
  printf("Out of bounds: %d\n", buf[10]);
  
  return 0;
}


Простейшая защита от переполнения буфера — всегда при доступе к элементам требовать проверки границ, но это приводит к снижению производительности.

Что делает Rust? Встроенные типы буферов в стандартной библиотеке требуют проверки границ для любого случайного доступа, но также предоставляют интерфейсы API итератора, чтобы ускорить последовательные обращения. Это гарантирует, что чтение и запись за пределами границ для этих типов невозможны. Rust продвигает шаблоны, которые требуют проверки границ только в тех местах, где почти наверняка придётся вручную размещать их в C/C++.


Нарушения безопасности приводят к уязвимостям, таким как утечка данных и удалённое выполнение кода. Существуют разные способы защитить память, в том числе умные указатели и сборка мусора. Вы даже можете формально доказать безопасность памяти. Хотя некоторые языки смирились с падением производительности ради безопасности памяти, концепция владения в Rust обеспечивает безопасность и минимизирует накладные расходы.

К сожалению, ошибки памяти — это лишь часть истории, когда мы говорим о написании безопасного кода. В следующей статье рассмотрим потокобезопасность и атаки на параллельный код.


© Habrahabr.ru