Back to lessons
Scrollytelling

Memory Safety

Why Rust prevents memory bugs at compile time

The Memory Problem

Memory safety bugs are the single largest class of security vulnerabilities in systems software. They have been responsible for decades of exploits, data breaches, and system crashes.

The numbers are staggering

  • Microsoft reported that ~70% of all CVEs they assign are caused by memory safety issues (buffer overflows, use-after-free, etc.).
  • Google's Chrome team found that ~70% of serious security bugs in Chrome are memory safety problems.
  • Android saw memory-safety vulnerabilities drop from 76% to 24% of total vulnerabilities as the percentage of new memory-unsafe code decreased.

These are not obscure corner cases. They are the bread and butter of real-world exploits: buffer overflows, use-after-free, double-free, null pointer dereferences, and data races. C and C++ give programmers full control over memory, but that freedom comes with enormous responsibility -- and humans inevitably make mistakes.

  Common Memory Bugs in C/C++
  ────────────────────────────────
  1. Buffer overflow      → write past array bounds
  2. Use-after-free       → access freed memory
  3. Double free          → free the same pointer twice
  4. Null dereference     → follow a NULL pointer
  5. Data race            → concurrent unsynchronized access
  6. Uninitialized memory → read before writing
  ────────────────────────────────
  Rust prevents ALL of these at compile time.

Buffer Overflow

A buffer overflow occurs when a program writes data beyond the bounds of an allocated memory buffer. In C, arrays have no built-in bounds checking, so writing past the end of an array silently corrupts adjacent memory.

vulnerable.c -- Classic Buffer Overflow
#include <string.h>

void vulnerable(const char *input) {
    char buffer[64];
    // No bounds checking! If input > 64 bytes,
    // it overwrites the stack frame, return address,
    // and potentially allows arbitrary code execution.
    strcpy(buffer, input);
}

int main() {
    // An attacker sends 200 bytes...
    char payload[200];
    memset(payload, 'A', 199);
    payload[199] = '\0';
    vulnerable(payload);  // BOOM: stack smash
    return 0;
}
  Stack Layout During Overflow
  ─────────────────────────────
  High addresses
  ┌──────────────────────┐
  │   return address     │ ← overwritten by attacker!
  ├──────────────────────┤
  │   saved base pointer │ ← corrupted
  ├──────────────────────┤
  │                      │
  │   buffer[64]         │ ← only 64 bytes allocated
  │                      │
  ├──────────────────────┤
  Low addresses

  The attacker's 200 bytes overflow the 64-byte
  buffer and overwrite the return address, hijacking
  control flow.

This class of bug has been the basis of countless exploits since the 1988 Morris Worm. Stack canaries, ASLR, and DEP are mitigations, but they are band-aids -- not solutions. The root cause is that C trusts the programmer to stay in bounds.

Use-After-Free

A use-after-free bug occurs when a program continues to use a pointer after the memory it points to has been freed. The freed memory may be reallocated for a different purpose, leading to data corruption, crashes, or exploitable vulnerabilities.

dangling.c -- Use-After-Free
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char name[32];
    int admin;       // 0 = normal user, 1 = admin
} User;

int main() {
    User *user = malloc(sizeof(User));
    strcpy(user->name, "Alice");
    user->admin = 0;

    printf("User: %s, admin: %d\n", user->name, user->admin);

    free(user);  // Memory is freed

    // ... later in the code, someone allocates again:
    char *data = malloc(sizeof(User));
    memset(data, 1, sizeof(User));  // Fill with 1s

    // The old pointer still exists and is used:
    printf("User: %s, admin: %d\n",
           user->name,    // reading freed memory!
           user->admin);  // now reads 1 → admin!

    return 0;
}
  Timeline of a Use-After-Free
  ─────────────────────────────
  1. malloc(User)    → user points to 0x1000
  2. user->admin = 0 → memory at 0x1000 is valid
  3. free(user)      → 0x1000 returned to allocator
                        (but 'user' still holds 0x1000!)
  4. malloc(User)    → allocator reuses 0x1000
     memset(data,1)  → fills 0x1000 with 0x01 bytes
  5. user->admin     → reads from 0x1000 → gets 1
                        user is now "admin"!

