Posted in Engineering

Memory Safety in Systems Programming: How Rust’s Borrow Checker Prevents Entire Bug Classes

For four decades, the dominant systems programming languages — C and C++ — have made a trade that most developers only notice when it fails: they hand full control of memory to the programmer and trust the programmer to get it right every single time. That trust is expensive. Microsoft has reported that roughly 70 percent of the security vulnerabilities it fixes each year, across more than a decade of patches, are memory-safety issues, and Google has independently reported the same figure for serious Chrome security bugs. Rust was designed specifically to make these bug classes — use-after-free, double-free, buffer overflows, and data races — unrepresentable in a program that compiles, without adding a garbage collector or a runtime safety net. It does this through a compile-time analysis called the borrow checker, built on three interlocking ideas: ownership, borrowing, and lifetimes. This explainer walks through that model and shows, with a real compiler error and its fix, exactly how it catches a bug before the program ever runs.

At a Glance

Core guaranteeNo use-after-free, double-free, or data races in “safe” Rust code
Enforcement mechanismStatic analysis at compile time (the borrow checker) — no garbage collector
Ownership ruleEvery value has exactly one owner at any given time
Borrowing ruleEither one mutable reference, or any number of immutable references — never both at once
Lifetime ruleA reference can never outlive the data it points to
Runtime cost of the checksNone — all analysis happens before the binary is produced
Escape hatchunsafe blocks, which opt back into manual, unchecked memory operations

What Problem This Solves

“Memory safety” refers to a cluster of related bug classes that all come from the same root cause: a program accessing memory it should not be allowed to access. The usual suspects are use-after-free (reading or writing through a pointer after the memory it points to has been freed), double-free (releasing the same block of memory twice, corrupting the allocator’s internal state), buffer overflows and out-of-bounds reads/writes (accessing an array or buffer past its allocated bounds), and data races (two threads reading and writing the same memory without synchronization, producing undefined results). MITRE’s 2024 CWE Top 25 Most Dangerous Software Weaknesses list ranks Out-of-bounds Write (CWE-787) at #2, Out-of-bounds Read (CWE-125) at #6, and Use After Free (CWE-416) at #8 — these are not rare, exotic bugs, they are consistently among the most frequently exploited weaknesses in software shipped today.

The scale is what makes this a systemic problem rather than a handful of unlucky bugs. In a 2019 MSRC blog post, Microsoft stated that “~70% of the vulnerabilities Microsoft assigns a CVE each year continue to be memory safety issues,” based on an analysis of roughly a decade of its own patch data. Google reached a nearly identical conclusion independently: in a September 2021 post on its Security Blog, the Chrome team wrote that “more than 70% of our severe security bugs are memory safety problems.” In November 2022, the U.S. National Security Agency published a Cybersecurity Information Sheet explicitly citing both figures and recommending that organizations move toward memory-safe languages where feasible. When two unrelated engineering organizations, analyzing two unrelated codebases, arrive at the same number, it stops looking like coincidence and starts looking like a property of the tools.

How C and C++ Leave This to the Runtime

C and C++ give the programmer raw pointers and manual allocation (malloc/free, new/delete) with no compiler-enforced tracking of who is responsible for freeing what, or whether a pointer is still valid. Freeing memory does not erase it or invalidate every pointer to it — it just marks the block available for reuse. A pointer that still refers to that address (“dangling”) will often still “work” until the memory is reused for something else, at which point reads and writes through it silently corrupt unrelated data. The compiler does not reject this code; the behavior is classified as “undefined,” meaning anything from a crash to a silent miscalculation to, in the worst case, attacker-controlled code execution. CVE-2014-0160 (“Heartbleed”), an out-of-bounds read in OpenSSL’s C codebase, is one of the most widely documented real-world examples of exactly this class of bug reaching production for years before detection. None of this requires programmer carelessness in the usual sense — it requires only that a large, evolving codebase have a single lapse anywhere in its call graph.

Ownership: Rust’s Core Model

Rust’s starting rule is simple: every value has exactly one owner at a time. When you assign a heap-allocated value like a String to a new variable, ownership moves to that variable, and the original variable becomes invalid — the compiler will refuse to compile any later use of it. When the owner goes out of scope, Rust automatically calls the value’s destructor and frees its memory, deterministically, with no garbage collector deciding when to run. Because exactly one piece of code is ever responsible for freeing a given value, double-free becomes structurally impossible in safe Rust: there is never a second owner left around to free the same memory again.

Borrowing and the Aliasing Rule

Moving ownership every time you want to use a value would make most programs unworkable, so Rust also lets you borrow a value through a reference without taking ownership of it. The rule governing borrows is where the real payoff shows up: at any given point in the program, you may have either one mutable reference (&mut T) or any number of immutable references (&T) to a value — never both kinds alive at the same time. This is sometimes called the “aliasing XOR mutability” rule. It directly prevents data races: if only one piece of code can ever hold a mutable reference while all other references are read-only, there is no way for two pieces of code to simultaneously read and write — or write and write — the same memory without going through an explicit, checked synchronization type such as Mutex or RwLock. The same rule also prevents the classic “reading through a reference after the underlying collection resized and moved” bug, as the code example below demonstrates.

Lifetimes: Making “How Long” Part of the Type System

