Хакер - Безопасность памяти. Учимся использовать указатели и линейные типы
hacker_frei
Даниил Батурин
Содержание статьи
- Строгая типизация указателей
- Умные указатели
- Линейные типы (borrow checker)
- Заключение
Основные проблемы с управлением памятью можно разделить на два класса. Первый — утечки памяти. Утечка возникает, если программа запрашивает у ядра ОС блок памяти и забывает ее освободить. Проблема с утечками памяти неприятная, но относительно безопасная — в крайнем случае ее можно «решить» периодическим перезапуском программы. Второй класс — проблемы безопасности памяти, которые мы и обсудим в этой статье.
К проблемам безопасности относятся:
- обращение к неверному указателю (в том числе переполнение буфера и разыменование
NULL); - ошибочное обращение к уже освобожденной памяти (use after free);
- ошибочная попытка освободить ранее освобожденный указатель (double free).
INFO
Рекомендую также ознакомиться с моей предыдущей статьей, где я рассматривал механизмы сборки мусора и их особенности.
Как мы видели на примере с использованием Boehm GC в C, сборка мусора сама по себе решает только проблему с утечками памяти. Безопасность памяти обеспечивают уже свойства самого языка.
Ассоциация между сборкой мусора и безопасностью памяти возникает от того, что многие популярные прикладные языки не разрешают ручное управление памятью и адресную арифметику вовсе, — в этих условиях у пользователя просто нет возможности выполнить небезопасную операцию. Однако и возможности освободить память тоже нет, поэтому нужен какой‑то механизм автоматического управления, и сборка мусора — самый популярный.
Самый популярный не значит единственный и универсальный. Первая и главная проблема языков с принудительной сборкой мусора — на них можно писать только программы, которые выполняются в пространстве пользователя. Ядру операционной системы или прошивке микроконтроллера не на кого положиться, они вынуждены управлять памятью самостоятельно, а значит, и язык должен поддерживать указатели и адресную арифметику.
Вторая проблема — потеря производительности и предсказуемости времени выполнения. Классические однопоточные сборщики мусора создают паузы в выполнении программы, которые могут быть заметны пользователю. При использовании многопоточных алгоритмов и верной настройке таймеров под задачи конкретного приложения можно свести паузы к минимуму, но свести затраты ресурсов на сборку мусора к нулю невозможно.
Вполне логично, что разработчики языков ищут альтернативные и промежуточные варианты. Давай посмотрим, какими способами разные языки пытаются обеспечить безопасность памяти.
СТРОГАЯ ТИПИЗАЦИЯ УКАЗАТЕЛЕЙ
Проблемы с безопасностью памяти в C возникают в первую очередь из‑за отсутствия строгой типизации. Функция malloc() возвращает нетипизированный указатель (void*), который пользователь может привести к любому типу. Следить за совпадением размера блока памяти с размером данных тоже обязанность пользователя. К примеру, портирование старого кода на 64-битные платформы может принести много веселых минут, если его авторы жестко прописали размер указателя 32 бит.
Ну и самая классическая ошибка, конечно, — случайное обращение к нулевому указателю.
#include <stdio.h>
void main(void) {
char* str = NULL;
printf("%sn", str);
}
$ gcc -o segfault ./segfault.c
$ ./segfault
Segmentation fault (core dumped)
Более современные языки для системного программирования относятся к этому вопросу более ответственно.
Например, в аде нетипизированные указатели — особый и редкий случай. Обычные указатели всегда типизированные. Вместо malloc() применяется оператор new с явным указанием типа. Простого способа освободить память «вообще» там тоже нет, вместо этого есть обобщенная функция (дженерик) Ada.Unchecked_Deallocation, которую перед использованием нужно специализировать под конкретный тип данных.
Таким способом запросить или освободить неверный объем памяти гораздо сложнее. Использование после освобождения, впрочем, также будет обнаружено только во время выполнения программы.
INFO
Указатели в аде называются access types. Например, access integer — указатель на целое число.
Для демонстрации сохраним следующий код в файл access_example.adb (имя файла должно совпадать с названием основной процедуры).
with Ada.Unchecked_Deallocation;
procedure Access_Example is
type Int_Ptr is access Integer;
-- Специализация дженерика под Int_Ptr
procedure Free_Integer is new Ada.Unchecked_Deallocation
(Object => Integer, Name => Int_Ptr);
P : Int_Ptr;
I : Integer;
begin
-- Запрашиваем память под целое число с помощью new
-- и сохраняем туда значение 42
P := new Integer'(42);
-- Освобождаем память, теперь P = null
Free_Integer(P);
-- Пробуем получить значение по указателю
I := P.all;
end Access_Example;
Теперь скомпилируем с помощью GNAT и запустим.
$ gnatmake ./access_example.adb
gcc -c -I./ -I- ./access_example.adb
gnatbind -x access_example.ali
gnatlink access_example.ali
$ ./access_example
raised CONSTRAINT_ERROR : access_example.adb:17 access check failed
Как видим, тип указателя access Integer не защитил нас от обращения к освобожденной памяти. Одно хорошо: хотя бы исключение, а не segmentation fault, как в C, так что наша проблема просто баг, а не потенциальная уязвимость.
Однако начиная с Ada 2005 поддерживается и проверка, что указатель ненулевой. Для этого нужно исправить type Int_Ptr is access Integer на type Int_Ptr is not null access Integer. В этом случае наша программа перестанет компилироваться.
$ gnatmake ./access_example.adb
gcc -c -I./ -I- ./access_example.adb
access_example.adb:8:35: non null exclusion of actual and formal "Name" do not match
access_example.adb:10:04: warning: (Ada 2005) null-excluding objects must be initialized
access_example.adb:10:04: warning: "Constraint_Error" will be raised at run time
access_example.adb:15:04: warning: freeing "not null" object will raise Constraint_Error
gnatmake: "./access_example.adb" compilation error
На практике от такого типа мало пользы, поскольку его память невозможно освободить. По этой причине опцию not null обычно применяют для подтипов, чтобы предотвратить использование null в качестве аргумента функции, а для самих значений применяют обычный указатель.
type Int_Ptr is access Integer;
subtype Initialized_Int_Ptr is not null Int_Ptr;
procedure Some_Proc(Arg: Initialized_Int_Ptr);
УМНЫЕ УКАЗАТЕЛИ
В языках вроде Python или Go сборщик мусора работает с памятью всей программы. Очевидно, это возможно, только если пользователь не может управлять памятью вручную, иначе конфликты между пользователем и сборщиком мусора неизбежны.
Как быть, если хочется оставить в языке возможность ручного управления? Можно сделать объект — обертку для динамически выделенной памяти, который будет отслеживать ее использование. Такой объект называют умным указателем (smart pointer).
К примеру, стандарт C++11 определяет класс shared_ptr. Язык C++ поддерживает перегрузку функций и операторов. С помощью этой возможности класс shared_ptr меняет поведение объекта так, что его передача в функцию, присваивание и прочие операции увеличивают внутренний счетчик ссылок.
Посмотрим на примере. Сохраним следующий код в файл shared.cpp.
#include<iostream>
#include<memory>
using namespace std;
void print_pointer_value(std::shared_ptr<int> ptr) {
cout << "Creating a copyn";
auto ptr_copy = ptr;
cout << "Use count: " << ptr.use_count() << endl;
cout << "Pointer value: " << *ptr_copy.get() << endl;
}
int main(void) {
auto ptr = make_shared<int>(42);
cout << "Use count: " << ptr.use_count() << endl;
print_pointer_value(ptr);
cout << "Use count: " << ptr.use_count() << endl;
}
Теперь скомпилируем и запустим.
$ g++ -o shared ./shared.cpp
$ ./shared
Use count: 1
Creating a copy
Use count: 3
Pointer value: 42
Use count: 1
Как видим, передача умного указателя в функцию print_pointer_value() сама по себе увеличила счетчик ссылок на единицу. Создание копии вручную путем присваивания внутри функции увеличило его еще раз, а выход обеих копий из области видимости при завершении функции вернул значение счетчика назад.
Увы, от ошибок класса use after free умные указатели тоже не спасают. Если мы добавим в конец программы следующий код, мы получим ошибку доступа к памяти (segmentation fault):
ptr.reset();
cout << "Pointer value: " << *ptr.get() << endl;
К тому же простой подсчет ссылок — весьма наивный подход, при котором циклические ссылки создают гарантированную утечку памяти. Тем не менее в C++ лучший компромисс придумать сложно.
C++11 также предлагает класс unique_ptr. Такие объекты можно передавать с помощью функции move, но нельзя копировать, поэтому размножение ссылок на них в программе невозможно.
ЛИНЕЙНЫЕ ТИПЫ (BORROW CHECKER)
В заключение рассмотрим подход, который позволяет обеспечить безопасность памяти и ее автоматическое освобождение одновременно.
Математический аппарат этого подхода известен как линейная логика. Основная идея в том, что каждое значение в каждый момент может использоваться только в одной области видимости.
Этот подход использует Rust и ряд экспериментальных языков, например ATS.
Создать несколько ссылок на одно и то же значение при этом подходе просто невозможно. Ссылку можно только «позаимствовать» (borrow). Все ссылки ведут себя подобно упомянутому выше unique_ptr из C++. Компонент компилятора под названием borrow checker следит за выполнением этого правила автоматически.
Следующая программа на Rust не пройдет компиляцию.
fn main() {
let str = String::from("hello world");
let str_copy = str;
println!("{}", str);
}
Компилятор выдаст такую ошибку: error[E0382]: borrow of moved value: str. Каждое объявление переменной с помощью let создает новую область видимости. С помощью let str_copy = str мы не создали вторую ссылку на ту же строку, а передали ее значение новой переменной, поэтому попытка передать str в функцию уже нарушает правила заимствования ссылок.
Передача ссылки возможна во вложенную область видимости, например при вызове функции. Рассмотрим пример из документации.
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
Здесь ссылка на значение переменной s1 заимствуется функцией calculate_length. После завершения вызова calculate_length(&s1) она возвращается обратно.
Таким образом компилятор может отследить, в какой области видимости ссылка на переменную используется в последний раз, и вставить вызов функции освобождения памяти этой переменной на выходе из этой области видимости.
Это и позволяет Rust обходиться без сборщика мусора, но при этом предотвращать и утечки памяти, и ошибки доступа к ней.
Очевидно, этот подход не панацея. Во‑первых, он требует совершенно другого стиля программирования и многие привычные вещи, вроде глобальных переменных, оказываются невозможными. Во‑вторых, он делает невозможными циклические ссылки, а значит, и многие структуры данных вроде графов и двусвязных списков. По этой причине Rust включает в себя библиотеку для работы с динамическими ссылками, правила доступа к которым проверяются во время выполнения, а не на этапе компиляции.
Кроме того, проверки передачи ссылок негативно сказываются на скорости компиляции. Станет ли этот подход стандартом для нового поколения языков системного программирования — время покажет.
ЗАКЛЮЧЕНИЕ
Управление памятью и ее безопасность — обширная, сложная и очень важная тема. Каждому разработчику нужно знать, какие механизмы предлагает его язык и как они работают. Чем больше ты о них знаешь, тем проще выбрать лучший язык под задачу и писать на нем быстрые, корректные и безопасные программы.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei