Безопасность памяти: переполнения и почему 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 устраняют эти классы на уровне инструмента — безопасность по умолчанию.