Хакер - Безопасность памяти. Учимся использовать указатели и линейные типы

Хакер - Безопасность памяти. Учимся использовать указатели и линейные типы

hacker_frei

https://t.me/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

Report Page