Every reference in Rust has a lifetime: the span of the program during which it is guaranteed to point at valid data. Most of the time the compiler infers lifetimes automatically. When a function’s signature is ambiguous about how long an input and output reference should be related — for example, a function that takes two string references and returns whichever is longer — the programmer writes an explicit lifetime annotation such as 'a to tell the compiler “the reference I return lives no longer than the shorter of these two inputs.” Lifetime annotations don’t change how long anything actually lives in memory; they only describe relationships the compiler then checks. The practical effect is that a function can never hand back a reference to data that is about to be destroyed — the kind of mistake that, in C or C++, produces a dangling pointer discovered only when something crashes much later.

How the Borrow Checker Catches This at Compile Time

Modern Rust (since the 2018 edition) uses an analysis called non-lexical lifetimes (NLL), which ends a borrow at its last actual use in the code, rather than at the end of its enclosing block. This makes the checker precise enough to accept common, correct patterns while still rejecting the unsafe ones. The example below is a well-known case: borrowing the first element of a growable vector, then trying to push a new element onto that same vector while the borrow is still “alive.” Pushing can force the vector to reallocate and move its entire contents to a new memory address, which would leave first pointing at freed memory — a use-after-free. The borrow checker rejects this at compile time, before any such reallocation could ever happen at runtime.

// This does NOT compile. The immutable borrow `first` is still
// considered "alive" at the point where `v.push(4)` tries to
// take a mutable borrow of `v` to grow it.
fn broken() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{}", first);
}

// rustc's error (abridged):
//   cannot borrow `v` as mutable because it is also
//   borrowed as immutable

// This compiles. Thanks to non-lexical lifetimes, the compiler
// sees that `first` is never used again after the println!, so
// its borrow has already ended before `v.push(4)` runs.
fn fixed() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    println!("{}", first);  // last use of `first` — borrow ends here
    v.push(4);                // OK: no active borrow when we mutate `v`
}

Trade-Offs

  • Pro: Use-after-free, double-free, and unsynchronized data races are caught at compile time, not discovered in production or by an attacker.
  • Pro: All of this is static analysis — there is no garbage collector, no runtime borrow-tracking, and no performance penalty in the compiled binary.
  • Pro: The same rules that prevent memory bugs also tend to surface certain logic bugs (like aliased mutation) earlier in development.
  • Con: The learning curve is real — “fighting the borrow checker” is a common experience for programmers used to C, C++, Java, or Python, especially around self-referential data structures and graphs.
  • Con: Some valid, safe patterns (e.g., certain graph or GUI widget structures) require extra machinery like Rc<RefCell<T>> or arena allocators to satisfy the checker.
  • Con: Memory safety is not the same as correctness — Rust does not prevent logic errors, memory leaks (which are safe but still wasteful), or vulnerabilities unrelated to memory, such as injection or authentication flaws.
  • Con: unsafe blocks are sometimes necessary (for FFI, certain low-level optimizations, or OS interfaces) and reintroduce the possibility of memory-safety bugs within that scope.

Bottom Line

Rust’s borrow checker is not a style preference or a lint — it is a formal set of rules about ownership and aliasing that, enforced at compile time, make an entire class of bugs responsible for a documented majority of serious security vulnerabilities in memory-unsafe languages structurally impossible to write in safe Rust. That is precisely why the NSA, alongside Microsoft’s and Google’s own internal data, now points to memory-safe languages as a strategic mitigation rather than a nice-to-have. For teams building new systems-level software — network services, parsers, embedded firmware, browser engines — the upfront cost of learning ownership and borrowing is traded for the removal of one of the largest sources of critical vulnerabilities in the industry’s own data.

FAQ

Does Rust eliminate all security vulnerabilities?
No. It eliminates memory-safety bugs in safe Rust code specifically. Logic errors, injection flaws, authentication bypasses, and vulnerabilities inside unsafe blocks or in C libraries linked via FFI are all still possible.

What is unsafe Rust, and doesn’t it defeat the whole point?
unsafe is a keyword that lets a specific, explicitly marked block of code perform operations the borrow checker can’t verify, such as raw pointer dereferencing or calling into C. It’s intentionally rare, explicit, and auditable — the rest of the codebase stays checked.

Is Rust slower than C or C++ because of all these checks?
No. Ownership, borrowing, and lifetime checks happen entirely at compile time and leave no trace in the compiled binary — there is no runtime borrow-tracking overhead, unlike a garbage-collected language’s runtime.

Can the borrow checker reject programs that are actually safe?
Yes — it is deliberately conservative. Some patterns that a human can reason are safe (certain data structures with internal aliasing) aren’t provable by the checker’s current rules, and need wrapper types like RefCell or restructuring to compile.

What’s the difference between ownership and borrowing?
Ownership determines who is responsible for freeing a value and lasts until that owner goes out of scope. Borrowing lets other code read (or, exclusively, write) a value temporarily without taking that responsibility.

Why are Microsoft and Google talking about this if they mostly ship C and C++?
Because their own vulnerability data — patches issued over many years across Windows and Chrome respectively — showed memory safety bugs as the dominant category of serious security fixes, which is why both now invest in memory-safe language adoption (including Rust) for new and rewritten components.

Sources


Discover more from Software Online Review

Subscribe to get the latest posts sent to your email.

Unknown's avatar

Author:

Innovations, logic & design thinking