This is not hypothetical. Use-after-free vulnerabilities have been exploited in browsers, operating systems, and network services. They are especially dangerous because the program appears to work normally until the freed memory is reused, making them difficult to detect through testing alone.

Rust's Solution: Ownership

Rust introduces a system of ownership with three simple rules, enforced entirely at compile time. These rules make buffer overflows and use-after-free impossible without any runtime cost.

The Three Rules of Ownership

  1. Each value in Rust has exactly one owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped (freed).
ownership.rs -- Ownership prevents use-after-free
fn main() {
    let user = String::from("Alice");

    let new_owner = user;  // ownership MOVES to new_owner

    // println!("{}", user);
    // ^^^^^^^^^^^^^^^^^^^^^^^^
    // ERROR: value borrowed here after move
    // Rust won't let you use 'user' after the move!

    println!("{}", new_owner);  // This is fine
}   // new_owner is dropped here, memory freed exactly once
bounds.rs -- Rust prevents buffer overflow
fn main() {
    let buffer = [0u8; 64];

    // Rust checks bounds at compile time when possible,
    // and at runtime otherwise:
    // let val = buffer[100];
    //           ^^^^^^^^^^^ index out of bounds!
    //           This panics instead of silently corrupting memory.

    // Safe iteration -- no overflow possible:
    for (i, byte) in buffer.iter().enumerate() {
        // 'i' is always in bounds
        println!("buffer[{}] = {}", i, byte);
    }
}

The ownership system also governs borrowing: you can have either one mutable reference OR any number of immutable references, but never both at the same time. This eliminates data races at compile time.

The Compiler as Guardian

In C/C++, the compiler trusts you. In Rust, the compiler verifies you. The borrow checker is a compile-time analysis pass that ensures every reference is valid for its entire lifetime. Let's see it catch a real dangling reference.

dangling.rs -- The compiler catches the bug
fn create_greeting() -> &str {
    let s = String::from("Hello, world!");
    &s  // returning a reference to a local variable!
}
// s is dropped here, so the reference would dangle.

// COMPILER OUTPUT:
// error[E0106]: missing lifetime specifier
// error[E0515]: cannot return reference to local variable `s`
//   --> dangling.rs:3:5
//   |
// 3 |     &s
//   |     ^^ returns a reference to data owned
//   |        by the current function

The equivalent C code would compile without warning and produce undefined behavior. Rust catches this at compile time -- before any code runs, before any test is written, before any user is affected.

dangling.c -- C compiles this without error
#include <stdlib.h>
#include <string.h>

// WARNING: This compiles but is undefined behavior!
char* create_greeting() {
    char s[64];
    strcpy(s, "Hello, world!");
    return s;  // returning pointer to stack memory!
}
// s no longer exists after this function returns.
// The caller gets a dangling pointer.
  Rust's Compile-Time Safety Net
  ─────────────────────────────────
  Bug type              C/C++           Rust
  ──────────────────────────────────────────────
  Buffer overflow       Compiles ✓      Compile error ✗
  Use-after-free        Compiles ✓      Compile error ✗
  Double free           Compiles ✓      Compile error ✗
  Dangling pointer      Compiles ✓      Compile error ✗
  Data race             Compiles ✓      Compile error ✗
  Null dereference      Compiles ✓      No null refs  ✗
  ──────────────────────────────────────────────
  ✓ = bug gets through   ✗ = bug is caught

Summary

Rust achieves memory safety without a garbage collector. There is no pause-the-world collection, no reference counting overhead, no runtime safety checks beyond the occasional bounds check. All the heavy lifting is done at compile time.

Ownership eliminates memory bugs

The three rules of ownership -- one owner, one at a time, drop when out of scope -- make use-after-free, double-free, and memory leaks structurally impossible.

Borrowing eliminates data races

The rule that you can have either one mutable reference or many immutable references prevents concurrent mutation, the root cause of data races.

Zero runtime cost

All ownership and borrowing checks happen at compile time. The generated machine code is as fast as equivalent C/C++ -- with none of the memory bugs.

This is Rust's core promise: the performance of C, the safety of a managed language, and no garbage collector. The compiler is your partner, catching bugs that would otherwise lurk undetected until deployment.