Безопасность по памяти? 🙈



Наверняка многие слышали, что высокоуровневые языки, навроде Java, JS, Python и т.д. обеспечивают безопасность по памяти. А вот низкоуровневые, такие как C или C++ - нет. Но что такое безопасность памяти?



На самом деле этот термин состоит из множества контрактов, выполнение которых берет на себя ЯП и его runtime. А именно:



1. Невозможность чтения неинициализированной памяти.

2. Отсутствие висячих указателей на недействительную память.

3. Запрет на разыменовывание нулевого указателя.

4. Защита выхода за пределы буфера.

5. Ограничение прямого доступа к памяти.

6. Автоматическое управление памятью.



Я планирую написать цикл статей по каждом из пунктов и сегодня начну с чтения неинициализированной памяти.



Итак, обозначим проблему. Если создать переменную/свойство объекта/статическое значение и т.д., но не задать сразу значение при создании, то у нас получается интересная ситуация. Память нам выделили и мы в неё должны что-то записать, но что если попытаться прочитать такую вот неинициализированную память? Кто пишет на JS, тот скажет: будет undefined. Кто на Java: ошибка компиляции или базовое значение для конкретного типа.



А теперь давайте я напишу простейшую программу на С.



#include <stdio.h>



int f() {

int i = 13;

return i;

}



int g() {

int i;

return i;

}



int main() {

f();

printf("Результат вызова g(): %d\n", g()); // Что здесь будет?

}




При компиляции через Clang в Ubuntu у меня получается, что значение неинициализированной i будет 13. Как такое возможно? Ну, дело в том, что С такой вот сценарий - это undefined behaviour. Говоря простым языком: поведение программы непредсказуемо.



Давайте разбираться почему так. В нашем примере мы предварительно вызвали функцию f, которая положила на стек локальную переменную i. Данная переменная занимает 32 бита и в ней закодировано знаковое целое число. Далее мы возвращаем это число как результат функции (фактически делаем копирование памяти на стеке).



Функция закончила выполнение и вся занимая её на стеке память была автоматически освобождена. Но что это значит? Ну, фактически мы просто меняем значение нескольких стековых регистров процессора, тем самым помечая, что эта память снова стала доступна для использования. Т.е. сами значения, которые находились в памяти мы никак не затираем. Это просто не нужно.



Далее мы вызываем функцию g, которая выделяет место на стеке по свою переменную i. И вот тут кроется проблема: будучи низкоуровневым ЯП, С позволяет нам практически любые манипуляции с памятью компьютера. И вот тут как раз такая ситуация. Мы прочитали память не установив в него никакое предварительное значение и там оказалась память оставшееся после вызова функции f.



Более того, там могла быть память, оставшееся от другой программы на компьютере, которая работала одновременно с нашей или до неё. Да и вообще, как только вы включили компьютер и пошел электрический ток, то наша память априори будет чем-то заполнена.



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



Если это так опасно, то почему С не защищает нас от ошибок такого рода? Ну, язык С дает нам возможность работать с нашей "железкой" максимально близко на ты. Своего рода высокоуровневый ассемблер. Это позволяет писать максимально эффективный код, но при этом всю ответственность на себя берет разработчик.



А вот высокоуровневые ЯП тут нас страхуют. В зависимости от ЯП у нас могут быть разные стратегии. В JS любое неинициализированное значения неявно проставляется самой VM в undefined. Undefined - это просто специальная константа, напоминающее указатель, которая обозначает, что значение не установлено. Или в Java, вот создали мы массив целых чисел, то VM гарантированно инициализирует все элементы массива значением 0, а для других типов будут свои дефолтные значения.