☰ Конспект ← Курс
ЛЕКЦІЯ 4

ЧОМУ ПРОГРАМИ ПСУЮТЬ ПАМ'ЯТЬ

Ціна помилки керування пам'яттю

VTFK · 2026

План лекції

  • Як програма бачить пам'ять: стек і купа
  • Класичні помилки: use-after-free, double free, витік, переповнення буфера
  • Чому C/C++ дозволяють такі помилки
  • Три підходи до керування пам'яттю: ручне, збирач сміття, володіння
  • Живе демо: один і той самий баг у C та в Rust

Фокус: ціна помилки, а не синтаксис жодної з мов

Стек і купа

  • Стек — пам'ять функції: локальні змінні, звільняється сама при виході з функції
  • Купа (heap) — динамічний час життя: виділяється і звільняється явно (malloc/free) або керовано
  • Вказівник на купу можна скопіювати, а виділена ділянка — одна фізична адреса
  • Помилки керування пам'яттю майже завжди стосуються саме купи

Стек дешевий і безпечний за конструкцією; купа — джерело всіх подальших проблем

СТЕК (швидко, LIFO) КУПА (heap) let s = String ptr,len,cap x: i32 = 5 на стеку "HELLO" дані рядка let s2 = s; // MOVE s — недійсний moved out s2 → "HELLO" один власник рівно один власник даних у купі → немає подвійного звільнення

Стек проти купи

ОзнакаСтекКупа
Розмірфіксований, відомий під час компіляціїдинамічний, часто невідомий заздалегідь
ВиділенняO(1): зсув вказівника стекапошук вільного блоку аллокатором
Час життяобласть видимості функціїявно (free) або через володіння
Типові данілокальні змінні, i32, boolBox, Vec, String, об'єкти
Джерело помилокпереповнення стека (рекурсія)use-after-free, double free, витік

Стек дешевий і безпечний за конструкцією; купа — джерело всіх подальших проблем

Класичні помилки керування пам'яттю

💀
Use-after-freeвикористання пам'яті після її звільнення
✂️
Double freeповторне звільнення тієї самої ділянки
🌊
Buffer overflowзапис за межі виділеного блоку
🕳️
Memory leakвиділили і забули звільнити
🧷
Dangling pointerвказівник на вже звільнену ділянку
Data raceдва потоки, ті самі дані, хоч один запис (Л6)

П'ять перших формують левову частку критичних уразливостей CWE Top 25 десятиліттями

Use-after-free на C

c
int *p = malloc(sizeof(int));
*p = 3101;             // 0xC1D
free(p);
printf("%d\n", *p);   // UB: читаємо звільнену пам'ять
*p = 100;              // UB: пишемо у звільнену пам'ять

Компілятор мовчить: жодного попередження за замовчуванням

Чому use-after-free «працює» на тестах

  1. 1
    mallocаллокатор віддає блок; вказівник p — просто число
  2. 2
    free(p)блок повернуто в пул; байти фізично ще лежать на місці
  3. 3
    Читання *p на тестізначення ще там — тест зелений, компілятор мовчить
  4. 4
    Прод під навантаженнямблок перевикористано під інший об'єкт
  5. 5
    Крах або експлойтчитання дає сміття, запис ламає чужі дані

Санітайзери (ASan, Valgrind) ловлять це під час виконання — і лише на пройдених шляхах

Ціна помилки

≈70%
CVE високої критичності Microsoft
помилки безпеки пам'яті (MSRC, 2006–2018)
≈70%
серйозних багів безпеки Chromium
Google: та сама частка для Chrome та Android
№1
CWE Top 25 (2021–2023)
CWE-787 Out-of-bounds Write
0
попереджень gcc за замовчуванням
на use-after-free із попереднього слайда

Ціна — не в моменті написання рядка, а коли рядок зустрічає реальний, часто зловмисний вхідний потік

Три підходи до керування пам'яттю

ПідхідМовиХто перевіряєЦіна в рантайміПомилки пам'яті
Ручне (malloc/free)C, C++програмістнемаєможливі всі п'ять
Збирач сміття (GC)Java, Python, Go, JSрантаймпаузи stop-the-world, трасуванняздебільшого виключені
Володіння (ownership)Rustкомпіляторнемаєвиключені на етапі компіляції

Rust — спроба взяти безпеку GC-мов і швидкість C без компромісу в рантаймі

Ідея Rust: володіння перевіряється компілятором

  • У кожного значення є рівно один власник (owner)
  • Власник виходить з області видимості — компілятор сам вставляє звільнення пам'яті
  • Використати «мертве» значення — помилка компіляції, а не помилка виконання
  • Саме це розберемо в Л5–Л7: володіння, запозичення, Option/Result

Той самий баг у Rust не компілюється

rust
let p = Box::new(42);
drop(p);              // явно звільняємо пам'ять
println!("{}", p);   // error[E0382]: use of moved value: `p`

// компілятор відмовляється зібрати цей код —
// баг знайдено ДО того, як програма хоч раз запустилась

Помилка виявлена на етапі компіляції, а не на етапі експлуатації

Одна помилка — дві ціни

USE-AFTER-FREE: ДВІ ЦІНИ ОДНІЄЇ ПОМИЛКИ C · ПОМИЛКУ ЗНАЙДЕ ПРОД malloc p ← блок *p = 3101 p живий free(p) блок у пул p той самий p — просто число байти ще на місці *p = UB · крах у проді ⟶ час RUST · ПОМИЛКУ ЗНАЙДЕ КОМПІЛЯТОР Box::new(42) p — власник p живий власник один drop(p) або кінець { } E0382 код не збереться спроба вжити p жодного GC у рантаймі drop автоматичний бінарника з багом не буде ⟶ час звільненняUB у рантайміпомилка компіляції ціну помилки платять або на етапі компіляції, або в проді — третього не буває

