Безопасность памяти: переполнения и почему managed безопаснее

В языках с ручным управлением памятью ошибка обращения к памяти сама по себе уязвимость.

Безопасность памяти — гарантия, что программа не обращается к памяти за границами и временем жизни объектов. В C/C++ её обеспечивает программист; в managed-языках и Rust — система.

Почему это отдельный класс

В C/C++ программист сам выделяет и освобождает память и сам следит за границами массивов. Ошибка здесь — не просто баг: выход за границу буфера или обращение к освобождённой памяти позволяет испортить соседние данные и в худшем случае перенаправить исполнение. Десятилетиями значительная доля критических уязвимостей в системном ПО — именно ошибки работы с памятью.

Чтобы понять, почему именно память так опасна, вспомните, что в этих языках нет «полицейского», который на лету проверяет каждое обращение. Указатель — это просто число-адрес, и язык доверяет программисту, что число корректно. Если оно «промахивается» мимо отведённой области, обращение всё равно состоится — просто прочитает или запишет не то. В программе на управляемом языке эквивалентная ошибка вызвала бы аккуратное исключение; в C/C++ она молча портит соседнюю память, и последствия проявляются позже и в другом месте, что делает такие баги ещё и трудноуловимыми.

Почему это уязвимость, а не просто нестабильность? Потому что рядом с данными в памяти часто лежит управляющая информация — адреса возврата, указатели на функции, метаданные. Если атакующий контролирует, что именно «перельётся» за границу, он может прицельно переписать такую управляющую ячейку и направить исполнение туда, куда захочет. Иными словами, ошибка памяти превращает «битые данные» в «контроль над выполнением» — поэтому эти дефекты исторически и относятся к самым тяжёлым.

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

Идея концептуальна: программа копирует во входной буфер больше данных, чем он вмещает, и «лишнее» затирает соседнюю память. Если туда попадает управляющая информация, поведение программы меняется.

// Уязвимо (C): копируем без учёта размера приёмника
char buf[16];
strcpy(buf, input);     // input длиннее 16 -> запись за границей буфера

// Безопаснее: ограничиваем по размеру приёмника
char buf[16];
strncpy(buf, input, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';   // и всегда завершаем строку

Защитная практика в C/C++: функции с явным указанием размера, проверка длины до копирования, никаких предположений о длине ввода. Корень классической ошибки strcpy в том, что функция копирует «пока не встретит конец строки», ничего не зная о вместимости приёмника. Семейство функций с явной длиной переносит ответственность на программиста явно: вы обязаны указать, сколько байт максимум допустимо записать, и тем самым убираете молчаливое предположение «вход точно поместится». Дополнительная аккуратность — всегда самостоятельно завершать строку нулём, потому что усечённая копия может его не получить.

Use-after-free и висячие указатели (обзор)

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

// Уязвимо (C): использование после free
free(ptr);
use(ptr);          // память освобождена -> обращение к чужому/невалидному участку

// Безопаснее: обнулять указатель и не использовать после освобождения
free(ptr);
ptr = NULL;        // явный признак «больше не валиден»

Почему managed-языки и Rust безопаснее

Целые классы этих ошибок устраняются на уровне языка:

ПодходКак закрывает
managed (Java, C#, Python, Go, JS)проверка границ массивов, сборщик мусора — нет ручного free, нет use-after-free
Rustмодель владения и заимствований проверяется компилятором: нет висячих ссылок и гонок без GC

Это иллюстрация принципа безопасных по умолчанию на уровне языка: если инструмент в принципе не даёт совершить ошибку, она не случится — не нужно полагаться на дисциплину каждого разработчика. Поэтому для нового системного кода всё чаще выбирают memory-safe языки.

Важно правильно понять, что именно делает Rust особенным. Управляемые языки добиваются безопасности памяти ценой сборщика мусора, который в рантайме следит за временем жизни объектов, — это удобно, но добавляет накладные расходы и паузы, неприемлемые для части системного кода. Rust ставит другую цель: дать безопасность памяти без сборщика мусора, перенеся всю проверку на этап компиляции. Его модель владения и заимствований — это набор правил, которые компилятор проверяет статически: у каждого значения есть владелец, ссылки не могут пережить то, на что указывают, и нельзя одновременно иметь изменяющую и читающие ссылки. Программа, нарушающая эти правила, просто не собирается.

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

Как работает под капотом: границы и владение

Managed-рантайм хранит длину массива и проверяет индекс при доступе, выбрасывая исключение вместо порчи памяти; сборщик мусора освобождает объект только когда на него нет ссылок — use-after-free невозможен. Rust идёт дальше: компилятор статически отслеживает, кто владеет данными и какие ссылки активны, и не даёт собрать программу с висячей ссылкой или гонкой — проверка происходит до запуска, без накладных расходов GC.

Частые ошибки

  • Копирование без учёта размера приёмника в C/C++. Прямой путь к переполнению.
  • Использование указателя после free. Обнуляйте и не обращайтесь.
  • Считать «у нас высокоуровневый язык, памяти нет». Уязвимости памяти всё ещё возможны в нативных расширениях и FFI.
  • Чтение за границей буфера. Не только запись опасна: чтение лишнего раскрывает соседнюю память и может утечь секретами.

Итоги

  • В C/C++ ошибки памяти (переполнение, use-after-free) — самостоятельный класс уязвимостей.
  • Защита в C/C++: операции с явным размером, проверки длины, аккуратное освобождение.
  • Managed-языки и Rust устраняют эти классы на уровне инструмента — безопасность по умолчанию.
Проверьте себя
1. Почему переполнение буфера в C/C++ — это уязвимость, а не просто баг?
AОно лишь замедляет программу
BЗапись за границей буфера затирает соседнюю память, включая управляющую, и может изменить поведение программы
CОно нарушает кодировку строк
DОно всегда приводит только к падению
2. Почему managed-языки и Rust устраняют целые классы уязвимостей памяти?
AОни работают медленнее и потому безопаснее
BОни на уровне языка проверяют границы и время жизни данных (GC или проверка владения), не давая совершить ошибку памяти
CОни запрещают работу с массивами
DОни шифруют всю память
3. Что концептуально предотвращает use-after-free в C?
AУвеличение размера буфера
BНе обращаться к памяти после free и обнулять указатель, делая его явно невалидным
CШифрование указателя
DЗапуск программы от root