Ліворуч C: free(p) лишає p висячим, *p — UB уже в проді. Праворуч Rust: після drop(p) використання p — error[E0382], бінарник просто не збереться

Живе демо

  1. Компілюємо і запускаємо приклад use-after-free на C (gcc, без прапорів санітайзера) — програма щось виводить, поведінка не гарантована
  2. Компілюємо той самий приклад із санітайзером (-fsanitize=address) — тепер помилка виявляється, але тільки під час виконання
  3. Пишемо еквівалентний код на Rust з Box і drop(p) перед використанням
  4. Запускаємо `cargo build` — компіляція падає з error[E0382] ще до запуску

Очікувано: C: непередбачувана поведінка або крах у рантаймі. Rust: помилка компіляції, програма взагалі не збирається

Санітайзер проти перевірки компілятора

ASan / Valgrind (C, C++)
  • Ловить під час виконання
  • Лише пройдені шляхи виконання
  • Потребує покриття тестами
  • Сповільнює програму в кілька разів
  • Баг уже в бінарнику
vs
Перевірка володіння (Rust)
  • Ловить під час компіляції
  • Усі шляхи виконання одразу
  • Не залежить від тестів
  • Нуль накладних витрат у рантаймі
  • Бінарник із багом не з'явиться

Санітайзер знаходить помилку після запуску; компілятор — до появи бінарника

Компілятор — союзник, не ворог

  • Перевірка запозичень (borrow checker) здається суворою новачкам — це нормально, це її робота
  • Кожна помилка компіляції — це баг, який НЕ потрапить у прод
  • Ціна: трохи довше писати перший робочий варіант коду
  • Виграш: use-after-free, double free, data race стають структурно неможливими

Встановлення Rust через rustup

sh.rustup.rscurl … | sh — один інсталятор
rustupканали stable · beta · nightly
stableкурс працює лише на stable
rustc + cargo--version — перевірка
rustup updateоновлення тулчейна

Один інструмент (rustup) керує компілятором, Cargo і документацією одразу

Перший проєкт: cargo new

bash
cargo new lockpick
cd lockpick
tree .
# lockpick/
# ├── Cargo.toml   ← маніфест: назва, версія, залежності
# ├── Cargo.lock   ← точні версії залежностей (генерується автоматично)
# └── src/
#     └── main.rs  ← точка входу

cargo new одразу ініціалізує git-репозиторій, якщо він ще не існує

Команди Cargo, які знадобляться щодня

КомандаЩо робитьКоли
cargo checkперевіряє компіляцію без бінарниканайшвидша ітерація під час написання
cargo build / runкомпілює / компілює й запускає (debug)коли треба виконати
cargo testзапускає юніт- та інтеграційні тестиперед кожним комітом; автотести ЛР
cargo add <crate>додає залежність у Cargo.tomlпотрібен зовнішній крейт
cargo fmtформатує за офіційним стилемперед комітом
cargo clippyлінтер антипатернівперед рев'ю

Перший юніт-тест

rust
fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn adds_two_positive_numbers() {
        assert_eq!(add(2, 3), 5);
    }
}

`#[cfg(test)]` виключає тестовий модуль з релізного бінарника

Cargo.toml проти Cargo.lock

Cargo.toml — маніфест
  • Пишеться вручну
  • [package]: name, version, edition = "2021"
  • [dependencies]: serde = "1.0"
  • "1.0" означає ^1.0 — сумісні 1.x.y
Cargo.lock — зафіксовані версії
  • Генерується автоматично
  • Точні версії, що реально зібралися
  • Комітиться в git для бінарників
  • Гарантує відтворювану збірку

Семантичне версіювання: мажорна.мінорна.патч; ^ дозволяє оновлення в межах мажорної

Демо: тест, що падає → тест, що проходить

  1. cargo new lockpick && cd lockpick
  2. Дописуємо функцію add(a, b) і тест, що навмисно очікує неправильний результат
  3. cargo test — бачимо FAILED і повідомлення "assertion `left == right` failed"
  4. Виправляємо очікуване значення в тесті, запускаємо cargo test знову — усі тести ok

Очікувано: Перший запуск: test result: FAILED. Другий запуск: test result: ok. 1 passed; 0 failed

Маршрут модуля 2

Л4 · ЛР4мотивація · Cargo і перший тест
Л5 · ЛР5володіння · borrow checker
Л6 · ЛР6запозичення · struct і enum
Л7 · ЛР7Option/Result · читання файлу

Наступні три лекції — як саме працює перевірка: володіння, запозичення, Option/Result

Тестування програми може показати наявність помилок, але ніколи — їхню відсутність.

— Едсгер Дейкстра, «Notes on Structured Programming» (1970)

Підсумок

  • Помилки керування пам'яттю (use-after-free, double free, переповнення) — джерело більшості критичних вразливостей
  • C/C++ довіряють програмісту й перевіряють це в кращому разі в рантаймі
  • Rust переносить перевірку володіння на етап компіляції — без накладних витрат у рантаймі
  • Наступні три лекції — як саме працює ця перевірка: володіння, запозичення, Option/Result

🥚 У прикладі use-after-free у виділену ділянку записано 3101 — це 0xC1D у шістнадцятковій системі. Число не випадкове: воно ще не раз зустрінеться в матеріалах курсу.

VTFK · TERMLINK · L04← → · space · f — на весь екран